From 8532d88771f8a57b3230c328b72b0f24bd074906 Mon Sep 17 00:00:00 2001 From: beezm Date: Thu, 6 Aug 2026 07:27:42 -0400 Subject: [PATCH] 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