Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a5a387381 | ||
|
|
33f734da2d | ||
|
|
3d186293bf | ||
|
|
6973e7d60c | ||
|
|
85595b8c52 | ||
|
|
106fa610c0 | ||
|
|
8ba428b6a4 | ||
|
|
91640dcbf2 | ||
|
|
eefb8054e0 | ||
|
|
8730b9b966 | ||
|
|
1d95a800fd | ||
|
|
f07df88acb | ||
|
|
13f8e0456b | ||
|
|
80ee1d26ea | ||
|
|
a5f25c8bb8 | ||
|
|
6a77fd0991 | ||
|
|
d30ce8bdbe | ||
|
|
cb7c655530 | ||
|
|
60893172c3 | ||
|
|
e87a8a22f1 | ||
|
|
e0a3c72370 | ||
|
|
1daaf4af78 |
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: verify
|
||||
description: Drive the TeslaConsole WinForms app against vPOD to verify console changes end-to-end on this machine.
|
||||
---
|
||||
|
||||
# Verifying console changes against vPOD
|
||||
|
||||
Build (Debug is what the exes below point at):
|
||||
|
||||
```
|
||||
dotnet build Console/TeslaConsole.csproj
|
||||
dotnet build vPOD/vPOD.csproj
|
||||
```
|
||||
|
||||
Launch both (the machine's `C:\ProgramData\Tesla Console\local.siteconfig`
|
||||
already has squad `bay1` with a provisioned `vPOD` pod at 127.0.0.1; vPOD's
|
||||
session key persists in `%LocalAppData%\vPOD\TeslaKeyStore.key`):
|
||||
|
||||
```
|
||||
Start-Process vPOD\bin\Debug\net40\vPOD.exe -ArgumentList "-app","bt" # or rp
|
||||
Start-Process Console\bin\Debug\net40\TeslaConsole.exe
|
||||
```
|
||||
|
||||
(net40 since the XP11 port — both exes run on the machine's 4.8 runtime.)
|
||||
|
||||
Drive the UI with System.Windows.Automation (UIA) — WinForms exposes
|
||||
menus, buttons, checkboxes, and DataGridView rows/cells as real UIA elements:
|
||||
|
||||
- Menus: ExpandCollapsePattern on "Games", InvokePattern on the item.
|
||||
- Pilots grid cells are named like `Enabled Row 1`, `Pilot Row 1`; real mouse
|
||||
clicks at the cell's BoundingRectangle center behave exactly like a user
|
||||
(needed for CellEndEdit paths — programmatic value sets bypass them).
|
||||
Click another cell afterwards to commit a checkbox cell edit.
|
||||
- Type into a grid cell: click it, then `[System.Windows.Forms.SendKeys]::SendWait()`.
|
||||
- The WeifenLuo dock tabs are NOT UIA TabItems; switch documents with
|
||||
Ctrl+Tab sent to the focused window.
|
||||
- Screenshots: Graphics.CopyFromScreen of the element's BoundingRectangle
|
||||
(coordinates are physical pixels; fine to pass straight to SetCursorPos).
|
||||
|
||||
Observe behavior:
|
||||
|
||||
- Game connection endpoint: `Get-NetTCPConnection -OwningProcess <consolePid>
|
||||
-RemotePort 1501`. vPOD binds 0.0.0.0:1501, so any 127.x.x.x target lands on
|
||||
it — distinguish targets by the netstat RemoteAddress, not by whether vPOD
|
||||
answered.
|
||||
- Mission content: vPOD's Current Egg pane shows the decoded egg (player IPs,
|
||||
pilots, map) after clicking Load; its protocol log timestamps connects,
|
||||
disconnects, and state transitions.
|
||||
- Full lifecycle: enable a pod row + pilot name → Load → Run Mission → Stop
|
||||
Mission. With the watchdog checkbox on, vPOD drops the connection after a
|
||||
mission ends and comes back in WaitingForEgg — an observed reconnect there
|
||||
is normal.
|
||||
|
||||
Gotchas: enabling a pod row only requests the connection once the cell edit
|
||||
commits. A helper script with these UIA primitives from a previous session:
|
||||
scratchpad `uia.ps1` (Get-Window / Find-ByName / Click-Elem / Screenshot) —
|
||||
recreate from this recipe if gone. Close both apps when done
|
||||
(`CloseMainWindow`, then Stop-Process stragglers).
|
||||
@@ -0,0 +1,109 @@
|
||||
# Addendum: mission-close / egress-hold — decoded + disassembled (2026-07-10)
|
||||
|
||||
From the TeslaRel410 side. Verified against a live DOSBox BTL4OPT pod run
|
||||
BY THIS .NET CONSOLE over the host bridge (it works). Full writeup:
|
||||
TeslaRel410/emulator/NET-NOTES.md.
|
||||
|
||||
> **ISSUE CLOSED 2026-07-10 (won't-fix, operator decision).** The floor
|
||||
> egress lamps no longer exist on any surviving cockpit, so the hold has
|
||||
> no restoration value. FINAL, corrected finding (supersedes the "~30s"
|
||||
> phrasing below): the pod's post-mission egress window is a compiled
|
||||
> **~3 seconds** — the game exits at its stage-1 timer (Step-0 proven:
|
||||
> a no-loop single-shot boot still held 3.9s then the game self-exited),
|
||||
> and the deeper 30s timer is never reached. Byte-identical across all
|
||||
> four BTL4OPT builds, so nothing regressed. **Console takeaway is
|
||||
> unchanged and simple: send StopMission(RunBattleTech) promptly at your
|
||||
> timer end; the pod handles its own (short) close. No egress delay to
|
||||
> implement.** The rest of this file is preserved for the record.
|
||||
|
||||
## Bottom line for TeslaConsole
|
||||
|
||||
**The customer-egress window is a HARDCODED ~30s timer inside the GAME,
|
||||
not the console.** You do NOT need to implement the egress delay. Just
|
||||
send `Application__StopMissionMessage` promptly at mission end with
|
||||
`RunBattleTechExitCodeID` (=3), and the pod runs the whole authentic
|
||||
sequence itself: egress lamps ON -> ~30s hold -> lamps OFF -> exit ->
|
||||
GO.BAT relaunch. The console's only timing responsibility is not to send
|
||||
StopMission too EARLY (let the mission actually end first).
|
||||
|
||||
## The sequence (game side)
|
||||
|
||||
1. Mission end -> game fades to black hold, sends the wrap-up
|
||||
(ConsolePlayerMechScoreUpdate / ConsoleBTTeamScoreUpdate /
|
||||
RankAndScore). Its StateResponse to your 1/s StateQuery flips state
|
||||
0 -> 2 (mission-over/hold). It waits INDEFINITELY here for you.
|
||||
**CAUTION — "mission end" is NOT the pod's own clock.** Bench-confirmed
|
||||
2026-07-09 (12s mission via this console): the pod does NOT self-end;
|
||||
the egg `length=` only drives the cockpit clock DISPLAY, which hits 0
|
||||
and counts back UP while play continues. The console is the game's
|
||||
sole timekeeper — this hold state is only ever entered off the
|
||||
console's close (or an in-game end condition), so in practice steps
|
||||
1-2 collapse: the console's StopMission at ITS timer end triggers the
|
||||
fade/wrap-up/lamps directly (see step 3).
|
||||
2. You send `Application__StopMissionMessage(ExitCodeID)`.
|
||||
3. `StopMissionMessageHandler` (BTL4OPT.EXE @0x47b864, disassembled):
|
||||
- calls mission-shutdown/EndMission (0x44eeb4)
|
||||
- turns the RIO egress lamps ON (routine @0x47bba8, flag=1; lamps
|
||||
0x16/0x17/0x1e = the floor/entry cluster; the "LightsOut" string
|
||||
@0x4fd5ac names this family)
|
||||
- schedules the lights-out+exit for `now + 30.0*timebase + 0.5`
|
||||
(30.0f constant @0x47b8e4) -- the customer-egress hold
|
||||
4. ~30s later the timer fires -> lamps swept off -> game exits with the
|
||||
exit code -> pod's BAT loop dispatches it (RunBattleTech = relaunch).
|
||||
|
||||
## Console message set + exit codes (from CODE/RP/MUNGA/APPMSG.HPP)
|
||||
|
||||
Messages: StateQuery, CheckLoad, RunMission, **StopMission**, KeyCommand,
|
||||
SuspendMission, ResumeMission, LoadMission, Abort.
|
||||
|
||||
`ExitCodeID` (the game's exit status = what the pod's BAT loop runs next):
|
||||
```
|
||||
Null=0, Abort, RunRedPlanet, RunBattleTech, RunSinglePlayerRedPlanet,
|
||||
RunSinglePlayerBattleTech, DisplayMainTestPattern, DisplayAuxTestPattern,
|
||||
TestPlasmaDisplay, ResetRIO, RunAudioTest, RunNortonDiskDoctor,
|
||||
CheckDiskUsage, RefreshRedPlanet, RefreshBattleTech, ChangeScreenMode,
|
||||
SoftwareReset, ClearCrashlog, KillSpoolFile, RunRedPlanetCamera,
|
||||
RunBattleTechCamera, RunRedPlanetMissionReview, RunBattleTechMissionReview
|
||||
```
|
||||
= a whole remote-operations menu the console UI could expose (RIO reset,
|
||||
test patterns, plasma test, disk tools, camera/mission-review launches).
|
||||
|
||||
## Notes / open
|
||||
|
||||
- A configurable *pre*-StopMission delay is still fine to expose, but the
|
||||
~30s customer window itself is the game's and is authentic at 30s.
|
||||
- Measured lamp-on-to-sweep was only ~3.4s in two live runs; that sweep
|
||||
was likely the teardown GeneralReset, with the real 30s timer firing
|
||||
past the tap window. To confirm on the TeslaRel410 side: one mission
|
||||
with a 90s+ RIO serial tap past mission end (btdis2.py found the
|
||||
constant; a live tap confirms wall-time).
|
||||
- Bonus authenticity: the floor lamp is ALSO entry lighting -- on from
|
||||
game-ready until the mission drops (customer climbs in lit).
|
||||
- Bench 2026-07-09 (12s mission): pod clock counted UP past 0 with play
|
||||
continuing until the console's stop — confirms the console is the sole
|
||||
timekeeper and step 1 never fires off the pod's own clock. If a
|
||||
self-end path exists in the binary it wasn't reachable with our egg.
|
||||
- Cross-ref: the BT411 up-port source (engine/MUNGA/APP.cpp ~1634) has a
|
||||
TWO-stage StopMissionMessageHandler (stop#1 while Running -> EndingMission
|
||||
+ lamps; stop#2 while Ending -> Stop()/exit; +30s LightsOut = lamps off
|
||||
only). The shipping BTL4OPT.EXE disassembly above (one stop -> lamps +
|
||||
scheduled exit at +30s) is authoritative for the DOSBox pod; the up-port
|
||||
evidently diverges — worth knowing if BT411-built binaries ever drive a
|
||||
real pod.
|
||||
- A console-side "egress hold delay" (wait between console timer zero and
|
||||
the StopMission send) was prototyped in TeslaConsole 2026-07-09 and
|
||||
REVERTED: since the pod plays until stopped, that window is overtime
|
||||
play, not lamps-on time. The game's own 30s window is the authentic
|
||||
egress hold.
|
||||
- CROSS-BUILD DISASM (TeslaRel410 side, 2026-07-10, stopmission_cmp3.py):
|
||||
the StopMission close is BYTE-IDENTICAL across all four BTL4OPT builds
|
||||
-- BTLIVE (May-96), BTRAVINE (Sep-96), BTDAVE, Rel410 -- so no build
|
||||
regressed it (this settles the operator's "did Rel410 cut the hold?"
|
||||
question: no). Each build's dispatched outer handler (Rel410 @0x47c2c4)
|
||||
is the re-entrant two-stage one and carries BOTH a 3.0f and (via the
|
||||
inner @0x47b864) a 30.0f constant. Confirms the up-port's two-stage
|
||||
structure at the binary level. Whether 30s is honored live is still
|
||||
open: our RIO tap saw only ~3.4s of lamp-on (the 3.0f stage) before the
|
||||
exe recycled -- the 30s LightsOut may be pre-empted by teardown in the
|
||||
loop-conf flow. Not console-actionable either way (prompt StopMission is
|
||||
correct); noted for completeness.
|
||||
|
After Width: | Height: | Size: 576 B |
|
After Width: | Height: | Size: 576 B |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -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.
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Net;
|
||||
|
||||
namespace TeslaConsole;
|
||||
|
||||
/// <summary>
|
||||
/// Support for the DOSBox-X preservation pods. The emulator that runs the
|
||||
/// original DOS build of a game is bridged onto the pod network with its last
|
||||
/// octet enumerated 100 above the host pod's address, so when a game page is
|
||||
/// driving the emulated version it must target host+100 instead of the
|
||||
/// address stored in the site config. Only the game traffic shifts — the
|
||||
/// launcher and provisioning services still run on the host itself.
|
||||
/// </summary>
|
||||
internal static class DosBox
|
||||
{
|
||||
public const int AddressOffset = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the address with its last octet raised by <see cref="AddressOffset"/>.
|
||||
/// The octet wraps modulo 256; real site configs keep host octets at or
|
||||
/// below 155 so the shifted address stays inside the subnet.
|
||||
/// </summary>
|
||||
public static IPAddress ShiftAddress(IPAddress address)
|
||||
{
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
bytes[bytes.Length - 1] = (byte)(bytes[bytes.Length - 1] + AddressOffset);
|
||||
return new IPAddress(bytes);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ public class MungaGame
|
||||
|
||||
private MungaConnectionState mState;
|
||||
|
||||
private 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)
|
||||
{
|
||||
|
||||
@@ -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 "<text>_<width>x<height>.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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 938 B |
|
After Width: | Height: | Size: 693 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 314 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 454 B |
|
After Width: | Height: | Size: 345 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 501 B |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 997 B |
|
After Width: | Height: | Size: 772 B |
|
After Width: | Height: | Size: 700 B |
|
After Width: | Height: | Size: 404 B |
|
After Width: | Height: | Size: 138 KiB |
@@ -2,9 +2,10 @@
|
||||
:: =============================================================================
|
||||
:: TeslaConsole - Build ^& Package
|
||||
:: =============================================================================
|
||||
:: 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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
# vPOD — virtual pod / game-client stand-in
|
||||
|
||||
A test tool that impersonates a Tesla game client (Red Planet's `rpl4opt.exe`
|
||||
or BattleTech's `btl4.exe`) so the operator **consoles can be exercised without
|
||||
real cockpit hardware**. It speaks the Munga command/control protocol as a
|
||||
server on TCP 1501 — the console connects to it exactly as it would a real pod —
|
||||
emulates the pod `ApplicationState` machine, reassembles the streamed egg, and
|
||||
shows everything on a live display.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Listens on TCP 1501** (configurable) and answers the console's
|
||||
`StateQuery` with a `StateResponse`, reporting the game (`ApplicationID`) and
|
||||
the current `ApplicationState`.
|
||||
- **Walks the mission lifecycle** the console drives it through:
|
||||
`WaitingForEgg → LoadingMission → WaitingForLaunch → LaunchingMission →
|
||||
RunningMission → …`, reacting to the egg stream and to Run / Stop / Abort /
|
||||
Suspend / Resume messages, and acknowledging the egg.
|
||||
- **End-mission graceful exit + watchdog restart** — on the console's end-mission
|
||||
command the "game exe" exits (the listener closes, the console's connection
|
||||
drops), then a watchdog relaunches it a moment later and it comes back up in
|
||||
`WaitingForEgg`. This is the real pod's per-game cycle (`autoRestart`); the
|
||||
*Restart game after mission ends (watchdog)* checkbox (on by default) toggles
|
||||
it — unchecked, the pod just returns to `WaitingForEgg` without exiting.
|
||||
- **Power On / Power Off** — Power Off closes the TCP listener so the console
|
||||
cannot connect, mimicking a pod with no game client running; Power On reopens
|
||||
it. **Reset** returns a live pod to `WaitingForEgg`.
|
||||
- **Reassembles and shows the egg** the console streams (the `EggFileMessage`
|
||||
chunks), one field per line, with a summary line (adventure / map / scenario /
|
||||
pilot count). The last egg is **kept** across missions/restarts (so it can be
|
||||
copied for dev use) until the **Clear** button empties the viewer.
|
||||
- **Game toggle** — a Red Planet ⇄ BattleTech switch on the window changes which
|
||||
`ApplicationID` the pod reports, live, so one vPOD can stand in for either
|
||||
game. (`-app rp|bt` sets the initial choice.)
|
||||
- A **newest-first protocol log** of the traffic.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
vPOD.exe [-net <port>] [-app rp|bt] [-lc|-mr] [-host <id>] [-res W H]
|
||||
```
|
||||
|
||||
- `-net <port>` Munga control port (default **1501**).
|
||||
- `-app rp|bt` which game to report initially (also switchable in the UI).
|
||||
- `-lc` / `-mr` live-camera / mission-review role (cosmetic; the state model is
|
||||
identical to a game machine).
|
||||
- `-host <id>` responding host id reported in state responses (default 1).
|
||||
- `-res W H` accepted and ignored (real clients take it; kept for drop-in
|
||||
launch compatibility).
|
||||
|
||||
## Deploying from the console (Manage Site → Install Product)
|
||||
|
||||
vPOD is a catalog product (`RedPlanet\Apps.xml`, id `0041C870-…`) with Game
|
||||
Client / Live Camera / Mission Review entries, so it appears in **Manage Site →
|
||||
Install Product** like any game. Build the deployable package first:
|
||||
|
||||
```
|
||||
pwsh -File pack.ps1 # produces dist\vPOD.zip
|
||||
```
|
||||
|
||||
The zip lays out `vPOD\vPOD.exe` (+ `Munga Net.dll`) so the launcher extracts it
|
||||
to `C:\Games\vPOD` and the catalog entry launches `C:\Games\vPOD\vPOD.exe`.
|
||||
|
||||
## Testing locally against the console
|
||||
|
||||
The default site ships a **`local` pod at 127.0.0.1**. Run vPOD on the console
|
||||
machine, open a game window (e.g. *Games → Red Planet: Death Race*), and enable
|
||||
the local pod — the console connects to `127.0.0.1:1501` (vPOD), and you can
|
||||
drive Load → Run → Stop and watch vPOD's state and egg viewer follow along.
|
||||
@@ -1,514 +0,0 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Text;
|
||||
using System.Windows.Forms;
|
||||
using Munga.Net;
|
||||
|
||||
namespace VPod;
|
||||
|
||||
/// <summary>
|
||||
/// The vPOD window: a live view of the simulated pod. Top panel shows the
|
||||
/// listening/connection status and the current ApplicationState (colour-coded),
|
||||
/// with the game toggle (Red Planet / BattleTech) that changes which
|
||||
/// ApplicationID the pod reports. Below, the left pane is the egg viewer (the
|
||||
/// last egg the console streamed, one field per line) and the right pane is a
|
||||
/// scrolling protocol log.
|
||||
/// </summary>
|
||||
internal sealed class VPodForm : Form
|
||||
{
|
||||
private readonly PodArguments mOptions;
|
||||
private readonly MungaPodServer mServer;
|
||||
private readonly PodSimulator mSimulator;
|
||||
|
||||
private Label mListeningLabel;
|
||||
private Label mConnectionLabel;
|
||||
private Label mStateLabel;
|
||||
private Label mEggSummaryLabel;
|
||||
private RadioButton mRedPlanetRadio;
|
||||
private RadioButton mBattleTechRadio;
|
||||
private Button mPowerButton;
|
||||
private Button mPowerOffButton;
|
||||
private Button mResetButton;
|
||||
private CheckBox mRestartCheckbox;
|
||||
private Timer mRestartTimer;
|
||||
private SplitContainer mSplit;
|
||||
private TextBox mEggBox;
|
||||
private TextBox mLogBox;
|
||||
|
||||
// How long the "watchdog" waits before relaunching the exited game.
|
||||
private const int WatchdogRestartMs = 1500;
|
||||
|
||||
public VPodForm(PodArguments options)
|
||||
{
|
||||
mOptions = options;
|
||||
mServer = new MungaPodServer(options.Port);
|
||||
mSimulator = new PodSimulator(mServer, options.Application, options.HostId);
|
||||
BuildUi();
|
||||
|
||||
mServer.Log += OnLog;
|
||||
mServer.ConnectionChanged += OnConnectionChanged;
|
||||
mServer.MessageReceived += OnMessageReceived;
|
||||
mSimulator.Log += OnLog;
|
||||
mSimulator.StateChanged += OnStateChanged;
|
||||
mSimulator.EggReceived += OnEggReceived;
|
||||
mSimulator.EggProgress += OnEggProgress;
|
||||
mSimulator.EndMissionExit += OnEndMissionExit;
|
||||
|
||||
mRestartTimer = new Timer { Interval = WatchdogRestartMs };
|
||||
mRestartTimer.Tick += OnRestartTimerTick;
|
||||
|
||||
Load += OnFormLoad;
|
||||
FormClosing += OnFormClosing;
|
||||
}
|
||||
|
||||
private void BuildUi()
|
||||
{
|
||||
Text = $"vPOD - virtual pod (port {mOptions.Port}, host {mOptions.HostId})";
|
||||
ClientSize = new Size(920, 560);
|
||||
MinimumSize = new Size(760, 460);
|
||||
Font = new Font("Segoe UI", 9f);
|
||||
|
||||
// ---- status panel ----
|
||||
GroupBox statusGroup = new GroupBox
|
||||
{
|
||||
Text = "Pod Status",
|
||||
Dock = DockStyle.Top,
|
||||
Height = 176,
|
||||
Padding = new Padding(10)
|
||||
};
|
||||
|
||||
mListeningLabel = new Label { AutoSize = true, Location = new Point(16, 26) };
|
||||
mConnectionLabel = new Label { AutoSize = true, Location = new Point(16, 50) };
|
||||
|
||||
Label roleLabel = new Label
|
||||
{
|
||||
AutoSize = true,
|
||||
Location = new Point(16, 74),
|
||||
Text = "Role: " + RoleText(mOptions.HostType)
|
||||
};
|
||||
|
||||
Label stateCaption = new Label
|
||||
{
|
||||
AutoSize = true,
|
||||
Location = new Point(16, 106),
|
||||
Text = "Application State:"
|
||||
};
|
||||
mStateLabel = new Label
|
||||
{
|
||||
AutoSize = true,
|
||||
Location = new Point(130, 100),
|
||||
Font = new Font("Segoe UI", 15f, FontStyle.Bold),
|
||||
Text = "—"
|
||||
};
|
||||
|
||||
// game toggle
|
||||
GroupBox gameGroup = new GroupBox
|
||||
{
|
||||
Text = "Mimicking Game",
|
||||
Location = new Point(560, 20),
|
||||
Size = new Size(330, 130)
|
||||
};
|
||||
mRedPlanetRadio = new RadioButton
|
||||
{
|
||||
Text = "Red Planet (RPL4)",
|
||||
Location = new Point(18, 28),
|
||||
AutoSize = true,
|
||||
Checked = mOptions.Application == ApplicationID.RPL4
|
||||
};
|
||||
mBattleTechRadio = new RadioButton
|
||||
{
|
||||
Text = "BattleTech (BTL4)",
|
||||
Location = new Point(18, 56),
|
||||
AutoSize = true,
|
||||
Checked = mOptions.Application == ApplicationID.BTL4
|
||||
};
|
||||
mRedPlanetRadio.CheckedChanged += GameToggleChanged;
|
||||
mBattleTechRadio.CheckedChanged += GameToggleChanged;
|
||||
mPowerButton = new Button { Text = "Power On", Location = new Point(12, 90), Size = new Size(94, 28) };
|
||||
mPowerOffButton = new Button { Text = "Power Off", Location = new Point(112, 90), Size = new Size(94, 28) };
|
||||
mResetButton = new Button { Text = "Reset", Location = new Point(212, 90), Size = new Size(94, 28) };
|
||||
mPowerButton.Click += PowerOnClicked;
|
||||
mPowerOffButton.Click += PowerOffClicked;
|
||||
mResetButton.Click += (s, e) => mSimulator.Reset();
|
||||
gameGroup.Controls.Add(mRedPlanetRadio);
|
||||
gameGroup.Controls.Add(mBattleTechRadio);
|
||||
gameGroup.Controls.Add(mPowerButton);
|
||||
gameGroup.Controls.Add(mPowerOffButton);
|
||||
gameGroup.Controls.Add(mResetButton);
|
||||
|
||||
mRestartCheckbox = new CheckBox
|
||||
{
|
||||
Text = "Restart game after mission ends (watchdog)",
|
||||
Location = new Point(16, 146),
|
||||
AutoSize = true,
|
||||
Checked = true
|
||||
};
|
||||
mRestartCheckbox.CheckedChanged += (s, e) => mSimulator.RestartOnEndMission = mRestartCheckbox.Checked;
|
||||
|
||||
statusGroup.Controls.Add(mListeningLabel);
|
||||
statusGroup.Controls.Add(mConnectionLabel);
|
||||
statusGroup.Controls.Add(roleLabel);
|
||||
statusGroup.Controls.Add(stateCaption);
|
||||
statusGroup.Controls.Add(mStateLabel);
|
||||
statusGroup.Controls.Add(mRestartCheckbox);
|
||||
statusGroup.Controls.Add(gameGroup);
|
||||
|
||||
// ---- egg viewer + log ----
|
||||
// Split the egg viewer (left) and protocol log (right). The 50/50 default
|
||||
// is applied in OnFormLoad once the control has its real width (setting it
|
||||
// here would clamp to the control's default size).
|
||||
mSplit = new SplitContainer
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Orientation = Orientation.Vertical
|
||||
};
|
||||
SplitContainer split = mSplit;
|
||||
|
||||
GroupBox eggGroup = new GroupBox { Text = "Current Egg", Dock = DockStyle.Fill, Padding = new Padding(8) };
|
||||
// The egg is kept after a mission/restart (not auto-cleared) so it can be
|
||||
// copied for dev use; the Clear button empties the viewer on demand.
|
||||
Panel eggHeader = new Panel { Dock = DockStyle.Top, Height = 30 };
|
||||
Button clearEggButton = new Button { Text = "Clear", Dock = DockStyle.Right, Width = 70 };
|
||||
mEggSummaryLabel = new Label
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Text = "No egg loaded.",
|
||||
TextAlign = ContentAlignment.MiddleLeft
|
||||
};
|
||||
clearEggButton.Click += (s, e) =>
|
||||
{
|
||||
mEggBox.Clear();
|
||||
mEggSummaryLabel.Text = "No egg loaded.";
|
||||
};
|
||||
eggHeader.Controls.Add(mEggSummaryLabel);
|
||||
eggHeader.Controls.Add(clearEggButton);
|
||||
mEggBox = new TextBox
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Both,
|
||||
WordWrap = false,
|
||||
Font = new Font("Consolas", 9f),
|
||||
BackColor = Color.White
|
||||
};
|
||||
eggGroup.Controls.Add(mEggBox);
|
||||
eggGroup.Controls.Add(eggHeader);
|
||||
|
||||
GroupBox logGroup = new GroupBox { Text = "Protocol Log", Dock = DockStyle.Fill, Padding = new Padding(8) };
|
||||
mLogBox = new TextBox
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Both,
|
||||
WordWrap = false,
|
||||
Font = new Font("Consolas", 9f),
|
||||
BackColor = Color.FromArgb(24, 24, 24),
|
||||
ForeColor = Color.Gainsboro
|
||||
};
|
||||
logGroup.Controls.Add(mLogBox);
|
||||
|
||||
split.Panel1.Controls.Add(eggGroup);
|
||||
split.Panel2.Controls.Add(logGroup);
|
||||
|
||||
Controls.Add(split);
|
||||
Controls.Add(statusGroup);
|
||||
}
|
||||
|
||||
private void OnFormLoad(object sender, EventArgs e)
|
||||
{
|
||||
// Now that the split has its real width, give the log half the window.
|
||||
if (mSplit.Width > mSplit.Panel1MinSize + mSplit.Panel2MinSize)
|
||||
{
|
||||
mSplit.SplitterDistance = mSplit.Width / 2;
|
||||
}
|
||||
UpdateConnectionLabel(null);
|
||||
UpdateStateLabel(mSimulator.State);
|
||||
if (StartServer())
|
||||
{
|
||||
mSimulator.PowerOn();
|
||||
SetPoweredState(on: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetPoweredState(on: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFormClosing(object sender, FormClosingEventArgs e)
|
||||
{
|
||||
mServer.Stop();
|
||||
}
|
||||
|
||||
private void PowerOnClicked(object sender, EventArgs e)
|
||||
{
|
||||
mRestartTimer.Stop(); // cancel any pending watchdog restart
|
||||
if (!mServer.IsListening && !StartServer())
|
||||
{
|
||||
return; // couldn't bind the port; stay powered off
|
||||
}
|
||||
mSimulator.PowerOn();
|
||||
SetPoweredState(on: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Powers the pod off by closing the TCP listener, so the console can no
|
||||
/// longer connect — the same condition as a pod with no game client running.
|
||||
/// </summary>
|
||||
private void PowerOffClicked(object sender, EventArgs e)
|
||||
{
|
||||
mRestartTimer.Stop();
|
||||
mServer.Stop();
|
||||
SetPoweredState(on: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The game exited gracefully on the console's end-mission command. Simulate
|
||||
/// the process exit by closing the listener (the console's connection drops),
|
||||
/// then let the watchdog timer relaunch it.
|
||||
/// </summary>
|
||||
private void OnEndMissionExit()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
BeginInvoke((Action)(() =>
|
||||
{
|
||||
OnLog("Game exited gracefully (end mission); watchdog will restart it...");
|
||||
mServer.Stop();
|
||||
UpdateListeningLabel(false);
|
||||
UpdateConnectionLabel(null);
|
||||
mStateLabel.Text = "Restarting...";
|
||||
mStateLabel.ForeColor = Color.DarkOrange;
|
||||
mPowerButton.Enabled = true;
|
||||
mPowerOffButton.Enabled = false;
|
||||
mResetButton.Enabled = false;
|
||||
mRestartTimer.Stop();
|
||||
mRestartTimer.Start();
|
||||
}));
|
||||
}
|
||||
|
||||
private void OnRestartTimerTick(object sender, EventArgs e)
|
||||
{
|
||||
mRestartTimer.Stop();
|
||||
if (StartServer())
|
||||
{
|
||||
OnLog("Watchdog restarted the game.");
|
||||
mSimulator.PowerOn();
|
||||
SetPoweredState(on: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetPoweredState(on: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts the listener and updates the status label. Returns false (with a message) if the port is unavailable.</summary>
|
||||
private bool StartServer()
|
||||
{
|
||||
try
|
||||
{
|
||||
mServer.Start();
|
||||
UpdateListeningLabel(true);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UpdateListeningLabel(false);
|
||||
OnLog("FAILED to listen on port " + mOptions.Port + ": " + ex.Message);
|
||||
MessageBox.Show(this,
|
||||
"Could not listen on TCP port " + mOptions.Port + ".\n\n" + ex.Message +
|
||||
"\n\nIs another pod or vPOD already using it?",
|
||||
"vPOD", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reflects the on/off state in the buttons and, when off, the status labels.</summary>
|
||||
private void SetPoweredState(bool on)
|
||||
{
|
||||
mPowerButton.Enabled = !on;
|
||||
mPowerOffButton.Enabled = on;
|
||||
mResetButton.Enabled = on;
|
||||
if (!on)
|
||||
{
|
||||
UpdateListeningLabel(false);
|
||||
UpdateConnectionLabel(null);
|
||||
mStateLabel.Text = "Powered Off";
|
||||
mStateLabel.ForeColor = Color.Gray;
|
||||
}
|
||||
}
|
||||
|
||||
private void GameToggleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (sender is RadioButton rb && !rb.Checked)
|
||||
{
|
||||
return; // only act on the newly-checked one
|
||||
}
|
||||
mSimulator.ApplicationId = mBattleTechRadio.Checked ? ApplicationID.BTL4 : ApplicationID.RPL4;
|
||||
}
|
||||
|
||||
// ---- event handlers (marshal to UI thread) ----
|
||||
|
||||
private const int MaxLogLines = 500;
|
||||
|
||||
private void OnLog(string message)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
BeginInvoke((Action)(() =>
|
||||
{
|
||||
// Newest first: prepend, and cap the buffer so it can't grow without bound.
|
||||
string line = $"[{DateTime.Now:HH:mm:ss}] {message}";
|
||||
string existing = mLogBox.Text;
|
||||
string combined = existing.Length > 0 ? line + "\r\n" + existing : line;
|
||||
string[] lines = combined.Split(new[] { "\r\n" }, StringSplitOptions.None);
|
||||
if (lines.Length > MaxLogLines)
|
||||
{
|
||||
combined = string.Join("\r\n", lines, 0, MaxLogLines);
|
||||
}
|
||||
mLogBox.Text = combined;
|
||||
}));
|
||||
}
|
||||
|
||||
private void OnConnectionChanged(string remote)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
BeginInvoke((Action)(() => UpdateConnectionLabel(remote)));
|
||||
}
|
||||
|
||||
private void OnMessageReceived(MungaPodServer.Incoming incoming)
|
||||
{
|
||||
// The simulator drives all protocol behaviour; the UI only logs
|
||||
// non-query traffic (StateQuery is once-a-second noise).
|
||||
if (!(incoming.Message is StateQueryMessage) && incoming.Message != null)
|
||||
{
|
||||
OnLog("<- " + incoming.Message.GetType().Name);
|
||||
}
|
||||
mSimulator.HandleMessage(incoming);
|
||||
}
|
||||
|
||||
private void OnStateChanged(ApplicationState state)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
// The egg viewer is intentionally NOT cleared here — the last egg stays
|
||||
// visible (copyable) across missions/restarts until the Clear button.
|
||||
BeginInvoke((Action)(() => UpdateStateLabel(state)));
|
||||
}
|
||||
|
||||
private void OnEggReceived(string eggText)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
BeginInvoke((Action)(() =>
|
||||
{
|
||||
mEggBox.Text = eggText.Replace("\n", "\r\n");
|
||||
mEggSummaryLabel.Text = SummarizeEgg(eggText);
|
||||
}));
|
||||
}
|
||||
|
||||
private void OnEggProgress()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
BeginInvoke((Action)(() =>
|
||||
{
|
||||
int pct = mSimulator.EggPercent;
|
||||
if (pct < 100 && pct > 0)
|
||||
{
|
||||
mEggSummaryLabel.Text = $"Receiving egg... {pct}%";
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- label helpers ----
|
||||
|
||||
private void UpdateListeningLabel(bool listening)
|
||||
{
|
||||
mListeningLabel.Text = listening
|
||||
? $"Listening on TCP {mOptions.Port} ●"
|
||||
: $"Not listening on TCP {mOptions.Port}";
|
||||
mListeningLabel.ForeColor = listening ? Color.ForestGreen : Color.Firebrick;
|
||||
}
|
||||
|
||||
private void UpdateConnectionLabel(string remote)
|
||||
{
|
||||
if (string.IsNullOrEmpty(remote))
|
||||
{
|
||||
mConnectionLabel.Text = "Console: not connected";
|
||||
mConnectionLabel.ForeColor = Color.Gray;
|
||||
}
|
||||
else
|
||||
{
|
||||
mConnectionLabel.Text = "Console: connected from " + remote;
|
||||
mConnectionLabel.ForeColor = Color.ForestGreen;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateStateLabel(ApplicationState state)
|
||||
{
|
||||
mStateLabel.Text = Prettify(state);
|
||||
mStateLabel.ForeColor = ColorFor(state);
|
||||
}
|
||||
|
||||
private static string RoleText(HostType hostType)
|
||||
{
|
||||
switch (hostType)
|
||||
{
|
||||
case HostType.MissionReviewHostType:
|
||||
return "Camera / Mission Review";
|
||||
case HostType.ConsoleHostType:
|
||||
return "Console";
|
||||
default:
|
||||
return "Game Machine (player)";
|
||||
}
|
||||
}
|
||||
|
||||
private static string Prettify(ApplicationState state)
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case ApplicationState.InitializingState: return "Initializing";
|
||||
case ApplicationState.WaitingForEgg: return "Waiting For Egg";
|
||||
case ApplicationState.LoadingMission: return "Loading Mission";
|
||||
case ApplicationState.WaitingForLaunch: return "Waiting For Launch";
|
||||
case ApplicationState.LaunchingMission: return "Launching Mission";
|
||||
case ApplicationState.RunningMission: return "Running Mission";
|
||||
case ApplicationState.EndingMission: return "Ending Mission";
|
||||
case ApplicationState.StoppingMission: return "Stopping Mission";
|
||||
case ApplicationState.SuspendingMission: return "Suspended";
|
||||
case ApplicationState.ResumingMission: return "Resuming Mission";
|
||||
case ApplicationState.AbortingMission: return "Aborting Mission";
|
||||
case ApplicationState.CreatingMission: return "Creating Mission";
|
||||
default: return state.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private static Color ColorFor(ApplicationState state)
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case ApplicationState.WaitingForEgg: return Color.ForestGreen;
|
||||
case ApplicationState.WaitingForLaunch: return Color.DarkGoldenrod;
|
||||
case ApplicationState.RunningMission: return Color.RoyalBlue;
|
||||
case ApplicationState.InitializingState: return Color.Gray;
|
||||
default: return Color.DarkOrange;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SummarizeEgg(string eggText)
|
||||
{
|
||||
string adventure = null, map = null, scenario = null;
|
||||
int pilots = 0;
|
||||
foreach (string raw in eggText.Split('\n'))
|
||||
{
|
||||
string line = raw.Trim();
|
||||
if (line.StartsWith("adventure=")) adventure = line.Substring(10);
|
||||
else if (line.StartsWith("map=")) map = line.Substring(4);
|
||||
else if (line.StartsWith("scenario=")) scenario = line.Substring(9);
|
||||
else if (line.StartsWith("pilot=")) pilots++;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.Append(adventure ?? "(egg)");
|
||||
if (map != null) sb.Append(" • map=").Append(map);
|
||||
if (scenario != null) sb.Append(" • ").Append(scenario);
|
||||
sb.Append(" • ").Append(pilots).Append(pilots == 1 ? " pilot" : " pilots");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
vPOD - a virtual pod / game-client stand-in for testing the Tesla game
|
||||
consoles (Red Planet and BattleTech) without real cockpit hardware.
|
||||
|
||||
It speaks the Munga command/control protocol as a SERVER on TCP 1501 (the
|
||||
console connects to it exactly as it would a real rpl4opt.exe / btl4.exe),
|
||||
emulates the pod ApplicationState machine, reassembles the streamed egg,
|
||||
and shows both on a live display. Deployable to a pod machine via the
|
||||
console's Manage Site -> Install Product (see dist\ + RedPlanet\Apps.xml).
|
||||
|
||||
net48 to match the rest of the suite and the vendored Munga Net.dll.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<AssemblyName>vPOD</AssemblyName>
|
||||
<RootNamespace>VPod</RootNamespace>
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<Version>1.0.0</Version>
|
||||
<Product>vPOD</Product>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- net48 reference assemblies so this builds without a full targeting pack installed -->
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The Munga wire types (messages, header, enums). Copied next to vPOD.exe so
|
||||
the deployable package is self-contained. -->
|
||||
<Reference Include="Munga Net">
|
||||
<HintPath>..\lib\Munga Net.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,5 +1,5 @@
|
||||
// =============================================================================
|
||||
// Tesla.Contract — Console-side RPC client (net48 only)
|
||||
// Tesla.Contract — Console-side RPC client
|
||||
// =============================================================================
|
||||
// Opens an OFB-encrypted TCP connection to the pod (port 53290) and dispatches
|
||||
// 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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
// =============================================================================
|
||||
// TeslaLauncher — Shared IPC Models
|
||||
// =============================================================================
|
||||
// IPC types (Tesla.Launcher.Shared): JSON messages between the Windows Service
|
||||
// and the user-session Agent over a Named Pipe. These never touch the Console
|
||||
// TCP connection.
|
||||
//
|
||||
// The BinaryFormatter wire types (Tesla.Net: LaunchData, InvokeCommand, ...)
|
||||
// formerly replicated here by hand now live in the shared Tesla.Contract project
|
||||
// (assembly TeslaConsoleLaunchLib), referenced by the Service. This file is also
|
||||
// compiled into the Agent, which uses only the IPC types below.
|
||||
// =============================================================================
|
||||
|
||||
namespace Tesla.Launcher.Shared
|
||||
{
|
||||
/// <summary>Command forwarded from the Windows Service to the Userspace Agent.</summary>
|
||||
public sealed class IpcMessage
|
||||
{
|
||||
public string Command { get; set; }
|
||||
public string LaunchKey { get; set; }
|
||||
public string PayloadJson { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Response from the Userspace Agent back to the Windows Service.</summary>
|
||||
public sealed class IpcResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; }
|
||||
public object Data { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// =============================================================================
|
||||
// TeslaLauncher — MiniZip
|
||||
// =============================================================================
|
||||
// Minimal ZIP extractor for net40: System.IO.Compression.ZipFile/ZipArchive are
|
||||
// net45+, so they do not exist on the XP-compatible framework. This reads the
|
||||
// archive via the central directory and supports exactly what the Console's
|
||||
// install packages use:
|
||||
// - compression methods 0 (stored) and 8 (deflate; DeflateStream is net20+)
|
||||
// - UTF-8 (general-purpose flag bit 11) or ANSI/CP437 entry names
|
||||
// - ZIP64 sizes/offsets/entry counts (archives > 4 GB or > 65535 entries)
|
||||
// CRC is not verified — archives arrive from our own Console over the
|
||||
// OFB-encrypted link, and the install already fails loudly on truncation.
|
||||
// =============================================================================
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
|
||||
namespace Tesla.Launcher
|
||||
{
|
||||
internal static class MiniZip
|
||||
{
|
||||
/// <summary>Extracts every entry of <paramref name="zipPath"/> under
|
||||
/// <paramref name="destDir"/> (existing files overwritten), reporting
|
||||
/// (entriesDone, entriesTotal) after each entry. Entries that would
|
||||
/// escape the destination directory (zip-slip) are skipped.</summary>
|
||||
public static void ExtractToDirectory(string zipPath, string destDir, Action<int, int> onEntry)
|
||||
{
|
||||
using (var fs = new FileStream(zipPath, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
var entries = ReadCentralDirectory(fs);
|
||||
var destRoot = Path.GetFullPath(destDir).TrimEnd('\\');
|
||||
int done = 0;
|
||||
foreach (var e in entries)
|
||||
{
|
||||
ExtractEntry(fs, e, destRoot);
|
||||
done++;
|
||||
if (onEntry != null) onEntry(done, entries.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Entry
|
||||
{
|
||||
public string Name;
|
||||
public int Method;
|
||||
public long CompressedSize;
|
||||
public long LocalHeaderOffset;
|
||||
}
|
||||
|
||||
// ── Central directory ─────────────────────────────────────────────────
|
||||
|
||||
private static List<Entry> ReadCentralDirectory(FileStream fs)
|
||||
{
|
||||
// End-of-central-directory record: fixed 22 bytes + up to 64K comment.
|
||||
long fileLen = fs.Length;
|
||||
int maxScan = (int)Math.Min(fileLen, 22 + 65535);
|
||||
var tail = new byte[maxScan];
|
||||
fs.Seek(fileLen - maxScan, SeekOrigin.Begin);
|
||||
ReadExact(fs, tail, maxScan);
|
||||
|
||||
int eocd = -1;
|
||||
for (int i = maxScan - 22; i >= 0; i--)
|
||||
{
|
||||
if (tail[i] == 0x50 && tail[i + 1] == 0x4B &&
|
||||
tail[i + 2] == 0x05 && tail[i + 3] == 0x06) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) throw new IOException("Not a ZIP archive (no end-of-central-directory record).");
|
||||
|
||||
long eocdPos = fileLen - maxScan + eocd;
|
||||
long totalEntries = BitConverter.ToUInt16(tail, eocd + 10);
|
||||
long cdOffset = BitConverter.ToUInt32(tail, eocd + 16);
|
||||
|
||||
// ZIP64: any maxed-out field means the real values live in the
|
||||
// ZIP64 EOCD record, found via the locator 20 bytes before EOCD.
|
||||
if (totalEntries == 0xFFFF || cdOffset == 0xFFFFFFFF)
|
||||
{
|
||||
long locPos = eocdPos - 20;
|
||||
if (locPos >= 0)
|
||||
{
|
||||
var loc = new byte[20];
|
||||
fs.Seek(locPos, SeekOrigin.Begin);
|
||||
ReadExact(fs, loc, 20);
|
||||
if (loc[0] == 0x50 && loc[1] == 0x4B && loc[2] == 0x06 && loc[3] == 0x07)
|
||||
{
|
||||
long z64Pos = BitConverter.ToInt64(loc, 8);
|
||||
var z64 = new byte[56];
|
||||
fs.Seek(z64Pos, SeekOrigin.Begin);
|
||||
ReadExact(fs, z64, 56);
|
||||
if (!(z64[0] == 0x50 && z64[1] == 0x4B && z64[2] == 0x06 && z64[3] == 0x06))
|
||||
throw new IOException("Corrupt ZIP64 end-of-central-directory record.");
|
||||
totalEntries = BitConverter.ToInt64(z64, 32);
|
||||
cdOffset = BitConverter.ToInt64(z64, 48);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var list = new List<Entry>((int)totalEntries);
|
||||
fs.Seek(cdOffset, SeekOrigin.Begin);
|
||||
var br = new BinaryReader(fs); // not disposed — would close fs
|
||||
|
||||
for (long i = 0; i < totalEntries; i++)
|
||||
{
|
||||
if (br.ReadUInt32() != 0x02014B50)
|
||||
throw new IOException("Corrupt central directory (bad entry signature).");
|
||||
|
||||
br.ReadUInt16(); // version made by
|
||||
br.ReadUInt16(); // version needed
|
||||
ushort flags = br.ReadUInt16();
|
||||
ushort method = br.ReadUInt16();
|
||||
br.ReadUInt32(); // DOS mod time/date
|
||||
br.ReadUInt32(); // CRC-32
|
||||
long comp = br.ReadUInt32();
|
||||
long uncomp = br.ReadUInt32();
|
||||
ushort nameLen = br.ReadUInt16();
|
||||
ushort extraLen = br.ReadUInt16();
|
||||
ushort commentLen = br.ReadUInt16();
|
||||
br.ReadUInt16(); // disk number start
|
||||
br.ReadUInt16(); // internal attributes
|
||||
br.ReadUInt32(); // external attributes
|
||||
long lho = br.ReadUInt32();
|
||||
|
||||
byte[] nameBytes = br.ReadBytes(nameLen);
|
||||
byte[] extra = br.ReadBytes(extraLen);
|
||||
if (commentLen > 0) br.ReadBytes(commentLen);
|
||||
|
||||
// ZIP64 extended-information extra field (id 0x0001): 8-byte
|
||||
// values present, in order, only for headers that were 0xFFFFFFFF.
|
||||
int p = 0;
|
||||
while (p + 4 <= extra.Length)
|
||||
{
|
||||
ushort id = BitConverter.ToUInt16(extra, p);
|
||||
ushort sz = BitConverter.ToUInt16(extra, p + 2);
|
||||
if (id == 0x0001)
|
||||
{
|
||||
int q = p + 4;
|
||||
if (uncomp == 0xFFFFFFFF && q + 8 <= p + 4 + sz) { uncomp = BitConverter.ToInt64(extra, q); q += 8; }
|
||||
if (comp == 0xFFFFFFFF && q + 8 <= p + 4 + sz) { comp = BitConverter.ToInt64(extra, q); q += 8; }
|
||||
if (lho == 0xFFFFFFFF && q + 8 <= p + 4 + sz) { lho = BitConverter.ToInt64(extra, q); }
|
||||
}
|
||||
p += 4 + sz;
|
||||
}
|
||||
|
||||
bool utf8 = (flags & 0x0800) != 0;
|
||||
var name = (utf8 ? Encoding.UTF8 : Encoding.Default).GetString(nameBytes);
|
||||
|
||||
list.Add(new Entry
|
||||
{
|
||||
Name = name,
|
||||
Method = method,
|
||||
CompressedSize = comp,
|
||||
LocalHeaderOffset = lho
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// ── Entry extraction ──────────────────────────────────────────────────
|
||||
|
||||
private static void ExtractEntry(FileStream fs, Entry e, string destRoot)
|
||||
{
|
||||
if (string.IsNullOrEmpty(e.Name)) return;
|
||||
|
||||
string destPath;
|
||||
try { destPath = Path.GetFullPath(Path.Combine(destRoot, e.Name.Replace('/', '\\'))); }
|
||||
catch { return; } // illegal characters in name — skip
|
||||
|
||||
// Zip-slip protection: never write outside the destination root.
|
||||
if (!destPath.StartsWith(destRoot + "\\", StringComparison.OrdinalIgnoreCase)
|
||||
&& !destPath.Equals(destRoot, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
if (e.Name.EndsWith("/") || e.Name.EndsWith("\\"))
|
||||
{
|
||||
Directory.CreateDirectory(destPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Local header: its name/extra lengths can differ from the central
|
||||
// directory's, so read them from the local header itself.
|
||||
fs.Seek(e.LocalHeaderOffset, SeekOrigin.Begin);
|
||||
var lh = new byte[30];
|
||||
ReadExact(fs, lh, 30);
|
||||
if (!(lh[0] == 0x50 && lh[1] == 0x4B && lh[2] == 0x03 && lh[3] == 0x04))
|
||||
throw new IOException("Corrupt local header for entry '" + e.Name + "'.");
|
||||
int lNameLen = BitConverter.ToUInt16(lh, 26);
|
||||
int lExtraLen = BitConverter.ToUInt16(lh, 28);
|
||||
fs.Seek(lNameLen + lExtraLen, SeekOrigin.Current);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destPath));
|
||||
using (var outFs = File.Create(destPath))
|
||||
{
|
||||
if (e.Method == 0) // stored
|
||||
{
|
||||
CopyExactly(fs, outFs, e.CompressedSize);
|
||||
}
|
||||
else if (e.Method == 8) // deflate
|
||||
{
|
||||
var limited = new LimitedReadStream(fs, e.CompressedSize);
|
||||
using (var inflate = new DeflateStream(limited, CompressionMode.Decompress, leaveOpen: true))
|
||||
inflate.CopyTo(outFs);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new IOException(string.Format(
|
||||
"Unsupported compression method {0} for entry '{1}'.", e.Method, e.Name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyExactly(Stream src, Stream dst, long count)
|
||||
{
|
||||
var buffer = new byte[65536];
|
||||
while (count > 0)
|
||||
{
|
||||
int n = src.Read(buffer, 0, (int)Math.Min(buffer.Length, count));
|
||||
if (n == 0) throw new IOException("Unexpected end of archive.");
|
||||
dst.Write(buffer, 0, n);
|
||||
count -= n;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadExact(Stream stream, byte[] buf, int count)
|
||||
{
|
||||
int off = 0;
|
||||
while (off < count)
|
||||
{
|
||||
int n = stream.Read(buf, off, count - off);
|
||||
if (n == 0) throw new IOException("Unexpected end of archive.");
|
||||
off += n;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Caps reads at N bytes of the underlying stream so DeflateStream's
|
||||
/// read-ahead cannot consume the next entry's bytes. Does not own the inner
|
||||
/// stream.</summary>
|
||||
private sealed class LimitedReadStream : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
private long _remaining;
|
||||
|
||||
public LimitedReadStream(Stream inner, long limit)
|
||||
{
|
||||
_inner = inner;
|
||||
_remaining = limit;
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (_remaining <= 0) return 0;
|
||||
int n = _inner.Read(buffer, offset, (int)Math.Min(count, _remaining));
|
||||
_remaining -= n;
|
||||
return n;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanWrite => false;
|
||||
public override bool CanSeek => false;
|
||||
public override void Flush() { }
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,33 @@
|
||||
# TeslaLauncher
|
||||
# TeslaLauncher (XP11 — single binary)
|
||||
|
||||
.NET Framework 4.8 (framework-dependent) rewrite of the original Elsewhen Studios LLC software (Windows 2000 / .NET Framework 2.0). net48 ships in Windows 10/11, so the pod needs no separate runtime install and the package stays small (~3.7 MB).
|
||||
.NET Framework **4.0** (framework-dependent) rewrite of the original Elsewhen Studios LLC
|
||||
software. One `TeslaLauncher.exe` runs on **Windows XP SP3 through Windows 11**:
|
||||
net40 is the newest framework XP can install, and net40 assemblies load in-place on the
|
||||
4.8 runtime built into Windows 10/11. XP pods need the .NET 4.0 redistributable
|
||||
(installed automatically by `install.bat` when `dotnet40\` is in the package);
|
||||
Win10/11 pods need nothing extra.
|
||||
|
||||
## Architecture
|
||||
|
||||
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`.
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -1,989 +0,0 @@
|
||||
// =============================================================================
|
||||
// TeslaLauncher — Userspace Agent
|
||||
// =============================================================================
|
||||
// Runs as a WinForms tray application in the logged-in user's desktop session.
|
||||
// On first boot (unconfigured machine), displays a splash with the SecureConfig
|
||||
// Request ID and Passphrase so the operator can read them to the console.
|
||||
// On subsequent boots, listens on a Named Pipe for commands from
|
||||
// TeslaLauncherService and manages simulation applications.
|
||||
// =============================================================================
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.IO.IsolatedStorage;
|
||||
using System.IO.Pipes;
|
||||
using System.Linq;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using Tesla.Launcher.Shared;
|
||||
|
||||
namespace Tesla.Launcher.Agent
|
||||
{
|
||||
public class AgentApplication : Form
|
||||
{
|
||||
// ── Configuration ─────────────────────────────────────────────────────
|
||||
private const string PIPE_NAME = "TeslaLauncherIPC";
|
||||
private const string CONFIG_FILE = @"C:\ProgramData\TeslaLauncher\LaunchApps.xml";
|
||||
private const string GAMES_DIR = @"C:\Games";
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private NotifyIcon _trayIcon;
|
||||
private ContextMenuStrip _trayMenu;
|
||||
|
||||
private List<LaunchData> _launchApps = new();
|
||||
private readonly Dictionary<string, List<Process>> _runningProcesses = new();
|
||||
private readonly object _processLock = new();
|
||||
private readonly List<Thread> _watcherThreads = new();
|
||||
private bool _stopping = false;
|
||||
|
||||
private readonly Dictionary<string, OutOfBandProgress> _installProgress = new();
|
||||
private readonly object _installLock = new();
|
||||
|
||||
private Thread _pipeThread;
|
||||
private CancellationTokenSource _cts = new();
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────
|
||||
|
||||
[STAThread]
|
||||
public static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
|
||||
// First-boot check: show the configuring splash (with the SecureConfig
|
||||
// Request ID + Passphrase) when the machine is unconfigured OR when
|
||||
// SecureConfig is actively running.
|
||||
//
|
||||
// The second condition is essential: during SecureConfig the Service
|
||||
// assigns a TEMPORARY static IP to the Ethernet adapter so it can
|
||||
// broadcast. That makes IsMachineConfigured() report true, so the DHCP
|
||||
// check ALONE would skip the splash and the codes would never reach the
|
||||
// screen — they'd only be in the log / on the COM2 plasma display. The
|
||||
// Service writes configuring.json *before* assigning that temp IP, so its
|
||||
// presence is the authoritative "SecureConfig in progress" signal.
|
||||
if (!IsMachineConfigured() || File.Exists(ConfiguringFilePath()))
|
||||
{
|
||||
ShowConfiguringWait(); // blocks until config completes or form closed
|
||||
|
||||
// If still not configured (config failed / user closed splash), exit.
|
||||
if (!IsMachineConfigured())
|
||||
return;
|
||||
}
|
||||
|
||||
Application.Run(new AgentApplication());
|
||||
}
|
||||
|
||||
// ── Secure Configuration ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the cockpit has already been configured —
|
||||
/// detected by the Ethernet adapter having a static IP assignment.
|
||||
/// SecureConfiguration writes the permanent address via netsh with
|
||||
/// no DHCP, so a static adapter means configuration is complete.
|
||||
/// A DHCP adapter (or no adapter) means we are on a fresh machine
|
||||
/// and need to run the SecureConfiguration protocol.
|
||||
/// </summary>
|
||||
private static bool IsMachineConfigured()
|
||||
{
|
||||
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
|
||||
if (nic.OperationalStatus != OperationalStatus.Up) continue;
|
||||
if (IsVirtualAdapter(nic)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var ipv4 = nic.GetIPProperties().GetIPv4Properties();
|
||||
// IsDhcpEnabled == false → static IP → already configured
|
||||
if (!ipv4.IsDhcpEnabled)
|
||||
return true;
|
||||
}
|
||||
catch (NetworkInformationException)
|
||||
{
|
||||
// GetIPv4Properties() throws if IPv4 is not configured at all;
|
||||
// treat that the same as DHCP (i.e. not yet configured).
|
||||
}
|
||||
}
|
||||
return false; // all Ethernet adapters are DHCP or absent → needs configuration
|
||||
}
|
||||
|
||||
/// <summary>Shared file the Service writes (before assigning the temp IP) while
|
||||
/// SecureConfig is in progress; holds the RequestId/Passphrase for the splash.</summary>
|
||||
private static string ConfiguringFilePath() => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"TeslaLauncher", "configuring.json");
|
||||
|
||||
private static bool IsVirtualAdapter(NetworkInterface nic)
|
||||
{
|
||||
var desc = nic.Description ?? "";
|
||||
var name = nic.Name ?? "";
|
||||
string[] markers = {
|
||||
"Virtual", "Hyper-V", "VMware", "VirtualBox",
|
||||
"Loopback", "Tunnel", "Miniport", "Wi-Fi Direct",
|
||||
"Bluetooth", "WAN Miniport", "Microsoft Kernel Debug"
|
||||
};
|
||||
foreach (var m in markers)
|
||||
if (desc.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
return true;
|
||||
var mac = nic.GetPhysicalAddress().GetAddressBytes();
|
||||
if (mac.Length == 6 && (mac[0] & 0x02) != 0) return true; // locally-administered MAC
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ShowConfiguringWait()
|
||||
{
|
||||
// The Service handles SecureConfig in Session 0. The Agent shows
|
||||
// a splash with the RequestId and Passphrase for the operator.
|
||||
using var form = new Form
|
||||
{
|
||||
Text = "Tesla Cockpit Configuration",
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog,
|
||||
StartPosition = FormStartPosition.CenterScreen,
|
||||
ClientSize = new System.Drawing.Size(480, 260),
|
||||
MaximizeBox = false,
|
||||
MinimizeBox = false,
|
||||
TopMost = true,
|
||||
};
|
||||
|
||||
var lblStatus = new Label
|
||||
{
|
||||
Text = "This cockpit is being configured.\r\n" +
|
||||
"Configuration will complete automatically.",
|
||||
AutoSize = false,
|
||||
Dock = DockStyle.Top,
|
||||
Height = 50,
|
||||
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
|
||||
Font = new System.Drawing.Font("Arial", 10f),
|
||||
Padding = new Padding(12, 8, 12, 0),
|
||||
};
|
||||
form.Controls.Add(lblStatus);
|
||||
|
||||
var lblRequestIdTitle = new Label
|
||||
{
|
||||
Text = "Request ID:",
|
||||
AutoSize = false,
|
||||
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
|
||||
Font = new System.Drawing.Font("Arial", 11f),
|
||||
Top = 60,
|
||||
Left = 0,
|
||||
Width = 480,
|
||||
Height = 25,
|
||||
};
|
||||
form.Controls.Add(lblRequestIdTitle);
|
||||
|
||||
var lblRequestId = new Label
|
||||
{
|
||||
Text = "waiting...",
|
||||
AutoSize = false,
|
||||
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
|
||||
Font = new System.Drawing.Font("Consolas", 28f, System.Drawing.FontStyle.Bold),
|
||||
ForeColor = System.Drawing.Color.DarkBlue,
|
||||
Top = 85,
|
||||
Left = 0,
|
||||
Width = 480,
|
||||
Height = 45,
|
||||
};
|
||||
form.Controls.Add(lblRequestId);
|
||||
|
||||
var lblPassphraseTitle = new Label
|
||||
{
|
||||
Text = "Passphrase:",
|
||||
AutoSize = false,
|
||||
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
|
||||
Font = new System.Drawing.Font("Arial", 11f),
|
||||
Top = 140,
|
||||
Left = 0,
|
||||
Width = 480,
|
||||
Height = 25,
|
||||
};
|
||||
form.Controls.Add(lblPassphraseTitle);
|
||||
|
||||
var lblPassphrase = new Label
|
||||
{
|
||||
Text = "waiting...",
|
||||
AutoSize = false,
|
||||
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
|
||||
Font = new System.Drawing.Font("Consolas", 28f, System.Drawing.FontStyle.Bold),
|
||||
ForeColor = System.Drawing.Color.DarkRed,
|
||||
Top = 165,
|
||||
Left = 0,
|
||||
Width = 480,
|
||||
Height = 45,
|
||||
};
|
||||
form.Controls.Add(lblPassphrase);
|
||||
|
||||
var lblHint = new Label
|
||||
{
|
||||
Text = "Read the Passphrase to the console operator.",
|
||||
AutoSize = false,
|
||||
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
|
||||
Font = new System.Drawing.Font("Arial", 9f, System.Drawing.FontStyle.Italic),
|
||||
ForeColor = System.Drawing.Color.Gray,
|
||||
Top = 220,
|
||||
Left = 0,
|
||||
Width = 480,
|
||||
Height = 25,
|
||||
};
|
||||
form.Controls.Add(lblHint);
|
||||
|
||||
string lastJson = null;
|
||||
bool sawFile = false;
|
||||
var cfgFile = ConfiguringFilePath();
|
||||
|
||||
// Poll every 2 s — read codes from the Service's shared file.
|
||||
// The file lifecycle:
|
||||
// 1. Service deletes stale file on startup
|
||||
// 2. Service writes fresh file with new codes (before the temp IP)
|
||||
// 3. Agent reads and displays codes
|
||||
// 4. Service deletes file when config is complete → splash closes
|
||||
// If the Agent started before the file appeared, it keeps waiting; it
|
||||
// closes once the file we saw is gone, or the machine becomes configured
|
||||
// (covers the case where config finished before we managed to read it).
|
||||
var timer = new System.Windows.Forms.Timer { Interval = 2000 };
|
||||
timer.Tick += (s, e) =>
|
||||
{
|
||||
if (File.Exists(cfgFile))
|
||||
{
|
||||
sawFile = true;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(cfgFile);
|
||||
if (json != lastJson)
|
||||
{
|
||||
lastJson = json;
|
||||
var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("RequestId", out var rid))
|
||||
lblRequestId.Text = rid.GetString();
|
||||
if (root.TryGetProperty("Passphrase", out var pp))
|
||||
lblPassphrase.Text = pp.GetString();
|
||||
}
|
||||
}
|
||||
catch { /* file may be mid-write, retry next tick */ }
|
||||
}
|
||||
else if (sawFile || IsMachineConfigured())
|
||||
{
|
||||
// File gone after we saw it, or the machine is now configured →
|
||||
// SecureConfig is complete.
|
||||
timer.Stop();
|
||||
form.Close();
|
||||
}
|
||||
};
|
||||
timer.Start();
|
||||
|
||||
Application.Run(form);
|
||||
}
|
||||
|
||||
// ── Form / tray setup ─────────────────────────────────────────────────
|
||||
|
||||
public AgentApplication()
|
||||
{
|
||||
ShowInTaskbar = false;
|
||||
WindowState = FormWindowState.Minimized;
|
||||
Opacity = 0;
|
||||
|
||||
BuildTrayIcon();
|
||||
LoadLaunchApps();
|
||||
StartPipeServer();
|
||||
}
|
||||
|
||||
private void BuildTrayIcon()
|
||||
{
|
||||
_trayMenu = new ContextMenuStrip();
|
||||
var version = typeof(AgentApplication).Assembly.GetName().Version;
|
||||
_trayMenu.Items.Add($"Tesla Launcher Agent v{version}", null, null).Enabled = false;
|
||||
_trayMenu.Items.Add(new ToolStripSeparator());
|
||||
_trayMenu.Items.Add("Reload Config", null, (s, e) => LoadLaunchApps());
|
||||
_trayMenu.Items.Add("Kill All Apps", null, (s, e) => CmdKillAllApps());
|
||||
_trayMenu.Items.Add(new ToolStripSeparator());
|
||||
_trayMenu.Items.Add("Exit", null, (s, e) => ExitAgent());
|
||||
|
||||
_trayIcon = new NotifyIcon
|
||||
{
|
||||
Text = "Tesla Launcher Agent",
|
||||
Icon = SystemIcons.Application,
|
||||
ContextMenuStrip = _trayMenu,
|
||||
Visible = true
|
||||
};
|
||||
}
|
||||
|
||||
private void ExitAgent()
|
||||
{
|
||||
_stopping = true;
|
||||
_cts.Cancel();
|
||||
_trayIcon.Visible = false;
|
||||
Application.Exit();
|
||||
}
|
||||
|
||||
// ── LaunchApps.xml ────────────────────────────────────────────────────
|
||||
|
||||
private void LoadLaunchApps()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CONFIG_FILE))
|
||||
{
|
||||
_launchApps = new List<LaunchData>();
|
||||
SetTrayStatus("Config not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var xml = new System.Xml.Serialization.XmlSerializer(typeof(List<LaunchData>));
|
||||
using var reader = File.OpenRead(CONFIG_FILE);
|
||||
_launchApps = (List<LaunchData>)xml.Deserialize(reader)
|
||||
?? new List<LaunchData>();
|
||||
|
||||
SetTrayStatus($"{_launchApps.Count} apps configured");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetTrayStatus($"Config error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveLaunchApps()
|
||||
{
|
||||
var xml = new System.Xml.Serialization.XmlSerializer(typeof(List<LaunchData>));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(CONFIG_FILE)!);
|
||||
using var writer = File.CreateText(CONFIG_FILE);
|
||||
xml.Serialize(writer, _launchApps);
|
||||
}
|
||||
|
||||
private void SetTrayStatus(string status)
|
||||
{
|
||||
if (_trayIcon != null) _trayIcon.Text = $"Tesla Launcher: {status}";
|
||||
}
|
||||
|
||||
// ── Named Pipe Server ─────────────────────────────────────────────────
|
||||
|
||||
private void StartPipeServer()
|
||||
{
|
||||
_pipeThread = new Thread(PipeServerLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "TeslaLauncherPipeServer"
|
||||
};
|
||||
_pipeThread.Start();
|
||||
}
|
||||
|
||||
private void PipeServerLoop()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var server = new NamedPipeServerStream(
|
||||
PIPE_NAME,
|
||||
PipeDirection.InOut,
|
||||
NamedPipeServerStream.MaxAllowedServerInstances,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
server.WaitForConnectionAsync(ct).GetAwaiter().GetResult();
|
||||
HandlePipeRequest(server);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception)
|
||||
{
|
||||
if (!ct.IsCancellationRequested) Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandlePipeRequest(NamedPipeServerStream pipe)
|
||||
{
|
||||
IpcResponse response;
|
||||
try
|
||||
{
|
||||
var lenBuf = new byte[4];
|
||||
ReadExact(pipe, lenBuf);
|
||||
var reqBuf = new byte[BitConverter.ToInt32(lenBuf, 0)];
|
||||
ReadExact(pipe, reqBuf);
|
||||
|
||||
var msg = JsonSerializer.Deserialize<IpcMessage>(
|
||||
Encoding.UTF8.GetString(reqBuf));
|
||||
|
||||
response = ExecuteCommand(msg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
response = new IpcResponse { Success = false, Message = ex.Message };
|
||||
}
|
||||
|
||||
var resBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(response));
|
||||
pipe.Write(BitConverter.GetBytes(resBytes.Length), 0, 4);
|
||||
pipe.Write(resBytes, 0, resBytes.Length);
|
||||
pipe.Flush();
|
||||
}
|
||||
|
||||
private static void ReadExact(Stream stream, byte[] buf)
|
||||
{
|
||||
var off = 0;
|
||||
while (off < buf.Length)
|
||||
{
|
||||
var n = stream.Read(buf, off, buf.Length - off);
|
||||
if (n == 0) throw new IOException("Pipe closed.");
|
||||
off += n;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command Dispatcher ────────────────────────────────────────────────
|
||||
|
||||
private IpcResponse ExecuteCommand(IpcMessage msg)
|
||||
{
|
||||
if (msg == null) return Fail("Null message received.");
|
||||
|
||||
try
|
||||
{
|
||||
object result = msg.Command switch
|
||||
{
|
||||
"PING" => (object)null,
|
||||
"CLEARSTORE" => CmdClearStore(),
|
||||
"LAUNCHAPP" => CmdLaunchApp(msg),
|
||||
"KILLAPP" => CmdKillApp(msg),
|
||||
"KILLALLOFTYPE" => CmdKillAllOfType(msg),
|
||||
"KILLALLAPPS" => CmdKillAllApps(),
|
||||
"GETLAUNCHEDAPPS" => CmdGetLaunchedApps(),
|
||||
"GETLAUNCHABLEAPPS" => CmdGetLaunchableApps(),
|
||||
"GETINSTALLEDAPPS" => CmdGetLaunchableApps(),
|
||||
"REMOVEAPP" => CmdRemoveApp(msg),
|
||||
"INSTALLAPP" => CmdInstallApp(msg),
|
||||
"UNINSTALLAPP" => CmdUninstallApp(msg),
|
||||
"GET_VOLUMELEVEL" => CmdGetVolumeLevel(),
|
||||
"SET_VOLUMELEVEL" => CmdSetVolumeLevel(msg),
|
||||
"FULLUPDATE" => CmdFullUpdate(),
|
||||
"INITIATEINSTALLPRODUCT" => CmdInitiateInstallProduct(msg),
|
||||
"GETOUTOFBANDPROGRESS" => CmdGetOutOfBandProgress(msg),
|
||||
"SHUTDOWN" => CmdShutdown(msg),
|
||||
_ => throw new Exception($"Unknown command: {msg.Command}")
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command Implementations ───────────────────────────────────────────
|
||||
|
||||
private object CmdClearStore()
|
||||
{
|
||||
var store = IsolatedStorageFile.GetMachineStoreForAssembly();
|
||||
foreach (var file in store.GetFileNames("*"))
|
||||
store.DeleteFile(file);
|
||||
LoadLaunchApps();
|
||||
return null;
|
||||
}
|
||||
|
||||
private object CmdLaunchApp(IpcMessage msg)
|
||||
{
|
||||
var key = msg.LaunchKey
|
||||
?? throw new Exception("LaunchApp requires a LaunchKey");
|
||||
|
||||
var app = FindApp(key)
|
||||
?? throw new Exception($"No app configured for key '{key}'");
|
||||
|
||||
// A launch entry can be registered (pushed from the Console) before its game
|
||||
// files are installed. Fail cleanly with an actionable message rather than
|
||||
// surfacing a raw Win32 "file not found" from Process.Start.
|
||||
if (string.IsNullOrWhiteSpace(app.ExeFile) || !File.Exists(app.ExeFile))
|
||||
throw new Exception(
|
||||
$"Cannot launch '{app.DisplayName ?? key}': executable not found at " +
|
||||
$"'{app.ExeFile}'. The product may be registered but not yet installed on this pod.");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = app.ExeFile,
|
||||
Arguments = app.Arguments ?? "",
|
||||
WorkingDirectory = app.WorkingDirectory ?? Path.GetDirectoryName(app.ExeFile),
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
var process = Process.Start(psi)
|
||||
?? throw new Exception($"Process.Start returned null for '{app.ExeFile}'");
|
||||
|
||||
lock (_processLock)
|
||||
{
|
||||
if (!_runningProcesses.ContainsKey(key))
|
||||
_runningProcesses[key] = new List<Process>();
|
||||
_runningProcesses[key].Add(process);
|
||||
}
|
||||
|
||||
if (app.AutoRestart) StartWatcher(app, process);
|
||||
|
||||
SetTrayStatus($"Running: {app.DisplayName ?? key}");
|
||||
return new LaunchedAppData { LaunchKey = key, ProcessId = process.Id };
|
||||
}
|
||||
|
||||
private object CmdKillApp(IpcMessage msg)
|
||||
{
|
||||
var key = msg.LaunchKey;
|
||||
int? pid = null;
|
||||
|
||||
if (msg.PayloadJson != null)
|
||||
{
|
||||
var parms = JsonSerializer.Deserialize<object[]>(msg.PayloadJson);
|
||||
if (parms?.Length > 1 && parms[1] is JsonElement je)
|
||||
pid = je.ValueKind == JsonValueKind.Number ? je.GetInt32() : (int?)null;
|
||||
}
|
||||
|
||||
lock (_processLock)
|
||||
{
|
||||
if (!_runningProcesses.TryGetValue(key ?? "", out var procs)) return null;
|
||||
|
||||
var toKill = pid.HasValue
|
||||
? procs.Where(p => p.Id == pid.Value).ToList()
|
||||
: procs.ToList();
|
||||
|
||||
foreach (var p in toKill)
|
||||
{
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
procs.Remove(p);
|
||||
}
|
||||
|
||||
if (procs.Count == 0) _runningProcesses.Remove(key ?? "");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private object CmdKillAllOfType(IpcMessage msg)
|
||||
{
|
||||
var key = msg.LaunchKey ?? throw new Exception("KillAllOfType requires LaunchKey");
|
||||
|
||||
lock (_processLock)
|
||||
{
|
||||
if (_runningProcesses.TryGetValue(key, out var procs))
|
||||
{
|
||||
foreach (var p in procs)
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
_runningProcesses.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private object CmdKillAllApps()
|
||||
{
|
||||
lock (_processLock)
|
||||
{
|
||||
foreach (var kvp in _runningProcesses)
|
||||
foreach (var p in kvp.Value)
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
_runningProcesses.Clear();
|
||||
}
|
||||
|
||||
SetTrayStatus("All apps stopped");
|
||||
return null;
|
||||
}
|
||||
|
||||
private object CmdGetLaunchedApps()
|
||||
{
|
||||
var result = new List<LaunchedAppData>();
|
||||
|
||||
lock (_processLock)
|
||||
{
|
||||
foreach (var kvp in _runningProcesses)
|
||||
{
|
||||
kvp.Value.RemoveAll(p => p.HasExited);
|
||||
|
||||
foreach (var p in kvp.Value)
|
||||
result.Add(new LaunchedAppData
|
||||
{
|
||||
LaunchKey = kvp.Key,
|
||||
ProcessId = p.Id
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var key in _runningProcesses.Keys
|
||||
.Where(k => _runningProcesses[k].Count == 0).ToList())
|
||||
_runningProcesses.Remove(key);
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private object CmdGetLaunchableApps() => _launchApps.ToArray();
|
||||
|
||||
private object CmdRemoveApp(IpcMessage msg)
|
||||
{
|
||||
var key = msg.LaunchKey ?? throw new Exception("RemoveApp requires LaunchKey");
|
||||
_launchApps.RemoveAll(a => a.LaunchKey == key);
|
||||
SaveLaunchApps();
|
||||
return null;
|
||||
}
|
||||
|
||||
private object CmdInstallApp(IpcMessage msg)
|
||||
{
|
||||
if (msg.PayloadJson == null)
|
||||
throw new Exception("InstallApp requires PayloadJson");
|
||||
|
||||
var parms = JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson);
|
||||
if (parms == null || parms.Length == 0)
|
||||
throw new Exception("InstallApp: no parameters");
|
||||
|
||||
var appData = JsonSerializer.Deserialize<LaunchData>(parms[0].GetRawText())
|
||||
?? throw new Exception("InstallApp: could not deserialize LaunchData");
|
||||
|
||||
var existing = _launchApps.FindIndex(a => a.LaunchKey == appData.LaunchKey);
|
||||
if (existing >= 0) _launchApps[existing] = appData;
|
||||
else _launchApps.Add(appData);
|
||||
|
||||
SaveLaunchApps();
|
||||
return null;
|
||||
}
|
||||
|
||||
private object CmdUninstallApp(IpcMessage msg)
|
||||
{
|
||||
var key = msg.LaunchKey ?? throw new Exception("UninstallApp requires LaunchKey");
|
||||
CmdKillAllOfType(msg);
|
||||
|
||||
var removed = _launchApps.FirstOrDefault(a => a.LaunchKey == key);
|
||||
_launchApps.RemoveAll(a => a.LaunchKey == key);
|
||||
SaveLaunchApps();
|
||||
|
||||
// If the removed app's product directory under C:\Games is no longer used by
|
||||
// any remaining registered entry, tell the Service (which runs as SYSTEM) to
|
||||
// run its pre-uninstall.bat and delete the directory. The orphan check keeps a
|
||||
// product with several launch entries sharing one folder (e.g. Red Planet's
|
||||
// GameClient/LC/MR) intact until its LAST entry is removed.
|
||||
string cleanupDir = null;
|
||||
if (removed != null)
|
||||
{
|
||||
var dir = ProductDirUnderGames(removed.WorkingDirectory)
|
||||
?? ProductDirUnderGames(removed.ExeFile);
|
||||
if (dir != null && !_launchApps.Any(a => string.Equals(
|
||||
ProductDirUnderGames(a.WorkingDirectory) ?? ProductDirUnderGames(a.ExeFile),
|
||||
dir, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
cleanupDir = dir;
|
||||
}
|
||||
}
|
||||
return new { CleanupDir = cleanupDir };
|
||||
}
|
||||
|
||||
/// <summary>The immediate child of C:\Games that contains <paramref name="path"/>
|
||||
/// (e.g. C:\Games\RIOJoy\app\x.exe → C:\Games\RIOJoy), or null if not under C:\Games.</summary>
|
||||
private static string ProductDirUnderGames(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
string full;
|
||||
try { full = Path.GetFullPath(path); } catch { return null; }
|
||||
const string games = @"C:\Games\";
|
||||
if (!full.StartsWith(games, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
var seg = full.Substring(games.Length).Split('\\', '/')[0];
|
||||
return string.IsNullOrEmpty(seg) ? null : games + seg;
|
||||
}
|
||||
|
||||
private object CmdGetVolumeLevel()
|
||||
{
|
||||
return GetMasterVolume();
|
||||
}
|
||||
|
||||
private object CmdSetVolumeLevel(IpcMessage msg)
|
||||
{
|
||||
if (msg.PayloadJson == null)
|
||||
throw new Exception("set_VolumeLevel requires parameters");
|
||||
|
||||
var parms = JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson);
|
||||
if (parms == null || parms.Length == 0)
|
||||
throw new Exception("Invalid volume value");
|
||||
var scalar = parms[0].ValueKind == JsonValueKind.Number
|
||||
? (float)parms[0].GetDouble()
|
||||
: throw new Exception("Invalid volume value");
|
||||
SetMasterVolume(Math.Max(0f, Math.Min(1f, scalar)));
|
||||
return null;
|
||||
}
|
||||
|
||||
private object CmdFullUpdate() =>
|
||||
new FullUpdateData
|
||||
{
|
||||
InstalledApps = _launchApps.ToArray(),
|
||||
LaunchedApps = (LaunchedAppData[])CmdGetLaunchedApps(),
|
||||
VolumeLevel = GetMasterVolume()
|
||||
};
|
||||
|
||||
private object CmdInitiateInstallProduct(IpcMessage msg)
|
||||
{
|
||||
// ILauncherService.InitiateInstallProduct() takes no parameters.
|
||||
// It just returns a tracking Guid. The actual game registration
|
||||
// happens via InstallApp(LaunchData) in a separate RPC call.
|
||||
var callId = Guid.NewGuid().ToString("N");
|
||||
var progress = new OutOfBandProgress
|
||||
{
|
||||
PercentComplete = 100,
|
||||
Status = "Complete",
|
||||
IsCompleted = true
|
||||
};
|
||||
|
||||
lock (_installLock) _installProgress[callId] = progress;
|
||||
|
||||
return callId;
|
||||
}
|
||||
|
||||
private object CmdGetOutOfBandProgress(IpcMessage msg)
|
||||
{
|
||||
var callId = msg.PayloadJson != null
|
||||
? JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson)?[0].GetString()
|
||||
: null;
|
||||
|
||||
if (callId == null)
|
||||
throw new Exception("GetOutOfBandProgress requires a callId");
|
||||
|
||||
lock (_installLock)
|
||||
{
|
||||
if (_installProgress.TryGetValue(callId, out var prog)) return prog;
|
||||
}
|
||||
|
||||
return new OutOfBandProgress
|
||||
{
|
||||
PercentComplete = 0,
|
||||
Status = "Unknown call ID",
|
||||
IsCompleted = true
|
||||
};
|
||||
}
|
||||
|
||||
private object CmdShutdown(IpcMessage msg)
|
||||
{
|
||||
bool doRestart = false;
|
||||
if (msg.PayloadJson != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parms = JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson);
|
||||
if (parms?.Length > 0 && parms[0].ValueKind == JsonValueKind.True)
|
||||
doRestart = true;
|
||||
}
|
||||
catch { /* default to shutdown */ }
|
||||
}
|
||||
|
||||
CmdKillAllApps();
|
||||
var flag = doRestart ? "/r" : "/s";
|
||||
new Thread(() =>
|
||||
{
|
||||
Thread.Sleep(2000);
|
||||
Process.Start("shutdown", $"{flag} /t 0");
|
||||
}) { IsBackground = true }.Start();
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private LaunchData FindApp(string launchKey) =>
|
||||
_launchApps.FirstOrDefault(a =>
|
||||
string.Equals(a.LaunchKey, launchKey, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private void StartWatcher(LaunchData app, Process process)
|
||||
{
|
||||
new Thread(() =>
|
||||
{
|
||||
process.WaitForExit();
|
||||
if (_stopping) return;
|
||||
|
||||
bool stillTracked;
|
||||
lock (_processLock)
|
||||
{
|
||||
stillTracked = _runningProcesses.TryGetValue(
|
||||
app.LaunchKey, out var procs) && procs.Contains(process);
|
||||
}
|
||||
|
||||
if (!stillTracked) return;
|
||||
|
||||
Thread.Sleep(2000);
|
||||
try { CmdLaunchApp(new IpcMessage { LaunchKey = app.LaunchKey }); }
|
||||
catch { }
|
||||
})
|
||||
{ IsBackground = true, Name = $"Watcher-{app.LaunchKey}" }.Start();
|
||||
}
|
||||
|
||||
// ── Volume Control ────────────────────────────────────────────────────
|
||||
// The Console stores/retrieves volume as a float (0.0–1.0 scalar).
|
||||
// We cache the exact value the Console sent so get returns the same
|
||||
// value without CoreAudio roundtrip quantization error.
|
||||
|
||||
private float _cachedVolumeScalar = 1.0f;
|
||||
|
||||
private float GetMasterVolume()
|
||||
{
|
||||
return _cachedVolumeScalar;
|
||||
}
|
||||
|
||||
private void SetMasterVolume(float scalar)
|
||||
{
|
||||
_cachedVolumeScalar = scalar;
|
||||
|
||||
// Try nircmd.exe first (legacy compatibility)
|
||||
var nircmd = Path.Combine(GAMES_DIR, "nircmd.exe");
|
||||
if (File.Exists(nircmd))
|
||||
{
|
||||
Process.Start(nircmd, $"setsysvolume {(int)(scalar * 65535)}")?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows Core Audio API fallback (Vista+, no external dependencies)
|
||||
CoreAudio.SetMasterScalar(scalar);
|
||||
}
|
||||
|
||||
// ── Response helpers ──────────────────────────────────────────────────
|
||||
|
||||
private static IpcResponse Ok(object data = null) => new() { Success = true, Data = data };
|
||||
private static IpcResponse Fail(string msg) => new() { Success = false, Message = msg };
|
||||
|
||||
protected override void OnLoad(EventArgs e)
|
||||
{
|
||||
base.OnLoad(e);
|
||||
Visible = false;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) { _trayIcon?.Dispose(); _trayMenu?.Dispose(); }
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent-local data types ────────────────────────────────────────────────
|
||||
// These are used internally by the Agent (XML config, process management).
|
||||
// They are NOT the BinaryFormatter wire types (Tesla.Net namespace).
|
||||
|
||||
/// <summary>
|
||||
/// Agent-internal representation of a launchable app.
|
||||
/// Loaded from LaunchApps.xml via XmlSerializer.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class LaunchData
|
||||
{
|
||||
public string LaunchKey { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
public string WorkingDirectory { get; set; }
|
||||
public string ExeFile { get; set; }
|
||||
public string Arguments { get; set; }
|
||||
public bool AutoRestart { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent-internal tracking of a running process.
|
||||
/// Returned via JSON IPC (string LaunchKey, not Guid).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class LaunchedAppData
|
||||
{
|
||||
public string LaunchKey { get; set; }
|
||||
public int ProcessId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent-internal full state snapshot.
|
||||
/// Returned via JSON IPC for the FullUpdate command.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class FullUpdateData
|
||||
{
|
||||
public LaunchData[] InstalledApps { get; set; }
|
||||
public LaunchedAppData[] LaunchedApps { get; set; }
|
||||
public float VolumeLevel { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class OutOfBandProgress
|
||||
{
|
||||
public int PercentComplete { get; set; }
|
||||
public string Status { get; set; }
|
||||
public bool IsCompleted { get; set; }
|
||||
}
|
||||
|
||||
// ── Windows Core Audio API — minimal COM interop (Vista+) ─────────────────
|
||||
// No external dependencies. Vtable slot order matches the SDK headers exactly.
|
||||
// Methods we don't call are declared as void stubs to preserve vtable offsets.
|
||||
|
||||
internal static class CoreAudio
|
||||
{
|
||||
internal static float GetMasterScalar()
|
||||
{
|
||||
var ep = GetEndpointVolume();
|
||||
try { ep.GetMasterVolumeLevelScalar(out float v); return v; }
|
||||
finally { ReleaseAll(ep); }
|
||||
}
|
||||
|
||||
internal static void SetMasterScalar(float scalar)
|
||||
{
|
||||
var ep = GetEndpointVolume();
|
||||
try { var ctx = Guid.Empty; ep.SetMasterVolumeLevelScalar(scalar, ref ctx); }
|
||||
finally { ReleaseAll(ep); }
|
||||
}
|
||||
|
||||
private static IAudioEndpointVolume GetEndpointVolume()
|
||||
{
|
||||
var enumerator = (IMMDeviceEnumerator)new MMAudioEnumeratorComClass();
|
||||
try
|
||||
{
|
||||
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out var device);
|
||||
try
|
||||
{
|
||||
var iid = typeof(IAudioEndpointVolume).GUID;
|
||||
device.Activate(ref iid, 23 /*CLSCTX_ALL*/, IntPtr.Zero, out var obj);
|
||||
return (IAudioEndpointVolume)obj;
|
||||
}
|
||||
finally { Marshal.ReleaseComObject(device); }
|
||||
}
|
||||
finally { Marshal.ReleaseComObject(enumerator); }
|
||||
}
|
||||
|
||||
private static void ReleaseAll(object obj)
|
||||
{
|
||||
if (obj != null) Marshal.ReleaseComObject(obj);
|
||||
}
|
||||
}
|
||||
|
||||
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
||||
internal class MMAudioEnumeratorComClass { }
|
||||
|
||||
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMMDeviceEnumerator
|
||||
{
|
||||
void _unused_EnumAudioEndpoints(); // slot 0 — not used
|
||||
[PreserveSig]
|
||||
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
|
||||
}
|
||||
|
||||
[Guid("D666063F-1587-4E43-81F1-B948E807363F"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMMDevice
|
||||
{
|
||||
[PreserveSig]
|
||||
int Activate(ref Guid iid, int clsCtx, IntPtr pActivationParams,
|
||||
[MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer);
|
||||
}
|
||||
|
||||
// Vtable order (after IUnknown): RegisterControlChangeNotify(0),
|
||||
// UnregisterControlChangeNotify(1), GetChannelCount(2),
|
||||
// SetMasterVolumeLevel(3), SetMasterVolumeLevelScalar(4),
|
||||
// GetMasterVolumeLevel(5), GetMasterVolumeLevelScalar(6)
|
||||
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IAudioEndpointVolume
|
||||
{
|
||||
void _unused_RegisterControlChangeNotify(); // slot 0
|
||||
void _unused_UnregisterControlChangeNotify(); // slot 1
|
||||
void _unused_GetChannelCount(); // slot 2
|
||||
[PreserveSig] int SetMasterVolumeLevel(float levelDB, ref Guid ctx);
|
||||
[PreserveSig] int SetMasterVolumeLevelScalar(float level, ref Guid ctx);
|
||||
[PreserveSig] int GetMasterVolumeLevel(out float levelDB);
|
||||
[PreserveSig] int GetMasterVolumeLevelScalar(out float level);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<WarningsAsErrors></WarningsAsErrors>
|
||||
<Version>4.11.4.1</Version>
|
||||
<ApplicationIcon>app.ico</ApplicationIcon>
|
||||
<AssemblyName>TeslaLauncherAgent</AssemblyName>
|
||||
<RootNamespace>Tesla.Launcher.Agent</RootNamespace>
|
||||
<StartupObject>Tesla.Launcher.Agent.AgentApplication</StartupObject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Exclude service-only source files — they belong to TeslaLauncherService.csproj -->
|
||||
<Compile Remove="TeslaLauncherService.cs" />
|
||||
<Compile Remove="SecureConfig.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||
<!-- Agent parses configuring.json + the IPC JSON; built-in on net6, a package on net48. -->
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,45 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<WarningsAsErrors></WarningsAsErrors>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Version>4.11.4.1</Version>
|
||||
<ApplicationIcon>app.ico</ApplicationIcon>
|
||||
<AssemblyName>TeslaLauncherService</AssemblyName>
|
||||
<RootNamespace>Tesla.Launcher.Service</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Exclude the Agent source — it belongs to TeslaLauncherAgent.csproj only -->
|
||||
<Compile Remove="TeslaLauncherAgent.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- net48 reference assemblies + GAC refs the framework-dependent build needs. -->
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
<Reference Include="System.ServiceProcess" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Generic host on .NET Framework: Microsoft.Extensions.Hosting(.WindowsServices)
|
||||
8.x ship a net462 target, so they run on net48 unchanged. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.EventLog" Version="8.*" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
<!-- COM2 / PlasmaIO serial output in SecureConfig.cs -->
|
||||
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Contract\Tesla.Contract.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,142 @@
|
||||
// =============================================================================
|
||||
// TeslaLauncher — system master volume (shared with vPOD)
|
||||
// =============================================================================
|
||||
// The Console's set_VolumeLevel ends here. Setter chain:
|
||||
// nircmd.exe (legacy, works everywhere incl. XP, if present in the games dir)
|
||||
// → Core Audio (Vista+) → winmm waveOutSetVolume (XP fallback).
|
||||
//
|
||||
// Like MiniZip.cs, this file is compiled into BOTH the launcher and vPOD
|
||||
// (linked source): vPOD's "Actually set system volume" mode applies the
|
||||
// console's volume commands through the exact code the real pod runs.
|
||||
// =============================================================================
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Tesla.Launcher
|
||||
{
|
||||
internal static class VolumeControl
|
||||
{
|
||||
/// <summary>Sets the system master volume to <paramref name="scalar"/>
|
||||
/// (clamped to 0.0–1.0). <paramref name="nircmdDir"/> is probed for
|
||||
/// nircmd.exe first (the legacy path used on the original pods); the
|
||||
/// Windows APIs are the fallback. Never throws.</summary>
|
||||
public static void SetMasterScalar(float scalar, string nircmdDir)
|
||||
{
|
||||
scalar = Math.Max(0f, Math.Min(1f, scalar));
|
||||
|
||||
if (!string.IsNullOrEmpty(nircmdDir))
|
||||
{
|
||||
var nircmd = Path.Combine(nircmdDir, "nircmd.exe");
|
||||
if (File.Exists(nircmd))
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = Process.Start(nircmd, "setsysvolume " + (int)(scalar * 65535));
|
||||
if (p != null) p.Dispose();
|
||||
return;
|
||||
}
|
||||
catch { /* fall through to API */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (Environment.OSVersion.Version.Major >= 6)
|
||||
{
|
||||
try { CoreAudio.SetMasterScalar(scalar); return; }
|
||||
catch { /* fall through */ }
|
||||
}
|
||||
|
||||
WinMmVolume.SetMasterScalar(scalar);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Windows Core Audio API — minimal COM interop (Vista+) ─────────────────
|
||||
// No external dependencies. Vtable slot order matches the SDK headers exactly.
|
||||
// Methods we don't call are declared as void stubs to preserve vtable offsets.
|
||||
// NOT available on XP — callers must gate on OS version.
|
||||
|
||||
internal static class CoreAudio
|
||||
{
|
||||
internal static void SetMasterScalar(float scalar)
|
||||
{
|
||||
var ep = GetEndpointVolume();
|
||||
try { var ctx = Guid.Empty; ep.SetMasterVolumeLevelScalar(scalar, ref ctx); }
|
||||
finally { Marshal.ReleaseComObject(ep); }
|
||||
}
|
||||
|
||||
private static IAudioEndpointVolume GetEndpointVolume()
|
||||
{
|
||||
var enumerator = (IMMDeviceEnumerator)new MMAudioEnumeratorComClass();
|
||||
try
|
||||
{
|
||||
IMMDevice device;
|
||||
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out device);
|
||||
try
|
||||
{
|
||||
var iid = typeof(IAudioEndpointVolume).GUID;
|
||||
object obj;
|
||||
device.Activate(ref iid, 23 /*CLSCTX_ALL*/, IntPtr.Zero, out obj);
|
||||
return (IAudioEndpointVolume)obj;
|
||||
}
|
||||
finally { Marshal.ReleaseComObject(device); }
|
||||
}
|
||||
finally { Marshal.ReleaseComObject(enumerator); }
|
||||
}
|
||||
}
|
||||
|
||||
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
||||
internal class MMAudioEnumeratorComClass { }
|
||||
|
||||
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMMDeviceEnumerator
|
||||
{
|
||||
void _unused_EnumAudioEndpoints(); // slot 0 — not used
|
||||
[PreserveSig]
|
||||
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
|
||||
}
|
||||
|
||||
[Guid("D666063F-1587-4E43-81F1-B948E807363F"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMMDevice
|
||||
{
|
||||
[PreserveSig]
|
||||
int Activate(ref Guid iid, int clsCtx, IntPtr pActivationParams,
|
||||
[MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer);
|
||||
}
|
||||
|
||||
// Vtable order (after IUnknown): RegisterControlChangeNotify(0),
|
||||
// UnregisterControlChangeNotify(1), GetChannelCount(2),
|
||||
// SetMasterVolumeLevel(3), SetMasterVolumeLevelScalar(4),
|
||||
// GetMasterVolumeLevel(5), GetMasterVolumeLevelScalar(6)
|
||||
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IAudioEndpointVolume
|
||||
{
|
||||
void _unused_RegisterControlChangeNotify(); // slot 0
|
||||
void _unused_UnregisterControlChangeNotify(); // slot 1
|
||||
void _unused_GetChannelCount(); // slot 2
|
||||
[PreserveSig] int SetMasterVolumeLevel(float levelDB, ref Guid ctx);
|
||||
[PreserveSig] int SetMasterVolumeLevelScalar(float level, ref Guid ctx);
|
||||
[PreserveSig] int GetMasterVolumeLevel(out float levelDB);
|
||||
[PreserveSig] int GetMasterVolumeLevelScalar(out float level);
|
||||
}
|
||||
|
||||
// ── winmm wave-out volume (XP fallback) ───────────────────────────────────
|
||||
// waveOutSetVolume with device -1 sets the wave mixer level of the default
|
||||
// device: low 16 bits = left channel, high 16 bits = right channel.
|
||||
|
||||
internal static class WinMmVolume
|
||||
{
|
||||
[DllImport("winmm.dll")]
|
||||
private static extern int waveOutSetVolume(IntPtr hwo, uint dwVolume);
|
||||
|
||||
internal static void SetMasterScalar(float scalar)
|
||||
{
|
||||
uint level = (uint)(Math.Max(0f, Math.Min(1f, scalar)) * 0xFFFF);
|
||||
try { waveOutSetVolume(IntPtr.Zero, (level << 16) | level); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,52 @@
|
||||
@echo off
|
||||
:: =============================================================================
|
||||
:: 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <id></c> the responding host id reported in state responses
|
||||
///
|
||||
/// vPOD-only (not a real game-client option):
|
||||
/// <c>-nomanage</c> disable the virtual launcher / site-management side
|
||||
/// (no provisioning beacons, no TCP 53290 listener)
|
||||
/// <c>-bind <ip></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;
|
||||