diff --git a/Core/README.md b/Core/README.md index 8e5f52d..26ed7cb 100644 --- a/Core/README.md +++ b/Core/README.md @@ -8,24 +8,16 @@ 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, **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. +map size, the float heightmap, the biome map, town locations, and **all four tiers** of A\* road +vectors (Highways, Branch Roads, Rugged Roads, Trails). -**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. +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. -⚠ **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.) +⚠ **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.) ### `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 deleted file mode 100644 index b857c3f..0000000 --- a/Core/Scripts/BLUEPRINT_FORMAT.md +++ /dev/null @@ -1,116 +0,0 @@ -# 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/BlueprintFormat.cs b/Core/Scripts/BlueprintFormat.cs deleted file mode 100644 index 153e166..0000000 --- a/Core/Scripts/BlueprintFormat.cs +++ /dev/null @@ -1,54 +0,0 @@ -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/BlueprintFormat.cs.uid b/Core/Scripts/BlueprintFormat.cs.uid deleted file mode 100644 index 1739cc2..0000000 --- a/Core/Scripts/BlueprintFormat.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cfhjrihiyqtau diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs deleted file mode 100644 index d904590..0000000 --- a/Core/Scripts/BlueprintWriter.cs +++ /dev/null @@ -1,165 +0,0 @@ -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/BlueprintWriter.cs.uid b/Core/Scripts/BlueprintWriter.cs.uid deleted file mode 100644 index 3af2e7d..0000000 --- a/Core/Scripts/BlueprintWriter.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://vevh7pfrbcf7 diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index 1a084fe..8703cac 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -11,29 +11,6 @@ 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 @@ -42,17 +19,12 @@ 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 @@ -61,262 +33,18 @@ 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)) - { - // 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 - // 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(); @@ -394,7 +122,7 @@ namespace IslaApocalypse.Core } } - GD.Print($"[MapDataParser] Successfully loaded v1 Blueprint. Dimension: {blueprint.MapSize}x{blueprint.MapSize}."); + GD.Print($"[MapDataParser] Successfully loaded Blueprint for Seed {seedStr}. Dimension: {blueprint.MapSize}x{blueprint.MapSize}."); return blueprint; } } diff --git a/Core/Scripts/README.md b/Core/Scripts/README.md index 31b56f8..4f278a4 100644 --- a/Core/Scripts/README.md +++ b/Core/Scripts/README.md @@ -24,25 +24,15 @@ rendering. ### Shared identifiers - **`Enums.cs`** — `Biome`, `TownTier`, `MapHalf`, `RoadTier`. - ⚠ **`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. + ⚠ **`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. `RoadTier` is exempt: road tiers live in separate sections of the file, so it is never serialized. ### World data -- **`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. +- **`MapDataParser.cs`** — decodes the `.dat` blueprint into a `WorldBlueprint` (heightmap, biome map, + towns, four road tiers). - **`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 789c64f..81bdf3e 100644 --- a/Server/README.md +++ b/Server/README.md @@ -8,15 +8,11 @@ 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 (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. +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. ⚠ **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/Server/Scripts/ServerChunkManager.cs b/Server/Scripts/ServerChunkManager.cs index ded094a..41faa67 100644 --- a/Server/Scripts/ServerChunkManager.cs +++ b/Server/Scripts/ServerChunkManager.cs @@ -39,22 +39,6 @@ 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 b9af832..90d629a 100644 --- a/ServerConfig.json +++ b/ServerConfig.json @@ -1,5 +1,5 @@ { - "WorldSeed": 1409879727, + "WorldSeed": 1063685222, "MapProfile": "8K", "TownDensity": "Normal", "ChunkRadius": 32 diff --git a/Tools/README.md b/Tools/README.md index 05fa653..947042c 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -33,8 +33,7 @@ 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 — **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 | +| `MapData_Seed_.dat` | The binary blueprint the server reads (~512 MB at 8K) | | `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 feb80ff..22bb960 100644 --- a/Tools/Scenes/README.md +++ b/Tools/Scenes/README.md @@ -23,19 +23,6 @@ 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/Scenes/RoundTripHarness.tscn b/Tools/Scenes/RoundTripHarness.tscn deleted file mode 100644 index 4091d96..0000000 --- a/Tools/Scenes/RoundTripHarness.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[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 cd75452..5a0f655 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -180,62 +180,9 @@ public partial class MapGenerator : TextureRect private void ExportMapData() { string seedStr = _noise.Seed.ToString(); - - // 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)) + string filePath = $"user://MapData_Seed_{seedStr}.dat"; + + using (FileStream stream = File.Open(ProjectSettings.GlobalizePath(filePath), FileMode.Create)) { using (BinaryWriter writer = new BinaryWriter(stream)) { @@ -290,7 +237,7 @@ public partial class MapGenerator : TextureRect } } } - GD.Print("Legacy v1 Binary Data Exported to: " + absolutePath); + GD.Print("Binary Data Exported to: " + ProjectSettings.GlobalizePath(filePath)); } // --- THE ORIGINAL MAP GENERATION CODE (Restored!) --- diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md index deaf7c8..8fad3a5 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Scripts/README.md @@ -18,10 +18,7 @@ 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 (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). +7. **Export** — writes the `.dat` blueprint, then renders the PNG snapshot. ### The road network @@ -55,15 +52,7 @@ Road colours on the snapshot, useful for identifying a road: **red** = Highway, **dark brown** = Rugged, **light brown** = Trail. ### Outputs -`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. +`MapData_Seed_.dat` and `Map_Seed_.png`, both to `user://`. ## Rules 1. **No magic numbers.** Distances, radii and thresholds derive from `MapSize` or `scaleFactor`, so diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs deleted file mode 100644 index bc0c6db..0000000 --- a/Tools/Scripts/RoundTripHarness.cs +++ /dev/null @@ -1,156 +0,0 @@ -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; - } -} diff --git a/Tools/Scripts/RoundTripHarness.cs.uid b/Tools/Scripts/RoundTripHarness.cs.uid deleted file mode 100644 index cfa3a5a..0000000 --- a/Tools/Scripts/RoundTripHarness.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://76veosvnqe8m