feat: blueprint v2 envelope — writer, dual-write, round-trip harness (terrain-water task 02)

v2 tagged-section container (D-030): raw ISLA magic + u32 version gate,
[u32 tag][u64 length][payload] sections — params (resolved generation
inputs incl. impact centre), heights f32, biomes u8, towns with the
highway-node flag, four road tiers identified by tag not position.

- BlueprintFormat.cs: the single tag/constant registry.
- BlueprintWriter.WriteV2: blueprint-typed, callable outside a
  generation run; generator and harness are both just callers.
- ExportMapData dual-writes: v2 under the primary seed name, legacy v1
  beside it as _v1.dat (safety net; removal is a future task).
- MapDataParser: LoadMapDataFromPath entry point; v1 parse body
  extracted intact as LoadV1 (v2 reader lands in the next commit).
- RoundTripHarness scene: load v1 -> write v2 -> load v2 -> semantic
  equality, headless, seconds per cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-06 07:24:08 -04:00
parent ad541dfbd8
commit c305fd6565
6 changed files with 494 additions and 9 deletions

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Tools/Scripts/RoundTripHarness.cs" id="1_rth"]
[node name="RoundTripHarness" type="Node"]
script = ExtResource("1_rth")

View file

@ -180,9 +180,62 @@ public partial class MapGenerator : TextureRect
private void ExportMapData()
{
string seedStr = _noise.Seed.ToString();
string filePath = $"user://MapData_Seed_{seedStr}.dat";
using (FileStream stream = File.Open(ProjectSettings.GlobalizePath(filePath), FileMode.Create))
// PRIMARY: the v2 tagged-section container (Core/Scripts/BLUEPRINT_FORMAT.md),
// under the name the server looks for.
string v2Path = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat");
BlueprintWriter.WriteV2(v2Path, BuildBlueprint());
// SAFETY NET: the legacy v1 format beside it, until the developer has lived
// with v2 across several regenerations. Removal is a future task.
ExportMapDataV1(ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}_v1.dat"));
}
/// <summary>
/// Packages the generator's internal arrays as a WorldBlueprint (plus the resolved
/// generation params) for BlueprintWriter. Arrays are shared, not copied.
/// </summary>
private WorldBlueprint BuildBlueprint()
{
var blueprint = new WorldBlueprint
{
MapSize = MapSize,
HeightMap = _heightMap,
BiomeMap = _biomeMap,
Highways = _highwayPaths,
BranchRoads = _branchPaths,
RuggedRoads = _ruggedPaths,
TrailRoads = _trailPaths,
FormatVersion = 2,
Params = new BlueprintParams
{
WorldSeed = _noise.Seed, // the RESOLVED seed — also names the file
MapSize = MapSize,
CraterRadius = _impactRadius,
DensityMultiplier = ConfigManager.DensityMultiplier,
ImpactCenter = _impactCenter
}
};
foreach (var town in _towns)
{
blueprint.Towns.Add(new TownLocation
{
Position = town.Position,
Tier = town.Tier,
IsHighwayNode = town.IsHighwayNode
});
}
return blueprint;
}
// ===============================================================
// LEGACY v1 WRITER — the original "ISLA_V1" positional format.
// Kept intact as the dual-write safety net; removal is a future
// task. Do not extend it — new content belongs in the v2 writer.
// ===============================================================
private void ExportMapDataV1(string absolutePath)
{
using (FileStream stream = File.Open(absolutePath, FileMode.Create))
{
using (BinaryWriter writer = new BinaryWriter(stream))
{
@ -237,7 +290,7 @@ public partial class MapGenerator : TextureRect
}
}
}
GD.Print("Binary Data Exported to: " + ProjectSettings.GlobalizePath(filePath));
GD.Print("Legacy v1 Binary Data Exported to: " + absolutePath);
}
// --- THE ORIGINAL MAP GENERATION CODE (Restored!) ---

View file

@ -0,0 +1,156 @@
using Godot;
using System.Collections.Generic;
using IslaApocalypse.Core;
/// <summary>
/// The blueprint round-trip oracle (terrain-water task 02). Runs headless in seconds,
/// no generation, no road pass:
///
/// 1. load a known-good v1 blueprint (default: the preserved reference copy of seed
/// 1409879727) through the real parser,
/// 2. write it back out as v2 through the real writer (to a harness-only name — the
/// reference file is never overwritten),
/// 3. load the v2 file through the real parser,
/// 4. assert semantic equality: heights bitwise, biomes equal, towns equal in
/// position/tier/count, each road tier point-for-point.
///
/// Params and the per-town highway flag are EXCLUDED from equality — a v1 source
/// cannot supply them; they are verified in the full-generation acceptance run.
///
/// Run: Godot --headless --path <repo> res://Tools/Scenes/RoundTripHarness.tscn
/// Env overrides: HARNESS_V1_PATH (source file), HARNESS_V2_PATH (output file).
/// Exit code 0 = PASS, 1 = FAIL. Worth keeping permanently as a format regression test.
/// </summary>
public partial class RoundTripHarness : Node
{
public override void _Ready()
{
string v1Path = OS.GetEnvironment("HARNESS_V1_PATH");
if (string.IsNullOrEmpty(v1Path))
v1Path = ProjectSettings.GlobalizePath("user://reference_1409879727/MapData_Seed_1409879727.dat");
string v2Path = OS.GetEnvironment("HARNESS_V2_PATH");
if (string.IsNullOrEmpty(v2Path))
v2Path = ProjectSettings.GlobalizePath("user://Harness_RoundTrip_v2.dat");
GD.Print($"[Harness] v1 source: {v1Path}");
GD.Print($"[Harness] v2 output: {v2Path}");
bool pass = false;
try
{
pass = RunRoundTrip(v1Path, v2Path);
}
catch (System.Exception e)
{
GD.PrintErr($"[Harness] EXCEPTION: {e}");
}
GD.Print(pass ? "[Harness] RESULT: PASS" : "[Harness] RESULT: FAIL");
GetTree().Quit(pass ? 0 : 1);
}
private bool RunRoundTrip(string v1Path, string v2Path)
{
ulong t0 = Time.GetTicksMsec();
WorldBlueprint original = MapDataParser.LoadMapDataFromPath(v1Path);
ulong t1 = Time.GetTicksMsec();
if (original == null) { GD.PrintErr("[Harness] v1 load failed."); return false; }
GD.Print($"[Harness] v1 parse: {(t1 - t0) / 1000.0:F1}s (format v{original.FormatVersion})");
BlueprintWriter.WriteV2(v2Path, original);
ulong t2 = Time.GetTicksMsec();
GD.Print($"[Harness] v2 write: {(t2 - t1) / 1000.0:F1}s");
WorldBlueprint reread = MapDataParser.LoadMapDataFromPath(v2Path);
ulong t3 = Time.GetTicksMsec();
if (reread == null) { GD.PrintErr("[Harness] v2 load failed."); return false; }
GD.Print($"[Harness] v2 parse: {(t3 - t2) / 1000.0:F1}s (format v{reread.FormatVersion})");
return Compare(original, reread);
}
private bool Compare(WorldBlueprint a, WorldBlueprint b)
{
bool ok = true;
if (a.MapSize != b.MapSize)
{
GD.PrintErr($"[Harness] MapSize mismatch: {a.MapSize} vs {b.MapSize}");
return false; // nothing below is comparable
}
// Heights: bitwise-identical floats.
long heightDiffs = 0;
for (int x = 0; x < a.MapSize; x++)
for (int y = 0; y < a.MapSize; y++)
if (System.BitConverter.SingleToInt32Bits(a.HeightMap[x, y]) !=
System.BitConverter.SingleToInt32Bits(b.HeightMap[x, y]))
heightDiffs++;
if (heightDiffs > 0) { GD.PrintErr($"[Harness] {heightDiffs} height pixels differ bitwise."); ok = false; }
// Biomes: equal values (i32 -> u8 narrowing is documented and lossless for 0..255).
long biomeDiffs = 0;
for (int x = 0; x < a.MapSize; x++)
for (int y = 0; y < a.MapSize; y++)
if (a.BiomeMap[x, y] != b.BiomeMap[x, y])
biomeDiffs++;
if (biomeDiffs > 0) { GD.PrintErr($"[Harness] {biomeDiffs} biome pixels differ."); ok = false; }
// Towns: identical position/tier/count (highway flag deliberately not compared).
if (a.Towns.Count != b.Towns.Count)
{
GD.PrintErr($"[Harness] Town count mismatch: {a.Towns.Count} vs {b.Towns.Count}");
ok = false;
}
else
{
for (int i = 0; i < a.Towns.Count; i++)
{
if (a.Towns[i].Position != b.Towns[i].Position || a.Towns[i].Tier != b.Towns[i].Tier)
{
GD.PrintErr($"[Harness] Town {i} mismatch: " +
$"{a.Towns[i].Position}/{a.Towns[i].Tier} vs {b.Towns[i].Position}/{b.Towns[i].Tier}");
ok = false;
}
}
}
ok &= CompareRoads("Highway", a.Highways, b.Highways);
ok &= CompareRoads("Branch", a.BranchRoads, b.BranchRoads);
ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads);
ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads);
if (ok)
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
$"{a.Towns.Count} towns, roads {a.Highways.Count}/{a.BranchRoads.Count}/" +
$"{a.RuggedRoads.Count}/{a.TrailRoads.Count} paths.");
return ok;
}
private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b)
{
if (a.Count != b.Count)
{
GD.PrintErr($"[Harness] {tier} path count mismatch: {a.Count} vs {b.Count}");
return false;
}
for (int i = 0; i < a.Count; i++)
{
if (a[i].Length != b[i].Length)
{
GD.PrintErr($"[Harness] {tier} path {i} length mismatch: {a[i].Length} vs {b[i].Length}");
return false;
}
for (int p = 0; p < a[i].Length; p++)
{
if (System.BitConverter.SingleToInt32Bits(a[i][p].X) != System.BitConverter.SingleToInt32Bits(b[i][p].X) ||
System.BitConverter.SingleToInt32Bits(a[i][p].Y) != System.BitConverter.SingleToInt32Bits(b[i][p].Y))
{
GD.PrintErr($"[Harness] {tier} path {i} point {p} differs: {a[i][p]} vs {b[i][p]}");
return false;
}
}
}
return true;
}
}