From c305fd656550db061b5a1ae1f3a0c4d2a74fd1e1 Mon Sep 17 00:00:00 2001 From: beezm Date: Thu, 6 Aug 2026 07:24:08 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20blueprint=20v2=20envelope=20?= =?UTF-8?q?=E2=80=94=20writer,=20dual-write,=20round-trip=20harness=20(ter?= =?UTF-8?q?rain-water=20task=2002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Core/Scripts/BlueprintFormat.cs | 54 ++++++++++ Core/Scripts/BlueprintWriter.cs | 165 +++++++++++++++++++++++++++++ Core/Scripts/MapDataParser.cs | 61 ++++++++++- Tools/Scenes/RoundTripHarness.tscn | 6 ++ Tools/Scripts/MapGenerator.cs | 61 ++++++++++- Tools/Scripts/RoundTripHarness.cs | 156 +++++++++++++++++++++++++++ 6 files changed, 494 insertions(+), 9 deletions(-) create mode 100644 Core/Scripts/BlueprintFormat.cs create mode 100644 Core/Scripts/BlueprintWriter.cs create mode 100644 Tools/Scenes/RoundTripHarness.tscn create mode 100644 Tools/Scripts/RoundTripHarness.cs diff --git a/Core/Scripts/BlueprintFormat.cs b/Core/Scripts/BlueprintFormat.cs new file mode 100644 index 0000000..153e166 --- /dev/null +++ b/Core/Scripts/BlueprintFormat.cs @@ -0,0 +1,54 @@ +namespace IslaApocalypse.Core +{ + /// + /// The single registry of constants for the v2 blueprint container — magic, version, + /// section tags, validation bounds, and the sentinel values used when a v2 file is + /// re-encoded from a v1 source that cannot supply real generation parameters. + /// + /// The byte-accurate layout lives in Core/Scripts/BLUEPRINT_FORMAT.md. Writer: + /// BlueprintWriter.WriteV2. Reader: MapDataParser (v2 branch). Tags are FourCC codes + /// stored as little-endian u32, so the raw bytes on disk read as ASCII ("PRMS", "HGTS", + /// ...) in a hex dump. + /// + public static class BlueprintFormat + { + // "ISLA" as raw bytes 'I','S','L','A' == little-endian u32 0x414C5349. + // Deliberately NOT a length-prefixed .NET string: the v1 header starts with the + // 7-bit length prefix 0x07, so the first byte alone (0x49 vs 0x07) identifies the + // format and the reader can dispatch without ambiguity. + public const uint MAGIC = 0x414C5349; + public const uint VERSION = 2; + + // Section tags (FourCC, little-endian). Reader rule: known tag -> parse, + // unknown tag -> skip by length and continue. Never reuse a retired tag value. + public const uint TAG_PARAMS = 0x534D5250; // "PRMS" + public const uint TAG_HEIGHTS = 0x53544748; // "HGTS" + public const uint TAG_BIOMES = 0x4D4F4942; // "BIOM" + public const uint TAG_TOWNS = 0x4E574F54; // "TOWN" + public const uint TAG_ROADS_HIGHWAY = 0x57484452; // "RDHW" + public const uint TAG_ROADS_BRANCH = 0x52424452; // "RDBR" + public const uint TAG_ROADS_RUGGED = 0x47524452; // "RDRG" + public const uint TAG_ROADS_TRAIL = 0x4C544452; // "RDTL" + + // MapSize sanity bounds, checked before any allocation on read. + public const int MIN_MAP_SIZE = 256; + public const int MAX_MAP_SIZE = 32768; + + // Sentinels written into the params section when the source blueprint came from a + // v1 file (a re-encode cannot know the original generation inputs). WorldSeed 0 is + // also the config "randomize" sentinel, which no real resolved seed ever is. + public const int SENTINEL_WORLD_SEED = 0; + public const float SENTINEL_CRATER_RADIUS = -1f; + public const float SENTINEL_DENSITY_MULTIPLIER = -1f; + public const float SENTINEL_IMPACT_CENTER = -1f; // both components + + /// Renders a tag as its 4 ASCII characters, for log messages. + public static string TagToString(uint tag) + { + return new string(new[] { + (char)(tag & 0xFF), (char)((tag >> 8) & 0xFF), + (char)((tag >> 16) & 0xFF), (char)((tag >> 24) & 0xFF) + }); + } + } +} diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs new file mode 100644 index 0000000..d904590 --- /dev/null +++ b/Core/Scripts/BlueprintWriter.cs @@ -0,0 +1,165 @@ +using Godot; +using System; +using System.IO; + +namespace IslaApocalypse.Core +{ + /// + /// Writes a WorldBlueprint to disk in the v2 tagged-section container + /// (layout: Core/Scripts/BLUEPRINT_FORMAT.md; constants: BlueprintFormat). + /// + /// Deliberately written against the blueprint data types, not the generator's + /// internal arrays, so it is callable outside a generation run — the map generator + /// and the round-trip harness are both just callers. + /// + /// Provenance (timestamp, git hash) is stamped at write time — it describes the + /// file being written, not whatever was in blueprint.Params. If blueprint.Params is + /// null (a re-encode of a legacy v1 file), the sentinel values from BlueprintFormat + /// are written instead of real generation inputs. + /// + public static class BlueprintWriter + { + public static void WriteV2(string absolutePath, WorldBlueprint bp) + { + ulong started = Time.GetTicksMsec(); + + using (FileStream stream = File.Open(absolutePath, FileMode.Create)) + using (BinaryWriter writer = new BinaryWriter(stream)) + { + // Header: raw "ISLA" magic + u32 version. Little-endian throughout. + writer.Write(BlueprintFormat.MAGIC); + writer.Write(BlueprintFormat.VERSION); + + WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp)); + WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp)); + WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp)); + WriteSection(writer, BlueprintFormat.TAG_TOWNS, w => WriteTowns(w, bp)); + WriteSection(writer, BlueprintFormat.TAG_ROADS_HIGHWAY, w => WriteRoadTier(w, bp.Highways)); + WriteSection(writer, BlueprintFormat.TAG_ROADS_BRANCH, w => WriteRoadTier(w, bp.BranchRoads)); + WriteSection(writer, BlueprintFormat.TAG_ROADS_RUGGED, w => WriteRoadTier(w, bp.RuggedRoads)); + WriteSection(writer, BlueprintFormat.TAG_ROADS_TRAIL, w => WriteRoadTier(w, bp.TrailRoads)); + } + + double seconds = (Time.GetTicksMsec() - started) / 1000.0; + Vector2 impact = bp.Params?.ImpactCenter + ?? new Vector2(BlueprintFormat.SENTINEL_IMPACT_CENTER, BlueprintFormat.SENTINEL_IMPACT_CENTER); + GD.Print($"[BlueprintWriter] v2 blueprint written in {seconds:F1}s to: {absolutePath} " + + $"(seed {bp.Params?.WorldSeed ?? BlueprintFormat.SENTINEL_WORLD_SEED}, " + + $"MapSize {bp.MapSize}, impactCenter ({impact.X:F1},{impact.Y:F1}))"); + } + + /// + /// Frames one section: [u32 tag][u64 payload-length][payload]. The length is + /// back-patched after the payload is written, so payload writers never have to + /// pre-compute their own size. + /// + private static void WriteSection(BinaryWriter writer, uint tag, Action payload) + { + writer.Write(tag); + long lengthPos = writer.BaseStream.Position; + writer.Write((ulong)0); + long payloadStart = writer.BaseStream.Position; + + payload(writer); + + long payloadEnd = writer.BaseStream.Position; + writer.BaseStream.Position = lengthPos; + writer.Write((ulong)(payloadEnd - payloadStart)); + writer.BaseStream.Position = payloadEnd; + } + + private static void WriteParams(BinaryWriter writer, WorldBlueprint bp) + { + BlueprintParams p = bp.Params; + writer.Write(p?.WorldSeed ?? BlueprintFormat.SENTINEL_WORLD_SEED); + writer.Write(bp.MapSize); // always real — the blueprint knows its own size + writer.Write(p?.CraterRadius ?? BlueprintFormat.SENTINEL_CRATER_RADIUS); + writer.Write(p?.DensityMultiplier ?? BlueprintFormat.SENTINEL_DENSITY_MULTIPLIER); + Vector2 impact = p?.ImpactCenter + ?? new Vector2(BlueprintFormat.SENTINEL_IMPACT_CENTER, BlueprintFormat.SENTINEL_IMPACT_CENTER); + writer.Write(impact.X); + writer.Write(impact.Y); + + // Provenance, stamped now (length-prefixed .NET strings — fine inside a + // length-framed section; only the file HEADER must avoid them). + writer.Write(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")); + writer.Write(TryGetGitShortHash()); + } + + private static void WriteHeights(BinaryWriter writer, WorldBlueprint bp) + { + // X outer / Y inner — the v1 convention, kept: the second index is the map's + // north/south axis and the server consumes it as world Z. + int n = bp.MapSize; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + writer.Write(bp.HeightMap[x, y]); + } + + private static void WriteBiomes(BinaryWriter writer, WorldBlueprint bp) + { + int n = bp.MapSize; + for (int x = 0; x < n; x++) + { + for (int y = 0; y < n; y++) + { + int ordinal = (int)bp.BiomeMap[x, y]; + if (ordinal < 0 || ordinal > byte.MaxValue) + throw new InvalidDataException( + $"[BlueprintWriter] Biome ordinal {ordinal} at ({x},{y}) does not fit in a byte."); + writer.Write((byte)ordinal); + } + } + } + + private static void WriteTowns(BinaryWriter writer, WorldBlueprint bp) + { + writer.Write(bp.Towns.Count); + foreach (TownLocation town in bp.Towns) + { + int tier = (int)town.Tier; + if (tier < 0 || tier > byte.MaxValue) + throw new InvalidDataException( + $"[BlueprintWriter] TownTier ordinal {tier} does not fit in a byte."); + writer.Write(town.Position.X); + writer.Write(town.Position.Y); + writer.Write((byte)tier); + writer.Write(town.IsHighwayNode ? (byte)1 : (byte)0); + } + } + + private static void WriteRoadTier(BinaryWriter writer, System.Collections.Generic.List paths) + { + writer.Write(paths.Count); + foreach (Vector2[] path in paths) + { + writer.Write(path.Length); + foreach (Vector2 p in path) { writer.Write(p.X); writer.Write(p.Y); } + } + } + + /// + /// Best-effort short hash of the generator repo (reads res://.git directly, no + /// git invocation). Returns "" when unavailable — e.g. an exported build. + /// + private static string TryGetGitShortHash() + { + try + { + string gitDir = Path.Combine(ProjectSettings.GlobalizePath("res://"), ".git"); + string head = File.ReadAllText(Path.Combine(gitDir, "HEAD")).Trim(); + if (head.StartsWith("ref: ")) + { + string refPath = Path.Combine(gitDir, head.Substring(5)); + if (!File.Exists(refPath)) return ""; + head = File.ReadAllText(refPath).Trim(); + } + return head.Length >= 8 ? head.Substring(0, 8) : head; + } + catch + { + return ""; + } + } + } +} diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index 8703cac..6b354c2 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -11,6 +11,29 @@ namespace IslaApocalypse.Core { public Vector2 Position; public TownTier Tier; + + // Whether the generator marked this settlement as a highway node (it drives the + // road topology at generation time). Carried by the v2 blueprint; always false + // when loading a legacy v1 file, which never stored it. Nothing server-side + // consumes it yet — it is carried and exposed, not acted on. + public bool IsHighwayNode; + } + + /// + /// The resolved generation inputs embedded in a v2 blueprint, so a file is + /// self-describing and config desync is detectable. Null on a legacy v1 load. + /// A v2 file re-encoded from a v1 source carries the BlueprintFormat sentinel + /// values instead of real inputs. + /// + public class BlueprintParams + { + public int WorldSeed; // the generator's RESOLVED noise seed (names the file) + public int MapSize; + public float CraterRadius; + public float DensityMultiplier; + public Vector2 ImpactCenter; // the crater's _impactCenter, in map pixels + public string GeneratedUtc = ""; // stamped at write time, ISO-8601 + public string GeneratorGitHash = ""; // short hash of the generator repo, "" if unknown } public class WorldBlueprint @@ -19,12 +42,17 @@ namespace IslaApocalypse.Core public float[,] HeightMap; public Biome[,] BiomeMap; public List Towns = new List(); - + // All 4 road tiers! public List Highways = new List(); public List BranchRoads = new List(); public List RuggedRoads = new List(); public List TrailRoads = new List(); + + // Which on-disk format this blueprint was parsed from (1 or 2), and the embedded + // generation parameters. Params is null when the source was a legacy v1 file. + public int FormatVersion = 1; + public BlueprintParams Params; } // 2. The Parser Utility @@ -33,18 +61,41 @@ namespace IslaApocalypse.Core public static WorldBlueprint LoadMapData(string seedStr) { string filePath = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat"); - + return LoadMapDataFromPath(filePath); + } + + /// + /// Loads a blueprint from an explicit absolute path. Same result as LoadMapData; + /// exists so tooling (the round-trip harness) can load files that do not follow + /// the seed-derived naming. + /// + public static WorldBlueprint LoadMapDataFromPath(string filePath) + { if (!File.Exists(filePath)) { GD.PrintErr($"[MapDataParser] CRITICAL ERROR: Map file not found at {filePath}"); return null; } - WorldBlueprint blueprint = new WorldBlueprint(); - using (FileStream stream = File.OpenRead(filePath)) { using (BinaryReader reader = new BinaryReader(stream)) + { + return LoadV1(reader); + } + } + } + + // =============================================================== + // LEGACY v1 PARSE PATH — the original "ISLA_V1" positional format. + // Kept intact and untouched; do not extend it. New content belongs + // in the v2 tagged-section format. + // =============================================================== + private static WorldBlueprint LoadV1(BinaryReader reader) + { + WorldBlueprint blueprint = new WorldBlueprint(); + + { { // 1. Header Validation (CRITICAL: Must read string first to align bytes!) string header = reader.ReadString(); @@ -122,7 +173,7 @@ namespace IslaApocalypse.Core } } - GD.Print($"[MapDataParser] Successfully loaded Blueprint for Seed {seedStr}. Dimension: {blueprint.MapSize}x{blueprint.MapSize}."); + GD.Print($"[MapDataParser] Successfully loaded v1 Blueprint. Dimension: {blueprint.MapSize}x{blueprint.MapSize}."); return blueprint; } } diff --git a/Tools/Scenes/RoundTripHarness.tscn b/Tools/Scenes/RoundTripHarness.tscn new file mode 100644 index 0000000..4091d96 --- /dev/null +++ b/Tools/Scenes/RoundTripHarness.tscn @@ -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") diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 5a0f655..cd75452 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -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")); + } + + /// + /// Packages the generator's internal arrays as a WorldBlueprint (plus the resolved + /// generation params) for BlueprintWriter. Arrays are shared, not copied. + /// + 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!) --- diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs new file mode 100644 index 0000000..bc0c6db --- /dev/null +++ b/Tools/Scripts/RoundTripHarness.cs @@ -0,0 +1,156 @@ +using Godot; +using System.Collections.Generic; +using IslaApocalypse.Core; + +/// +/// 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 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. +/// +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 a, List 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; + } +} From 8532d88771f8a57b3230c328b72b0f24bd074906 Mon Sep 17 00:00:00 2001 From: beezm Date: Thu, 6 Aug 2026 07:27:42 -0400 Subject: [PATCH 2/4] feat: blueprint v2 reader dispatch + validation + params cross-check (terrain-water task 02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader sniffs the first byte (0x49 raw-ISLA v2 vs 0x07 v1 string prefix) and routes to the v2 tagged-section parser or the intact v1 path (v1 loads log a deprecation warning — fallback stays live). v2 validation: magic/version gate (loud reject), MapSize sanity bound [256, 32768] before allocation, every section length checked against remaining file, section-consumed-exactly check, params-first and no-duplicate-section rules, biome and town-tier ordinal range checks. Unknown tags skip by length — the forward-compat property D-030 buys. ServerChunkManager cross-checks embedded params against config (seed + MapSize) and logs a prominent desync warning — canon H7's silent failure becomes loud. ServerConfig.json pins the reference world: WorldSeed 1409879727, 8K. Round-trip oracle GREEN: reference v1 (537,054,156 B) -> v2 (335,727,522 B, -192 MB) -> parse -> semantic equality holds (heights bitwise, biomes, 94 towns, 4/1/37/39 road paths point-for-point). v1 parse 1.9s, v2 write 1.4s, v2 parse 1.6s. Co-Authored-By: Claude Fable 5 --- Core/Scripts/MapDataParser.cs | 223 ++++++++++++++++++++++++++- Server/Scripts/ServerChunkManager.cs | 16 ++ ServerConfig.json | 2 +- 3 files changed, 239 insertions(+), 2 deletions(-) diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index 6b354c2..1a084fe 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -81,11 +81,232 @@ namespace IslaApocalypse.Core { using (BinaryReader reader = new BinaryReader(stream)) { - return LoadV1(reader); + // Format dispatch by the very first byte: v2 opens with raw "ISLA" + // (0x49 'I'), v1 opens with 0x07 — the 7-bit length prefix of its + // length-prefixed "ISLA_V1" string. Unambiguous by construction. + int firstByte = stream.ReadByte(); + stream.Position = 0; + + if (firstByte == 0x49) + return LoadV2(reader); + + if (firstByte == 0x07) + { + GD.Print("[MapDataParser] ⚠ DEPRECATED: loading a legacy v1 blueprint — regenerate to produce v2."); + return LoadV1(reader); + } + + GD.PrintErr($"[MapDataParser] ERROR: {filePath} is neither a v2 nor a v1 blueprint (first byte 0x{firstByte:X2})."); + return null; } } } + // =============================================================== + // v2 PARSE PATH — the tagged-section container. + // Layout: Core/Scripts/BLUEPRINT_FORMAT.md. Constants: BlueprintFormat. + // Reader rule: known tag -> parse, unknown tag -> skip by length. + // =============================================================== + private static WorldBlueprint LoadV2(BinaryReader reader) + { + Stream stream = reader.BaseStream; + long fileLength = stream.Length; + + uint magic = reader.ReadUInt32(); + if (magic != BlueprintFormat.MAGIC) + { + GD.PrintErr($"[MapDataParser] ERROR: bad v2 magic 0x{magic:X8} (expected \"ISLA\")."); + return null; + } + uint version = reader.ReadUInt32(); + if (version != BlueprintFormat.VERSION) + { + GD.PrintErr($"[MapDataParser] ERROR: unsupported blueprint format version {version} " + + $"(this build reads version {BlueprintFormat.VERSION}). Refusing to load."); + return null; + } + + WorldBlueprint blueprint = new WorldBlueprint { FormatVersion = 2 }; + var seenTags = new HashSet(); + bool isFirstSection = true; + + while (stream.Position < fileLength) + { + if (fileLength - stream.Position < 12) + { + GD.PrintErr($"[MapDataParser] ERROR: truncated section header at offset {stream.Position}."); + return null; + } + + uint tag = reader.ReadUInt32(); + ulong payloadLength = reader.ReadUInt64(); + long payloadStart = stream.Position; + + if (payloadLength > (ulong)(fileLength - payloadStart)) + { + GD.PrintErr($"[MapDataParser] ERROR: section '{BlueprintFormat.TagToString(tag)}' declares " + + $"{payloadLength} bytes but only {fileLength - payloadStart} remain. Truncated or corrupt file."); + return null; + } + + if (isFirstSection && tag != BlueprintFormat.TAG_PARAMS) + { + GD.PrintErr($"[MapDataParser] ERROR: first section is '{BlueprintFormat.TagToString(tag)}' — " + + "the params section must come first."); + return null; + } + isFirstSection = false; + + if (!seenTags.Add(tag)) + { + GD.PrintErr($"[MapDataParser] ERROR: duplicate section '{BlueprintFormat.TagToString(tag)}'."); + return null; + } + + bool sectionOk; + if (tag == BlueprintFormat.TAG_PARAMS) sectionOk = ParseParams(reader, blueprint); + else if (tag == BlueprintFormat.TAG_HEIGHTS) sectionOk = ParseHeights(reader, blueprint, payloadLength); + else if (tag == BlueprintFormat.TAG_BIOMES) sectionOk = ParseBiomes(reader, blueprint, payloadLength); + else if (tag == BlueprintFormat.TAG_TOWNS) sectionOk = ParseTowns(reader, blueprint); + else if (tag == BlueprintFormat.TAG_ROADS_HIGHWAY) sectionOk = ParseRoadTier(reader, blueprint.Highways); + else if (tag == BlueprintFormat.TAG_ROADS_BRANCH) sectionOk = ParseRoadTier(reader, blueprint.BranchRoads); + else if (tag == BlueprintFormat.TAG_ROADS_RUGGED) sectionOk = ParseRoadTier(reader, blueprint.RuggedRoads); + else if (tag == BlueprintFormat.TAG_ROADS_TRAIL) sectionOk = ParseRoadTier(reader, blueprint.TrailRoads); + else + { + // The property the redesign exists to buy: future sections (water, + // anything) are invisible to a reader that predates them. + GD.Print($"[MapDataParser] Skipping unknown section '{BlueprintFormat.TagToString(tag)}' ({payloadLength} bytes)."); + stream.Seek((long)payloadLength, SeekOrigin.Current); + continue; + } + + if (!sectionOk) return null; + + if (stream.Position != payloadStart + (long)payloadLength) + { + GD.PrintErr($"[MapDataParser] ERROR: section '{BlueprintFormat.TagToString(tag)}' parsed " + + $"{stream.Position - payloadStart} bytes but declared {payloadLength}."); + return null; + } + } + + if (blueprint.Params == null || blueprint.HeightMap == null || blueprint.BiomeMap == null) + { + GD.PrintErr("[MapDataParser] ERROR: v2 blueprint is missing a mandatory section (params/heights/biomes)."); + return null; + } + if (!seenTags.Contains(BlueprintFormat.TAG_TOWNS)) + GD.Print("[MapDataParser] ⚠ v2 blueprint has no towns section — town list is empty."); + if (!seenTags.Contains(BlueprintFormat.TAG_ROADS_HIGHWAY) && !seenTags.Contains(BlueprintFormat.TAG_ROADS_BRANCH) && + !seenTags.Contains(BlueprintFormat.TAG_ROADS_RUGGED) && !seenTags.Contains(BlueprintFormat.TAG_ROADS_TRAIL)) + GD.Print("[MapDataParser] ⚠ v2 blueprint has no road sections — road lists are empty."); + + GD.Print($"[MapDataParser] Successfully loaded v2 Blueprint. Dimension: {blueprint.MapSize}x{blueprint.MapSize}, " + + $"seed {blueprint.Params.WorldSeed}, generated {blueprint.Params.GeneratedUtc}."); + return blueprint; + } + + private static bool ParseParams(BinaryReader reader, WorldBlueprint blueprint) + { + var p = new BlueprintParams(); + p.WorldSeed = reader.ReadInt32(); + p.MapSize = reader.ReadInt32(); + p.CraterRadius = reader.ReadSingle(); + p.DensityMultiplier = reader.ReadSingle(); + p.ImpactCenter = new Vector2(reader.ReadSingle(), reader.ReadSingle()); + p.GeneratedUtc = reader.ReadString(); + p.GeneratorGitHash = reader.ReadString(); + + if (p.MapSize < BlueprintFormat.MIN_MAP_SIZE || p.MapSize > BlueprintFormat.MAX_MAP_SIZE) + { + GD.PrintErr($"[MapDataParser] ERROR: MapSize {p.MapSize} outside sane bounds " + + $"[{BlueprintFormat.MIN_MAP_SIZE}, {BlueprintFormat.MAX_MAP_SIZE}]. Refusing to allocate."); + return false; + } + + blueprint.Params = p; + blueprint.MapSize = p.MapSize; + return true; + } + + private static bool ParseHeights(BinaryReader reader, WorldBlueprint blueprint, ulong payloadLength) + { + int n = blueprint.MapSize; + if (payloadLength != 4UL * (ulong)n * (ulong)n) + { + GD.PrintErr($"[MapDataParser] ERROR: heights section is {payloadLength} bytes, expected {4UL * (ulong)n * (ulong)n}."); + return false; + } + blueprint.HeightMap = new float[n, n]; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + blueprint.HeightMap[x, y] = reader.ReadSingle(); + return true; + } + + private static bool ParseBiomes(BinaryReader reader, WorldBlueprint blueprint, ulong payloadLength) + { + int n = blueprint.MapSize; + if (payloadLength != (ulong)n * (ulong)n) + { + GD.PrintErr($"[MapDataParser] ERROR: biomes section is {payloadLength} bytes, expected {(ulong)n * (ulong)n}."); + return false; + } + int biomeCount = System.Enum.GetValues().Length; + blueprint.BiomeMap = new Biome[n, n]; + for (int x = 0; x < n; x++) + { + for (int y = 0; y < n; y++) + { + byte ordinal = reader.ReadByte(); + if (ordinal >= biomeCount) + { + GD.PrintErr($"[MapDataParser] ERROR: biome ordinal {ordinal} at ({x},{y}) is out of range " + + $"(known biomes: 0..{biomeCount - 1}). Corrupt file or enum drift."); + return false; + } + blueprint.BiomeMap[x, y] = (Biome)ordinal; + } + } + return true; + } + + private static bool ParseTowns(BinaryReader reader, WorldBlueprint blueprint) + { + int tierCount = System.Enum.GetValues().Length; + int townCount = reader.ReadInt32(); + for (int i = 0; i < townCount; i++) + { + TownLocation town = new TownLocation(); + town.Position = new Vector2(reader.ReadSingle(), reader.ReadSingle()); + byte tier = reader.ReadByte(); + if (tier >= tierCount) + { + GD.PrintErr($"[MapDataParser] ERROR: town {i} tier ordinal {tier} is out of range " + + $"(known tiers: 0..{tierCount - 1})."); + return false; + } + town.Tier = (TownTier)tier; + town.IsHighwayNode = reader.ReadByte() != 0; + blueprint.Towns.Add(town); + } + return true; + } + + private static bool ParseRoadTier(BinaryReader reader, List into) + { + int pathCount = reader.ReadInt32(); + for (int i = 0; i < pathCount; i++) + { + int pathLength = reader.ReadInt32(); + Vector2[] path = new Vector2[pathLength]; + for (int p = 0; p < pathLength; p++) { path[p] = new Vector2(reader.ReadSingle(), reader.ReadSingle()); } + into.Add(path); + } + return true; + } + // =============================================================== // LEGACY v1 PARSE PATH — the original "ISLA_V1" positional format. // Kept intact and untouched; do not extend it. New content belongs diff --git a/Server/Scripts/ServerChunkManager.cs b/Server/Scripts/ServerChunkManager.cs index 41faa67..ded094a 100644 --- a/Server/Scripts/ServerChunkManager.cs +++ b/Server/Scripts/ServerChunkManager.cs @@ -39,6 +39,22 @@ namespace IslaApocalypse.Server if (_blueprint != null) { + // Params cross-check (v2 blueprints only): the file carries its resolved + // generation inputs, so a config edited after generation is detectable — + // canon H7's silent desync becomes loud. Warning, not an abort: the world + // still loads; the developer decides what to do about the mismatch. + if (_blueprint.Params != null) + { + var p = _blueprint.Params; + if (p.WorldSeed != BlueprintFormat.SENTINEL_WORLD_SEED && p.WorldSeed != ConfigManager.WorldSeed) + GD.PrintErr($"[Server] ⚠⚠ BLUEPRINT/CONFIG DESYNC: blueprint was generated with seed " + + $"{p.WorldSeed} but ServerConfig.json says {ConfigManager.WorldSeed}. " + + "The world you load is not the world this config describes."); + if (p.MapSize != ConfigManager.MapSize) + GD.PrintErr($"[Server] ⚠⚠ BLUEPRINT/CONFIG DESYNC: blueprint MapSize {p.MapSize} " + + $"vs config MapSize {ConfigManager.MapSize}."); + } + GD.Print("[Server] Blueprint loaded. Locating Capitol City..."); // 2. Find the Capitol in the parsed data diff --git a/ServerConfig.json b/ServerConfig.json index 90d629a..b9af832 100644 --- a/ServerConfig.json +++ b/ServerConfig.json @@ -1,5 +1,5 @@ { - "WorldSeed": 1063685222, + "WorldSeed": 1409879727, "MapProfile": "8K", "TownDensity": "Normal", "ChunkRadius": 32 From a7e6c174226b7de3113971293e02508751f94944 Mon Sep 17 00:00:00 2001 From: beezm Date: Thu, 6 Aug 2026 07:31:03 -0400 Subject: [PATCH 3/4] docs: BLUEPRINT_FORMAT.md + README updates for the v2 container (terrain-water task 02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-accurate v2 contract (magic/version, tagged sections, registered FourCC table, validation rules, re-encode sentinels, extension path) plus the v1 legacy summary — v1 stays live per the safety-net plan. READMEs updated additively: parser dispatch, dual-write outputs, harness usage, server params cross-check, wire-format hazard rewording (u8 on v2 path, range-checked; append-only rule unchanged). Co-Authored-By: Claude Fable 5 --- Core/README.md | 24 ++++--- Core/Scripts/BLUEPRINT_FORMAT.md | 116 +++++++++++++++++++++++++++++++ Core/Scripts/README.md | 22 ++++-- Server/README.md | 14 ++-- Tools/README.md | 3 +- Tools/Scenes/README.md | 13 ++++ Tools/Scripts/README.md | 15 +++- 7 files changed, 185 insertions(+), 22 deletions(-) create mode 100644 Core/Scripts/BLUEPRINT_FORMAT.md diff --git a/Core/README.md b/Core/README.md index 26ed7cb..8e5f52d 100644 --- a/Core/README.md +++ b/Core/README.md @@ -8,16 +8,24 @@ define what things are and how to calculate them; they never remember what is cu ### `MapDataParser.cs` — the data bridge Deserializes the binary `.dat` blueprint written by `/Tools` into a `WorldBlueprint` held in RAM: -map size, the float heightmap, the biome map, town locations, and **all four tiers** of A\* road -vectors (Highways, Branch Roads, Rugged Roads, Trails). +map size, the float heightmap, the biome map, town locations, **all four tiers** of A\* road +vectors (Highways, Branch Roads, Rugged Roads, Trails), and — from v2 files — the embedded +generation params (seed, sizes, impact centre, provenance) and each town's highway-node flag. -Read order is fixed and must match the writer exactly: magic string `"ISLA_V1"` → map size → -per-pixel `{height float, biome int}` in x-major order → towns → the four road tiers in order. +**Two formats are live** (byte-accurate contract: `Core/Scripts/BLUEPRINT_FORMAT.md`). The parser +dispatches on the file's first byte: +- **v2 (primary)** — raw `ISLA` magic, u32 version gate, then tagged sections + `[u32 tag][u64 length][payload]`. Unknown tags are skipped by length, so future sections are + invisible to older readers. Validated on read: version, MapSize bounds, section lengths against + the file, biome/tier ordinal ranges. +- **v1 (legacy)** — the positional `"ISLA_V1"` format, still written beside v2 as `_v1.dat` and + still loadable (with a deprecation warning) until a future removal task. -⚠ **Wire-format hazard.** Biome and town-tier enums are serialized as their **ordinal values**, with -no version gate and no range validation on read. **Never reorder or insert members** in `Enums.cs` — -append only, at the end. Reordering silently reinterprets every pixel of every existing `.dat`. -(`RoadTier` is exempt: road tiers are stored in separate file sections, so that enum never hits disk.) +⚠ **Wire-format hazard.** Biome and town-tier enums are serialized as their **ordinal values** +(u8 in v2, i32 in v1). **Never reorder or insert members** in `Enums.cs` — append only, at the end. +v2's range checks make drift fail loudly at parse time; legacy v1 has no validation and silently +reinterprets every pixel. (`RoadTier` is exempt: road tiers are stored in separate file sections — +tagged in v2, positional in v1 — so that enum never hits disk.) ### `ChunkData.cs` + `Constants.cs` — voxel containers and tuning - **Chunk dimensions:** `24 × 24` horizontal, `256` vertical (`Constants.cs`). diff --git a/Core/Scripts/BLUEPRINT_FORMAT.md b/Core/Scripts/BLUEPRINT_FORMAT.md new file mode 100644 index 0000000..b857c3f --- /dev/null +++ b/Core/Scripts/BLUEPRINT_FORMAT.md @@ -0,0 +1,116 @@ +# BLUEPRINT_FORMAT — the `.dat` container, v2 (and the v1 legacy summary) + +The `.dat` blueprint is the sole handoff from the offline generator (`/Tools`) to the server +(`/Server`). This document is the byte-accurate contract. Code authority: constants in +`BlueprintFormat.cs`, writer in `BlueprintWriter.cs`, reader in `MapDataParser.cs`. On any +disagreement between this file and the code, the code wins and this file must be corrected. + +- **Byte order: little-endian throughout** (BinaryWriter/BinaryReader platform default; stated here + because the format itself records no endianness marker). +- **File naming:** `user://MapData_Seed_.dat`, where `` is the generator's **resolved** + noise seed. The server looks the file up by config `WorldSeed` — a config seed of 0 ("randomize") + therefore never finds the file it just generated (historical hazard H4; config-side, unchanged). +- The generator currently **dual-writes**: v2 under the primary name, legacy v1 beside it as + `MapData_Seed__v1.dat`. The v1 fallback is scheduled for removal in a future task. + +## Format detection + +The reader dispatches on the **first byte** of the file: + +| First byte | Format | Why it's unambiguous | +|---|---|---| +| `0x49` (`'I'`) | **v2** — opens with the raw 4 bytes `ISLA` | v2's magic is raw bytes, deliberately not a .NET string | +| `0x07` | **v1 (legacy)** — opens with the length-prefixed .NET string `"ISLA_V1"` (prefix byte 7) | v1 loads intact, with a deprecation warning | +| anything else | rejected loudly, `null` return | | + +## v2 layout + +``` +[4 B] magic: raw bytes 'I','S','L','A' (== little-endian u32 0x414C5349) +[4 B] format version: u32 = 2 (any other value -> loud reject, null) +[...] sections, sequentially, until EOF +``` + +### Section framing + +Every section: `[u32 tag][u64 payload-length in bytes][payload]`. + +- **Reader rule: known tag → parse; unknown tag → skip payload-length bytes and continue.** This is + the forward-compatibility property the v2 redesign exists to buy: a reader that predates a section + (a future water layer, for instance) loads the file and never sees it. +- The u64 length is deliberate headroom (a u32 caps a section at 4 GiB; a 16K-map height section + would already be 1 GiB, and future per-pixel sections should never have to shard on length). +- Tag values are FourCC codes stored as little-endian u32, so the on-disk bytes read as ASCII in a + hex dump. **All tags are registered in `BlueprintFormat.cs` and in the table below — one place + each. Never reuse a retired tag value.** +- **Rules enforced by the reader:** the params section must be **first**; duplicate tags are an + error; every declared length is checked against the remaining file before the payload is read; a + parsed section must consume exactly its declared length. + +### Registered sections (v2, current content) + +| Tag (ASCII / u32) | Payload | Notes | +|---|---|---| +| `PRMS` / `0x534D5250` | `i32 WorldSeed` · `i32 MapSize` · `f32 CraterRadius` · `f32 DensityMultiplier` · `f32 ImpactCenterX` · `f32 ImpactCenterY` · `str GeneratedUtc` · `str GeneratorGitHash` | Mandatory, first. The **resolved** generation inputs — the file is self-describing; the server cross-checks seed and MapSize against config and warns loudly on desync. `str` = .NET length-prefixed UTF-8 (fine *inside* a length-framed section; only the file header must avoid it). `CraterRadius` is f32 because `ConfigManager.CraterRadius` is a float in code. `GeneratorGitHash` is the repo short hash or `""`. | +| `HGTS` / `0x53544748` | `MapSize²` × `f32` height, **X outer / Y inner** | Mandatory. The second index is the map's north/south axis; the server consumes it as world **Z**. (This axis convention was never written down for v1 — it is now normative.) Length must equal `4·MapSize²`. | +| `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome` — **append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. | +| `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. | +| `RDHW` / `0x57484452` | `i32 pathCount`, then per path: `i32 pointCount` + `pointCount` × (`f32 X` · `f32 Y`) | Highway tier. **Tags, not file position, identify the tier** — the v1 order-fragility is gone. | +| `RDBR` / `0x52424452` | same layout | Branch tier. | +| `RDRG` / `0x47524452` | same layout | Rugged tier. | +| `RDTL` / `0x4C544452` | same layout | Trail tier. | + +Missing `TOWN`/road sections load as empty lists with a warning; missing `PRMS`/`HGTS`/`BIOM` is an +error. Section order after `PRMS` is not significant (the reference writer emits the table order). + +### Reader validation (v2 path) + +1. Magic and version gate — loud, specific errors; `null` return (the caller's null-check aborts + world load cleanly). +2. `MapSize` sanity bound **before any allocation**: `256 ≤ MapSize ≤ 32768`. +3. Every section length checked against the remaining file length; truncation is a loud error, not + a read-past-end crash. +4. Section parsers must consume exactly their declared length. +5. Biome and town-tier ordinals range-checked against the known enum counts. +6. After load, `ServerChunkManager` compares embedded `WorldSeed`/`MapSize` against + `ServerConfig.json` and logs a prominent desync warning on mismatch (warning, not abort). + +### Sentinel params (re-encoded files) + +A v2 file produced by re-encoding a v1 source (e.g. the round-trip harness) cannot know the +original generation inputs. It carries: `WorldSeed = 0`, `CraterRadius = -1`, `DensityMultiplier = +-1`, `ImpactCenter = (-1, -1)` (`MapSize` is always real). The seed cross-check skips sentinel +seed 0. Provenance (`GeneratedUtc`, `GeneratorGitHash`) is stamped at **write** time and describes +the file, not the original generation. + +### Size + +`total = 8 (header) + Σ per section (12 + payload)`. The pixel grid dominates: `5·MapSize²` bytes +(4 height + 1 biome) ≈ **320 MiB at 8K**, vs v1's `8·MapSize²` ≈ 512 MiB — the u8 biome section +saves ~192 MiB at 8K. + +### Adding a new section (the intended extension path) + +1. Register a fresh FourCC in `BlueprintFormat.cs` and in the table above. +2. Emit it from `BlueprintWriter.WriteV2` (any position after `PRMS`). +3. Parse it in `MapDataParser.LoadV2`'s tag dispatch. +4. Old readers skip it automatically; **no version bump is needed for additive sections.** Bump + `VERSION` only for changes that alter the meaning of *existing* bytes. + +## v1 legacy summary (still readable, still dual-written, removal pending) + +Positional and untagged — every field's meaning derives from its offset; no lengths, no checksums, +no skip capability. Layout: length-prefixed string `"ISLA_V1"` → `i32 MapSize` → `MapSize²` × +(`f32 height` · **`i32` biome ordinal**) X-outer/Y-inner → `i32 townCount` + per town (`f32 X` · +`f32 Y` · `i32 tier`) → four road blocks **identified by position** (Highway → Branch → Rugged → +Trail), each `i32 pathCount` + per path `i32 pointCount` + points. The v1 reader stops after the +Trail block and ignores trailing bytes. No validation beyond the header string. It does not carry +generation params, the impact centre, or the highway-node flag. + +## Shared wire-format rule (both formats) + +⚠ **`Biome` and `TownTier` ordinals are the serialized values** (u8 in v2, i32 in v1). The enums in +`Enums.cs` are declared without explicit numeric values, so their ordinals are positional: +**append new members only at the end; never reorder or insert.** v2 adds parse-time range checks, +which turn enum drift from silent reinterpretation into a loud load failure — but the append-only +rule is still what keeps old files *meaning* the same thing. diff --git a/Core/Scripts/README.md b/Core/Scripts/README.md index 4f278a4..31b56f8 100644 --- a/Core/Scripts/README.md +++ b/Core/Scripts/README.md @@ -24,15 +24,25 @@ rendering. ### Shared identifiers - **`Enums.cs`** — `Biome`, `TownTier`, `MapHalf`, `RoadTier`. - ⚠ **`Biome` and `TownTier` are the `.dat` wire format.** They are declared without explicit values, - so each member's ordinal *is* the number written to disk, with no version gate and no validation on - read. **Append only, at the end — never reorder or insert.** Doing so silently reinterprets every - pixel and every settlement in every existing blueprint. + ⚠ **`Biome` and `TownTier` are the `.dat` wire format** (u8 in the v2 container, i32 in legacy + v1). They are declared without explicit values, so each member's ordinal *is* the number written + to disk. **Append only, at the end — never reorder or insert.** The v2 reader range-checks + ordinals so drift fails loudly at parse time, but only the append-only rule keeps old files + *meaning* the same thing. Legacy v1 has no gate and no validation at all. `RoadTier` is exempt: road tiers live in separate sections of the file, so it is never serialized. ### World data -- **`MapDataParser.cs`** — decodes the `.dat` blueprint into a `WorldBlueprint` (heightmap, biome map, - towns, four road tiers). +- **`BLUEPRINT_FORMAT.md`** — the byte-accurate `.dat` container contract (v2 tagged sections + + the v1 legacy summary). Read this before touching the writer or parser. +- **`BlueprintFormat.cs`** — the single registry of v2 constants: magic, version gate, section + tags (FourCC), validation bounds, re-encode sentinels. +- **`BlueprintWriter.cs`** — writes a `WorldBlueprint` as a v2 file. Blueprint-typed on purpose: + the map generator and the round-trip harness are both just callers. +- **`MapDataParser.cs`** — decodes a `.dat` blueprint into a `WorldBlueprint` (heightmap, biome map, + towns, four road tiers, and — v2 only — the embedded generation params and per-town highway-node + flag). Dispatches on the first byte: v2 tagged-section files get validation (version gate, size + bounds, section-length and ordinal range checks); legacy v1 files still load, intact, with a + deprecation warning. - **`ChunkData.cs`** — one chunk's density and block-ID fields, `+1` padded on every axis so the mesher can reach into the neighbouring chunk, plus the per-column data the renderer needs. - **`Constants.cs`** — chunk dimensions, `ISO_LEVEL`, `VOXEL_SCALE`, and the visual/road tunables diff --git a/Server/README.md b/Server/README.md index 81bdf3e..789c64f 100644 --- a/Server/README.md +++ b/Server/README.md @@ -8,11 +8,15 @@ future multiplayer setup this is the side that dictates terrain and ships chunk Attached to the `World` root node of `Scenes/Main.tscn`. Runs the whole 3D world at boot. ### Startup -1. Loads `ServerConfig.json` and the seed's `.dat` blueprint. -2. **Finds the Capitol** in the parsed town list and uses it as the world origin point. -3. Converts its pixel position to chunk coordinates (`pixel / CHUNK_SIZE`). -4. Builds a `(2 × ChunkRadius)²` grid of chunks around it — **synchronously, all at boot**. -5. Teleports the `Camera3D` to 120 m above the Capitol, looking down. +1. Loads `ServerConfig.json` and the seed's `.dat` blueprint (v2 or legacy v1 — the parser + dispatches automatically; see `Core/Scripts/BLUEPRINT_FORMAT.md`). +2. **Cross-checks the blueprint's embedded params** (v2 only) against the config and logs a + prominent `BLUEPRINT/CONFIG DESYNC` warning if the seed or MapSize disagree — a config edited + after generation is loud now, not silent. +3. **Finds the Capitol** in the parsed town list and uses it as the world origin point. +4. Converts its pixel position to chunk coordinates (`pixel / CHUNK_SIZE`). +5. Builds a `(2 × ChunkRadius)²` grid of chunks around it — **synchronously, all at boot**. +6. Teleports the `Camera3D` to 120 m above the Capitol, looking down. ⚠ **Two things to know about startup.** The chunk grid is built in one blocking pass with no streaming or unloading, so `ChunkRadius` directly controls boot cost — 32 means 4,096 chunks and diff --git a/Tools/README.md b/Tools/README.md index 947042c..05fa653 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -33,7 +33,8 @@ so they only help between paths. Expect a regeneration to take minutes, or to ne | File | What it is | |---|---| -| `MapData_Seed_.dat` | The binary blueprint the server reads (~512 MB at 8K) | +| `MapData_Seed_.dat` | The binary blueprint the server reads — **v2 tagged container** (~336 MB at 8K; contract in `Core/Scripts/BLUEPRINT_FORMAT.md`) | +| `MapData_Seed__v1.dat` | The same content in the legacy v1 format (~512 MB), dual-written as a safety net until a future removal task | | `Map_Seed_.png` | Visual snapshot of the map, for reviewing and picking seeds | On Linux: `~/.local/share/godot/app_userdata/islaApocolypse/`. diff --git a/Tools/Scenes/README.md b/Tools/Scenes/README.md index 22bb960..feb80ff 100644 --- a/Tools/Scenes/README.md +++ b/Tools/Scenes/README.md @@ -23,6 +23,19 @@ tiny or black PNG. Note the generator writes the `.dat` on its own — the capture wrapper only matters for the full-resolution image. +## `RoundTripHarness.tscn` + +The blueprint format regression test (script: `Tools/Scripts/RoundTripHarness.cs`). Run it +headless from a terminal — it never generates, it round-trips an existing blueprint through the +real parser and v2 writer and asserts semantic equality: + +``` +Godot --headless --path res://Tools/Scenes/RoundTripHarness.tscn +``` + +Defaults to the preserved reference blueprint (`user://reference_1409879727/`); override with the +`HARNESS_V1_PATH` / `HARNESS_V2_PATH` environment variables. Exit code 0 = pass. + ## Generating a new world 1. Open `MapPreview.tscn` (or `Scenes/MapCaptureTool.tscn` for the full-resolution PNG). diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md index 8fad3a5..deaf7c8 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Scripts/README.md @@ -18,7 +18,10 @@ Generates the entire 2D blueprint. Roughly in order: 5. **Biomes and towns** — biome zoning by height and temperature; tiered town placement (Capitol, Hubs, Villages, Outposts, POIs) filtered by slope, water proximity and spacing. 6. **Roads** — see below. -7. **Export** — writes the `.dat` blueprint, then renders the PNG snapshot. +7. **Export** — writes the `.dat` blueprint (dual-write: the v2 tagged container under the primary + seed name, plus the legacy v1 format beside it as `_v1.dat` — see + `Core/Scripts/BLUEPRINT_FORMAT.md`), then renders the PNG snapshot. The v2 file embeds the + resolved generation params (seed, MapSize, crater radius, density, impact centre, provenance). ### The road network @@ -52,7 +55,15 @@ Road colours on the snapshot, useful for identifying a road: **red** = Highway, **dark brown** = Rugged, **light brown** = Trail. ### Outputs -`MapData_Seed_.dat` and `Map_Seed_.png`, both to `user://`. +`MapData_Seed_.dat` (v2), `MapData_Seed__v1.dat` (legacy dual-write), and +`Map_Seed_.png`, all to `user://`. + +## `RoundTripHarness.cs` + +The blueprint format regression test (scene: `Tools/Scenes/RoundTripHarness.tscn`). Loads a +known-good blueprint through the real parser, re-writes it as v2 through the real writer, re-loads +it, and asserts semantic equality (heights bitwise, biomes, towns, every road point). Headless, +seconds per cycle, no generation. Exit code 0 = pass. ## Rules 1. **No magic numbers.** Distances, radii and thresholds derive from `MapSize` or `scaleFactor`, so From fb14b0693bda1a452b3cc8a162c7e74113560157 Mon Sep 17 00:00:00 2001 From: beezm Date: Fri, 7 Aug 2026 00:17:46 -0400 Subject: [PATCH 4/4] chore: Godot .uid files for the new task-02 scripts Generated by the engine on first run of the project after the v2 envelope landed (terrain-water task 02). Co-Authored-By: Claude Fable 5 --- Core/Scripts/BlueprintFormat.cs.uid | 1 + Core/Scripts/BlueprintWriter.cs.uid | 1 + Tools/Scripts/RoundTripHarness.cs.uid | 1 + 3 files changed, 3 insertions(+) create mode 100644 Core/Scripts/BlueprintFormat.cs.uid create mode 100644 Core/Scripts/BlueprintWriter.cs.uid create mode 100644 Tools/Scripts/RoundTripHarness.cs.uid diff --git a/Core/Scripts/BlueprintFormat.cs.uid b/Core/Scripts/BlueprintFormat.cs.uid new file mode 100644 index 0000000..1739cc2 --- /dev/null +++ b/Core/Scripts/BlueprintFormat.cs.uid @@ -0,0 +1 @@ +uid://cfhjrihiyqtau diff --git a/Core/Scripts/BlueprintWriter.cs.uid b/Core/Scripts/BlueprintWriter.cs.uid new file mode 100644 index 0000000..3af2e7d --- /dev/null +++ b/Core/Scripts/BlueprintWriter.cs.uid @@ -0,0 +1 @@ +uid://vevh7pfrbcf7 diff --git a/Tools/Scripts/RoundTripHarness.cs.uid b/Tools/Scripts/RoundTripHarness.cs.uid new file mode 100644 index 0000000..cfa3a5a --- /dev/null +++ b/Tools/Scripts/RoundTripHarness.cs.uid @@ -0,0 +1 @@ +uid://76veosvnqe8m