diff --git a/Core/Scripts/BlueprintFormat.cs b/Core/Scripts/BlueprintFormat.cs index 153e166..1aacec7 100644 --- a/Core/Scripts/BlueprintFormat.cs +++ b/Core/Scripts/BlueprintFormat.cs @@ -29,6 +29,26 @@ namespace IslaApocalypse.Core public const uint TAG_ROADS_BRANCH = 0x52424452; // "RDBR" public const uint TAG_ROADS_RUGGED = 0x47524452; // "RDRG" public const uint TAG_ROADS_TRAIL = 0x4C544452; // "RDTL" + public const uint TAG_WATER_BODY_IDS = 0x44494257; // "WBID" + public const uint TAG_WATER_BODY_TABLE = 0x42544257; // "WBTB" + public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF" + + // WSRF quantization: u16, 0 reserved as the no-water sentinel. A real level L + // (raw blueprint height units) encodes as 1 + round(L × 32768), so a genuine + // level can never encode to 0. Decodes back via (q − 1) / 32768. Covers + // [0 .. ~1.99997] raw at 1/32768 raw resolution ≈ 7.7 mm of world height + // (1 raw unit = 251 m) — far below the 1 m voxel. + public const float WSRF_SCALE = 1f / 32768f; + + public static ushort EncodeWaterLevel(float level) + { + return (ushort)Godot.Mathf.Clamp(1 + Godot.Mathf.RoundToInt(level * 32768f), 1, ushort.MaxValue); + } + + public static float DecodeWaterLevel(ushort quantized) + { + return (quantized - 1) * WSRF_SCALE; + } // MapSize sanity bounds, checked before any allocation on read. public const int MIN_MAP_SIZE = 256; diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs index d904590..eb2ac5e 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -33,6 +33,16 @@ namespace IslaApocalypse.Core 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)); + + // Water sections are written only when the blueprint carries water data + // (a legacy v1 re-encode has none — the sections are simply absent). + if (bp.WaterBodyIds != null) + { + WriteSection(writer, BlueprintFormat.TAG_WATER_BODY_IDS, w => WriteWaterBodyIds(w, bp)); + WriteSection(writer, BlueprintFormat.TAG_WATER_BODY_TABLE, w => WriteWaterBodyTable(w, bp)); + WriteSection(writer, BlueprintFormat.TAG_WATER_SURFACE, w => WriteWaterSurface(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)); @@ -112,6 +122,61 @@ namespace IslaApocalypse.Core } } + private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp) + { + int n = bp.MapSize; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + writer.Write(bp.WaterBodyIds[x, y]); + } + + private static void WriteWaterBodyTable(BinaryWriter writer, WorldBlueprint bp) + { + writer.Write(bp.WaterBodies.Count); + foreach (WaterBodyInfo body in bp.WaterBodies) + { + writer.Write(body.Id); + writer.Write(body.Type); + writer.Write(body.Salinity); + writer.Write(body.SurfaceLevel); + writer.Write(body.PixelCount); + writer.Write(body.Centroid.X); + writer.Write(body.Centroid.Y); + } + } + + private static void WriteWaterSurface(BinaryWriter writer, WorldBlueprint bp) + { + int n = bp.MapSize; + + // A parsed blueprint carries the quantized surface verbatim; a freshly + // generated one derives it from body ids + per-body levels. + if (bp.WaterSurfaceQ != null) + { + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + writer.Write(bp.WaterSurfaceQ[x, y]); + return; + } + + // id -> encoded level lookup (ids are small and dense: 1..N) + int maxId = 0; + foreach (WaterBodyInfo body in bp.WaterBodies) + if (body.Id > maxId) maxId = body.Id; + ushort[] encoded = new ushort[maxId + 1]; + foreach (WaterBodyInfo body in bp.WaterBodies) + encoded[body.Id] = BlueprintFormat.EncodeWaterLevel(body.SurfaceLevel); + + for (int x = 0; x < n; x++) + { + for (int y = 0; y < n; y++) + { + ushort id = bp.WaterBodyIds[x, y]; + writer.Write(id == 0 ? (ushort)0 : encoded[id]); + } + } + } + private static void WriteTowns(BinaryWriter writer, WorldBlueprint bp) { writer.Write(bp.Towns.Count); diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index 7234950..a840ed6 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -75,6 +75,13 @@ namespace IslaApocalypse.Core // generation parameters. Params is null when the source was a legacy v1 file. public int FormatVersion = 1; public BlueprintParams Params; + + // Water-bodies data (v2 WBID/WBTB/WSRF sections, terrain-water task 03). + // Null / empty when the source carries no water sections (legacy v1, or a v2 + // written before the water stage existed). Consumed by nothing at runtime yet. + public ushort[,] WaterBodyIds; // 0 = no water, 1 = ocean, 2..N = lakes + public List WaterBodies = new List(); + public ushort[,] WaterSurfaceQ; // quantized levels — BlueprintFormat.DecodeWaterLevel } // 2. The Parser Utility @@ -194,6 +201,9 @@ namespace IslaApocalypse.Core 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 if (tag == BlueprintFormat.TAG_WATER_BODY_IDS) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: false); + else if (tag == BlueprintFormat.TAG_WATER_SURFACE) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: true); + else if (tag == BlueprintFormat.TAG_WATER_BODY_TABLE) sectionOk = ParseWaterBodyTable(reader, blueprint); else { // The property the redesign exists to buy: future sections (water, @@ -316,6 +326,46 @@ namespace IslaApocalypse.Core return true; } + private static bool ParseWaterGrid(BinaryReader reader, WorldBlueprint blueprint, ulong payloadLength, bool isSurface) + { + int n = blueprint.MapSize; + string name = isSurface ? "WSRF" : "WBID"; + if (payloadLength != 2UL * (ulong)n * (ulong)n) + { + GD.PrintErr($"[MapDataParser] ERROR: {name} section is {payloadLength} bytes, expected {2UL * (ulong)n * (ulong)n}."); + return false; + } + ushort[,] grid = new ushort[n, n]; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + grid[x, y] = reader.ReadUInt16(); + if (isSurface) blueprint.WaterSurfaceQ = grid; + else blueprint.WaterBodyIds = grid; + return true; + } + + private static bool ParseWaterBodyTable(BinaryReader reader, WorldBlueprint blueprint) + { + int count = reader.ReadInt32(); + for (int i = 0; i < count; i++) + { + var body = new WaterBodyInfo(); + body.Id = reader.ReadUInt16(); + body.Type = reader.ReadByte(); + body.Salinity = reader.ReadByte(); + body.SurfaceLevel = reader.ReadSingle(); + body.PixelCount = reader.ReadInt32(); + body.Centroid = new Vector2(reader.ReadSingle(), reader.ReadSingle()); + if (body.Type > WaterBodyInfo.TYPE_LAKE) + { + GD.PrintErr($"[MapDataParser] ERROR: water body {i} has unknown type {body.Type}."); + return false; + } + blueprint.WaterBodies.Add(body); + } + return true; + } + private static bool ParseRoadTier(BinaryReader reader, List into) { int pathCount = reader.ReadInt32(); diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 9eb05ad..f2fb4e0 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -231,6 +231,8 @@ public partial class MapGenerator : TextureRect BranchRoads = _branchPaths, RuggedRoads = _ruggedPaths, TrailRoads = _trailPaths, + WaterBodyIds = _waterBodyIds, + WaterBodies = _waterBodies ?? new List(), FormatVersion = 2, Params = new BlueprintParams { diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index bc0c6db..d2306a5 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -119,6 +119,7 @@ public partial class RoundTripHarness : Node ok &= CompareRoads("Branch", a.BranchRoads, b.BranchRoads); ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads); ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads); + ok &= CompareWater(a, b); if (ok) GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " + @@ -127,6 +128,63 @@ public partial class RoundTripHarness : Node return ok; } + // Water sections (WBID/WBTB/WSRF) are compared whenever the SOURCE carries them — + // the regression test grows with the format. A source without water (legacy v1) + // must round-trip to a file without water. + private bool CompareWater(WorldBlueprint a, WorldBlueprint b) + { + if (a.WaterBodyIds == null && b.WaterBodyIds == null) + { + GD.Print("[Harness] Water sections: absent in source — nothing to compare (and none reappeared)."); + return true; + } + if (a.WaterBodyIds == null || b.WaterBodyIds == null) + { + GD.PrintErr($"[Harness] Water sections presence mismatch: source {(a.WaterBodyIds != null ? "has" : "lacks")} them, reread {(b.WaterBodyIds != null ? "has" : "lacks")} them."); + return false; + } + + bool ok = true; + long idDiffs = 0, surfDiffs = 0; + int n = a.MapSize; + for (int x = 0; x < n; x++) + { + for (int y = 0; y < n; y++) + { + if (a.WaterBodyIds[x, y] != b.WaterBodyIds[x, y]) idDiffs++; + ushort sa = a.WaterSurfaceQ != null ? a.WaterSurfaceQ[x, y] : (ushort)0; + ushort sb = b.WaterSurfaceQ != null ? b.WaterSurfaceQ[x, y] : (ushort)0; + if (sa != sb) surfDiffs++; + } + } + if (idDiffs > 0) { GD.PrintErr($"[Harness] {idDiffs} WBID pixels differ."); ok = false; } + if (surfDiffs > 0) { GD.PrintErr($"[Harness] {surfDiffs} WSRF pixels differ."); ok = false; } + + if (a.WaterBodies.Count != b.WaterBodies.Count) + { + GD.PrintErr($"[Harness] Water body count mismatch: {a.WaterBodies.Count} vs {b.WaterBodies.Count}"); + ok = false; + } + else + { + for (int i = 0; i < a.WaterBodies.Count; i++) + { + var wa = a.WaterBodies[i]; + var wb = b.WaterBodies[i]; + if (wa.Id != wb.Id || wa.Type != wb.Type || wa.Salinity != wb.Salinity || + System.BitConverter.SingleToInt32Bits(wa.SurfaceLevel) != System.BitConverter.SingleToInt32Bits(wb.SurfaceLevel) || + wa.PixelCount != wb.PixelCount || wa.Centroid != wb.Centroid) + { + GD.PrintErr($"[Harness] Water body {i} mismatch (id {wa.Id} vs {wb.Id})."); + ok = false; + } + } + } + + if (ok) GD.Print($"[Harness] Water sections equal: {a.WaterBodies.Count} bodies, WBID+WSRF grids identical."); + return ok; + } + private bool CompareRoads(string tier, List a, List b) { if (a.Count != b.Count)