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 <siteName>.siteconfig inputs into master.siteconfig, renaming squads "<siteName>-<original squad name>" - 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <file.siteconfig> [...]
|
||||
Decode and print squads/pods of each file.
|
||||
|
||||
SiteConfigMerge merge -o <master.siteconfig> <siteName>.siteconfig [...]
|
||||
Merge sites into one config. The site name is taken from each input
|
||||
file name; every squad is renamed ""<siteName>-<original squad name>"".
|
||||
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<string>();
|
||||
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<SquadEntry>();
|
||||
var squadNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var podIds = new Dictionary<Guid, string>();
|
||||
var podMacs = new Dictionary<string, string>();
|
||||
var podIps = new Dictionary<string, string>();
|
||||
|
||||
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('-', ':');
|
||||
}
|
||||
}
|
||||
@@ -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 `<siteName>.siteconfig` when an event is being set up.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
SiteConfigMerge dump <file.siteconfig> [...]
|
||||
```
|
||||
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 **`<siteName>-<original squad name>`** (`FSA-bay1`);
|
||||
unnamed squads become `<siteName>-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.<site>.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.
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>disable</Nullable>
|
||||
<AssemblyTitle>SiteLink siteconfig merge tool</AssemblyTitle>
|
||||
<Version>0.1.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<PodEntry> Pods = new List<PodEntry>();
|
||||
}
|
||||
|
||||
// .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<SquadEntry> 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<SquadEntry>();
|
||||
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<SquadEntry> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user