feat: WBID/WBTB/WSRF blueprint sections + parser + harness extension (terrain-water task 03)

Three new v2 tagged sections carrying the water-bodies stage output:
WBID (MapSize^2 u16 body ids; 0 none, 1 ocean, 2..N lakes), WBTB
(count-prefixed 20-byte records: id, type, provisional salinity,
transitional flat surface level, pixel count, centroid), WSRF
(MapSize^2 u16 quantized levels; 0 = no-water sentinel, 1 + L*32768
encoding, ~7.7 mm world resolution). Written only when the blueprint
carries water; absent on legacy re-encodes. Parser registers all
three (length + type checks) into new WorldBlueprint members —
consumed by nothing at runtime. Round-trip harness now compares the
water sections whenever the source carries them; v1-source baseline
stays green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-07 01:31:50 -04:00
parent d296ef30c4
commit 3bf822dac6
5 changed files with 195 additions and 0 deletions

View file

@ -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;

View file

@ -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);

View file

@ -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<WaterBodyInfo> WaterBodies = new List<WaterBodyInfo>();
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<Vector2[]> into)
{
int pathCount = reader.ReadInt32();

View file

@ -231,6 +231,8 @@ public partial class MapGenerator : TextureRect
BranchRoads = _branchPaths,
RuggedRoads = _ruggedPaths,
TrailRoads = _trailPaths,
WaterBodyIds = _waterBodyIds,
WaterBodies = _waterBodies ?? new List<WaterBodyInfo>(),
FormatVersion = 2,
Params = new BlueprintParams
{

View file

@ -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<Vector2[]> a, List<Vector2[]> b)
{
if (a.Count != b.Count)