feat: blueprint v2 reader dispatch + validation + params cross-check (terrain-water task 02)
Reader sniffs the first byte (0x49 raw-ISLA v2 vs 0x07 v1 string prefix) and routes to the v2 tagged-section parser or the intact v1 path (v1 loads log a deprecation warning — fallback stays live). v2 validation: magic/version gate (loud reject), MapSize sanity bound [256, 32768] before allocation, every section length checked against remaining file, section-consumed-exactly check, params-first and no-duplicate-section rules, biome and town-tier ordinal range checks. Unknown tags skip by length — the forward-compat property D-030 buys. ServerChunkManager cross-checks embedded params against config (seed + MapSize) and logs a prominent desync warning — canon H7's silent failure becomes loud. ServerConfig.json pins the reference world: WorldSeed 1409879727, 8K. Round-trip oracle GREEN: reference v1 (537,054,156 B) -> v2 (335,727,522 B, -192 MB) -> parse -> semantic equality holds (heights bitwise, biomes, 94 towns, 4/1/37/39 road paths point-for-point). v1 parse 1.9s, v2 write 1.4s, v2 parse 1.6s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c305fd6565
commit
8532d88771
3 changed files with 239 additions and 2 deletions
|
|
@ -81,10 +81,231 @@ namespace IslaApocalypse.Core
|
||||||
{
|
{
|
||||||
using (BinaryReader reader = new BinaryReader(stream))
|
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);
|
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
|
||||||
|
{
|
||||||
|
// 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 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.
|
// LEGACY v1 PARSE PATH — the original "ISLA_V1" positional format.
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,22 @@ namespace IslaApocalypse.Server
|
||||||
|
|
||||||
if (_blueprint != null)
|
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...");
|
GD.Print("[Server] Blueprint loaded. Locating Capitol City...");
|
||||||
|
|
||||||
// 2. Find the Capitol in the parsed data
|
// 2. Find the Capitol in the parsed data
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"WorldSeed": 1063685222,
|
"WorldSeed": 1409879727,
|
||||||
"MapProfile": "8K",
|
"MapProfile": "8K",
|
||||||
"TownDensity": "Normal",
|
"TownDensity": "Normal",
|
||||||
"ChunkRadius": 32
|
"ChunkRadius": 32
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue