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:
parent
d296ef30c4
commit
3bf822dac6
5 changed files with 195 additions and 0 deletions
|
|
@ -29,6 +29,26 @@ namespace IslaApocalypse.Core
|
||||||
public const uint TAG_ROADS_BRANCH = 0x52424452; // "RDBR"
|
public const uint TAG_ROADS_BRANCH = 0x52424452; // "RDBR"
|
||||||
public const uint TAG_ROADS_RUGGED = 0x47524452; // "RDRG"
|
public const uint TAG_ROADS_RUGGED = 0x47524452; // "RDRG"
|
||||||
public const uint TAG_ROADS_TRAIL = 0x4C544452; // "RDTL"
|
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.
|
// MapSize sanity bounds, checked before any allocation on read.
|
||||||
public const int MIN_MAP_SIZE = 256;
|
public const int MIN_MAP_SIZE = 256;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,16 @@ namespace IslaApocalypse.Core
|
||||||
WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp));
|
WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp));
|
||||||
WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp));
|
WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp));
|
||||||
WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(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_TOWNS, w => WriteTowns(w, bp));
|
||||||
WriteSection(writer, BlueprintFormat.TAG_ROADS_HIGHWAY, w => WriteRoadTier(w, bp.Highways));
|
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_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)
|
private static void WriteTowns(BinaryWriter writer, WorldBlueprint bp)
|
||||||
{
|
{
|
||||||
writer.Write(bp.Towns.Count);
|
writer.Write(bp.Towns.Count);
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,13 @@ namespace IslaApocalypse.Core
|
||||||
// generation parameters. Params is null when the source was a legacy v1 file.
|
// generation parameters. Params is null when the source was a legacy v1 file.
|
||||||
public int FormatVersion = 1;
|
public int FormatVersion = 1;
|
||||||
public BlueprintParams Params;
|
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
|
// 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_BRANCH) sectionOk = ParseRoadTier(reader, blueprint.BranchRoads);
|
||||||
else if (tag == BlueprintFormat.TAG_ROADS_RUGGED) sectionOk = ParseRoadTier(reader, blueprint.RuggedRoads);
|
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_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
|
else
|
||||||
{
|
{
|
||||||
// The property the redesign exists to buy: future sections (water,
|
// The property the redesign exists to buy: future sections (water,
|
||||||
|
|
@ -316,6 +326,46 @@ namespace IslaApocalypse.Core
|
||||||
return true;
|
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)
|
private static bool ParseRoadTier(BinaryReader reader, List<Vector2[]> into)
|
||||||
{
|
{
|
||||||
int pathCount = reader.ReadInt32();
|
int pathCount = reader.ReadInt32();
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,8 @@ public partial class MapGenerator : TextureRect
|
||||||
BranchRoads = _branchPaths,
|
BranchRoads = _branchPaths,
|
||||||
RuggedRoads = _ruggedPaths,
|
RuggedRoads = _ruggedPaths,
|
||||||
TrailRoads = _trailPaths,
|
TrailRoads = _trailPaths,
|
||||||
|
WaterBodyIds = _waterBodyIds,
|
||||||
|
WaterBodies = _waterBodies ?? new List<WaterBodyInfo>(),
|
||||||
FormatVersion = 2,
|
FormatVersion = 2,
|
||||||
Params = new BlueprintParams
|
Params = new BlueprintParams
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ public partial class RoundTripHarness : Node
|
||||||
ok &= CompareRoads("Branch", a.BranchRoads, b.BranchRoads);
|
ok &= CompareRoads("Branch", a.BranchRoads, b.BranchRoads);
|
||||||
ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads);
|
ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads);
|
||||||
ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads);
|
ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads);
|
||||||
|
ok &= CompareWater(a, b);
|
||||||
|
|
||||||
if (ok)
|
if (ok)
|
||||||
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
|
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
|
||||||
|
|
@ -127,6 +128,63 @@ public partial class RoundTripHarness : Node
|
||||||
return ok;
|
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)
|
private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b)
|
||||||
{
|
{
|
||||||
if (a.Count != b.Count)
|
if (a.Count != b.Count)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue