10 Commits
Author SHA1 Message Date
CydandClaude Fable 5 0deb8303ee Add GAME-INTEGRATION.md: the pod-bay integration guide for new games
Specs everything a game team needs to deploy and command a title in a
Tesla pod bay: package zip + Apps.xml catalog contract, launch/watchdog
semantics, the full Munga wire spec (framing, message set, state machine,
egg envelope), the dedicated game-console escape hatch, the modern bay
address plan, LC/MR presentation roles, score-sheet printing, and RIO
board integration (native preferred over the RIOJoy shim). XP support is
documented as a nice-to-have for new games; the floor remains suite-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:18:46 -05:00
CydandClaude Fable 5 8d6c1e7881 Register Descent 3 in the product catalog
Single GameClient entry (no {res}: D3 takes -width/-height, resolution
pinned to the pod main screen; no supervisor: single process). Args boot
straight into the campaign first level for phase-0 free flight; they
change to -venue once the D3_VENUE Munga build ships. LC/MR keys
...971/...972 reserved. Catalog diff tests updated (21 passing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:11:46 -05:00
CydandClaude Opus 5 ad6f566834 Document CTCL: what commanding FireStorm from the console would take
BT4/RP4 speak Munga (TCP 1501); BattleTech FireStorm is a MechWarrior 4
conversion and speaks CTCL instead - pod launcher on TCP 1000, game on
1001. Researched from the FireStorm sources at C:\VWE\firestorm and
written up in FIRESTORM-CTCL.md: roles, framing, message/order tables,
the mission handshake (including how the server pod is designated - the
console picks it positionally, there is no election), and the session
rendezvous by game-name GUID over LAN enumeration.

The console already launches FireStorm via Apps.xml; what it cannot do
is build or run a mission. Three blockers: the bit-packed NMP blob is
the MW4 engine's own MWNetMissionParameters serialization, the mech and
map identifiers (including a 64-bit map CRC) come from installed game
content, and CTCL carries no in-mission telemetry at all.

Decision recorded: a pure C# CTCL client is rejected (the NMP layout is
engine-internal and would need reworking on every FireStorm change);
pod-level control is unnecessary since Site Management already covers
it; teaching MW4 to accept a plain mission spec and build the NMP
pod-side is the option if this is ever picked up. Parked for now.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:38:36 -05:00
CydandClaude Fable 5 85595b8c52 Console: fix volume menu check-mark off-by-one (original 4.11.3 bug)
mnuVolume_DropDownOpening checked the item where i+1 == num/10, but the
items are index=level (rVolumeItems[0]="mute", [8]="80"), so the mark
sat one step below the reported volume and mute (0) never got a mark.
Set path was always correct — display only. Now checks i == num/10.

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:07:56 -05:00
34 changed files with 2679 additions and 195 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+60
View File
@@ -0,0 +1,60 @@
# Plasma Images
Hand-made art for the pods' plasma name displays. Anything dropped in here is
version-controlled and rolls out with the release: `build-package.bat` copies the
folder into `App\`, and `install.bat` puts it beside `TeslaConsole.exe`.
The folder ships empty on purpose — populate it per site.
## How it works
When the console builds a mission egg it renders every participant name into a
1-bit-per-pixel bitmap in two sizes and embeds it in the egg. By default those are
drawn procedurally: `Microsoft Sans Serif`, white on black, no anti-aliasing, the
point size stepped down from 24 until the text fits.
With **Settings → Enable Custom Bitmaps** ticked, the console looks for a matching
file here first and only falls back to the procedural renderer when there isn't
one. The toggle is remembered in `console.settings` (see below), so it survives a
restart.
## File naming
```
<text>_<width>x<height>.bmp
```
The `<text>` part is matched **exactly** against the string being rendered — the
pilot/player name, the Red Planet football team name, or the literal `Camera` for
camera pods. Matching is per-name, not global, so you can override one name and
let the rest render normally.
Two sizes are needed to cover a name completely; supplying only one is fine, the
other falls back to the font renderer:
| Size | Egg section | Example |
| -------- | ------------------ | ---------------------- |
| 128 × 32 | `BitMap::Large::…` | `Camera_128x32.bmp` |
| 64 × 16 | `BitMap::Small::…` | `Camera_64x16.bmp` |
Rules:
- **Dimensions must match exactly.** A 130×32 file is ignored, silently, and the
name renders procedurally instead.
- **Only brightness matters.** Every pixel at or above 50% brightness lights up on
the plasma; everything else is dark. Colour art gets thresholded, so design in
black and white.
- Any name that cannot be a Windows file name (`:`, `\`, `|`, `?`, `*`, …) can't
have an override — those names always render procedurally.
- Files are read at egg-build time and copied into memory, not locked, so art can
be swapped between missions without restarting the console.
Preview how a name looks before a mission with the console's **Plasma Font Tool**,
which renders through the same path — with the option on it shows the override.
## Where the console looks
1. `%ProgramData%\Tesla Console\Plasma Images\` — this machine's own art. Takes
precedence, and survives a reinstall, so a site can keep local overrides that
a release will not overwrite.
2. `Plasma Images\` next to `TeslaConsole.exe` — this folder, as shipped.
+2 -2
View File
@@ -8,9 +8,9 @@ using System.Runtime.InteropServices;
[assembly: AssemblyCopyright("Copyright © 2009")]
[assembly: AssemblyConfiguration("")]
[assembly: Guid("581ca4b6-a91c-4d24-b9b5-207f3b5da379")]
[assembly: AssemblyFileVersion("4.11.4.3")]
[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.3")]
[assembly: AssemblyVersion("4.11.4.5")]
+9 -2
View File
@@ -103,9 +103,16 @@ dependency DLLs copied alongside it).
- `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
+21
View File
@@ -121,6 +121,27 @@
hostType="None" />
</Product>
<!-- Descent 3 (open-source engine + site-owned GOG assets; C:\VWE\Descent3
repo, venue\pack-dist.ps1 builds the package). Pod input arrives through
the RIOJoy "Descent 3" profile (RioGamepad HID), merged by the package's
postinstall. No {res} token: D3 takes -width/-height, not " -res W H" -
resolution is pinned to the pod main screen. Single process, no
supervisor; kill = terminate Descent3.exe. The args boot straight into
the campaign's first level (phase-0 free flight); when the D3_VENUE
Munga build lands, args change to "-venue -nointro{res} -framecap 60"
and LC/MR role entries get added (keys ...971/...972 reserved). -->
<Product id="B7E6D3A0-52C4-4F19-9A8E-6D40C1F2A970"
name="Descent 3"
menuText="Descent 3..."
hostTypeDialog="false">
<Launch key="B7E6D3A0-52C4-4F19-9A8E-6D40C1F2A970"
displayName="Descent 3"
exe="C:\Games\Descent3\Descent3.exe"
args="-pilot pod -nointro -nooutragelogo -fullscreen -width 800 -height 600 -framecap 60 -mission d3 -loadlevel 1"
autoRestart="true"
hostType="None" />
</Product>
<!-- 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
+156
View File
@@ -184,6 +184,18 @@ internal class BTGame : DockContent
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
@@ -300,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;
@@ -403,6 +522,21 @@ internal class BTGame : DockContent
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;
@@ -735,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;
@@ -854,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;
@@ -1004,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();
}
+158
View File
@@ -178,6 +178,18 @@ public class RPGame : DockContent
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
@@ -335,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();
@@ -430,6 +551,21 @@ public class RPGame : DockContent
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;
@@ -775,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;
@@ -914,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;
@@ -1111,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();
}
+6
View File
@@ -41,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>
+79
View File
@@ -0,0 +1,79 @@
using System;
using System.IO;
using System.Windows.Forms;
using System.Xml;
namespace TeslaConsole;
/// <summary>
/// Machine-level console settings that belong to no single game — the Settings
/// menu toggles that used to live only in memory and reset on every restart.
/// Stored as XML in %ProgramData%\Tesla Console\console.settings, next to
/// RPDefaults.rpd / BTDefaults.btd / local.siteconfig.
///
/// The values themselves stay where they are used (PlasmaBitmaps owns
/// EnableCustomBitmaps); this class only moves them to and from disk, so the
/// original decompiled classes keep their shape. Load() is called once from
/// Program.Main and never from a static initializer: the differential test suite
/// drives PlasmaBitmaps directly, and must keep seeing the original defaults
/// rather than whatever this operator happens to have saved.
/// </summary>
internal static class ConsoleSettings
{
private static readonly string sSettingsFilePath = Path.Combine(Program.GetCommonAppDataDirectory(), "console.settings");
/// <summary>
/// Applies the saved settings. A missing file is the normal first-run case;
/// a corrupt one is ignored and rewritten by the next Save(), because losing
/// a menu toggle must never stop the console from starting.
/// </summary>
public static void Load()
{
try
{
if (!File.Exists(sSettingsFilePath))
{
return;
}
XmlDocument xmlDocument = new XmlDocument();
xmlDocument.Load(sSettingsFilePath);
foreach (XmlNode childNode in xmlDocument.DocumentElement.ChildNodes)
{
switch (childNode.Name)
{
case "EnableCustomBitmaps":
{
if (bool.TryParse(childNode.InnerText, out var result))
{
PlasmaBitmaps.EnableCustomBitmaps = result;
}
break;
}
}
}
}
catch (Exception)
{
}
}
public static void Save()
{
try
{
XmlDocument xmlDocument = new XmlDocument();
xmlDocument.AppendChild(xmlDocument.CreateElement("ConsoleSettings"));
xmlDocument.DocumentElement.AppendChild(xmlDocument.CreateElement("EnableCustomBitmaps")).InnerText = PlasmaBitmaps.EnableCustomBitmaps.ToString();
string directoryName = Path.GetDirectoryName(sSettingsFilePath);
if (!Directory.Exists(directoryName))
{
Directory.CreateDirectory(directoryName);
}
xmlDocument.Save(sSettingsFilePath);
}
catch (Exception)
{
MessageBox.Show("The console settings file could not be saved. This setting will only be remembered until the application is closed.", "Error Saving Console Settings!", MessageBoxButtons.OK);
}
}
}
+57 -14
View File
@@ -4,6 +4,7 @@ using System.Drawing.Drawing2D;
using System.Drawing.Text;
using System.IO;
using System.Text;
using System.Windows.Forms;
namespace TeslaConsole;
@@ -45,21 +46,10 @@ public class PlasmaBitmaps
{
if (sEnableCustomBitmaps)
{
string text = Path.Combine(Program.GetCommonAppDataDirectory(), $"Plasma Images\\{str}_{width}x{height}.bmp");
if (File.Exists(text))
Bitmap bitmap = LoadCustomBitmap(width, height, str);
if (bitmap != null)
{
try
{
Bitmap bitmap = (Bitmap)Image.FromFile(text);
if (bitmap.Width == width && bitmap.Height == height)
{
return bitmap;
}
bitmap.Dispose();
}
catch
{
}
return bitmap;
}
}
Bitmap bitmap2 = new Bitmap(width, height);
@@ -87,6 +77,59 @@ public class PlasmaBitmaps
}
}
/// <summary>
/// The operator-supplied plasma image for this string at this size, or null
/// when there is none (the caller then renders the text procedurally).
///
/// Two folders are searched, in order:
/// 1. %ProgramData%\Tesla Console\Plasma Images - this machine's own art,
/// which survives reinstalls and wins over anything shipped.
/// 2. Plasma Images\ next to TeslaConsole.exe - the set that rolls with
/// the release, so art can be version-controlled and deployed.
/// The file name is "&lt;text&gt;_&lt;width&gt;x&lt;height&gt;.bmp", e.g. Camera_128x32.bmp.
///
/// Nothing here may throw: participant names come from operator input, and a
/// name containing a character that is illegal in a path used to take out
/// egg generation entirely once this option was switched on.
/// </summary>
private static Bitmap LoadCustomBitmap(int width, int height, string str)
{
string fileName = $"{str}_{width}x{height}.bmp";
if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
return null;
}
string[] searchRoots = new string[2]
{
Program.GetCommonAppDataDirectory(),
Path.GetDirectoryName(Application.ExecutablePath)
};
foreach (string searchRoot in searchRoots)
{
try
{
string path = Path.Combine(Path.Combine(searchRoot, "Plasma Images"), fileName);
if (!File.Exists(path))
{
continue;
}
// Copied out of the file rather than Image.FromFile'd: that keeps the
// bitmap backed by the file for its whole lifetime, which locks the art
// against an operator swapping it while the console is running.
using FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using Image image = Image.FromStream(stream);
if (image.Width == width && image.Height == height)
{
return new Bitmap(image);
}
}
catch
{
}
}
return null;
}
public static void GenerateStrings(out string large, out string small, string str)
{
GenerateStrings(out large, out small, "Microsoft Sans Serif", str);
+2
View File
@@ -59,6 +59,8 @@ internal static class Program
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(defaultValue: false);
// Machine-level Settings-menu toggles, before the form reads them to set its check marks.
ConsoleSettings.Load();
Application.Run(new TeslaConsoleForm());
}
+857
View File
@@ -0,0 +1,857 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Net;
using System.Text;
using System.Windows.Forms;
using Newtonsoft.Json.Linq;
namespace TeslaConsole;
/// <summary>
/// Reads the session roster TeslaLobby writes when an internet session
/// launches: which of the eight internet slots a human actually claimed, and
/// what that human is called. The slot-to-IP map is frozen
/// (<c>emulator\steam\SESSION-CONTRACT.md</c> section 1), so the eight internet
/// pod rows are furniture the operator builds once in Manage Site; only the
/// claims change from session to session.
///
/// Two properties are load-bearing and everything here is shaped around them:
///
/// 1. ONLY CLAIMED SLOTS APPEAR IN THE FILE. Absence is the unclaimed signal,
/// so a truncated, stale, unreadable or refused file yields FEWER
/// participants, never more. Every failure path below therefore degrades to
/// "no roster", which is byte-for-byte today's arcade behaviour. Museums run
/// this software; a bad JSON file must never be able to stop a mission that
/// would otherwise run by hand.
/// 2. ENABLED IMPLIES CLAIMED, not the reverse. The operator may always
/// subtract from an applied roster (sit a pilot out); the operator may never
/// add, because an enabled row nobody claimed puts a dead IP in the mission
/// egg and the pods sit waiting on a peer that will never boot.
///
/// The file is ONE PER LAUNCH GENERATION -- written at the state=launching flip
/// and not rewritten as the lobby churns -- because pod peer tables are
/// boot-static: a player whose TeslaLobby crashes is still in every pod's peer
/// table and still perfectly playable, and a live-tracking roster would evict
/// that working pod from the mission. Do not "fix" this into a live view.
///
/// UI thread only. <see cref="Poll"/> is cheap enough to call at ~1Hz from the
/// existing mission timers.
/// </summary>
internal static class SessionRoster
{
/// <summary>One claimed slot. Absence of a slot from <see cref="Claims"/> means nobody claimed it.</summary>
internal sealed class Claim
{
internal Claim(int slot, IPAddress address, string pilot, string steamId, bool host)
{
Slot = slot;
Address = address;
Pilot = pilot;
SteamId = steamId;
Host = host;
}
internal int Slot { get; private set; }
/// <summary>The game IP for the slot: 200.0.0.(111 + slot). Frozen, see SESSION-CONTRACT.md section 1.</summary>
internal IPAddress Address { get; private set; }
internal string Pilot { get; private set; }
internal string SteamId { get; private set; }
/// <summary>True for the session host (slot 0). Informational -- the console drives every claim the same way.</summary>
internal bool Host { get; private set; }
}
private const int SupportedSchema = 1;
private const int MaxSlots = 8;
// Slot 0 -> 200.0.0.111 ... slot 7 -> 200.0.0.118. Frozen in
// SESSION-CONTRACT.md section 1 and mirrored by the lobby's SlotPlan.cs;
// .119/.120 are reserved for the live-review / camera roles, which is why
// the block stops at eight.
private const byte SlotNetA = 200;
private const byte SlotNetB = 0;
private const byte SlotNetC = 0;
private const int SlotZeroOctet = 111;
// Pilot-name rules, decided by the operator 2026-07-25. The name becomes an
// INI section name in the egg (BTMission builds [BitMap::Large::<name>]) and
// the egg is ASCII, so '[', ']' and '=' would corrupt the egg's structure
// silently. The lobby sanitizes; the console only re-validates and refuses,
// because a name the operator never saw must not be quietly rewritten on its
// way into a mission file.
private const int MaxPilotNameLength = 12;
private const string BarredCharacters = "[]=";
// A roster older than one evening is a leftover from a previous session.
private const double MaxAgeHours = 12.0;
// Sanity bound. The real file is well under 2 KB; anything larger is not ours.
private const long MaxFileBytes = 64L * 1024L;
private static readonly IList<Claim> sNoClaims = new ReadOnlyCollection<Claim>(new List<Claim>());
private static bool sSeenExists;
private static DateTime sSeenStamp = DateTime.MinValue;
private static long sSeenLength = -1L;
private static bool sParsed;
private static bool sActive;
private static int sSchema;
private static string sSessionId = "";
private static string sSessionKey = "";
private static string sGame = "";
private static bool sAddressShift;
private static DateTime sWrittenUtc = DateTime.MinValue;
private static int sWaiting;
private static string sLoadError = "";
private static IList<Claim> sClaims = sNoClaims;
private static string sLoggedError = "";
/// <summary>
/// A roster file is present, parses, and is fresh. NOT "usable": call
/// <see cref="Validate"/> before applying, which is what catches a roster
/// for the wrong game, the wrong schema or the wrong addressing mode.
/// </summary>
internal static bool Active => sActive;
/// <summary>Matches steam_session.json. A change means a NEW launch generation, so re-apply.</summary>
internal static string SessionKey => sSessionKey;
internal static string SessionId => sSessionId;
/// <summary>"bt" or "rp", as written. Compare case-insensitively.</summary>
internal static string Game => sGame;
/// <summary>The addressing the session REQUIRES; internet sessions are flat, so this is false.</summary>
internal static bool AddressShift => sAddressShift;
internal static DateTime WrittenUtc => sWrittenUtc;
/// <summary>Lobby members holding no slot, for the banner. Not a blocker -- spectators are legal.</summary>
internal static int WaitingCount => sWaiting;
/// <summary>Empty when nothing is wrong. Operator-facing; show it in the banner, it is why there is no roster.</summary>
internal static string LoadError => sLoadError;
// IList, not IReadOnlyList: the read-only interfaces are net45+ and the
// XP11 console targets net40. The instance is already immutable.
internal static IList<Claim> Claims => sClaims;
internal static string FilePath =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Tesla Console", "session_roster.json");
/// <summary>
/// Re-reads the roster if the file changed. Returns true when the roster
/// state changed and the caller should repaint. Never throws.
/// </summary>
internal static bool Poll()
{
string before = StateStamp();
try
{
PollCore();
}
catch (Exception ex)
{
// Belt and braces: PollCore already catches everything it expects to
// fail. Anything reaching here is a surprise, and the answer to a
// surprise is still "there is no roster".
Unsee();
Clear("The session roster could not be read: " + ex.Message);
}
return StateStamp() != before;
}
/// <summary>The claim on an address, or null. The address is the game IP, i.e. what the egg will carry.</summary>
internal static Claim Find(IPAddress ip)
{
if (!sActive || ip == null)
{
return null;
}
for (int i = 0; i < sClaims.Count; i++)
{
if (ip.Equals(sClaims[i].Address))
{
return sClaims[i];
}
}
return null;
}
/// <summary>
/// Gate A. Returns "" when the roster may be applied to this mission
/// window, else operator-facing text explaining what to fix.
///
/// Returns "" when no roster is active: with no roster the console is the
/// 1995 console and must not grow a new way to refuse a mission.
/// <paramref name="enabledCol"/> and <paramref name="pilotCol"/> are taken
/// for call-shape symmetry with <see cref="Apply"/>; the checks here need
/// only each row's pod (row.Tag).
/// </summary>
internal static string Validate(string game, bool paneShift, DataGridView grid, int enabledCol, int pilotCol)
{
if (!sActive)
{
return "";
}
try
{
if (sSchema != SupportedSchema)
{
return $"This session roster is format version {sSchema}; this console reads version {SupportedSchema}. "
+ "Install matching TeslaLobby and TeslaConsole builds, then start the session again.";
}
if (!string.IsNullOrEmpty(game) && !string.Equals(game, sGame, StringComparison.OrdinalIgnoreCase))
{
return $"This session roster is for {GameName(sGame)}. This is a {GameName(game)} mission. "
+ $"Start a {GameName(game)} session in TeslaLobby, or close this window and open the {GameName(sGame)} one.";
}
if (paneShift != sAddressShift)
{
return AddressShiftMessage(paneShift);
}
if (sClaims.Count == 0)
{
return "This session roster has no claimed slots. Nobody took a pod in TeslaLobby, so there is nothing to launch.";
}
StringBuilder problems = new StringBuilder();
for (int i = 0; i < sClaims.Count; i++)
{
Claim claim = sClaims[i];
string problem = PilotNameProblem(claim.Pilot);
if (problem != null)
{
problems.AppendLine(string.IsNullOrEmpty(claim.Pilot)
? $"Slot {claim.Slot} has no pilot name. Set one in TeslaLobby and start the session again."
: $"Pilot name \"{claim.Pilot}\" (slot {claim.Slot}) {problem}. Fix it in TeslaLobby and start the "
+ "session again -- the console will not rewrite a name that is about to be written into the mission egg.");
continue;
}
// Ordinal, matching the panes' own duplicate check in
// CheckAllValues (BTGame.cs:965), so the two agree on what a
// duplicate is.
for (int j = i + 1; j < sClaims.Count; j++)
{
if (string.Equals(claim.Pilot, sClaims[j].Pilot, StringComparison.Ordinal))
{
problems.AppendLine($"Pilot name \"{claim.Pilot}\" is claimed by slot {claim.Slot} and slot {sClaims[j].Slot}. "
+ "Pilot names must be unique. Rename one in TeslaLobby and start the session again.");
}
}
}
if (grid != null)
{
for (int i = 0; i < sClaims.Count; i++)
{
Claim claim = sClaims[i];
Pod pod = FindPod(grid, claim.Address, paneShift);
if (pod == null)
{
problems.AppendLine($"No pod is configured at {claim.Address} (slot {claim.Slot}). "
+ "Add the Internet squad in Manage Site.");
}
else if (pod.HostType != HostType.GameMachineHostType)
{
problems.AppendLine($"The pod at {claim.Address} (slot {claim.Slot}) is not a game machine. "
+ "Internet slots must be Game Machine pods in Manage Site.");
}
}
}
return problems.ToString();
}
catch (Exception ex)
{
LogOnce("SessionRoster.Validate failed: " + ex);
// Fail closed: an active roster we cannot check is not a roster the
// operator should be allowed to apply.
return "The session roster could not be checked. Enable pods by hand, or restart the console.";
}
}
/// <summary>
/// Fills the mission grid in from the roster: every claimed pod row gets its
/// pilot name, is enabled and is connected; every other player row is
/// cleared, disabled and released.
///
/// Callers gate on <see cref="Validate"/> first and repaint their own issue
/// list afterwards (CheckAllValues) -- this method deliberately knows
/// nothing about either pane's UI. Never throws.
/// </summary>
internal static void Apply(DataGridView grid, int enabledCol, int pilotCol, object requestor, bool paneShift)
{
if (!sActive || grid == null || requestor == null)
{
return;
}
try
{
foreach (DataGridViewRow row in grid.Rows)
{
Pod pod = row.Tag as Pod;
if (pod == null || pod.HostType != HostType.GameMachineHostType)
{
// Camera and mission-review rows are site furniture, never
// lobby slots. Leave them exactly as the operator set them:
// the roster describes players, and turning off a recording
// host the operator armed would be an edit nobody asked for.
continue;
}
if (enabledCol < 0 || enabledCol >= row.Cells.Count || pilotCol < 0 || pilotCol >= row.Cells.Count)
{
continue;
}
Claim claim = MatchClaim(pod, paneShift);
if (claim != null)
{
row.Cells[pilotCol].Value = claim.Pilot;
row.Cells[enabledCol].Value = true;
// Setting cell values connects NOTHING -- programmatic writes
// raise no CellEndEdit. The only thing that opens a pod
// connection is MungaGame.MakeRequested, so reproduce the
// hand-edit sequence exactly, shift first: assigning
// DosBoxAddressShift while connected drops the socket
// (MungaGame.cs:120-143), so it must be settled before the
// request that dials. Modelled on
// mPilotsDataGrid_CellEndEdit, BTGame.cs:1085-1089 (and the
// identical RPGame.cs:1195-1199).
pod.MungaGame.DosBoxAddressShift = paneShift;
pod.MungaGame.MakeRequested(requestor);
}
else
{
// The clear-and-release the Delete/Backspace path uses,
// BTGame.cs:1102-1105: "" rather than null, so the pane's
// blank-name check sees what a hand-cleared cell leaves.
row.Cells[pilotCol].Value = "";
row.Cells[enabledCol].Value = false;
pod.MungaGame.ReleaseRequest(requestor);
}
}
}
catch (Exception ex)
{
// Rows already processed keep their state; a half-applied roster is
// still enabled-implies-claimed, and Issues() is the net under it.
LogOnce("SessionRoster.Apply failed: " + ex);
}
}
/// <summary>
/// Gate B. One line per enabled player row that nobody claimed. Empty when
/// there is nothing to say -- and always empty with no active roster, which
/// is what keeps the arcade path untouched.
/// </summary>
internal static string Issues(DataGridView grid, int enabledCol, bool paneShift)
{
if (!sActive || grid == null)
{
return "";
}
try
{
StringBuilder issues = new StringBuilder();
foreach (DataGridViewRow row in grid.Rows)
{
Pod pod = row.Tag as Pod;
if (pod == null || pod.HostType != HostType.GameMachineHostType || !IsEnabled(row, enabledCol))
{
continue;
}
// The pane's shift, matching Apply's signature. It equals
// sAddressShift by the time this can matter (Validate refuses a
// disagreeing pane before Apply can run), but the join from a row
// to a claim is now spelled the same way in both methods -- two
// spellings of one rule is how they drift apart later.
if (MatchClaim(pod, paneShift) != null)
{
continue;
}
IPAddress address = MissionAddress(pod, paneShift);
int slot = SlotForAddress(address);
string who = (slot >= 0)
? $"Slot {slot} ({address})"
: (string.IsNullOrEmpty(pod.Name) ? address.ToString() : $"{pod.Name} ({address})");
issues.AppendLine($"{who} is enabled but nobody claimed it in this session.");
}
return issues.ToString();
}
catch (Exception ex)
{
LogOnce("SessionRoster.Issues failed: " + ex);
// Fail closed, as in Validate: an active roster we cannot check
// against must not be allowed to launch.
return "The session roster could not be checked against the enabled pods. Restart the console.";
}
}
private static void PollCore()
{
string path = FilePath;
FileInfo info = new FileInfo(path);
bool exists = info.Exists;
DateTime stamp = exists ? info.LastWriteTimeUtc : DateTime.MinValue;
long length = exists ? info.Length : -1L;
if (exists == sSeenExists && stamp == sSeenStamp && length == sSeenLength)
{
// Freshness is time-dependent, not file-dependent: a console left
// open overnight must drop a roster that ages out of the window even
// though nothing on disk moved.
RefreshActive();
return;
}
if (!exists)
{
See(false, stamp, length);
// No file is not an error. This is the arcade path.
Clear("");
return;
}
if (length > MaxFileBytes || length <= 0L)
{
See(true, stamp, length);
Clear($"The session roster at {path} is {length} bytes, which is not a roster file. It was ignored.");
LogOnce(sLoadError);
return;
}
JObject root;
try
{
root = JObject.Parse(ReadAllTextShared(path));
}
catch (Exception ex)
{
// Leave the stat uncommitted so the next tick retries: the usual
// cause is a read that raced the lobby's write, and latching that
// failure until something touches the file again would strand a
// perfectly good roster.
Unsee();
Clear("The session roster could not be read: " + ex.Message);
LogOnce(sLoadError + " (" + path + ")");
return;
}
See(true, stamp, length);
Parse(root, path);
}
private static void Parse(JObject root, string path)
{
int schema = ReadInt(root["schema"], 0);
string sessionId = ReadString(root["sessionId"]);
string sessionKey = ReadString(root["sessionKey"]);
string game = ReadString(root["game"]).Trim();
// Missing means flat addressing: that is what an internet session
// requires (SESSION-CONTRACT.md section 1) and it is the safe default,
// since a wrong "false" is caught by Validate against the pane's flag.
bool addressShift = ReadBool(root["addressShift"], defaultValue: false);
int waiting = ReadInt(root["waiting"], 0);
DateTime writtenUtc;
if (!ReadUtc(root["writtenUtc"], out writtenUtc))
{
Clear("The session roster has no usable writtenUtc timestamp, so its age cannot be checked. It was ignored.");
LogOnce(sLoadError + " (" + path + ")");
return;
}
JArray slots = root["slots"] as JArray;
if (slots == null)
{
Clear("The session roster has no slots list. It was ignored.");
LogOnce(sLoadError + " (" + path + ")");
return;
}
List<Claim> claims = new List<Claim>();
bool[] seen = new bool[MaxSlots];
foreach (JToken token in slots)
{
JObject entry = token as JObject;
if (entry == null)
{
Refuse("a slot entry is not an object", path);
return;
}
int slot;
if (!ReadIntStrict(entry["slot"], out slot) || slot < 0 || slot >= MaxSlots)
{
Refuse($"a slot entry has an out-of-range or unreadable slot number ({ReadString(entry["slot"])})", path);
return;
}
if (seen[slot])
{
Refuse($"slot {slot} is claimed twice", path);
return;
}
seen[slot] = true;
IPAddress address;
if (!IPAddress.TryParse(ReadString(entry["ip"]), out address))
{
Refuse($"slot {slot} has an unreadable ip", path);
return;
}
// The mapping is frozen. A disagreement here means the lobby and the
// console do not share an addressing plan, and every downstream
// decision -- which row to enable, which slot to name in an issue --
// would be built on a guess. Refuse the file and let the operator
// enable pods by hand instead.
IPAddress expected = AddressForSlot(slot);
if (!expected.Equals(address))
{
Refuse($"slot {slot} claims {address}, but slot {slot} is {expected}", path);
return;
}
claims.Add(new Claim(slot, address, ReadString(entry["pilot"]), ReadString(entry["steamId"]),
ReadBool(entry["host"], defaultValue: false)));
}
sParsed = true;
sSchema = schema;
sSessionId = sessionId;
sSessionKey = sessionKey;
sGame = game;
sAddressShift = addressShift;
sWrittenUtc = writtenUtc;
sWaiting = waiting;
sClaims = new ReadOnlyCollection<Claim>(claims);
sLoadError = "";
RefreshActive();
}
/// <summary>Structural refusals: the file contradicts itself, so none of it is trusted.</summary>
private static void Refuse(string reason, string path)
{
Clear($"The session roster was ignored: {reason}. Enable pods by hand for this mission.");
LogOnce(sLoadError + " (" + path + ")");
}
/// <summary>
/// Applies the freshness window to already-parsed data. Split out of
/// <see cref="Parse"/> because age changes with the clock, not with the file.
/// </summary>
private static void RefreshActive()
{
if (!sParsed)
{
return;
}
// Absolute difference: a timestamp far in the future is clock skew
// between the lobby machine and this one, which is no more trustworthy
// than one from last night.
double hours = Math.Abs((DateTime.UtcNow - sWrittenUtc).TotalHours);
if (hours > MaxAgeHours)
{
string stamp = sWrittenUtc.ToString("u", CultureInfo.InvariantCulture);
Clear($"The session roster was written {stamp} and is more than {(int)MaxAgeHours} hours old. "
+ "It is left over from an earlier session and was ignored.");
return;
}
sActive = true;
sLoadError = "";
}
private static void Clear(string error)
{
sParsed = false;
sActive = false;
sSchema = 0;
sSessionId = "";
sSessionKey = "";
sGame = "";
sAddressShift = false;
sWrittenUtc = DateTime.MinValue;
sWaiting = 0;
sClaims = sNoClaims;
sLoadError = error;
}
private static void See(bool exists, DateTime stamp, long length)
{
sSeenExists = exists;
sSeenStamp = stamp;
sSeenLength = length;
}
/// <summary>Forgets the stat so the next Poll re-reads the same file.</summary>
private static void Unsee()
{
sSeenExists = false;
sSeenStamp = DateTime.MinValue;
sSeenLength = -1L;
}
private static string ReadAllTextShared(string path)
{
// FileShare.ReadWrite | Delete: the lobby may still hold the file open,
// and a sharing violation here would read as "no roster" for a whole
// second. UTF-8 without BOM per the contract; BOM detection costs
// nothing and forgives a writer that adds one.
using (FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
{
using (StreamReader reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
{
return reader.ReadToEnd();
}
}
}
private static string StateStamp()
{
return string.Concat(sActive ? "1|" : "0|", sSessionKey, "|", sGame, "|",
sWrittenUtc.ToString("u", CultureInfo.InvariantCulture), "|",
sClaims.Count.ToString(CultureInfo.InvariantCulture), "|",
sWaiting.ToString(CultureInfo.InvariantCulture), "|", sLoadError);
}
private static void LogOnce(string message)
{
// Once per distinct message: Poll runs at 1Hz and a permanently broken
// file would otherwise fill ExceptionLog.txt overnight.
if (sLoggedError == message)
{
return;
}
sLoggedError = message;
Program.LogMessage(message);
}
/// <summary>The address this pod will carry into the mission -- MissionAddress() in both game panes.</summary>
private static IPAddress MissionAddress(Pod pod, bool shift)
{
return shift ? DosBox.ShiftAddress(pod.IPAddress) : pod.IPAddress;
}
private static Claim MatchClaim(Pod pod, bool shift)
{
if (pod == null || pod.IPAddress == null)
{
return null;
}
// Join on the mission address, not the raw site-config address: the
// claim's IP is the game IP, which is what the egg gets. With flat
// addressing (every internet session) the two are the same value.
return Find(MissionAddress(pod, shift));
}
private static Pod FindPod(DataGridView grid, IPAddress address, bool shift)
{
foreach (DataGridViewRow row in grid.Rows)
{
Pod pod = row.Tag as Pod;
if (pod != null && pod.IPAddress != null && address.Equals(MissionAddress(pod, shift)))
{
return pod;
}
}
return null;
}
private static bool IsEnabled(DataGridViewRow row, int enabledCol)
{
if (enabledCol < 0 || enabledCol >= row.Cells.Count)
{
return false;
}
object value = row.Cells[enabledCol].Value;
return value is bool && (bool)value;
}
private static IPAddress AddressForSlot(int slot)
{
return new IPAddress(new byte[4] { SlotNetA, SlotNetB, SlotNetC, (byte)(SlotZeroOctet + slot) });
}
/// <summary>The slot an address belongs to, or -1 when it is outside the internet block.</summary>
private static int SlotForAddress(IPAddress address)
{
if (address == null)
{
return -1;
}
byte[] octets = address.GetAddressBytes();
if (octets.Length != 4 || octets[0] != SlotNetA || octets[1] != SlotNetB || octets[2] != SlotNetC)
{
return -1;
}
int slot = octets[3] - SlotZeroOctet;
return (slot >= 0 && slot < MaxSlots) ? slot : -1;
}
private static string GameName(string game)
{
if (string.Equals(game, "bt", StringComparison.OrdinalIgnoreCase))
{
return "BattleTech";
}
if (string.Equals(game, "rp", StringComparison.OrdinalIgnoreCase))
{
return "Red Planet";
}
return string.IsNullOrEmpty(game) ? "an unnamed game" : "\"" + game + "\"";
}
private static string AddressShiftMessage(bool paneShift)
{
// This mismatch is the silent hang this whole path exists to kill: the
// console dials pod+100, nothing answers, and the mission never starts.
// The part operators get wrong is that the pane LATCHES the flag when
// the mission window opens (BTGame.cs:185 / RPGame.cs:179 read the
// default into a readonly field at construction), so changing the
// default with the window open does nothing at all. Say so.
string state = paneShift ? "ON" : "OFF";
string wanted = sAddressShift ? "ON" : "OFF";
return $"This session needs the +100 DOSBox address shift {wanted}, but this mission window has it {state}. "
+ $"Turn \"Shift all pod IPs +100 (DOSBox-X preservation build)\" {wanted} in the Defaults dialog, "
+ "then CLOSE AND REOPEN this mission window -- the shift is latched when the window opens, "
+ "so changing the default alone will not take effect.";
}
/// <summary>
/// Why this pilot name is unusable, or null when it is clean. Mirrors the
/// lobby's sanitizer; the console refuses rather than rewrites, so the name
/// the player saw in the lobby is the name that reaches the egg.
/// </summary>
private static string PilotNameProblem(string name)
{
if (string.IsNullOrEmpty(name))
{
return "is blank";
}
if (name.Length > MaxPilotNameLength)
{
return $"is longer than {MaxPilotNameLength} characters";
}
for (int i = 0; i < name.Length; i++)
{
char c = name[i];
if (BarredCharacters.IndexOf(c) >= 0)
{
return $"contains '{c}', which the mission egg cannot carry -- the pilot name becomes an INI section "
+ "name, and '[', ']' and '=' corrupt the egg's structure silently";
}
if (c < ' ' || c > '~')
{
return "contains a character that is not printable ASCII";
}
}
if (name != name.Trim())
{
return "has leading or trailing spaces";
}
if (name.IndexOf(" ", StringComparison.Ordinal) >= 0)
{
return "has a doubled space";
}
return null;
}
private static string ReadString(JToken token)
{
if (token == null || token.Type == JTokenType.Null || token.Type == JTokenType.Undefined)
{
return "";
}
if (token.Type == JTokenType.Object || token.Type == JTokenType.Array)
{
return "";
}
// SteamID64s are written as strings but survive being written as numbers.
return token.ToString();
}
private static int ReadInt(JToken token, int defaultValue)
{
int value;
return ReadIntStrict(token, out value) ? value : defaultValue;
}
private static bool ReadIntStrict(JToken token, out int value)
{
value = 0;
if (token == null)
{
return false;
}
if (token.Type == JTokenType.Integer)
{
try
{
value = token.Value<int>();
return true;
}
catch
{
return false;
}
}
return token.Type == JTokenType.String
&& int.TryParse(token.Value<string>(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
}
private static bool ReadBool(JToken token, bool defaultValue)
{
if (token == null)
{
return defaultValue;
}
if (token.Type == JTokenType.Boolean)
{
return token.Value<bool>();
}
if (token.Type == JTokenType.String)
{
bool value;
return bool.TryParse(token.Value<string>(), out value) ? value : defaultValue;
}
return defaultValue;
}
private static bool ReadUtc(JToken token, out DateTime value)
{
value = DateTime.MinValue;
if (token != null && token.Type == JTokenType.Date)
{
// Newtonsoft already parsed it; normalise the kind rather than trust it.
value = token.Value<DateTime>().ToUniversalTime();
return true;
}
string text = ReadString(token);
if (string.IsNullOrEmpty(text))
{
return false;
}
DateTime parsed;
if (!DateTime.TryParse(text, CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out parsed))
{
return false;
}
value = parsed;
return true;
}
}
+5 -1
View File
@@ -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;
}
}
+1
View File
@@ -478,6 +478,7 @@ public class TeslaConsoleForm : Form
private void enableCustomBitmapsToolStripMenuItem_Click(object sender, EventArgs e)
{
enableCustomBitmapsToolStripMenuItem.Checked = (PlasmaBitmaps.EnableCustomBitmaps = !PlasmaBitmaps.EnableCustomBitmaps);
ConsoleSettings.Save();
}
private void mThrowHandledExceptionMenuItem_Click(object sender, EventArgs e)
+6 -1
View File
@@ -64,8 +64,13 @@ echo Files copied.
:: -- STEP 2: Data directory (site config, mission recordings) -----------------
echo [2/4] Preparing data directory...
if not exist "%DATA_DIR%" mkdir "%DATA_DIR%"
:: Per-site plasma-display art. Created empty and never overwritten: what is in
:: here wins over the copy shipped in App\Plasma Images, so a release cannot
:: clobber a site's own name bitmaps.
if not exist "%DATA_DIR%\Plasma Images" mkdir "%DATA_DIR%\Plasma Images"
:: Grant the local Users group modify access so a normal operator account can
:: write local.siteconfig / RP Missions when the console is not run elevated.
:: write local.siteconfig / console.settings / RP Missions when the console is
:: not run elevated.
icacls "%DATA_DIR%" /grant *S-1-5-32-545:(OI)(CI)M /T >nul 2>&1
echo %DATA_DIR% (Users: modify access)
@@ -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.3", _fx.Recovered.AssemblyFullName);
Assert.Contains("4.11.4.5", _fx.Recovered.AssemblyFullName);
}
// ---- RPStrings.GetTimeString: mm:ss formatting with 0.5s rounding ----
@@ -25,8 +25,8 @@ namespace TeslaConsole.DiffTests
=> _fx.Recovered.Run("CatalogEntry", new[] { _catalog, launchKey, w, h });
[Fact]
public void Catalog_Has_Five_Products_And_Fourteen_Entries()
=> Assert.Equal("products=5;entries=14",
public void Catalog_Has_Six_Products_And_Fifteen_Entries()
=> Assert.Equal("products=6;entries=15",
_fx.Recovered.Run("CatalogSummary", new[] { _catalog }));
[Fact]
@@ -101,6 +101,22 @@ namespace TeslaConsole.DiffTests
@"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"));
// Descent 3 — open-source engine + site GOG assets (C:\VWE\Descent3 repo,
// venue\pack-dist.ps1 package). Single GameClient entry, resolution pinned
// in args ({res} absent — D3 takes -width/-height, not " -res W H").
[Fact]
public void Descent3_Matches_Expected()
=> Assert.Equal(
@"Descent 3|b7e6d3a0-52c4-4f19-9a8e-6d40c1f2a970|C:\Games\Descent3\Descent3.exe|-pilot pod -nointro -nooutragelogo -fullscreen -width 800 -height 600 -framecap 60 -mission d3 -loadlevel 1|C:\Games\Descent3|True",
Entry("B7E6D3A0-52C4-4F19-9A8E-6D40C1F2A970"));
[Fact]
public void Descent3_Resolution_Choice_Has_No_Effect()
=> Assert.Equal(
@"Descent 3|b7e6d3a0-52c4-4f19-9a8e-6d40c1f2a970|C:\Games\Descent3\Descent3.exe|-pilot pod -nointro -nooutragelogo -fullscreen -width 800 -height 600 -framecap 60 -mission d3 -loadlevel 1|C:\Games\Descent3|True",
Entry("B7E6D3A0-52C4-4F19-9A8E-6D40C1F2A970", "1024", "768"));
// 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
+288
View File
@@ -0,0 +1,288 @@
# Commanding FireStorm from TeslaConsole — CTCL research
Researched 2026-07-28. **Status: parked.** No code was written; this is the record of
what CTCL is, what integrating it would cost, and which option we'd take if we ever
pick it up.
BT4 and RP4 speak **Munga** (TCP 1501) — the protocol the console already drives via
the vendored `Munga Net.dll`. BattleTech **FireStorm** is a MechWarrior 4 total
conversion and speaks something else entirely: **CTCL**, the coin-op / LAN-centre
control layer the Korean team added to the MW4 engine (every call site is bracketed
`// jcem - begin` / `// jcem - end`; the acronym is never expanded in the source).
Source for everything below is the FireStorm repo at `C:\VWE\firestorm`
(`gitea.mysticmachines.com/VWE/firestorm.git`). Paths in this document are relative to
`firestorm/`. That repo's `LAUNCHER-AND-MW4.md` already documents the **launcher**
link; the **game** link (the mission handshake on port 1001) is documented here for the
first time.
---
## Where we are today
The console can already **launch** FireStorm on a pod — it is a product in
[`Console/RedPlanet/Apps.xml`](Console/RedPlanet/Apps.xml), pointing at
`C:\Games\MW4\launcher.exe`. So today a FireStorm pod runs:
```
TeslaLauncher (ours, TCP 53290)
└─ launcher.exe (MW4's CTCL agent, listens TCP 1000)
└─ MW4.exe -ctcltype 2 (the game, listens TCP 1001) ← started from c:\ctcl.ini [config] run=
```
What the console **cannot** do is build or run a mission. That is the gap.
---
## CTCL in one page
### Roles (`Gameleap/code/ctcls/ctcl_params.h:28-33`)
| Value | Role | Who | Listens |
|-------|------|-----|---------|
| `_ECTCL_Launcher` = 0 | pod agent | `Launcher.exe` | 1000 |
| `_ECTCL_Console` = 1 | operator console | `MW4.exe` with **no** `-ctcltype` | — (dials out) |
| `_ECTCL_Game` = 2 | pod running the game | `MW4.exe -ctcltype 2` | 1001 |
| `_ECTCL_CameraShip` = 3 | spectator / camera pod | `MW4.exe -ctcltype 3` | 1001 |
| `_ECTCL_None` = 4 | CTCL disabled | `MW4.exe -dragon` | — |
The console is *the same binary as the game*. It reads `c:\ctcl.ini` `[teslas]` for the
pod list and opens two sockets per pod (1000 + 1001). Pods listen; the console never does.
### Framing (`Gameleap/code/Launcher/mugSocs.cpp`)
A tiny hand-rolled "MUG" socket library. Frame is:
```
uint16 length (big-endian, = 1 + payload) uint8 cmd payload…
```
Payload fields are described by printf-style format strings
(`CPacket::vAssemble`, `mugSocs.cpp:1078`):
| code | meaning |
|------|---------|
| `b` `B` | uint8 |
| `w` `W` | uint16, network byte order |
| `n` `d` `D` | uint32, network byte order |
| `f` / `F` / `6` | float / double / int64 (host order) |
| `s` | NUL-terminated string, inline |
| `S` | uint16 length + NUL-terminated string |
| `x` | uint16 length + raw bytes |
| `X` | raw bytes, no length prefix |
### Messages (`Gameleap/code/Launcher/ctcl.h:73-91`)
| id | name | direction | notes |
|----|------|-----------|-------|
| 1 | `C_GameInfo` / `S_GameInfo` | console ↔ launcher (:1000) | poll ~1 Hz; reply is `applType, applState, gameState, gameTime, isServer` |
| 10 | `C_OrderAppl` | console → launcher **or** game | routed by value: **≥100 → :1000, <100 → :1001** |
| 19 | `C_ErrorStartGame` | console → all | abort, back to main menu |
| 20 | `C_ReadyStartGame` | console → each pod (:1001) | the big one — NMP blob + that pod's player record |
| 21 | `C_BOTS` | console → **server pod only** | full roster incl. bots; doubles as "you are the host, create it" |
| 22 | `C_SetMechs` | console → **clients only** | "join now" |
| 23 | `C_GetReady` | — | **declared but dead** — no handler in any packet map |
| 24 | `C_DoLaunch` | console → server | drop |
| 30 | `S_GameReturn` | game → console | progress / error, `_EGR_*` codes |
| 40 | `C_SendFile` / `S_SendFile` | both ways | print + mission-review file *notifications* only |
| 50 | `C_InviteCOOP` / `S_InviteCOOP` | both ways | coin-op invite; irrelevant to console-run games |
### Orders (`ctcl_params.h:40-46`)
| order | value | handled by | effect |
|-------|-------|-----------|--------|
| `_CTCL_Order_Terminate` | 0 | game | `gos_TerminateApplication()` |
| `_CTCL_Order_EndMission` | 1 | game | drop out of the mission |
| `_CTCL_Order_Launch` | 100 | launcher | `RunExec()` the `c:\ctcl.ini` `[games]` command line |
| `_CTCL_Order_Shutdown` | 101 | launcher | `ExitWindowsEx(EWX_POWEROFF)` |
| `_CTCL_Order_Reboot` | 102 | launcher | `ExitWindowsEx(EWX_REBOOT)` |
| `_CTCL_Order_Unload` | 103 | launcher | `PostQuitMessage(0)` — quit the launcher |
### Pod state (`ctcl_params.h:1-26`)
The launcher has no visibility into the game process; it reads five ints out of
`ctcls.dll`'s `.SHARED_DATA` section (a process-spanning shared segment the game writes):
`applType` (`_EAT_MW4`), `applState` (PreLaunch/Launched/PostLaunch),
`gameState` (Idle/Preparing/Running/Closing), `gameTime` (seconds), `isServer`.
---
## The mission handshake
### Who hosts — the console decides, there is no election
While validating the player list (`Gameleap/code/mw4/Code/MW4/MW4Shell.cpp:13492-13504`)
the console records the first cameraship pod (`:13421`) and the first ordinary pilot pod
(`:13432`), then:
```cpp
if (nCameraship != -1) g_nServer = nCameraship; // cameraship hosts if one is playing
else g_nServer = n1stTesla; // otherwise the topmost pilot pod
```
**The server designation is positional** — whatever pilot ordering the operator UI shows
*is* the host-selection UI. The original console offered no explicit control over it.
Each pod learns its role from one byte in `C_ReadyStartGame`
(`mw4/Code/MW4Application/ctcl.cpp:1544`); the receiver sets `g_bIsServer = (bType == 1)`
(`:822`). The server's IP travels in the same packet but is discarded on arrival
(`MW4Shell/MWApplication.cpp:18534``PI.m_dwAddr = 0; // dwAddr;`).
### Rendezvous is by name, not address
The console generates `g_guidGameDatas = gos_GenerateUniqueGUID()` per mission and ships
it in `C_ReadyStartGame`; every pod formats it identically into `g_szGameName`
(`ctcl.cpp:824-827`).
- **Server** (`CTCL_DoCreateGame`, `MW4Application.cpp:1708`): `gos_NetStartGame`
`PreConnect``CTCL_DefaultHostSetup(0)``Mech4CreateGame(g_szGameName, pilot, NULL)`.
`AdvertiseThisGame = 0` — LAN enumeration only, never published to the Zone.
- **Clients** (`CTCL_DoJoinGame` `:1745``CTCL_CheckJoinGame` `:1768`): open the LAN
browser, poll `gos_GameIsExist(g_szGameName)` until it appears, then
`gos_JoinGame(...)`. 25-second timeout.
### The sequence (`mw4/Code/MW4Application/ctcl.cpp:1486-1670`, state var `g_nMech4Comm`)
Its whole purpose is to **serialize create-before-join** — a client that browses before
the host exists just burns its timeout.
| console sends | waits for | pod does |
|---|---|---|
| `C_ReadyStartGame` → all pods | every pod → `_EGR_PreparingStarted` | stores roster + NMP |
| `C_BOTS`**server only** | server → `_EGR_OkCreateSession` | `CTCL_DoCreateGame` |
| `C_SetMechs`**clients only** | server → `_EGR_OkLaunchReady` | `CTCL_DoJoinGame`, browse + join |
| *(operator presses Launch)* | | |
| `C_DoLaunch` → server | | everyone drops |
Failures come back as `_EGR_ErrCreateSession` / `_EGR_ErrJoinSession` on `S_GameReturn`;
the console responds by broadcasting `C_ErrorStartGame` (`ctcl.cpp:1520-1533`).
---
## The three blockers
### 1. The NMP blob
`C_ReadyStartGame` carries an opaque byte array the console produces by calling the MW4
engine's own `MWNetMissionParameters::SaveParameters()`
(`mw4/Code/MW4/MWApplication.cpp:749-864`) — a **bit-packed** stream: ~60 fields at
widths of 1, 2, 3, 5, 7, 8, 16, 32 and 64 bits, then 8 × `TeamParameters` (`:513-535`,
another 10 × 32-bit allow-masks each), a byte-align, then eight length-prefixed strings.
Reproducing it in C# means porting `Stuff::DynamicMemoryStream::WriteBits` bit-for-bit
and getting every field width right. **A one-bit drift silently corrupts every field
after it**, and the layout changes whenever the FireStorm codebase adds a parameter — the
existing `// MSL 5.05 Advance Mode` / `// MSL 5.06 Armor Mode` markers in that function
are exactly that happening twice already.
### 2. Content-derived identifiers
The CTCL console is a full MW4 install because it needs game data:
- `m_nMechIndex` / `m_fileID` / `m_recordID` come from the console's own sorted mech
resource table (`shl->m_mechIDs`, built from the installed `.erf` content).
- `m_mapID` indexes the console's `scenarios[]` table.
- `m_mapClientCRC` is a **64-bit CRC of the map's `.mw4` file**
(`MWApplication.cpp:7048`) which every pod recomputes and compares (`:7538-7553`);
a mismatch makes the pod decide it doesn't have the map.
So console and pods must run byte-identical content, and any external console needs that
table regenerated per FireStorm build.
### 3. No telemetry
CTCL gives the console `gameState` and `gameTime` (seconds) and **nothing else** — no
kills, no damage, no scores. The whole `BTGame`/`RPGame` model (live scoreboard,
`BTMissionRecorder`, `BTPrintDocument`) has no CTCL equivalent.
FireStorm results instead land as files on the pod: the game writes
`{guid}.pr` (print) and `{guid}.mr` (mission review) into `\mw4files`
(`mw4/Code/MW4/recscore.cpp:246-249`). `S_SendFile` is only a *notification*; the console
reads the file over SMB as `\\<pod>\mw4files\{guid}.pr` and hands it to the `mw4print`
helper via `WM_COPYDATA`. Mission review is replayed by sending the path to the
cameraship pod flagged `m_bMissionReview`.
Also free, for the same reason: **plasma displays and the RIO board are the game's own
business** on FireStorm pods (`PLASMA_Do`, `mw4/Code/MW4/CRIOMAIN.CPP:468`, gated by
`-noplasma`). The console does not drive them, unlike BT4/RP4.
---
## Options considered, and the decision
**A — Pure C# CTCL client in TeslaConsole.** Port the protocol *and* the bit-packed NMP
serializer *and* a per-build content-table extractor. No game-side change.
**Rejected:** the NMP layout is engine-internal and unversioned, so this would need
reworking every time the FireStorm codebase changes. Maintenance nightmare for the
value returned.
**B — Teach MW4 a console-friendly protocol.** We have a working VC6 build environment for
FireStorm (`build-env/`, with a verified Release build in `rel.bin/`). Add a message on
:1001 that accepts a plain mission spec (map name, rules, per-player mech names) and let
the *server pod* build the NMP locally with the engine's own serializer. The console then
never touches bit-packing, map CRCs or resource IDs.
**Viable — this is the option if we ever do it.** Cost is a FireStorm rebuild and a
redeploy to every pod.
**C — Pod-level control only** (`C_GameInfo` + `C_OrderAppl`: state, launch, terminate,
end mission, shutdown, reboot).
**Not needed.** Site Management already gives us reboot/shutdown/launch/kill through our
own TeslaLauncher; those same functions being built into MW4's `launcher.exe` is
redundant with what we already use. And if mission parameters still have to be set on the
MW4 console, there is no reason to come back to the TeslaConsole just to start and stop
the mission — the operator is already sitting at the machine that can do it.
---
## If B is ever picked up
Rough shape of the work:
1. **Game side (C++/VC6).** New packet id on :1001 carrying a text/JSON mission spec.
Handler calls the existing `CTCL_DefaultHostSetup` + the `MW4Shell` parameter setters
(`MAP_ID_PARAMETER` etc. — they already recompute `m_mapClientCRC` and the mech
ResourceIDs locally), then enters the normal `g_nMech4Comm` path. Reuse
`C_ReadyStartGame`/`C_BOTS`/`C_SetMechs`/`C_DoLaunch` unchanged between pods.
Remember: `ctcl.cpp` exists as **four hand-copied siblings** (Launcher,
MW4Application, MW4GameEd2, Tools\AnimScript) — change every copy.
2. **Console side (C#).** A `CtclGame` sibling to
[`Console/TeslaConsole/MungaGame.cs`](Console/TeslaConsole/MungaGame.cs) — same
connect/poll/own shape, ~400 lines — plus a `FSGame` pane modelled on `BTGame`, minus
everything telemetry-driven. `Pod`/`Site`/`AppRegistry` and the TeslaLauncher RPC are
already protocol-agnostic and need no change.
3. **vPOD.** A CTCL mode: listen on 1000/1001, answer `C_GameInfo`, walk the `_EGR_*`
ladder. Without it none of this is testable off-cockpit.
4. **Results.** Decide whether to parse `.pr` files ourselves or keep shelling out to
`mw4print`.
### Open questions
- **Which FireStorm build is canonical?** `FS Build V4H`, `FS507C_20160909` and
`FS507D_20161015` are all on this machine; content tables and map CRCs differ per build.
- **`ctcl.ini` ownership.** CTCL's pod list is hardcoded to `c:\ctcl.ini` and uses the
2005-era `200.0.0.x` scheme; TeslaConsole owns pod addressing via `Site`. Either the
console writes `ctcl.ini` at Install Product time, or the pod list becomes console-side
only (pods only ever read `[games]` + `[config]`).
- **Server designation UI.** Positional today; worth making explicit if pods differ in spec.
- **Known shipped bug** (`Gameleap/code/Launcher/ctcl.cpp:1310`): the remote-launch table
looks up the cameraship command under key `#1`, but every shipped ini writes `*1`. A
console `Launch` aimed at a cameraship pod silently does nothing. Cameraships were
evidently autostarted, not console-launched.
- `_EAT_RP` = 2 exists in `ctcl_params.h` alongside `_EAT_MW4` = 1 — CTCL had a Red Planet
application type. Vestigial as far as this repo is concerned; the second `[games]` slot.
## Source map
| Thing | Where (relative to `C:\VWE\firestorm`) |
|-------|------|
| Protocol + manager, game copy | `Gameleap/code/mw4/Code/MW4Application/ctcl.cpp` |
| Protocol + manager, launcher copy | `Gameleap/code/Launcher/ctcl.cpp` (built `/D CTCL_LAUNCHER`) |
| Constants (roles, orders, states) | `ctcl_params.h` (four copies) |
| Message ids + structs | `ctcl.h` |
| Socket library / framing | `Gameleap/code/Launcher/mugSocs.cpp`, `mugsocs.h` |
| Shared-state DLL | `Gameleap/code/ctcls/ctcls.cpp` |
| NMP serialization | `Gameleap/code/mw4/Code/MW4/MWApplication.cpp:749-864` |
| Session create/join | `Gameleap/code/mw4/Code/MW4Application/MW4Application.cpp:1708-1800` |
| Console mission build | `Gameleap/code/mw4/Code/MW4/MW4Shell.cpp:13369-13548` |
| Console UI (shell script) | `Gameleap/mw4/Content/ShellScripts/ConLobby.script` |
| Launcher link (already documented) | `LAUNCHER-AND-MW4.md` |
+510
View File
@@ -0,0 +1,510 @@
# Deploying and commanding a game in a Tesla pod bay
**Audience:** developers of games being brought to the Tesla cockpit pods —
current and future titles alike. This is the integration
contract from the game's point of view: what your package must look like so the
operator console can **deploy** it to pods, and what your executable must speak
so the console can **command** it through a mission.
Everything here is implemented and validated in this repo (TeslaSuite v4.11.4.x)
— file references point at the authoritative source.
---
## 1. The big picture: two independent channels
A pod (cockpit PC) runs two things that matter to you:
| Channel | Port | Who listens | Purpose |
|---|---|---|---|
| **Launcher RPC** | TCP **53290** | `TeslaLauncher.exe` (pod tray app) | Deploy: install/uninstall packages, register launch entries, launch/kill your exe, volume, reboot |
| **Munga game control** | TCP **1501** | **your game exe** | Command: mission load (the "egg"), run/stop/abort/suspend/resume, state polling, in-mission events |
These are separate. The launcher channel is fully game-agnostic — any exe can be
deployed and launched with **zero code changes** to your game. The Munga channel
is what your game implements if the console is to drive missions in it.
That split gives two integration tiers:
- **Tier 0 — deploy + launch only.** The console installs your package, starts
and stops your exe, and keeps it alive (watchdog). Your game runs its own show.
Examples in the shipped catalog: BattleTech Firestorm, RIOJoy.
Requires only §2 (a package + a catalog entry).
- **Tier 1 — full mission command.** Your game is a Munga TCP server; the console
streams it a mission egg, drives the state machine, and receives scoring
events. Examples: Red Planet 4.11 (`rpl4opt.exe`), BattleTech 4.11
(`btl4.exe`), TeslaRel410 (supervisor wrapping the DOS games). Requires §2 + §3,
plus a console-side game module (§3.7).
There is one escape hatch: a game that Munga control would not serve well may
ship its own dedicated **game console** instead (§3.9) — deployment still goes
through Tier 0 unchanged.
---
## 2. Deployment spec (Tier 0 — every game needs this)
### 2.1 The package zip
The console's **Manage Site → Install Product** streams a zip to the pod; the
launcher extracts it into the games root **`C:\Games`** (the zip is opened at
that root, not inside a product folder). Lay the zip out as:
```
YourGame.zip
├── YourGame\ ← your product folder → becomes C:\Games\YourGame\
│ ├── yourgame.exe
│ ├── (data files...)
│ └── pre-uninstall.bat ← optional; run on uninstall (driver/config removal)
└── postinstall.bat ← optional; at ZIP ROOT; run once after extract, then deleted
```
Rules and lifecycle (implementation: [Launcher/TeslaLauncher.cs](Launcher/TeslaLauncher.cs),
[Launcher/MiniZip.cs](Launcher/MiniZip.cs)):
- **Everything must extract under one `C:\Games\<Product>\` folder** (plus the
optional root `postinstall.bat`). Uninstall deletes `C:\Games\<Product>`
recursively — don't scatter files elsewhere unless `postinstall.bat` puts them
there and `pre-uninstall.bat` removes them.
- **`postinstall.bat`** (zip root) runs after extraction with the launcher's
token — the kiosk account is an Administrator, so driver installs work (RIOJoy
installs ViGEmBus this way). It is waited on, then deleted.
- **`pre-uninstall.bat`** (inside your product folder) runs before the folder is
deleted on uninstall.
- Zip format: stored + deflate (+ ZIP64) — the launcher uses its own extractor
(`MiniZip.cs`), no exotic compression methods.
- Install progress reported to the operator: 050% receive, 5095% extract,
~96% postinstall, 99100% complete.
- **OS range:** pods run **Windows XP SP3 through Windows 11** on one image.
The *suite itself* is deliberately held to the XP floor (one net40 binary
set) to retain compatibility with the original cockpit hardware. For new
games, XP support is a **nice-to-have, not a requirement**: meeting it
(native exes: x86 + XP-safe API surface; .NET exes: net40 — runs in-place on
Win10/11's 4.8 runtime) lets your game reach the original-hardware pods too.
If you skip it, note modern-pods-only in your catalog entry's comment so
operators don't push it to XP-era machines.
Existing package builders to crib from: [vPOD/pack.ps1](vPOD/pack.ps1)
(minimal) and TeslaRel410's `deploy\package.ps1` (in its own repo — produces
an Install-Product-ready zip with postinstall).
### 2.2 The catalog entry (`Apps.xml`)
The console's product menu is data-driven from
[Console/RedPlanet/Apps.xml](Console/RedPlanet/Apps.xml) (parser:
[Console/TeslaConsole/AppRegistry.cs](Console/TeslaConsole/AppRegistry.cs)).
Your game ships as one `<Product>` element:
```xml
<Product id="NEW-GUID-HERE" fresh Guid; also the install key
name="Your Game" friendly name (menus, status text)
menuText="Your Game..." exact Install Product submenu text
hostTypeDialog="false"> ← "true" only if you have LC/MR roles
<Launch key="NEW-GUID-HERE" first entry reuses the product id
displayName="Your Game" name in the pod's app list
exe="C:\Games\YourGame\yourgame.exe"
args="-net 1501{res}" whatever your exe takes; see below
workingDirectory="" optional; defaults to exe's folder
autoRestart="true"
hostType="None" /> ← GameClient|LiveCamera|MissionReview|None
</Product>
```
- **Key convention** (documented in the Apps.xml header — follow it exactly):
generate ONE fresh Guid for the product id; the first `<Launch>` reuses it;
each additional `<Launch>` increments the **last hex digit** (+1, +2…,
wrapping F→0). Never append `-1`/`-2` to the string — keys parse as
`System.Guid` and a suffixed string silently collapses to `Guid.Empty`.
- **`{res}`** in `args` expands to ` -res W H` when the operator picks a custom
resolution, else to nothing. Only use it if your exe accepts `-res W H`
(the RP411 engine convention); a game with different resolution flags just
pins them in `args`.
- **`autoRestart="true"`** enables the pod watchdog (§2.3). Almost always what
you want for a game client.
- **`hostTypeDialog="true"` + per-entry `hostType`** is for games with separate
live-camera / mission-review roles (RP/BT use `-lc` / `-mr` flags) — see §4
for what those stations do. A plain game ships one entry with
`hostType="None"` and `hostTypeDialog="false"`.
- XML gotcha: comments in this file must not contain `--``XmlDocument.Load`
throws and the whole catalog comes up empty.
Registering entries on pods does **not** require reinstalling files: the
console's **Register Product on Pods** context action pushes the catalog's
launch entries to connected pods over the `InstallApp` RPC. The wire shape is
`LaunchData { LaunchPair{LaunchKey, DisplayName}, WorkingDirectory, ExeFile,
Arguments, AutoRestart }` ([Contract/WireContract.cs](Contract/WireContract.cs)).
### 2.3 Launch / kill / watchdog semantics
What the launcher does with your entry
([Launcher/TeslaLauncher.cs](Launcher/TeslaLauncher.cs)):
- **LaunchApp** starts `exe` with `args`, working directory = `workingDirectory`
or the exe's folder. Missing exe → clean "registered but not yet installed"
error at the console (register-first / install-later is supported).
- **Kill** terminates the process. Console-ordered kills stay down.
- **Watchdog:** an `autoRestart` entry whose process **exits on its own** is
relaunched ~2 s later. The original games lean on this for their per-mission
cycle: `rpl4opt`/`btl4` **terminate after each mission** and the watchdog
brings a fresh process up waiting for the next egg. That exit-and-relaunch
cycle is **not strictly required** (FireStorm doesn't do it): a game may
instead stay resident and return itself to a dark waiting state, ready for
the next group (§3.6). Either way, design your exe so a cold start goes
straight to that ready state with no menus in the way — the watchdog is
still your crash recovery.
- Your process runs in the auto-logged-in kiosk session (account `Firestorm`,
Administrator, UAC disabled) — desktop, audio, and DirectX/OpenAL are all
available. Firewall is disabled on pods; don't ship your own rules.
---
## 3. Command spec (Tier 1 — the Munga protocol)
Reference implementations, in order of usefulness:
- **[vPOD/MungaPodServer.cs](vPOD/MungaPodServer.cs)** — the framing, complete
and commented (vPOD is a working software pod; the console can't tell it from
a real one).
- **[vPOD/PodSimulator.cs](vPOD/PodSimulator.cs)** — the pod-side state machine
and egg handling.
- **[Console/TeslaConsole/MungaGame.cs](Console/TeslaConsole/MungaGame.cs)** —
the console side you're talking to.
- The typed message classes live in the vendored `Console/lib/Munga Net.dll`;
the C++ originals are in the RP411 game repo.
### 3.1 Transport
**Your game is the TCP server.** Listen on **TCP 1501**; the console connects to
`<podIP>:1501` and keeps one connection open. One console at a time (a new
connection replaces the old — see `MungaPodServer.AcceptLoop`). Convention: the
port is passed on your command line (`-net 1501`) rather than hardcoded.
### 3.2 Framing (little-endian throughout)
Every message, both directions:
```
[16-byte NetworkPacketHeader][MungaMessage]
header: int32 ClientID | int32 GameID | int32 FromHost | int32 Timestamp(ms tick)
message: int32 MessageLength | int32 MessageID | int32 Flags | body...
```
`MessageLength` counts the 12-byte message base **but not** the 16-byte header.
Messages are dispatched by **(ClientID, MessageID)** pairs.
### 3.3 Message set
Console → pod (what you must accept):
| ClientID | MessageID | Message | Your reaction |
|---|---|---|---|
| Application | 3 | `StateQuery` | reply `StateResponse(host, state, appId)` |
| Application | 4 | `CheckLoad` | (load probe) |
| Application | 5 | `RunMission` | `WaitingForLaunch → LaunchingMission → RunningMission` |
| Application | 6 | `StopMission` | `RunningMission → EndingMission → exit` (§3.6) |
| Application | 8 | `SuspendMission` | `RunningMission → SuspendingMission` (pause) |
| Application | 9 | `ResumeMission` | `SuspendingMission → ResumingMission → RunningMission` |
| Application | 10 | `LoadMission` | `WaitingForEgg → LoadingMission` |
| Application | 11 | `AbortMission` | `AbortingMission → WaitingForEgg` (no results) |
| Application | 12 | `LightsOutMission` | cockpit lights-out |
| NetworkManager | 3 | `EggFile` | egg chunk — buffer it (§3.5) |
Pod → console (what you send): `StateResponse` (answer to every `StateQuery`),
`AcknowledgeEggFile` (NetworkManager 4, once the egg is complete), and the
in-mission event messages (§3.6).
### 3.4 Identity and state
- **`ApplicationID`** — which game this pod is running, reported in every
`StateResponse`. The enum lives in `Munga Net.dll`: `RPL4 = 0` (Red Planet),
`BTL4 = 1` (BattleTech), plus `NDL4`. **A brand-new title needs a new value**
agreed with the TeslaSuite side (the console maps `ApplicationID` → game
module), or it reuses an existing one if it's a port of that game.
- **`ApplicationState`** — the cockpit state machine. The values the console
drives/observes: `InitializingState, WaitingForEgg, LoadingMission,
WaitingForLaunch, LaunchingMission, RunningMission, SuspendingMission,
ResumingMission, EndingMission, AbortingMission, CreatingMission`.
- The console polls `StateQuery` about **once per second** and gates every
operator action on your reported state. Report honestly — the console's UI
("busy, must stop first", ready-to-run, etc.) is driven entirely by it.
The normal lifecycle:
```
boot → InitializingState → WaitingForEgg
← egg streamed (console sends it when it sees WaitingForEgg)
→ LoadingMission → WaitingForLaunch (send AcknowledgeEggFile)
← RunMission
→ LaunchingMission → RunningMission
← StopMission (once, at mission end)
→ EndingMission → back to WaitingForEgg, either by:
exiting (watchdog relaunches a fresh process — the original games), or
resetting in-process to a dark waiting state
```
### 3.5 The egg (mission definition)
The console streams the mission as **`EggFileMessage` chunks of ≤1000 bytes**
(`index, totalLength, thisLength, buffer`). Reassemble by index until
`totalLength` bytes have arrived, then send **`AcknowledgeEggFileMessage`** and
move to `LoadingMission`.
Content: ASCII, INI-style sections, with every `key=value` separated by **NUL**
(`\0`) on the wire (the console builds it with `\n` separators and replaces them
before encoding). Parse by section name — section **order is not guaranteed**.
General shape (full field-by-field spec for an existing game:
[410console/battletech-port/BATTLETECH-PORT-SPEC.md](410console/battletech-port/BATTLETECH-PORT-SPEC.md) §2):
```
[mission] adventure= map= scenario= time= weather= temperature= length=...
[pilots] pilot=<podIP> ← one line per participant
[<podIP>] hostType= name= vehicle= dropzone= color= ... ← per-participant
[ordinals] 1st4th place plasma bitmaps (128×32)
[BitMap::Large::<pilot>] 128×32 pilot-name plasma bitmap
[BitMap::Small::<pilot>] 64×16 variant
```
- Participants are keyed by **pod IP**. `hostType` assigns the pod's role:
`0` = game machine, `2` = mission review / camera, `3` = console
([Console/TeslaConsole/HostType.cs](Console/TeslaConsole/HostType.cs)).
- The `[BitMap::*]`/`[ordinals]` sections are pre-rendered graphics for the
cockpit's 128×32 plasma scoreboard — the console authors them; your game just
forwards them to the plasma display if the cockpit has one.
- Egg *content* is game-specific; the envelope above (chunking, ack, NUL
delimiting, sections) is fixed.
### 3.6 Mission end, events, results
- **In-mission events** are pod → console `MungaMessage`s. Red Planet sends
`Scored / Killed / Damaged / Boost / ScoreUpdate`; BattleTech's set maps its
DamageMatrix/KillMarker model. Your game defines its own set, but the console
module (§3.7) must know how to decode it — coordinate the two.
- **Mission end / egress:** the console is the **sole mission timekeeper**. It
sends **one** `StopMission` when mission time expires. Any end-of-mission
ritual (RP/BT hold pilots in the cockpit ~30 s of egress) is the *game's* own
behavior after receiving it — the console does not send a second stop. After
egress the game must end up back in a **dark waiting state** reporting
`WaitingForEgg`, ready for the next group. The original games get there by
**exiting** — the launcher watchdog relaunches a fresh process (§2.3) and the
console reconnects — but staying resident and resetting in-process is equally
valid (FireStorm-style); the console only acts on the state you report and
tolerates either a dropped-and-reconnected or a continuously open socket.
- `AbortMission` is the operator bailing out: return to `WaitingForEgg`
(via `AbortingMission`), no results expected.
### 3.7 The console side of Tier 1
Commanding a game isn't only pod-side work — the console needs a per-game module
that builds the egg and provides the mission UI:
`Console/TeslaConsole.<YourGame>/` mirroring
[Console/TeslaConsole.RedPlanet/](Console/TeslaConsole.RedPlanet/)
(mission classes + `ToEggString()`, a config XML catalog of maps/vehicles/
scenarios, the game pane driving `MungaGame`). The BattleTech port spec
([BATTLETECH-PORT-SPEC.md](410console/battletech-port/BATTLETECH-PORT-SPEC.md))
is the worked example of adding one — budget for it in your plan, and open the
conversation with the TeslaSuite maintainers early (ApplicationID assignment,
event vocabulary, egg fields).
### 3.8 If your game already has its own control protocol
Precedent: BattleTech FireStorm (MechWarrior 4) speaks **CTCL** on ports
1000/1001, not Munga — researched and parked in
[FIRESTORM-CTCL.md](FIRESTORM-CTCL.md). The standing guidance: **do not teach
the console a second protocol**. Put an adapter on the pod that speaks Munga to
the console and your native protocol to the game (TeslaRel410 does exactly this:
`pod-launch.exe` supervises the DOS game and fronts for it). The console then
sees a normal Munga pod.
### 3.9 Escape hatch: a dedicated game console
If Munga control — direct or through a §3.8 adapter — would be **limiting or
detrimental to the game experience** (the mission model doesn't map to the
egg/state machine, the game needs richer or real-time operator control than
load/run/stop, the adapter would cost fidelity), you may instead build a
separate **game console**: a purpose-built operator application for your game,
run on the console computer alongside TeslaConsole. FireStorm is the precedent —
MechWarrior 4 venues ran their own dedicated console rather than bending the
game to the Tesla mission model ([FIRESTORM-CTCL.md](FIRESTORM-CTCL.md)).
Rules if you take this path:
- **It ships inside the same deployment zip as the pod-side game.** One Install
Product archive is the whole product — no separate installer, no side-channel
distribution. Put it in a subfolder of your product
(e.g. `YourGame\GameConsole\`); the pod-side extraction just carries the
folder along, and the operator runs it from that same archive on the console
machine.
- It runs on the **console computer**, never on pods.
- **TeslaConsole still owns deployment and process lifecycle** (§2):
install/uninstall, launch/kill, and the watchdog all stay on the launcher
channel. Your game console owns only in-game command — it is a replacement
for §3.13.7, not for §2.
- Don't collide with the suite's ports on either end: 1501, 53290, 53291/53292
are spoken for.
---
## 4. Presentation: live camera, mission review, score sheets
A pod bay is more than cockpits: spectators watch the current game on a bay
display, players coming out of the pods watch a replay at the mission-review
station, and everyone walks away with a printed score sheet. Plan for all
three.
### 4.1 The bay address plan
Modern bay deployments follow the FireStorm/CTCL site layout: squads of eight
pods per address decade, with the `9`/`10` slots of each decade reserved for
stations.
| Address | Station |
|---|---|
| `x.x.x.18` | Pods, squad 1 |
| `x.x.x.9` | **Live camera** |
| `x.x.x.10` | **Operator console** |
| `x.x.x.1118` | Pods, squad 2 |
| `x.x.x.19` | **Mission review** |
| `x.x.x.20` | **Score-sheet printer** |
| `x.x.x.2128` | Pods, squad 3 |
| `x.x.x.3138` | Pods, squad 4 — and so on: pods at `18` of every further decade, `9`/`10` slots reserved for future stations |
(Legacy RP/BT-era installs used the `200.0.0.x` scheme with the console at
`.1` and pods from `.11`. The suite doesn't hardcode either — addressing is
per-site via Manage Site — but new bays should follow the plan above.)
### 4.2 Live camera — presenting the current game
The live-cam station runs your game exe in a **spectator role**, rendering the
running mission on the bay display. The RP/BT model, which the console
generalizes:
- The station is a pod like any other — installed, launched, watchdogged —
whose catalog launch entry has `hostType="LiveCamera"`; RP/BT pass a `-lc`
flag so the exe boots into the camera role (§2.2).
- The console enrolls every enabled camera station in the mission as a
**camera participant**: it receives the same egg and walks the same Munga
state machine as a game pod, but its participant block says `hostType=2`,
`vehicle=camera`, `name=Camera`, `loadzones=0`
([RPCamera.cs](Console/TeslaConsole.RedPlanet/RPCamera.cs)).
- Your game's job in the role: observe the running mission and render a
spectator view — no cockpit input, no scoring participation. The camera's
view of the action travels over the game's own network traffic between pods;
the console only issues the egg and state commands.
### 4.3 Mission review — replaying the finished game
The mission-review station **replays the completed mission** for the players
who just climbed out. To the console it looks exactly like the live cam — a
`hostType="MissionReview"` catalog entry (`-mr` flag in RP/BT), enrolled as an
egg camera participant — the difference is entirely inside your game: the MR
role captures the mission as it runs and replays it on demand afterwards.
**Replay capture and playback are the game's responsibility.** The console does
not record or transport replay data: RP/BT capture from the game's own network
traffic; FireStorm writes `{guid}.mr` files pod-side and its console points the
review station at them ([FIRESTORM-CTCL.md](FIRESTORM-CTCL.md)).
### 4.4 Score sheets
Players get a printed score sheet. How it works for a Tier 1 game:
- During the mission the console builds results from your **in-mission event
messages** (§3.6) via the game module's mission recorder
([RPMissionRecorder.cs](Console/TeslaConsole.RedPlanet/RPMissionRecorder.cs)).
The event vocabulary is what makes score sheets possible — a game that
reports no events has nothing to print.
- The game module renders the results as a print document
([RPPrintDocument.cs](Console/TeslaConsole.RedPlanet/RPPrintDocument.cs)).
The operator's **Auto Print** checkbox prints each mission as it ends;
**Print Last Mission** reprints on demand. Output goes to the bay's
score-sheet printer (`x.x.x.20`, a network printer configured on the console
machine).
- A §3.9 dedicated game console owns its own scoring and printing (FireStorm:
the game writes `{guid}.pr` files pod-side, the console hands them to the
`mw4print` helper) — it should print to the same bay printer.
---
## 5. Pod environment reference
| Fact | Value |
|---|---|
| OS range | Windows XP SP3 → Windows 11, one binary set (.NET products: net40) |
| Session | auto-login kiosk account `Firestorm` (Administrator, UAC off) |
| Games root | `C:\Games\<Product>\` |
| Launcher state | `<CommonAppData>\TeslaLauncher\` (`LaunchApps.xml`, key store, log) |
| Network plan | squads of 8 pods per address decade; live cam `.9`, console `.10`, mission review `.19`, printer `.20` — see §4.1 |
| Ports | 1501 TCP game control (your game listens) · 53290 TCP launcher RPC · 53291/53292 UDP first-boot provisioning |
| Cockpit extras | 128×32 plasma scoreboard on COM2; RIO board cockpit controls — native integration preferred, RIOJoy shim optional (§5.1) |
| Firewall | disabled by the pod installer |
### 5.1 Cockpit controls: the RIO board
The cockpit's controls come in through the **RIO board**. Two integration
paths:
- **Native RIO integration — preferred.** Talk to the board directly, as the
original games do (FireStorm's `CRIOMAIN.CPP` is the documented precedent).
This is the only path that reaches the board's **output side** — the
**cockpit lighting** — which native games get as a feedback channel to the
player. A game that only reads a gamepad can't touch it.
- **RIOJoy — optional shim.** A deployable catalog product that feeds RIO
board input into a virtual gamepad (RioGamepad HID via the ViGEmBus driver;
Win10+ only) with per-game mapping profiles. Zero game-side changes — if
your engine already reads a standard gamepad, a RIOJoy profile gets a
cockpit playable. **Input only:** no lighting, no feedback.
A RIOJoy profile is a fine way to get playable quickly during development;
plan on native RIO integration for the real deployment so the cockpit lighting
works for your game. (The board interface and the feeder implementation live
in the RIOJoy repo.)
---
## 6. Testing your integration without a pod bay
- **[vPOD/](vPOD/)** is a full software pod: it emulates the launcher side
(install your zip into a real `C:\Games`, launch/kill with the real watchdog
semantics) *and* the Munga side. Two ways to use it:
1. **Test your package/catalog entry:** run vPOD on any machine, provision it
from the console (README walk-through), Install Product your zip, launch —
with "Actually launch apps" checked your real exe runs.
2. **Test your Munga implementation:** point the console at your game instead
of vPOD (the shipped site has a `local` pod at `127.0.0.1` — run your exe
with `-net 1501` on the console machine). vPOD's egg viewer is also handy:
drive a mission at vPOD, copy the egg it captures, and use it as a fixture
for your parser.
- Console-side changes are pinned by the differential suite
([Console/tests/TeslaConsole.DiffTests](Console/tests/TeslaConsole.DiffTests)).
---
## 7. Checklists
**To make your game deployable (Tier 0):**
- [ ] Package zip: `<Product>\` folder + optional root `postinstall.bat`,
optional `<Product>\pre-uninstall.bat` (§2.1)
- [ ] Runs on your target pod OS range — XP SP3 support is a nice-to-have that
reaches the original hardware (x86 + net40 if .NET); if skipped, catalog
comment says modern-pods-only
- [ ] `Apps.xml` `<Product>` entry, key convention respected (§2.2)
- [ ] Cold start reaches gameplay/ready state unattended (kiosk + watchdog)
- [ ] Cockpit controls wired: native RIO integration preferred (enables
lighting feedback), RIOJoy profile acceptable (§5.1)
- [ ] Install → launch → kill → uninstall verified against vPOD
**To make your game commandable (Tier 1), additionally** *(or, if Munga control
would hurt the game: a dedicated game console per §3.9, shipped in the same
deployment zip)*:
- [ ] TCP server on 1501 (`-net` arg), Munga framing per §3.2
- [ ] `StateQuery``StateResponse` with an agreed `ApplicationID`
- [ ] Egg reassembly + `AcknowledgeEggFile` + state walk to `RunningMission`
- [ ] `Stop/Abort/Suspend/Resume` honored; after mission end, back to a dark
`WaitingForEgg` — by exiting (watchdog relaunch) or by in-process reset
- [ ] Event vocabulary agreed with the console module — rich enough for score
sheets (§4.4)
- [ ] Live-camera and mission-review roles: spectator rendering + replay
capture/playback in the game (§4.24.3), LC/MR catalog entries (§2.2)
- [ ] Console game module exists or is planned (§3.7)
+15 -2
View File
@@ -156,6 +156,12 @@ namespace TeslaSecureConfig
_port = new System.IO.Ports.SerialPort(comPort, baud,
System.IO.Ports.Parity.None, 8, System.IO.Ports.StopBits.One);
_port.Open();
// Hide the text cursor once at startup, exactly as the game and the
// controller's own ROM demo do (ESC G 0). ESC @ does not restore it,
// so a single hide holds for the session.
_port.BaseStream.WriteByte(0x1B); // ESC
_port.BaseStream.WriteByte(0x47); // G
_port.BaseStream.WriteByte(0x00); // 0 -> cursor hidden
ClearAll();
}
catch (Exception ex)
@@ -166,12 +172,19 @@ namespace TeslaSecureConfig
}
}
// ClearAll: send ESC J (clear display) as per Plasma protocol
// Clear + home, per the PD01D221 controller's recovered command set (EPROM
// dump -> vrio/PlasmaNew/FIRMWARE.md; the ROM's own demo prefixes every
// screen with exactly these two). The previous code sent ESC J, which on
// this controller is NOT a clear -- it toggles an orientation/mode bit -- so
// the panel never cleared and never homed, and each provisioning cycle wrote
// stale text at a stale cursor position (garbled overlap in the field).
public void ClearAll()
{
if (_port == null || !_port.IsOpen) return;
_port.BaseStream.WriteByte(0x1B); // ESC
_port.BaseStream.WriteByte(0x4A); // J (clear screen)
_port.BaseStream.WriteByte(0x40); // @ -> clear active buffer, reset text state
_port.BaseStream.WriteByte(0x1B); // ESC
_port.BaseStream.WriteByte(0x4C); // L -> home cursor to (0,0)
_port.BaseStream.Flush();
}
+64 -118
View File
@@ -502,6 +502,11 @@ namespace Tesla.Launcher
private void CmdKillApp(Guid launchKey, int? pid)
{
var key = FindKeyString(launchKey);
var victims = new List<Process>();
// Untrack under the lock, terminate outside it: KillProcessTree spawns
// taskkill and waits, which must not block the RPC lock. Untracking
// first also stops our own auto-restart watcher from relaunching (its
// stillTracked check fails once the key is gone).
lock (_processLock)
{
List<Process> procs;
@@ -513,38 +518,86 @@ namespace Tesla.Launcher
foreach (var p in toKill)
{
try { if (!p.HasExited) p.Kill(); } catch { }
victims.Add(p);
procs.Remove(p);
}
if (procs.Count == 0) _runningProcesses.Remove(key);
}
KillProcessTrees(victims);
}
private void CmdKillAllOfType(Guid launchKey)
{
var key = FindKeyString(launchKey);
List<Process> victims;
lock (_processLock)
{
List<Process> procs;
if (key == null || !_runningProcesses.TryGetValue(key, out procs)) return;
foreach (var p in procs)
try { if (!p.HasExited) p.Kill(); } catch { }
if (key == null || !_runningProcesses.TryGetValue(key, out victims)) return;
_runningProcesses.Remove(key);
}
KillProcessTrees(victims);
}
private void CmdKillAllApps()
{
var victims = new List<Process>();
lock (_processLock)
{
foreach (var kvp in _runningProcesses)
foreach (var p in kvp.Value)
try { if (!p.HasExited) p.Kill(); } catch { }
victims.AddRange(kvp.Value);
_runningProcesses.Clear();
}
KillProcessTrees(victims);
SetTrayStatus("All apps stopped");
}
private static void KillProcessTrees(IEnumerable<Process> procs)
{
if (procs == null) return;
foreach (var p in procs) KillProcessTree(p);
}
// Every Tesla game runs under a supervisor that stays alive and re-spawns
// the game (Firestorm -> launcher.exe, Red Planet -> a looping .bat under
// cmd.exe, tesla410revival -> pod-launch.exe -> dosbox). net40's
// Process.Kill() terminates ONLY the tracked supervisor PID, so the game
// survives (and the supervisor/loop relaunches it). taskkill /T walks the
// parent-PID tree and terminates the supervisor AND its children in one
// shot; it ships on XP Professional and Win10/11. Process.Kill() remains
// the fallback if taskkill is somehow unavailable.
private static void KillProcessTree(Process p)
{
if (p == null) return;
int pid;
try
{
if (p.HasExited) return;
pid = p.Id;
}
catch { return; }
try
{
var psi = new ProcessStartInfo("taskkill", "/PID " + pid + " /T /F")
{
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
};
using (var tk = Process.Start(psi))
{
if (tk != null)
{
tk.WaitForExit(10000);
return;
}
}
}
catch { /* taskkill missing/failed — fall back to single-process kill */ }
try { if (!p.HasExited) p.Kill(); } catch { }
}
private void CmdShutdown(bool restart)
{
CmdKillAllApps();
@@ -938,10 +991,8 @@ namespace Tesla.Launcher
// ── Volume control ────────────────────────────────────────────────────
// The Console stores/retrieves volume as a float (0.01.0 scalar). We
// cache the exact value the Console sent so get returns the same value
// without device roundtrip quantization error.
//
// Setter chain: nircmd.exe (legacy, works everywhere incl. XP)
// → CoreAudio (Vista+) → winmm waveOutSetVolume (XP fallback).
// without device roundtrip quantization error. The device chain
// (nircmd → CoreAudio → winmm) lives in VolumeControl.cs, shared with vPOD.
private float _cachedVolumeScalar = 1.0f;
@@ -953,26 +1004,7 @@ namespace Tesla.Launcher
private void SetMasterVolume(float scalar)
{
_cachedVolumeScalar = scalar;
var nircmd = Path.Combine(GAMES_DIR, "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);
VolumeControl.SetMasterScalar(scalar, GAMES_DIR);
}
// ── Session key ───────────────────────────────────────────────────────
@@ -1201,91 +1233,5 @@ namespace Tesla.Launcher
public bool AutoRestart { 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.
// 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 { }
}
}
// (Core Audio / winmm volume interop moved to VolumeControl.cs — shared with vPOD.)
}
+1 -1
View File
@@ -18,7 +18,7 @@
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>4.11.4.3</Version>
<Version>4.11.4.5</Version>
<ApplicationIcon>app.ico</ApplicationIcon>
<AssemblyName>TeslaLauncher</AssemblyName>
<RootNamespace>Tesla.Launcher</RootNamespace>
+142
View File
@@ -0,0 +1,142 @@
// =============================================================================
// TeslaLauncher — system master volume (shared with vPOD)
// =============================================================================
// The Console's set_VolumeLevel ends here. Setter chain:
// nircmd.exe (legacy, works everywhere incl. XP, if present in the games dir)
// → Core Audio (Vista+) → winmm waveOutSetVolume (XP fallback).
//
// Like MiniZip.cs, this file is compiled into BOTH the launcher and vPOD
// (linked source): vPOD's "Actually set system volume" mode applies the
// console's volume commands through the exact code the real pod runs.
// =============================================================================
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
namespace Tesla.Launcher
{
internal static class VolumeControl
{
/// <summary>Sets the system master volume to <paramref name="scalar"/>
/// (clamped to 0.01.0). <paramref name="nircmdDir"/> is probed for
/// nircmd.exe first (the legacy path used on the original pods); the
/// Windows APIs are the fallback. Never throws.</summary>
public static void SetMasterScalar(float scalar, string nircmdDir)
{
scalar = Math.Max(0f, Math.Min(1f, scalar));
if (!string.IsNullOrEmpty(nircmdDir))
{
var nircmd = Path.Combine(nircmdDir, "nircmd.exe");
if (File.Exists(nircmd))
{
try
{
var p = Process.Start(nircmd, "setsysvolume " + (int)(scalar * 65535));
if (p != null) p.Dispose();
return;
}
catch { /* fall through to API */ }
}
}
if (Environment.OSVersion.Version.Major >= 6)
{
try { CoreAudio.SetMasterScalar(scalar); return; }
catch { /* fall through */ }
}
WinMmVolume.SetMasterScalar(scalar);
}
}
// ── Windows Core Audio API — minimal COM interop (Vista+) ─────────────────
// No external dependencies. Vtable slot order matches the SDK headers exactly.
// Methods we don't call are declared as void stubs to preserve vtable offsets.
// NOT available on XP — callers must gate on OS version.
internal static class CoreAudio
{
internal static void SetMasterScalar(float scalar)
{
var ep = GetEndpointVolume();
try { var ctx = Guid.Empty; ep.SetMasterVolumeLevelScalar(scalar, ref ctx); }
finally { Marshal.ReleaseComObject(ep); }
}
private static IAudioEndpointVolume GetEndpointVolume()
{
var enumerator = (IMMDeviceEnumerator)new MMAudioEnumeratorComClass();
try
{
IMMDevice device;
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out device);
try
{
var iid = typeof(IAudioEndpointVolume).GUID;
object obj;
device.Activate(ref iid, 23 /*CLSCTX_ALL*/, IntPtr.Zero, out obj);
return (IAudioEndpointVolume)obj;
}
finally { Marshal.ReleaseComObject(device); }
}
finally { Marshal.ReleaseComObject(enumerator); }
}
}
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
internal class MMAudioEnumeratorComClass { }
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMMDeviceEnumerator
{
void _unused_EnumAudioEndpoints(); // slot 0 — not used
[PreserveSig]
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
}
[Guid("D666063F-1587-4E43-81F1-B948E807363F"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMMDevice
{
[PreserveSig]
int Activate(ref Guid iid, int clsCtx, IntPtr pActivationParams,
[MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer);
}
// Vtable order (after IUnknown): RegisterControlChangeNotify(0),
// UnregisterControlChangeNotify(1), GetChannelCount(2),
// SetMasterVolumeLevel(3), SetMasterVolumeLevelScalar(4),
// GetMasterVolumeLevel(5), GetMasterVolumeLevelScalar(6)
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IAudioEndpointVolume
{
void _unused_RegisterControlChangeNotify(); // slot 0
void _unused_UnregisterControlChangeNotify(); // slot 1
void _unused_GetChannelCount(); // slot 2
[PreserveSig] int SetMasterVolumeLevel(float levelDB, ref Guid ctx);
[PreserveSig] int SetMasterVolumeLevelScalar(float level, ref Guid ctx);
[PreserveSig] int GetMasterVolumeLevel(out float levelDB);
[PreserveSig] int GetMasterVolumeLevelScalar(out float level);
}
// ── winmm wave-out volume (XP fallback) ───────────────────────────────────
// waveOutSetVolume with device -1 sets the wave mixer level of the default
// device: low 16 bits = left channel, high 16 bits = right channel.
internal static class WinMmVolume
{
[DllImport("winmm.dll")]
private static extern int waveOutSetVolume(IntPtr hwo, uint dwVolume);
internal static void SetMasterScalar(float scalar)
{
uint level = (uint)(Math.Max(0f, Math.Min(1f, scalar)) * 0xFFFF);
try { waveOutSetVolume(IntPtr.Zero, (level << 16) | level); } catch { }
}
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ for %%a in (%*) do (
echo.
echo ============================================================
echo Tesla Launcher v4.11.4.3 - Build ^& Package (net40 single binary)
echo Tesla Launcher v4.11.4.5 - Build ^& Package (net40 single binary)
echo Output : %BUILD_DIR%
echo ============================================================
echo.
+9 -4
View File
@@ -20,7 +20,7 @@
setlocal enabledelayedexpansion
echo ============================================================
echo Tesla Launcher v4.11.4.3 - Installation (single binary)
echo Tesla Launcher v4.11.4.5 - Installation (single binary)
echo ============================================================
echo.
@@ -142,12 +142,15 @@ if not exist "C:\Games" mkdir "C:\Games"
:: 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
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)
@@ -203,12 +206,14 @@ if "%ISXP%"=="0" (
)
:: 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"
if "%ISXP%"=="1" (
cacls "C:\mw4files" /T /E /G Everyone:C >nul 2>&1
) else (
icacls "C:\mw4files" /grant *S-1-1-0:(OI)(CI)M /T >nul 2>&1
icacls "C:\mw4files" /grant "*S-1-1-0:(OI)(CI)M" /T >nul 2>&1
)
net share mw4files /delete >nul 2>&1
net share mw4files=C:\mw4files /grant:Everyone,FULL >nul 2>&1
+38 -14
View File
@@ -4,14 +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 |
| [`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 service (provisioning + Site Management / Install Product on TCP 53290). | .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.
@@ -28,15 +35,21 @@ 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). 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
- `Console/original/TeslaConsole.exe` — the **4.11.3.37076** reference baseline the
@@ -47,12 +60,23 @@ console's Install Product (or run directly on any machine).
baselines** — both are now built from source (`Contract/`, `SecureConfig/`).
- `Console/RedPlanet/Apps.xml` — the data-driven product catalog (see the console's
Site Management → Add Product / Register Product on Pods).
- [`GAME-INTEGRATION.md`](GAME-INTEGRATION.md) — the integration spec for games being
brought to the pods: package/catalog requirements to **deploy** a game, and the Munga
protocol contract to **command** one. Start here when adding a new title.
- [`FIRESTORM-CTCL.md`](FIRESTORM-CTCL.md) — research notes on driving **BattleTech
FireStorm** (MechWarrior 4) from the console. FireStorm speaks CTCL, not Munga; the
document specs that protocol, the three blockers, and why the work is parked.
## History
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).
+13 -8
View File
@@ -29,9 +29,10 @@ namespace VPod;
/// - Install completion reports 99% (not 100) — the console's
/// InstallProductWorker breaks its retry loop only on 99.
///
/// All state lives in <see cref="VirtualLauncher" />; a packaged postinstall.bat
/// is logged and removed unrun unless <see cref="VirtualLauncher.RunPostInstall" />
/// is set from the vPOD window, in which case it is executed like the real service.
/// 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
{
@@ -39,6 +40,7 @@ internal sealed class LauncherRpcServer
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;
@@ -52,10 +54,11 @@ internal sealed class LauncherRpcServer
public bool IsListening => mRunning;
public LauncherRpcServer(VirtualLauncher launcher, int port = ManagePort)
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
@@ -67,12 +70,14 @@ internal sealed class LauncherRpcServer
return;
}
mSessionKey = sessionKey;
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-launcher-accept" };
mAcceptThread.Start();
Log?.Invoke($"Launcher RPC listening on TCP {mPort}.");
Log?.Invoke(mBind == null
? $"Launcher RPC listening on TCP {mPort}."
: $"Launcher RPC listening on TCP {mPort} ({mBind} only).");
}
public void Stop()
@@ -350,13 +355,13 @@ internal sealed class LauncherRpcServer
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 RunPostInstall;
// 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.RunPostInstall)
if (mLauncher.RunPackageScripts)
{
mLauncher.UpdateProgress(callId, 96, "Running postinstall...");
Log?.Invoke($"Install {callId:N}: running postinstall.bat...");
+13 -2
View File
@@ -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()
+35
View File
@@ -1,4 +1,5 @@
using System;
using System.Net;
using Munga.Net;
namespace VPod;
@@ -18,11 +19,35 @@ namespace VPod;
/// vPOD-only (not a real game-client option):
/// <c>-nomanage</c> disable the virtual launcher / site-management side
/// (no provisioning beacons, no TCP 53290 listener)
/// <c>-bind &lt;ip&gt;</c> listen on ONE address instead of every interface.
/// Both listeners default to IPAddress.Any, which means
/// the second vPOD on a machine loses the port and the
/// console can only ever see one fake pod. Binding each
/// instance to its own address lets a whole roster run
/// side by side -- 200.0.0.111..118 are the internet
/// session's slot addresses, so an eight-pod session can
/// be exercised end to end with no cockpits and nobody
/// else in the room. Add the aliases first, e.g.
/// netsh interface ipv4 add address "Loopback" 200.0.0.113 255.255.255.0
///
/// DO NOT leave an unbound vPOD running alongside a bound
/// roster. Verified on this box 2026-07-25: Windows lets
/// IPAddress.Any bind the SAME port while specific
/// addresses already hold it (Linux would refuse). The
/// specific listeners still win for their own addresses,
/// but the unbound one silently swallows every address no
/// one claimed -- so a slot you thought was absent answers
/// anyway, which is precisely the stale-pod confusion the
/// console's claim gate exists to catch. Bind all of them
/// or none of them.
/// </summary>
internal sealed class PodArguments
{
public int Port { get; private set; } = MungaSocket.ConsolePort; // 1501
/// <summary>Address both listeners bind to; null = every interface (default).</summary>
public IPAddress Bind { get; private set; }
public ApplicationID Application { get; private set; } = ApplicationID.RPL4;
public HostType HostType { get; private set; } = HostType.GameMachineHostType;
@@ -46,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;
+35 -14
View File
@@ -55,9 +55,10 @@ internal sealed class VPodForm : Form
private Label mInstallStatusLabel;
private ProgressBar mInstallProgressBar;
private Button mReprovisionButton;
private CheckBox mRunPostInstallCheckbox;
private CheckBox mRunScriptsCheckbox;
private CheckBox mRealLaunchCheckbox;
private CheckBox mRealAutoRestartCheckbox;
private CheckBox mRealVolumeCheckbox;
private ListView mAppsView;
private bool mPoweredOn;
@@ -71,10 +72,10 @@ internal sealed class VPodForm : Form
public VPodForm(PodArguments options)
{
mOptions = options;
mServer = new MungaPodServer(options.Port);
mServer = new MungaPodServer(options.Port, options.Bind);
mSimulator = new PodSimulator(mServer, options.Application, options.HostId);
mLauncher = new VirtualLauncher();
mRpcServer = new LauncherRpcServer(mLauncher);
mRpcServer = new LauncherRpcServer(mLauncher, LauncherRpcServer.ManagePort, options.Bind);
mProvisioning = new PodProvisioning(PodProvisioning.MacForHost(options.HostId));
BuildUi();
@@ -283,7 +284,7 @@ internal sealed class VPodForm : Form
Padding = new Padding(8)
};
Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 278 };
Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 300 };
mProvisionStatusLabel = new Label
{
@@ -354,21 +355,22 @@ internal sealed class VPodForm : Form
};
mReprovisionButton.Click += ReprovisionClicked;
// Off (default) = a packaged postinstall.bat is logged and removed unrun;
// on = it is executed at the end of an install, like the real Agent.
mRunPostInstallCheckbox = new CheckBox
// Off (default) = packaged product scripts (postinstall.bat on install,
// pre-uninstall.bat on uninstall) are logged and removed unrun; on =
// they are executed like on the real pod.
mRunScriptsCheckbox = new CheckBox
{
Text = "Run postinstall.bat after install",
Text = "Run package install/uninstall scripts",
Location = new Point(4, 206),
AutoSize = true,
Checked = false
};
mRunPostInstallCheckbox.CheckedChanged += (s, e) =>
mRunScriptsCheckbox.CheckedChanged += (s, e) =>
{
mLauncher.RunPostInstall = mRunPostInstallCheckbox.Checked;
OnSiteLog(mRunPostInstallCheckbox.Checked
? "Installs now EXECUTE a packaged postinstall.bat."
: "Installs skip (and remove) a packaged postinstall.bat.");
mLauncher.RunPackageScripts = mRunScriptsCheckbox.Checked;
OnSiteLog(mRunScriptsCheckbox.Checked
? "Packaged postinstall.bat / pre-uninstall.bat now EXECUTE."
: "Packaged postinstall.bat / pre-uninstall.bat are skipped (and removed).");
};
// Off = LaunchApp records simulated PIDs; on = start/kill real processes
@@ -408,6 +410,24 @@ internal sealed class VPodForm : Form
: "Watchdog OFF: exited apps stay down.");
};
// Off (default) = set_VolumeLevel only stores/echoes the float; on = it
// drives this machine's real master volume through the launcher's own
// chain (nircmd in the games root -> Core Audio -> winmm).
mRealVolumeCheckbox = new CheckBox
{
Text = "Actually set system volume",
Location = new Point(4, 272),
AutoSize = true,
Checked = false
};
mRealVolumeCheckbox.CheckedChanged += (s, e) =>
{
mLauncher.RealVolume = mRealVolumeCheckbox.Checked;
OnSiteLog(mRealVolumeCheckbox.Checked
? "set_VolumeLevel now changes the REAL system master volume."
: "set_VolumeLevel now only stores the value (no audio change).");
};
siteTop.Controls.Add(mProvisionStatusLabel);
siteTop.Controls.Add(mPassphrasePanel);
siteTop.Controls.Add(mNetConfigLabel);
@@ -415,9 +435,10 @@ internal sealed class VPodForm : Form
siteTop.Controls.Add(mInstallStatusLabel);
siteTop.Controls.Add(mInstallProgressBar);
siteTop.Controls.Add(mReprovisionButton);
siteTop.Controls.Add(mRunPostInstallCheckbox);
siteTop.Controls.Add(mRunScriptsCheckbox);
siteTop.Controls.Add(mRealLaunchCheckbox);
siteTop.Controls.Add(mRealAutoRestartCheckbox);
siteTop.Controls.Add(mRealVolumeCheckbox);
mAppsView = new ListView
{
+72 -6
View File
@@ -6,6 +6,7 @@ using System.Threading;
// No System.Text.Json on net40 — persistence goes through Newtonsoft, which
// writes the same JSON shape (public fields, PascalCase; see PodRpcProtocol).
using Newtonsoft.Json;
using Tesla.Launcher;
using Tesla.Net;
namespace VPod;
@@ -53,11 +54,20 @@ internal sealed class VirtualLauncher
/// exactly like the real pod. Toggled from the vPOD window.</summary>
public bool RealAutoRestart { get; set; } = true;
/// <summary>When set, a packaged postinstall.bat is executed at the end of a
/// product install (like the real Agent) before being deleted, instead of
/// being logged and removed unrun. Toggled from the vPOD window; off by
/// default, since it runs package script code on the host machine.</summary>
public bool RunPostInstall { get; set; }
/// <summary>When set, packaged product scripts are executed like on the real
/// pod: postinstall.bat at the end of a product install (before being
/// deleted), and pre-uninstall.bat before the product directory is removed
/// on uninstall. Off by default — both run package script code on the host
/// machine — in which case they are logged and removed unrun. Toggled from
/// the vPOD window.</summary>
public bool RunPackageScripts { get; set; }
/// <summary>When set, the console's set_VolumeLevel changes this machine's
/// REAL master volume through the launcher's own chain (nircmd.exe in the
/// games root → Core Audio → winmm), exactly like the real pod. Off by
/// default: the value is only stored and displayed. Toggled from the vPOD
/// window.</summary>
public bool RealVolume { get; set; }
// Bumped to cancel watchdog restarts pending in their 2 s delay — the pod
// "machine" went dark (power off / reboot / reprovision), so nothing may
@@ -140,7 +150,17 @@ internal sealed class VirtualLauncher
set
{
lock (mLock) { mVolumeLevel = value; }
Log?.Invoke($"Volume set to {value:P0}.");
if (RealVolume)
{
// The launcher's own device chain; probes GamesRoot for
// nircmd.exe first, like the real pod's GAMES_DIR.
VolumeControl.SetMasterScalar(value, GamesRoot);
Log?.Invoke($"Volume set to {value:P0} (applied to system).");
}
else
{
Log?.Invoke($"Volume set to {value:P0} (simulated).");
}
}
}
@@ -235,6 +255,7 @@ internal sealed class VirtualLauncher
{
if (Directory.Exists(cleanupDir))
{
RunPreUninstallScript(cleanupDir);
Directory.Delete(cleanupDir, recursive: true);
Log?.Invoke($"UninstallApp: removed product directory {cleanupDir}");
}
@@ -248,6 +269,51 @@ internal sealed class VirtualLauncher
AppsChanged?.Invoke();
}
/// <summary>The real Agent's pre-uninstall hook: a product may ship a
/// pre-uninstall.bat (e.g. RIOJoy removes its driver) that runs before its
/// directory is deleted. Executed only when <see cref="RunPackageScripts" />
/// is opted in — otherwise logged and removed unrun (it dies with the
/// directory). Mirrors the launcher's CleanupProductDirectory.</summary>
private void RunPreUninstallScript(string productDir)
{
string preUninstall = Path.Combine(productDir, "pre-uninstall.bat");
if (!File.Exists(preUninstall))
{
return;
}
if (!RunPackageScripts)
{
Log?.Invoke("UninstallApp: pre-uninstall.bat present — NOT executed (vPOD), removed with the directory.");
return;
}
Log?.Invoke("UninstallApp: running pre-uninstall.bat...");
try
{
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = preUninstall,
WorkingDirectory = productDir,
UseShellExecute = false,
CreateNoWindow = true
};
using (Process proc = Process.Start(psi))
{
if (proc != null && proc.WaitForExit(120000))
{
Log?.Invoke($"UninstallApp: pre-uninstall.bat exited with code {proc.ExitCode}.");
}
else if (proc != null)
{
Log?.Invoke("UninstallApp: pre-uninstall.bat still running after 2 min — continuing.");
}
}
}
catch (Exception ex)
{
Log?.Invoke($"UninstallApp: pre-uninstall.bat failed to run: {ex.Message}");
}
}
public void RemoveApp(Guid launchKey)
{
lock (mLock)
+5 -2
View File
@@ -27,8 +27,8 @@
<RootNamespace>VPod</RootNamespace>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<!-- Versioned with the suite since v4.11.4.3 (was its own 1.0.0 line). -->
<AssemblyVersion>4.11.4.3</AssemblyVersion>
<Version>4.11.4.3</Version>
<AssemblyVersion>4.11.4.5</AssemblyVersion>
<Version>4.11.4.5</Version>
<Product>vPOD</Product>
</PropertyGroup>
@@ -58,6 +58,9 @@
behavior to the real pod service, and the differential suite's install
round-trip exercises MiniZip against real ZipArchive-built archives. -->
<Compile Include="..\Launcher\MiniZip.cs" Link="MiniZip.cs" />
<!-- The real pod's master-volume chain (nircmd -> CoreAudio -> winmm), for
the "Actually set system volume" mode. Same linked-source sharing. -->
<Compile Include="..\Launcher\VolumeControl.cs" Link="VolumeControl.cs" />
</ItemGroup>
<ItemGroup>