From 57fe6bf17363c919da0eedc67ba51685dc7f4b86 Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Thu, 30 Apr 2026 21:38:15 -0700 Subject: [PATCH] M1: Headless parity verified between Tools and Godot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves Theriapolis.Core works untouched under Godot's csproj — the worldgen pipeline produces byte-identical output whether invoked from the Tools CLI or from inside the Godot process. This is the determinism contract surviving the port. Architecture test: CoreNoDependencyTests now forbids Godot.* and GodotSharp in addition to Microsoft.Xna and MonoGame. Both bans stay in force for the duration of the port so neither engine can leak into Core. Determinism oracle: New worldgen-hash Tools command runs the full pipeline and prints FNV-1a hashes for every channel (elevation, moisture, temperature, biomes, settlements, polylines) plus per-stage hashes. Pairs with the Godot smoke-test for cross-process verification. Godot-side smoke test: SmokeTest.cs runs WorldGenerator.RunAll inside the Godot process; Main.cs fires it on --smoke-test . Resolves Content/Data via res:// walk-up. M0 hello-world behaviour preserved when launched without the flag. Verification (seed 12345): - dotnet run -- worldgen-hash and Godot --headless --smoke-test agree on all 6 channels and all 14 per-stage hashes (diff produces zero output) - 10-run sweeps stable on both sides post-determinism-fix - dotnet test: 708/708 pass Closes M1 of theriapolis-rpg-implementation-plan-godot-port.md. Next: M2 (world map render). Co-Authored-By: Claude Opus 4.7 --- Theriapolis.Godot/Main.cs | 21 +++++ Theriapolis.Godot/SmokeTest.cs | 59 ++++++++++++++ .../Architecture/CoreNoDependencyTests.cs | 13 ++- Theriapolis.Tools/Commands/WorldgenHash.cs | 81 +++++++++++++++++++ Theriapolis.Tools/Program.cs | 5 ++ 5 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 Theriapolis.Godot/SmokeTest.cs create mode 100644 Theriapolis.Tools/Commands/WorldgenHash.cs diff --git a/Theriapolis.Godot/Main.cs b/Theriapolis.Godot/Main.cs index b6c895c..7edb69b 100644 --- a/Theriapolis.Godot/Main.cs +++ b/Theriapolis.Godot/Main.cs @@ -6,6 +6,27 @@ public partial class Main : Node { public override void _Ready() { + var args = OS.GetCmdlineUserArgs(); + ulong? smokeTestSeed = null; + for (int i = 0; i < args.Length; i++) + { + if (args[i] == "--smoke-test") + { + ulong seed = 12345UL; + if (i + 1 < args.Length && ulong.TryParse(args[i + 1], out var parsed)) + seed = parsed; + smokeTestSeed = seed; + break; + } + } + + if (smokeTestSeed.HasValue) + { + int code = SmokeTest.Run(smokeTestSeed.Value); + GetTree().Quit(code); + return; + } + GD.Print("Theriapolis.Godot host ready (M0 hello-world)."); } diff --git a/Theriapolis.Godot/SmokeTest.cs b/Theriapolis.Godot/SmokeTest.cs new file mode 100644 index 0000000..4a707ed --- /dev/null +++ b/Theriapolis.Godot/SmokeTest.cs @@ -0,0 +1,59 @@ +using Godot; +using System.IO; +using System.Linq; +using Theriapolis.Core.World.Generation; + +namespace Theriapolis.GodotHost; + +/// +/// M1 determinism oracle. Runs the full Core worldgen pipeline from inside +/// the Godot process and prints FNV hashes. Output must match +/// dotnet run --project Theriapolis.Tools -- worldgen-hash --seed N +/// byte-for-byte. This proves Core works untouched under Godot's csproj. +/// +public static class SmokeTest +{ + public static int Run(ulong seed) + { + string dataDir = ResolveDataDir(); + if (!Directory.Exists(dataDir)) + { + GD.PrintErr($"[smoke-test] Data directory not found: {dataDir}"); + return 1; + } + + GD.Print($"[smoke-test] seed=0x{seed:X} data-dir={dataDir}"); + var ctx = new WorldGenContext(seed, dataDir); + WorldGenerator.RunAll(ctx); + var w = ctx.World; + + GD.Print($"[smoke-test] === FNV hashes ==="); + GD.Print($"[smoke-test] elevation = 0x{w.HashElevation():X16}"); + GD.Print($"[smoke-test] moisture = 0x{w.HashMoisture():X16}"); + GD.Print($"[smoke-test] temperature = 0x{w.HashTemperature():X16}"); + GD.Print($"[smoke-test] biomes = 0x{w.HashBiomes():X16}"); + GD.Print($"[smoke-test] settlements = 0x{w.HashSettlements():X16}"); + GD.Print($"[smoke-test] polylines = 0x{w.HashPolylines():X16}"); + GD.Print($"[smoke-test] === Per-stage hashes ({w.StageHashes.Count}) ==="); + foreach (var kv in w.StageHashes.OrderBy(k => k.Key, System.StringComparer.Ordinal)) + GD.Print($"[smoke-test] {kv.Key,-32} = 0x{kv.Value:X16}"); + return 0; + } + + private static string ResolveDataDir() + { + string fromRes = ProjectSettings.GlobalizePath("res://../Content/Data"); + if (Directory.Exists(fromRes)) return fromRes; + + string? dir = ProjectSettings.GlobalizePath("res://").TrimEnd('/', '\\'); + for (int i = 0; i < 6; i++) + { + if (string.IsNullOrEmpty(dir)) break; + string candidate = Path.Combine(dir, "Content", "Data"); + if (Directory.Exists(candidate)) return candidate; + dir = Path.GetDirectoryName(dir); + } + + return fromRes; + } +} diff --git a/Theriapolis.Tests/Architecture/CoreNoDependencyTests.cs b/Theriapolis.Tests/Architecture/CoreNoDependencyTests.cs index 24b7d9e..a69133c 100644 --- a/Theriapolis.Tests/Architecture/CoreNoDependencyTests.cs +++ b/Theriapolis.Tests/Architecture/CoreNoDependencyTests.cs @@ -4,9 +4,11 @@ using Xunit; namespace Theriapolis.Tests.Architecture; /// -/// Hard rule #1: Theriapolis.Core must not reference MonoGame or Microsoft.Xna. +/// Hard rule #1: Theriapolis.Core must not reference any rendering engine. /// This test reflects over Core.dll and fails the build if any forbidden assembly -/// is referenced. +/// is referenced. Both MonoGame/XNA (legacy) and Godot (M1+ port) are banned — +/// keep both bans for the duration of the port so the in-flight branch can't +/// regress either way. /// public sealed class CoreNoDependencyTests { @@ -14,10 +16,12 @@ public sealed class CoreNoDependencyTests { "Microsoft.Xna", "MonoGame", + "Godot", + "GodotSharp", }; [Fact] - public void Core_DoesNotReference_MonoGame() + public void Core_DoesNotReference_RenderingEngines() { var coreAssembly = typeof(Theriapolis.Core.C).Assembly; var referenced = coreAssembly.GetReferencedAssemblies(); @@ -28,7 +32,8 @@ public sealed class CoreNoDependencyTests .ToList(); Assert.True(violations.Count == 0, - $"Theriapolis.Core must not reference MonoGame/XNA. Violations: {string.Join(", ", violations)}"); + $"Theriapolis.Core must not reference any rendering engine (MonoGame/XNA/Godot). " + + $"Violations: {string.Join(", ", violations)}"); } /// diff --git a/Theriapolis.Tools/Commands/WorldgenHash.cs b/Theriapolis.Tools/Commands/WorldgenHash.cs new file mode 100644 index 0000000..3e5af1a --- /dev/null +++ b/Theriapolis.Tools/Commands/WorldgenHash.cs @@ -0,0 +1,81 @@ +using Theriapolis.Core.World.Generation; + +namespace Theriapolis.Tools.Commands; + +/// +/// worldgen-hash --seed <n> [--data-dir <dir>] +/// +/// Runs the full worldgen pipeline and prints the FNV-1a hashes of every +/// output channel (elevation, moisture, temperature, biomes, settlements, +/// polylines) plus the per-stage hash dictionary. Used as a determinism +/// oracle for the Godot port — the Godot host prints the same hashes, +/// they must match byte-for-byte. +/// +public static class WorldgenHash +{ + public static int Run(string[] args) + { + ulong seed = 12345; + string dataDir = ResolveDataDir(); + + for (int i = 0; i < args.Length; i++) + { + switch (args[i].ToLowerInvariant()) + { + case "--seed": + if (i + 1 < args.Length) + { + string raw = args[++i]; + seed = raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? Convert.ToUInt64(raw[2..], 16) + : ulong.Parse(raw); + } + break; + case "--data-dir": + if (i + 1 < args.Length) dataDir = args[++i]; + break; + } + } + + if (!Directory.Exists(dataDir)) + { + Console.Error.WriteLine($"Data directory not found: {dataDir}"); + return 1; + } + + Console.WriteLine($"[worldgen-hash] seed=0x{seed:X} data-dir={dataDir}"); + var ctx = new WorldGenContext(seed, dataDir); + WorldGenerator.RunAll(ctx); + var w = ctx.World; + + Console.WriteLine($"[worldgen-hash] === FNV hashes ==="); + Console.WriteLine($"[worldgen-hash] elevation = 0x{w.HashElevation():X16}"); + Console.WriteLine($"[worldgen-hash] moisture = 0x{w.HashMoisture():X16}"); + Console.WriteLine($"[worldgen-hash] temperature = 0x{w.HashTemperature():X16}"); + Console.WriteLine($"[worldgen-hash] biomes = 0x{w.HashBiomes():X16}"); + Console.WriteLine($"[worldgen-hash] settlements = 0x{w.HashSettlements():X16}"); + Console.WriteLine($"[worldgen-hash] polylines = 0x{w.HashPolylines():X16}"); + Console.WriteLine($"[worldgen-hash] === Per-stage hashes ({w.StageHashes.Count}) ==="); + foreach (var kv in w.StageHashes.OrderBy(k => k.Key, StringComparer.Ordinal)) + Console.WriteLine($"[worldgen-hash] {kv.Key,-32} = 0x{kv.Value:X16}"); + return 0; + } + + private static string ResolveDataDir() + { + string local = Path.Combine(AppContext.BaseDirectory, "Data"); + if (Directory.Exists(local)) return local; + + string? dir = AppContext.BaseDirectory.TrimEnd( + Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + for (int i = 0; i < 6; i++) + { + if (dir is null) break; + string candidate = Path.Combine(dir, "Content", "Data"); + if (Directory.Exists(candidate)) return candidate; + dir = Path.GetDirectoryName(dir); + } + + return local; + } +} diff --git a/Theriapolis.Tools/Program.cs b/Theriapolis.Tools/Program.cs index 4708110..3c67d99 100644 --- a/Theriapolis.Tools/Program.cs +++ b/Theriapolis.Tools/Program.cs @@ -10,6 +10,7 @@ return args[0].ToLowerInvariant() switch { "hello" => Hello(), "worldgen-dump" => WorldgenDump.Run(args[1..]), + "worldgen-hash" => WorldgenHash.Run(args[1..]), "settlement-report" => SettlementReport.Run(args[1..]), "tile-inspect" => TileInspect.Run(args[1..]), "tactical-dump" => TacticalDump.Run(args[1..]), @@ -49,6 +50,10 @@ static void PrintHelp() Console.WriteLine(" --out "); Console.WriteLine(" [--data-dir ]"); Console.WriteLine(" [--show-violations]"); + Console.WriteLine(" worldgen-hash --seed Run full pipeline and print FNV-1a hashes (elevation,"); + Console.WriteLine(" [--data-dir ] moisture, temperature, biomes, settlements, polylines)"); + Console.WriteLine(" plus per-stage hashes. Determinism oracle for the"); + Console.WriteLine(" Godot port (compare to Godot host's smoke-test output)."); Console.WriteLine(" settlement-report --seed Run full pipeline and print settlement report."); Console.WriteLine(" [--data-dir ]"); Console.WriteLine(" tile-inspect --seed Print polylines/bridges near a reported tile.");