From e0d30120e0f3d0222fe1526ac366aee716603092 Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 10 Jul 2026 12:36:07 -0500 Subject: [PATCH] SiteConfigMerge tool + operating-model updates from operator input New: tools/SiteConfigMerge (net48 console app) - dump: decode any .siteconfig (squads, pods, IPs, MACs, host types) - merge: combine .siteconfig inputs into master.siteconfig, renaming squads "-" - Pod records pass through byte-for-byte; only squad records (the rename) are re-serialized, under the TeslaConsole assembly identity captured from the input. Stand-in types + SerializationBinder, so no build dependency on TeslaSuite. - Warns on duplicate pod GUID/MAC and cross-site IP overlap. - Verified end-to-end: the real TeslaConsole.exe 4.11.4.1 loaded the merged master via its own Site.LoadFromFile (reflection harness). Doc updates from operator decisions: - Operating model settled: sites voluntarily hand console authority to the central console for the duration of a SiteLink event, by contributing their siteconfig. Federation ruled out at current scale. - Siteconfig "secrets" framing corrected: pod keys have no practical value outside the air-gapped bay; files are exchanged per event and never stored in this repo (tools only). - Fleet scale recorded: 6 active pod bays, <120 cockpits in existence; bay sizes range console+2 cockpits up to the full 20-node complement. Open question 9 answered. - Hub hosting direction: neutral Firestorm host at the WireGuard hub; the FS server usually IS the Live Cam, so stream its output to all sites and optionally to the public internet. Mission Review instance runs at the hub too - one authoritative debrief streamed everywhere. - Virtual PDF scoresheet printer at the hub: event debriefings print centrally, retrievable from any site on the link. - Voice (Mumble) backburnered - revisit only on event interest. - .gitignore: build outputs; siteconfig exclusion rationale reworded. Co-Authored-By: Claude Fable 5 --- .gitignore | 7 +- README.md | 3 +- docs/BRAINSTORM.md | 127 ++++++++------ docs/PODBAY-ECOSYSTEM.md | 33 ++-- tools/SiteConfigMerge/Program.cs | 160 ++++++++++++++++++ tools/SiteConfigMerge/README.md | 74 ++++++++ tools/SiteConfigMerge/SiteConfigMerge.csproj | 12 ++ tools/SiteConfigMerge/TeslaSiteConfig.cs | 169 +++++++++++++++++++ 8 files changed, 523 insertions(+), 62 deletions(-) create mode 100644 tools/SiteConfigMerge/Program.cs create mode 100644 tools/SiteConfigMerge/README.md create mode 100644 tools/SiteConfigMerge/SiteConfigMerge.csproj create mode 100644 tools/SiteConfigMerge/TeslaSiteConfig.cs diff --git a/.gitignore b/.gitignore index 25384d0..33702fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,14 @@ -# Secrets never enter this repo +# Site-operational data lives with the sites, not in the repo +# (.siteconfig files change over time; the repo carries only the tools) *.siteconfig *wg-private* *.key *.pem +# Build output +bin/ +obj/ + # Local scratch scratch/ *.log diff --git a/README.md b/README.md index 009851d..4137e07 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ live in the docs below. | File | What it is | |------|-----------| | [docs/PODBAY-ECOSYSTEM.md](docs/PODBAY-ECOSYSTEM.md) | Survey of the existing bay software stack — every component, every protocol/port, with source pointers into the sibling repos. The ground truth SiteLink has to work with. | -| [docs/BRAINSTORM.md](docs/BRAINSTORM.md) | The design record: goals, the seed proposal analyzed, addressing plan, VPN topology options (routed vs bridged), master-console vs federation, per-game linking analysis, shared services, security, open questions, and a phased roadmap. | +| [docs/BRAINSTORM.md](docs/BRAINSTORM.md) | The design record: goals, the seed proposal analyzed, addressing plan, VPN topology options (routed vs bridged), the event authority-handover model, per-game linking analysis, shared services, security, open questions, and a phased roadmap. | +| [tools/SiteConfigMerge](tools/SiteConfigMerge/README.md) | **Working tool**: decodes TeslaConsole `.siteconfig` files and merges `.siteconfig` inputs into one `master.siteconfig` for the central event console, renaming squads `-`. Verified against the real TeslaConsole 4.11.4.1 loader. | ## Related repositories (gitea.mysticmachines.com/VWE) diff --git a/docs/BRAINSTORM.md b/docs/BRAINSTORM.md index 4285b9e..e5745ad 100644 --- a/docs/BRAINSTORM.md +++ b/docs/BRAINSTORM.md @@ -12,7 +12,8 @@ brainstorm builds on. gets a route to the general internet. 3. **Fleet visibility/command**: an operator (eventually a master console) can see — and where appropriate command — pods at every site. -4. **Shared front-of-house**: cross-site events share a queue, callsigns, scores, voice. +4. **Shared front-of-house**: cross-site events share a queue, callsigns, scores. + (Voice: backburnered — see §6.) 5. **Zero changes to cockpit hardware**, minimal changes to bay-local operation: a bay must keep working stand-alone when the link is down. @@ -38,7 +39,9 @@ are about *how*, not *whether*. PQS, Mumble, NTP, a neutral dedicated game host. Nothing site-specific lives there. - **Site ID registry lives in this repo** (`sites/` — one small file per site: ID, name, operator, subnet, contact). First allocation: `10.0.1.0/24` = Fallout Shelter Arcade; - `10.0.2.0/24` = next site. 254 sites max — plenty. + `10.0.2.0/24` = next site. **Scale reality (2026): 6 active pod bays, fewer than 120 + cockpits in existence** — 254 site IDs is beyond generous, and every topology choice + below can assume single-digit sites. - **Netmask choice is the real design fork** (see §3): hosts configured `/24` + gateway = routed model; hosts configured `/16` = requires a bridged L2 overlay. - Renumbering mechanics: TeslaConsole already owns pod IP config (provisioning writes @@ -88,36 +91,33 @@ L3. The bay machines themselves never run VPN software; only the per-site gatewa The seed proposal: merge all `.siteconfig` files → one master console commands the fleet. -**Reality check on "concatenate":** `local.siteconfig` is a BinaryFormatter object graph -(Squad → Pods), not a text file — merging means a small tool that deserializes N -siteconfigs and emits one multi-squad config. Straightforward since TeslaConsole is -rebuilt from source; the natural mapping is **one Squad per site** ("FSA-bay1", -"Pharaoh-bay1", …). TeslaConsole's UI already organizes pods by squad. +**The merge tool exists:** [`tools/SiteConfigMerge`](../tools/SiteConfigMerge/README.md) +decodes `.siteconfig` files and emits one `master.siteconfig`, renaming every +squad **`-`** ("FSA-bay1", "Pharaoh-bay1", …) — one +squad group per site, which TeslaConsole's UI already organizes naturally. Pod records +pass through byte-for-byte; verified against the real TeslaConsole 4.11.4.1 loader. -**Security consequence:** a pod's `mKey` in the siteconfig *is* command authority over -that pod. Shipping your siteconfig to a master console = granting fleet-wide control. -Fine between trusted operators, but do it deliberately: +**The operating model (settled 2026-07-10): event-scoped authority handover.** When a +SiteLink event runs, **every participating site willingly hands console authority to +the central console for the duration of the event** — concretely, by contributing its +`.siteconfig` to the merged master. Outside events, each site console is +authoritative over its own bay. Notes that keep this clean: -- Siteconfigs travel operator-to-operator over a secure channel; **never through this - repo** (repo carries the merge tool + templates only). -- A master console with all keys is a single high-value target — it lives on shared - infra (`10.0.0.0/24`), not on someone's laptop. +- The pod keys inside a siteconfig have no practical value outside the bay's + air-gapped network (anyone with bay access has the file anyway) — handing the file + over *is* the handover ceremony, not a security event. +- Siteconfigs change over time, so they are exchanged fresh per event and are not + stored in this repo (tools only). +- Nothing *enforces* the handover — TeslaLauncher has no arbitration if a site console + and the central console command the same pod concurrently. Convention: site consoles + stand down for the event window. A soft "event mode" lock in TeslaConsole is a + nice-to-have if the convention ever gets violated in practice. +- Remaining technical to-do for the central console: WAN-tolerant RPC timeouts, and + it should live on shared infra (`10.0.0.0/24`) with a decent uplink. -**Three architectures, in order of effort:** - -1. **Merged-config master console (the seed).** One TeslaConsole instance with all - squads/keys. Cheapest to reach; needs the merge tool + WAN-tolerant RPC timeouts. - Risks: WAN blip mid-install, and two consoles (site + master) commanding the same pod - concurrently — the launcher has no arbitration. Convention needed: master commands - only during cross-site events, site console otherwise. -2. **Hybrid (probably the sweet spot).** Site consoles stay authoritative for - provisioning/installs; the master console gets *visibility everywhere* + - *game-orchestration rights* during events. Could be as simple as (1) plus agreed - scope, or a read-only fleet mode in TeslaConsole. -3. **Federation service.** A SiteLink coordinator each site console registers with; the - coordinator brokers cross-site game setup, no raw pod keys leave a site. Cleanest - trust story, most new code. Later phase, if the fleet grows past a handful of - trusted operators. +**Federation (a coordinator service so raw keys never leave a site) is off the table** +at current scale — 6 bays run by trusted operators doesn't need it. Recorded here only +in case the fleet's trust model ever changes. ## 5. Linking the games themselves @@ -126,10 +126,26 @@ Fine between trusted operators, but do it deliberately: host placement: - a) Host at one site's console (simplest; other site eats the WAN RTT; O(n²) load on that console's uplink); - - b) **Neutral dedicated host** on shared infra (`mw4dedicatedui` exists!) — - symmetric latency for both sites, best uplink, and the camera/review stations at - *each* site can join as local spectators. Needs validation that the dedicated - server builds/runs (it's in the solution but unexercised in the modern work). + - b) **Neutral host at the WireGuard hub (preferred direction, 2026-07-10).** + Symmetric latency for every site, best uplink — and it exploits an operational + fact: **the Firestorm server is almost always the Live Cam for that game.** A + hub-hosted host running the camera-ship role turns the neutral server into the + event's broadcast point: capture its output and **stream the show to every + participating site** (Live Cam screens everywhere show the same feed), and + optionally **stream it publicly to the internet** as the fleet's shop window. + Considerations: the camera ship *renders*, so the hub box needs a real GPU + (physical box at the hub location or a GPU cloud instance — not a bare VPS); + `mw4dedicatedui` (headless) remains the fallback if hub rendering is impractical, + with a camera-ship client joining from a site instead. Streaming transport is + one-way and latency-tolerant: OBS capture → SRT/RTMP internally, Twitch/YouTube + for the public leg. Needs validation either way (dedicated UI is unexercised in + the modern tree; camera-ship-as-DirectPlay-host in the CTCL flow should be + confirmed against `ctcl-game.ini`'s `*1`/cameraship entry and the PQS + `cameraship` flag). + - **The Mission Review instance lives at the hub too, for the same reasons:** one + authoritative post-match review (`ctcl-mr` role / MSRSpectator) running next to + the host that recorded the match, its output streamed to every site's Mission + Review screen — every bay debriefs from the same show. - **Player counts:** the golden first milestone is **8v8 across two sites (8 cockpits each) — inside the stock 16-player cap, zero engine changes.** Full 16v16 site-vs-site needs the already-drafted 16→32 plan in `firestorm\CLAUDE.md` (compiled defaults + @@ -178,16 +194,19 @@ Fine between trusted operators, but do it deliberately: | Service | Notes | |---------|-------| | WireGuard hub | The rendezvous point; a VPS (could sit near the existing mysticmachines.com infra) or a box at the best-connected site | -| Mumble | Cross-site voice day one — the FS507D release already integrated Mumble on the bay side. Channel per team, cross-team lobby channel | +| Virtual scoresheet printer | A virtual printer at the hub that "prints" event debriefings/scoresheets to **PDF** (Firestorm's print path: `printdebriefing` / `mw4print`). PDFs land in one place and are retrievable from any site on the link — a simple web share on the hub over the VPN. Sites can still print paper locally from the PDFs | +| Voice (backburnered) | The FS507D release integrated Mumble, but only one operator ever ran it. Technically easy to stand up on the shared network — revisit only if event interest warrants | | Queue/roster coordination (future) | Cross-site events will want one queue/roster. **PQS** — the operator-built event traffic-flow tool — is the natural seed, but for now it stays support tooling. If promoted: (a) one central instance all consoles poll — simplest, DB schema grows a `site` column; (b) per-site instances + sync — only if WAN-down resilience of the local queue matters during events. Callsign uniqueness would become fleet-wide | -| Neutral game host | Firestorm dedicated server for site-vs-site matches | +| Neutral game host + event broadcast | Firestorm host for site-vs-site matches, co-located with the WireGuard hub. Doubles as the Live Cam (the FS server usually *is* the Live Cam): stream its output to all sites' Live Cam screens, optionally to the public internet. The **Mission Review instance runs at the hub too** — one authoritative debrief streamed to every site's MR screen. Needs GPU at the hub | | NTP | One clock for scores, logs, and replay/debrief alignment | | Fleet monitoring | VncThumbnailViewer pointed across the VPN; later, master-console status board | -Local-only forever: printer, RIO/cockpit I/O, Mission Review & Live Cam *stations* -(they join the match as spectators over the VPN, but the screens/printer serve the local -audience). Cross-site match results reach both printers via PQS/debrief distribution — -worth a small design of its own later. +Local-only forever: printer and RIO/cockpit I/O. For cross-site events the Live Cam +and Mission Review *instances* run at the hub (above); each site's LC/MR *screens* +become display endpoints for the hub streams, serving the local audience. Bay-local +games keep running their own LC/MR stations exactly as today. Cross-site scoresheets +go to the hub's virtual PDF printer (table above) — retrievable from any site, with +local paper printing off the PDFs where wanted. ## 7. Security posture @@ -199,9 +218,11 @@ worth a small design of its own later. - *Game plane* (DirectPlay fixed port, Munga 1501, Mumble): site↔site and site↔shared. - *Management plane* (TCP 53290 launcher RPC, VNC 5900): only site-console↔own-pods and master-console↔pods — other sites' consoles have no business on your 53290. -- **Secrets:** siteconfigs (pod keys) and WireGuard private keys never enter this repo. - Repo holds templates, tools, and *public* site registry data (subnets, endpoints' - hostnames, WireGuard public keys). +- **Repo hygiene:** WireGuard private keys never enter this repo. Siteconfigs aren't + stored here either — not because the pod keys matter off-site (they don't; the bays + are air-gapped and anyone with bay access has the file), but because they're living + operational data exchanged fresh per event. Repo holds tools, templates, and + *public* site registry data (subnets, endpoint hostnames, WireGuard public keys). - Era-Windows reality: assume every bay box is compromised-if-reachable. The allowlist above is the actual security boundary; the VPN is transport, not trust. @@ -220,12 +241,15 @@ worth a small design of its own later. and can camera/MR spectators join it cleanly? 6. **PQS promotion** — if/when the event queue tool becomes cross-site coordination: site column vs event database; global callsign policy. -7. **Master-console arbitration** — what happens when site + master consoles command the - same pod; do we need a soft lock ("bay is in fleet mode")? +7. **Console arbitration** — *policy settled 2026-07-10: sites voluntarily hand + authority to the central console for the event window.* Residual: nothing enforces + it (TeslaLauncher has no arbitration); an optional soft "event mode" lock in + TeslaConsole only if the convention ever fails in practice. 8. **Who hosts shared infra** — VPS vs best-connected site; bandwidth math for a 32-player O(n²) host (~upstream estimate needed from real packet captures). -9. **Site count ambitions** — 2 sites soon, how many eventually? Affects hub sizing and - whether federation (§4.3) is ever needed. +9. ~~Site count ambitions~~ — **answered 2026-07-10: 6 active bays, fewer than 120 + cockpits in existence.** Hub-and-spoke is trivially sufficient; federation is + permanently unnecessary barring a trust-model change. 10. **WAN-drop behavior** — for each linked flow, what breaks when the tunnel drops mid-match, and does the bay cleanly fall back to stand-alone? 11. **NetNub over routed subnets (TeslaRel410)** — how do the original 4.10 games @@ -240,11 +264,14 @@ worth a small design of its own later. RPC (53290), a 2-client Firestorm match across subnets, latency sweep, BT411 cross-subnet egg push. *Everything above that's marked "verify" gets verified here.* - **Phase 1 — First real link.** Two sites, gateways, tunnel, renumber to - `10.0.1.x`/`10.0.2.x`. Mumble + fleet VNC + remote Mission-Review spectating. First + `10.0.1.x`/`10.0.2.x`. Fleet VNC + remote Mission-Review spectating. First cross-site Firestorm match at ≤16 total players (8v8) on the stock exe. -- **Phase 2 — Fleet operations.** siteconfig merge tool → master console visibility - (hybrid model, §4.2). PQS event mode with shared queue/callsigns. Debrief/scoresheet - distribution to both printers. +- **Phase 2 — Fleet operations.** ~~siteconfig merge tool~~ ✅ built + ([`tools/SiteConfigMerge`](../tools/SiteConfigMerge/README.md), verified against the + real console loader) → run an event under the authority-handover model (§4): collect + `.siteconfig`s, merge, central console commands the fleet. Optional PQS + event mode with shared queue/callsigns; debrief/scoresheet distribution to both + printers. - **Phase 3 — Full site-vs-site.** Execute the firestorm 16→32 plan (code defaults + drop zones), neutral dedicated host, 16v16 events. - **Phase 4 — More titles, more sites.** BT411/RP cross-site as those engines mature; diff --git a/docs/PODBAY-ECOSYSTEM.md b/docs/PODBAY-ECOSYSTEM.md index c1908f8..c6d1fbb 100644 --- a/docs/PODBAY-ECOSYSTEM.md +++ b/docs/PODBAY-ECOSYSTEM.md @@ -3,9 +3,13 @@ Surveyed 2026-07-10 from the sibling repos under `C:\VWE`. Every claim below has a source pointer so it can be re-verified as those repos evolve. -## 1. Physical bay composition +## 1. Physical bay composition and fleet scale -A Pod Bay is **an air-gapped network of ~20 computers**: +A Pod Bay is **an air-gapped network of up to ~20 computers**. Sizes vary widely: +the smallest active bay is a console + 2 cockpits; the full complement is below. +Fleet scale (2026): **fewer than 120 cockpits left in existence, 6 currently active +pod bays** — every design choice in SiteLink should assume single-digit sites and +a ~120-seat ceiling. | Count | Machine | Software role | |------:|---------|---------------| @@ -41,11 +45,19 @@ machines on the internet. `TeslaConsole.Squad` (mGuid, mName, mOnline) → `TeslaConsole.Pod` records: `mId, mIPAddress, mGateway, mDns, mSubnet, mHostName, mKey, mMacAddress, mName, mPodArtPath, mHostType, mOnline`. Two consequences for SiteLink: - 1. "Concatenating" siteconfigs = a real deserialize/merge/reserialize tool (or native - multi-site support in TeslaConsole — buildable, since the console is now rebuilt - from source). The `Squad` concept maps naturally to "one squad per site". - 2. `mKey` is the credential that lets a console command a pod. **Siteconfig files are - secrets.** Sharing one with a master console = handing over control of your bay. + 1. "Concatenating" siteconfigs = a real deserialize/merge/reserialize tool — **built: + [`tools/SiteConfigMerge`](../tools/SiteConfigMerge/README.md)** decodes + `.siteconfig` files and merges them into `master.siteconfig`, renaming + squads `-`. Verified against the real + TeslaConsole 4.11.4.1 loader. The `Squad` concept maps naturally to + "one squad per site". + 2. `mKey` is the credential that lets a console command a pod — but it has **no + practical value outside the bay**: pods live on an air-gapped network, and + anyone with physical bay access has the siteconfig anyway. Handing your + siteconfig to the central console is simply how a site joins an event (see the + brainstorm's authority-handover model). Siteconfigs are exchanged + operator-to-operator as `.siteconfig` and are not stored in this + repo — they change over time; the repo carries only the tools. - **vPOD** (`TeslaSuite\vPOD\`): impersonates both a pod's launcher (TCP 53290) and a game client (Munga TCP 1501). **This is our test double for a whole remote bay** — we can prototype every SiteLink flow without touching cockpit hardware. @@ -90,8 +102,9 @@ machines on the internet. - **Dormant internet-era code:** MSN Zone "GUN" matchmaking (`Games_GUN.cpp`, `GUNGameList.h`) and GameSpy advertisement (`Games_GSpy.cpp`) — dead services, live code paths; a revival hook if SiteLink ever wants a fleet-wide game browser. -- **2016 release integrated Mumble** for voice (FS507D postinstall) — cross-site voice is - a solved problem: one Mumble server on the shared network. +- **Voice: backburnered.** The 2016 FS507D release integrated Mumble, but only one + operator ever ran it. Cross-site voice is technically easy (one server on the shared + network) — revisit only if event interest warrants it. ### BT411 (classic BattleTech — Tesla 4.10 reconstruction, native Win32) - **Console-push model over plain TCP:** the console connects to each pod's `-net ` @@ -177,5 +190,5 @@ the current Windows 10 cockpit hardware. Full plan: `TeslaRel410\emulator\PLAN.m | 47624 + 2300–2400, or fixed `DirectPlayPort` | TCP/UDP | pod ↔ session host | Firestorm DirectPlay 4 session + game traffic | | 80 | TCP | console/game → PQS box | PQS HTTP endpoints (event tooling, when deployed) | | 3306 | TCP | PQS internal | MySQL (localhost) | -| 64738 | TCP/UDP | all → voice server | Mumble (2016 release convention) | +| 64738 | TCP/UDP | all → voice server | Mumble (2016 release convention; voice is backburnered) | | 5900 | TCP | operator → pods | VNC monitoring (optional) | diff --git a/tools/SiteConfigMerge/Program.cs b/tools/SiteConfigMerge/Program.cs new file mode 100644 index 0000000..169bb91 --- /dev/null +++ b/tools/SiteConfigMerge/Program.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace SiteConfigMerge +{ + internal static class Program + { + private static int Main(string[] args) + { + try + { + if (args.Length == 0) + return Usage(); + switch (args[0].ToLowerInvariant()) + { + case "dump": + return Dump(args.Skip(1).ToArray()); + case "merge": + return Merge(args.Skip(1).ToArray()); + default: + return Usage(); + } + } + catch (Exception ex) + { + Console.Error.WriteLine("ERROR: " + ex.Message); + return 2; + } + } + + private static int Usage() + { + Console.WriteLine( +@"SiteConfigMerge — decode and merge TeslaConsole .siteconfig files (SiteLink) + +Usage: + SiteConfigMerge dump [...] + Decode and print squads/pods of each file. + + SiteConfigMerge merge -o .siteconfig [...] + Merge sites into one config. The site name is taken from each input + file name; every squad is renamed ""-"". + Pod records are copied byte-for-byte from the inputs."); + return 1; + } + + private static int Dump(string[] files) + { + if (files.Length == 0) + return Usage(); + foreach (var file in files) + { + var binder = new TeslaBinder(); + var squads = SiteConfigFile.Read(file, binder); + Console.WriteLine($"=== {file}"); + Console.WriteLine($" serialized by: {binder.CapturedAssembly ?? "(no TeslaConsole records)"}"); + Console.WriteLine($" squads: {squads.Count}"); + foreach (var entry in squads) + { + Console.WriteLine($" Squad \"{entry.Squad.Name}\" guid={entry.Squad.Guid} online={entry.Squad.Online} pods={entry.Pods.Count}"); + foreach (var p in entry.Pods.Select(e => e.Pod)) + { + Console.WriteLine($" Pod \"{p.Name}\" host={p.HostName} type={p.HostType}"); + Console.WriteLine($" ip={p.IPAddress} subnet={p.Subnet} gw={p.Gateway} dns={p.Dns}"); + Console.WriteLine($" mac={FormatMac(p.MacAddress)} key={(p.Key == null || p.Key.Length == 0 ? "none" : p.Key.Length + " bytes")} " + + $"art={p.PodArtPath ?? "(null)"} id={p.Id}"); + } + } + } + return 0; + } + + private static int Merge(string[] args) + { + string output = null; + var inputs = new List(); + for (int i = 0; i < args.Length; i++) + { + if (args[i] == "-o" || args[i] == "--output") + { + if (i + 1 >= args.Length) + throw new ArgumentException("-o requires a file name"); + output = args[++i]; + } + else + { + inputs.Add(args[i]); + } + } + if (output == null || inputs.Count == 0) + return Usage(); + + var binder = new TeslaBinder(); // shared: output uses the first input's assembly identity + var merged = new List(); + var squadNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var podIds = new Dictionary(); + var podMacs = new Dictionary(); + var podIps = new Dictionary(); + + foreach (var file in inputs) + { + string site = Path.GetFileNameWithoutExtension(file); + var squads = SiteConfigFile.Read(file, binder); + Console.WriteLine($"{file}: site \"{site}\", {squads.Count} squad(s), {squads.Sum(s => s.Pods.Count)} pod(s)"); + + int unnamed = 0; + foreach (var entry in squads) + { + string original = entry.Squad.Name; + entry.Squad.Name = string.IsNullOrEmpty(original) + ? $"{site}-squad{++unnamed}" + : $"{site}-{original}"; + if (!squadNames.Add(entry.Squad.Name)) + throw new InvalidOperationException( + $"duplicate squad name \"{entry.Squad.Name}\" — same site file given twice?"); + + foreach (var p in entry.Pods.Select(e => e.Pod)) + { + string where = $"{entry.Squad.Name}/\"{p.Name}\""; + if (podIds.TryGetValue(p.Id, out var prev)) + Warn($"pod GUID {p.Id} appears in both {prev} and {where} — same pod imported twice?"); + else + podIds[p.Id] = where; + + string mac = FormatMac(p.MacAddress); + if (mac != "none") + { + if (podMacs.TryGetValue(mac, out prev)) + Warn($"MAC {mac} appears in both {prev} and {where}"); + else + podMacs[mac] = where; + } + + string ip = p.IPAddress?.ToString(); + if (!string.IsNullOrEmpty(ip) && ip != "0.0.0.0") + { + if (podIps.TryGetValue(ip, out prev)) + Warn($"IP {ip} used by both {prev} and {where} — sites need renumbering before linking"); + else + podIps[ip] = where; + } + } + merged.Add(entry); + } + } + + SiteConfigFile.Write(output, merged, binder); + Console.WriteLine($"wrote {output}: {merged.Count} squad(s), {merged.Sum(s => s.Pods.Count)} pod(s) " + + $"(identity: {binder.CapturedAssembly ?? TeslaBinder.DefaultAssembly})"); + return 0; + } + + private static void Warn(string message) => Console.Error.WriteLine("WARNING: " + message); + + private static string FormatMac(byte[] mac) => + mac == null || mac.Length == 0 ? "none" : BitConverter.ToString(mac).Replace('-', ':'); + } +} diff --git a/tools/SiteConfigMerge/README.md b/tools/SiteConfigMerge/README.md new file mode 100644 index 0000000..ab4155b --- /dev/null +++ b/tools/SiteConfigMerge/README.md @@ -0,0 +1,74 @@ +# SiteConfigMerge + +Decodes TeslaConsole `.siteconfig` files and merges multiple sites' configs into a +single `master.siteconfig` for the central console that commands the fleet during a +SiteLink event. + +The `.siteconfig` files themselves are **not** stored in this repo — they change over +time; sites hand them over as `.siteconfig` when an event is being set up. + +## Usage + +``` +SiteConfigMerge dump [...] +``` +Decode and print each file: squads, pods, IPs, MACs, host types, key presence, and +the TeslaConsole assembly identity that serialized it. + +``` +SiteConfigMerge merge -o master.siteconfig FSA.siteconfig Pharaoh.siteconfig [...] +``` +Merge sites into one config: + +- The **site name is taken from each input's file name** (`FSA.siteconfig` → `FSA`). +- Every squad is renamed **`-`** (`FSA-bay1`); + unnamed squads become `-squadN`. +- **Pod records are copied byte-for-byte** from the inputs — GUIDs, IPs, MACs, keys, + art paths all pass through untouched. Only the squad records (which carry the + name) are re-serialized. +- The output declares the TeslaConsole assembly identity captured from the first + input, so the console accepts it as its own. + +Warnings (merge proceeds; read them): +- duplicate pod GUID or MAC across inputs — same pod imported twice? +- same IP at two sites — expected until sites renumber into their `10.0..0/24`; + it must be resolved before actually linking. + +Hard error: duplicate post-rename squad name (same site file given twice). + +## Build + +``` +dotnet build -c Release +``` +Targets .NET Framework 4.8 (same toolchain as TeslaSuite); output at +`bin\Release\net48\SiteConfigMerge.exe`, runs on any Windows 10/11 box. + +## File format (from `TeslaSuite\Console\TeslaConsole\Site.cs`) + +``` +int32 squadCount +per squad: + BinaryFormatter(TeslaConsole.Squad) mGuid, mName, mOnline + int32 podCount + podCount × BinaryFormatter(TeslaConsole.Pod) + mId, mIPAddress, mGateway, mDns, mSubnet, mHostName, + mKey, mMacAddress, mName, mPodArtPath, mHostType, mOnline +``` + +The tool uses stand-in types with a `SerializationBinder` mapping `TeslaConsole.*` +both directions, so it has no build dependency on the TeslaSuite repo. + +## Verification status (2026-07-10) + +- Decoded a real `local.siteconfig` (squad `bay1`, TeslaConsole 4.11.4.1 identity). +- Merged two simulated sites; duplicate-GUID and IP-overlap warnings fired correctly. +- **The real `TeslaConsole.exe` (4.11.4.1) loaded the merged output through its own + `Site.LoadFromFile`** — squads renamed, pods intact (reflection harness). + +## Deploying to the central console + +TeslaConsole loads `local.siteconfig` from its common-appdata directory at startup +(`Site.Load()`). To arm the central console for an event: back up its existing +`local.siteconfig`, drop `master.siteconfig` in its place under that name, restart +the console. Restore the backup after the event. diff --git a/tools/SiteConfigMerge/SiteConfigMerge.csproj b/tools/SiteConfigMerge/SiteConfigMerge.csproj new file mode 100644 index 0000000..23fbfdd --- /dev/null +++ b/tools/SiteConfigMerge/SiteConfigMerge.csproj @@ -0,0 +1,12 @@ + + + + Exe + net48 + latest + disable + SiteLink siteconfig merge tool + 0.1.0 + + + diff --git a/tools/SiteConfigMerge/TeslaSiteConfig.cs b/tools/SiteConfigMerge/TeslaSiteConfig.cs new file mode 100644 index 0000000..74306c5 --- /dev/null +++ b/tools/SiteConfigMerge/TeslaSiteConfig.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Formatters.Binary; + +// CS0649: the stand-in fields are only ever assigned by BinaryFormatter via reflection. +#pragma warning disable 0649 + +namespace SiteConfigMerge +{ + // Stand-ins for the TeslaConsole types serialized into .siteconfig files. + // Field names/types MUST match TeslaSuite\Console\TeslaConsole\{Squad,Pod,HostType}.cs + // exactly ([NonSerialized] members omitted); BinaryFormatter matches by field name. + + [Serializable] + internal class Squad + { + private Guid mGuid = Guid.NewGuid(); + private string mName = ""; + private bool mOnline; + + public Guid Guid => mGuid; + public bool Online => mOnline; + public string Name { get => mName; set => mName = value; } + } + + [Serializable] + internal class Pod + { + private Guid mId = Guid.NewGuid(); + private IPAddress mIPAddress = IPAddress.Any; + private IPAddress mGateway = IPAddress.Any; + private IPAddress mDns = IPAddress.Any; + private IPAddress mSubnet = IPAddress.Any; + private string mHostName = ""; + private byte[] mKey; + private byte[] mMacAddress; + private string mName; + private string mPodArtPath; + private HostType mHostType; + private bool mOnline; + + public Guid Id => mId; + public IPAddress IPAddress => mIPAddress; + public IPAddress Gateway => mGateway; + public IPAddress Dns => mDns; + public IPAddress Subnet => mSubnet; + public string HostName => mHostName; + public byte[] Key => mKey; + public byte[] MacAddress => mMacAddress; + public string Name => mName; + public string PodArtPath => mPodArtPath; + public HostType HostType => mHostType; + public bool Online => mOnline; + } + + internal enum HostType + { + GameMachineHostType = 0, + MissionReviewHostType = 2, + ConsoleHostType = 3, + } + + // Maps "TeslaConsole.*" stream types to the stand-ins on read, and writes them + // back out under the original TeslaConsole assembly identity so the console + // accepts the result. The assembly string is captured from the first input file + // (falling back to the 4.11.4.1 identity). + internal sealed class TeslaBinder : SerializationBinder + { + public const string DefaultAssembly = + "TeslaConsole, Version=4.11.4.1, Culture=neutral, PublicKeyToken=null"; + + public string CapturedAssembly { get; private set; } + + public override Type BindToType(string assemblyName, string typeName) + { + switch (typeName) + { + case "TeslaConsole.Squad": + CapturedAssembly = CapturedAssembly ?? assemblyName; + return typeof(Squad); + case "TeslaConsole.Pod": + CapturedAssembly = CapturedAssembly ?? assemblyName; + return typeof(Pod); + case "TeslaConsole.HostType": + return typeof(HostType); + default: + return null; // system types resolve normally + } + } + + public override void BindToName(Type serializedType, out string assemblyName, out string typeName) + { + assemblyName = null; + typeName = null; + if (serializedType == typeof(Squad)) typeName = "TeslaConsole.Squad"; + else if (serializedType == typeof(Pod)) typeName = "TeslaConsole.Pod"; + else if (serializedType == typeof(HostType)) typeName = "TeslaConsole.HostType"; + if (typeName != null) + assemblyName = CapturedAssembly ?? DefaultAssembly; + } + } + + internal sealed class PodEntry + { + public Pod Pod; + public byte[] Raw; // the pod's original BinaryFormatter payload, copied verbatim on write + } + + internal sealed class SquadEntry + { + public Squad Squad; + public List Pods = new List(); + } + + // .siteconfig container format, per TeslaSuite\Console\TeslaConsole\Site.cs: + // int32 squadCount + // per squad: BinaryFormatter(Squad), int32 podCount, podCount x BinaryFormatter(Pod) + internal static class SiteConfigFile + { + public static List Read(string path, TeslaBinder binder) + { + byte[] bytes = File.ReadAllBytes(path); + var stream = new MemoryStream(bytes, writable: false); + var reader = new BinaryReader(stream); + var formatter = new BinaryFormatter { Binder = binder }; + + var squads = new List(); + int squadCount = reader.ReadInt32(); + for (int i = 0; i < squadCount; i++) + { + var entry = new SquadEntry { Squad = (Squad)formatter.Deserialize(stream) }; + int podCount = reader.ReadInt32(); + for (int j = 0; j < podCount; j++) + { + long start = stream.Position; + var pod = (Pod)formatter.Deserialize(stream); + var raw = new byte[stream.Position - start]; + Array.Copy(bytes, start, raw, 0, raw.Length); + entry.Pods.Add(new PodEntry { Pod = pod, Raw = raw }); + } + squads.Add(entry); + } + if (stream.Position != stream.Length) + Console.Error.WriteLine( + $"WARNING: {path}: {stream.Length - stream.Position} trailing byte(s) after the last squad were ignored."); + return squads; + } + + public static void Write(string path, List squads, TeslaBinder binder) + { + using (var stream = File.Create(path)) + { + var writer = new BinaryWriter(stream); + var formatter = new BinaryFormatter { Binder = binder }; + writer.Write(squads.Count); + foreach (var entry in squads) + { + formatter.Serialize(stream, entry.Squad); + writer.Write(entry.Pods.Count); + foreach (var pod in entry.Pods) + stream.Write(pod.Raw, 0, pod.Raw.Length); + } + } + } + } +}