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>
473 lines
No EOL
18 KiB
C#
473 lines
No EOL
18 KiB
C#
using Godot;
|
|
using System.IO;
|
|
using System.Collections.Generic;
|
|
|
|
namespace IslaApocalypse.Core
|
|
{
|
|
// 1. The Data Container
|
|
// This holds the unpacked data in RAM so the Server can ask it questions
|
|
// without having to re-read the hard drive every single frame.
|
|
public struct TownLocation
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
}
|
|
|
|
/// <summary>
|
|
/// One water body from the blueprint's WBTB section: the ocean (exactly one, id 1)
|
|
/// or a lake (ids 2..N). SurfaceLevel is the documented TRANSITIONAL rule — one flat
|
|
/// level per body, GetSeaLevel at the body's pixel centroid under the still-live
|
|
/// latitude field; superseded when the flat-scalar sea model lands. Salinity is a
|
|
/// provisional placeholder for the future fresh/salt mechanic (ocean salt, lake fresh).
|
|
/// </summary>
|
|
public class WaterBodyInfo
|
|
{
|
|
public const byte TYPE_OCEAN = 0;
|
|
public const byte TYPE_LAKE = 1;
|
|
public const byte SALINITY_FRESH = 0;
|
|
public const byte SALINITY_SALT = 1;
|
|
|
|
public ushort Id;
|
|
public byte Type;
|
|
public byte Salinity;
|
|
public float SurfaceLevel; // raw blueprint height units
|
|
public int PixelCount;
|
|
public Vector2 Centroid; // map pixels
|
|
}
|
|
|
|
public class WorldBlueprint
|
|
{
|
|
public int MapSize;
|
|
public float[,] HeightMap;
|
|
public Biome[,] BiomeMap;
|
|
public List<TownLocation> Towns = new List<TownLocation>();
|
|
|
|
// All 4 road tiers!
|
|
public List<Vector2[]> Highways = new List<Vector2[]>();
|
|
public List<Vector2[]> BranchRoads = new List<Vector2[]>();
|
|
public List<Vector2[]> RuggedRoads = new List<Vector2[]>();
|
|
public List<Vector2[]> TrailRoads = new List<Vector2[]>();
|
|
|
|
// 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;
|
|
|
|
// 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
|
|
public static class MapDataParser
|
|
{
|
|
public static WorldBlueprint LoadMapData(string seedStr)
|
|
{
|
|
string filePath = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat");
|
|
return LoadMapDataFromPath(filePath);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static WorldBlueprint LoadMapDataFromPath(string filePath)
|
|
{
|
|
if (!File.Exists(filePath))
|
|
{
|
|
GD.PrintErr($"[MapDataParser] CRITICAL ERROR: Map file not found at {filePath}");
|
|
return null;
|
|
}
|
|
|
|
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<uint>();
|
|
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 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,
|
|
// 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<Biome>().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<TownTier>().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 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();
|
|
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();
|
|
if (header != "ISLA_V1")
|
|
{
|
|
GD.PrintErr("[MapDataParser] ERROR: Invalid map version or corrupted file.");
|
|
return null;
|
|
}
|
|
|
|
// 2. Read Map Dimensions & Initialize Arrays
|
|
blueprint.MapSize = reader.ReadInt32();
|
|
blueprint.HeightMap = new float[blueprint.MapSize, blueprint.MapSize];
|
|
blueprint.BiomeMap = new Biome[blueprint.MapSize, blueprint.MapSize];
|
|
|
|
// 3. Read Raw Voxel Data (Height & Biome)
|
|
for (int x = 0; x < blueprint.MapSize; x++)
|
|
{
|
|
for (int y = 0; y < blueprint.MapSize; y++)
|
|
{
|
|
blueprint.HeightMap[x, y] = reader.ReadSingle();
|
|
blueprint.BiomeMap[x, y] = (Biome)reader.ReadInt32();
|
|
}
|
|
}
|
|
|
|
// 4. Read Logistical Anchors (Towns & POIs)
|
|
int townCount = reader.ReadInt32();
|
|
for (int i = 0; i < townCount; i++)
|
|
{
|
|
TownLocation town = new TownLocation();
|
|
town.Position = new Vector2(reader.ReadSingle(), reader.ReadSingle());
|
|
town.Tier = (TownTier)reader.ReadInt32();
|
|
blueprint.Towns.Add(town);
|
|
}
|
|
|
|
// 5. Read Road Networks
|
|
// Highway
|
|
int highwayCount = reader.ReadInt32();
|
|
for (int i = 0; i < highwayCount; 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()); }
|
|
blueprint.Highways.Add(path);
|
|
}
|
|
|
|
// Branch
|
|
int branchCount = reader.ReadInt32();
|
|
for (int i = 0; i < branchCount; 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()); }
|
|
blueprint.BranchRoads.Add(path);
|
|
}
|
|
|
|
// Rugged
|
|
int ruggedCount = reader.ReadInt32();
|
|
for (int i = 0; i < ruggedCount; 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()); }
|
|
blueprint.RuggedRoads.Add(path);
|
|
}
|
|
|
|
// Trails
|
|
int trailCount = reader.ReadInt32();
|
|
for (int i = 0; i < trailCount; 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()); }
|
|
blueprint.TrailRoads.Add(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
GD.Print($"[MapDataParser] Successfully loaded v1 Blueprint. Dimension: {blueprint.MapSize}x{blueprint.MapSize}.");
|
|
return blueprint;
|
|
}
|
|
}
|
|
} |