feat: blueprint v2 envelope — writer, dual-write, round-trip harness (terrain-water task 02)
v2 tagged-section container (D-030): raw ISLA magic + u32 version gate, [u32 tag][u64 length][payload] sections — params (resolved generation inputs incl. impact centre), heights f32, biomes u8, towns with the highway-node flag, four road tiers identified by tag not position. - BlueprintFormat.cs: the single tag/constant registry. - BlueprintWriter.WriteV2: blueprint-typed, callable outside a generation run; generator and harness are both just callers. - ExportMapData dual-writes: v2 under the primary seed name, legacy v1 beside it as _v1.dat (safety net; removal is a future task). - MapDataParser: LoadMapDataFromPath entry point; v1 parse body extracted intact as LoadV1 (v2 reader lands in the next commit). - RoundTripHarness scene: load v1 -> write v2 -> load v2 -> semantic equality, headless, seconds per cycle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ad541dfbd8
commit
c305fd6565
6 changed files with 494 additions and 9 deletions
54
Core/Scripts/BlueprintFormat.cs
Normal file
54
Core/Scripts/BlueprintFormat.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
namespace IslaApocalypse.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The single registry of constants for the v2 blueprint container — magic, version,
|
||||||
|
/// section tags, validation bounds, and the sentinel values used when a v2 file is
|
||||||
|
/// re-encoded from a v1 source that cannot supply real generation parameters.
|
||||||
|
///
|
||||||
|
/// The byte-accurate layout lives in Core/Scripts/BLUEPRINT_FORMAT.md. Writer:
|
||||||
|
/// BlueprintWriter.WriteV2. Reader: MapDataParser (v2 branch). Tags are FourCC codes
|
||||||
|
/// stored as little-endian u32, so the raw bytes on disk read as ASCII ("PRMS", "HGTS",
|
||||||
|
/// ...) in a hex dump.
|
||||||
|
/// </summary>
|
||||||
|
public static class BlueprintFormat
|
||||||
|
{
|
||||||
|
// "ISLA" as raw bytes 'I','S','L','A' == little-endian u32 0x414C5349.
|
||||||
|
// Deliberately NOT a length-prefixed .NET string: the v1 header starts with the
|
||||||
|
// 7-bit length prefix 0x07, so the first byte alone (0x49 vs 0x07) identifies the
|
||||||
|
// format and the reader can dispatch without ambiguity.
|
||||||
|
public const uint MAGIC = 0x414C5349;
|
||||||
|
public const uint VERSION = 2;
|
||||||
|
|
||||||
|
// Section tags (FourCC, little-endian). Reader rule: known tag -> parse,
|
||||||
|
// unknown tag -> skip by length and continue. Never reuse a retired tag value.
|
||||||
|
public const uint TAG_PARAMS = 0x534D5250; // "PRMS"
|
||||||
|
public const uint TAG_HEIGHTS = 0x53544748; // "HGTS"
|
||||||
|
public const uint TAG_BIOMES = 0x4D4F4942; // "BIOM"
|
||||||
|
public const uint TAG_TOWNS = 0x4E574F54; // "TOWN"
|
||||||
|
public const uint TAG_ROADS_HIGHWAY = 0x57484452; // "RDHW"
|
||||||
|
public const uint TAG_ROADS_BRANCH = 0x52424452; // "RDBR"
|
||||||
|
public const uint TAG_ROADS_RUGGED = 0x47524452; // "RDRG"
|
||||||
|
public const uint TAG_ROADS_TRAIL = 0x4C544452; // "RDTL"
|
||||||
|
|
||||||
|
// MapSize sanity bounds, checked before any allocation on read.
|
||||||
|
public const int MIN_MAP_SIZE = 256;
|
||||||
|
public const int MAX_MAP_SIZE = 32768;
|
||||||
|
|
||||||
|
// Sentinels written into the params section when the source blueprint came from a
|
||||||
|
// v1 file (a re-encode cannot know the original generation inputs). WorldSeed 0 is
|
||||||
|
// also the config "randomize" sentinel, which no real resolved seed ever is.
|
||||||
|
public const int SENTINEL_WORLD_SEED = 0;
|
||||||
|
public const float SENTINEL_CRATER_RADIUS = -1f;
|
||||||
|
public const float SENTINEL_DENSITY_MULTIPLIER = -1f;
|
||||||
|
public const float SENTINEL_IMPACT_CENTER = -1f; // both components
|
||||||
|
|
||||||
|
/// <summary>Renders a tag as its 4 ASCII characters, for log messages.</summary>
|
||||||
|
public static string TagToString(uint tag)
|
||||||
|
{
|
||||||
|
return new string(new[] {
|
||||||
|
(char)(tag & 0xFF), (char)((tag >> 8) & 0xFF),
|
||||||
|
(char)((tag >> 16) & 0xFF), (char)((tag >> 24) & 0xFF)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
Core/Scripts/BlueprintWriter.cs
Normal file
165
Core/Scripts/BlueprintWriter.cs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
using Godot;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a WorldBlueprint to disk in the v2 tagged-section container
|
||||||
|
/// (layout: Core/Scripts/BLUEPRINT_FORMAT.md; constants: BlueprintFormat).
|
||||||
|
///
|
||||||
|
/// Deliberately written against the blueprint data types, not the generator's
|
||||||
|
/// internal arrays, so it is callable outside a generation run — the map generator
|
||||||
|
/// and the round-trip harness are both just callers.
|
||||||
|
///
|
||||||
|
/// Provenance (timestamp, git hash) is stamped at write time — it describes the
|
||||||
|
/// file being written, not whatever was in blueprint.Params. If blueprint.Params is
|
||||||
|
/// null (a re-encode of a legacy v1 file), the sentinel values from BlueprintFormat
|
||||||
|
/// are written instead of real generation inputs.
|
||||||
|
/// </summary>
|
||||||
|
public static class BlueprintWriter
|
||||||
|
{
|
||||||
|
public static void WriteV2(string absolutePath, WorldBlueprint bp)
|
||||||
|
{
|
||||||
|
ulong started = Time.GetTicksMsec();
|
||||||
|
|
||||||
|
using (FileStream stream = File.Open(absolutePath, FileMode.Create))
|
||||||
|
using (BinaryWriter writer = new BinaryWriter(stream))
|
||||||
|
{
|
||||||
|
// Header: raw "ISLA" magic + u32 version. Little-endian throughout.
|
||||||
|
writer.Write(BlueprintFormat.MAGIC);
|
||||||
|
writer.Write(BlueprintFormat.VERSION);
|
||||||
|
|
||||||
|
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));
|
||||||
|
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));
|
||||||
|
WriteSection(writer, BlueprintFormat.TAG_ROADS_RUGGED, w => WriteRoadTier(w, bp.RuggedRoads));
|
||||||
|
WriteSection(writer, BlueprintFormat.TAG_ROADS_TRAIL, w => WriteRoadTier(w, bp.TrailRoads));
|
||||||
|
}
|
||||||
|
|
||||||
|
double seconds = (Time.GetTicksMsec() - started) / 1000.0;
|
||||||
|
Vector2 impact = bp.Params?.ImpactCenter
|
||||||
|
?? new Vector2(BlueprintFormat.SENTINEL_IMPACT_CENTER, BlueprintFormat.SENTINEL_IMPACT_CENTER);
|
||||||
|
GD.Print($"[BlueprintWriter] v2 blueprint written in {seconds:F1}s to: {absolutePath} " +
|
||||||
|
$"(seed {bp.Params?.WorldSeed ?? BlueprintFormat.SENTINEL_WORLD_SEED}, " +
|
||||||
|
$"MapSize {bp.MapSize}, impactCenter ({impact.X:F1},{impact.Y:F1}))");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frames one section: [u32 tag][u64 payload-length][payload]. The length is
|
||||||
|
/// back-patched after the payload is written, so payload writers never have to
|
||||||
|
/// pre-compute their own size.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteSection(BinaryWriter writer, uint tag, Action<BinaryWriter> payload)
|
||||||
|
{
|
||||||
|
writer.Write(tag);
|
||||||
|
long lengthPos = writer.BaseStream.Position;
|
||||||
|
writer.Write((ulong)0);
|
||||||
|
long payloadStart = writer.BaseStream.Position;
|
||||||
|
|
||||||
|
payload(writer);
|
||||||
|
|
||||||
|
long payloadEnd = writer.BaseStream.Position;
|
||||||
|
writer.BaseStream.Position = lengthPos;
|
||||||
|
writer.Write((ulong)(payloadEnd - payloadStart));
|
||||||
|
writer.BaseStream.Position = payloadEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteParams(BinaryWriter writer, WorldBlueprint bp)
|
||||||
|
{
|
||||||
|
BlueprintParams p = bp.Params;
|
||||||
|
writer.Write(p?.WorldSeed ?? BlueprintFormat.SENTINEL_WORLD_SEED);
|
||||||
|
writer.Write(bp.MapSize); // always real — the blueprint knows its own size
|
||||||
|
writer.Write(p?.CraterRadius ?? BlueprintFormat.SENTINEL_CRATER_RADIUS);
|
||||||
|
writer.Write(p?.DensityMultiplier ?? BlueprintFormat.SENTINEL_DENSITY_MULTIPLIER);
|
||||||
|
Vector2 impact = p?.ImpactCenter
|
||||||
|
?? new Vector2(BlueprintFormat.SENTINEL_IMPACT_CENTER, BlueprintFormat.SENTINEL_IMPACT_CENTER);
|
||||||
|
writer.Write(impact.X);
|
||||||
|
writer.Write(impact.Y);
|
||||||
|
|
||||||
|
// Provenance, stamped now (length-prefixed .NET strings — fine inside a
|
||||||
|
// length-framed section; only the file HEADER must avoid them).
|
||||||
|
writer.Write(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"));
|
||||||
|
writer.Write(TryGetGitShortHash());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteHeights(BinaryWriter writer, WorldBlueprint bp)
|
||||||
|
{
|
||||||
|
// X outer / Y inner — the v1 convention, kept: the second index is the map's
|
||||||
|
// north/south axis and the server consumes it as world Z.
|
||||||
|
int n = bp.MapSize;
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
writer.Write(bp.HeightMap[x, y]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteBiomes(BinaryWriter writer, WorldBlueprint bp)
|
||||||
|
{
|
||||||
|
int n = bp.MapSize;
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
int ordinal = (int)bp.BiomeMap[x, y];
|
||||||
|
if (ordinal < 0 || ordinal > byte.MaxValue)
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"[BlueprintWriter] Biome ordinal {ordinal} at ({x},{y}) does not fit in a byte.");
|
||||||
|
writer.Write((byte)ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteTowns(BinaryWriter writer, WorldBlueprint bp)
|
||||||
|
{
|
||||||
|
writer.Write(bp.Towns.Count);
|
||||||
|
foreach (TownLocation town in bp.Towns)
|
||||||
|
{
|
||||||
|
int tier = (int)town.Tier;
|
||||||
|
if (tier < 0 || tier > byte.MaxValue)
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"[BlueprintWriter] TownTier ordinal {tier} does not fit in a byte.");
|
||||||
|
writer.Write(town.Position.X);
|
||||||
|
writer.Write(town.Position.Y);
|
||||||
|
writer.Write((byte)tier);
|
||||||
|
writer.Write(town.IsHighwayNode ? (byte)1 : (byte)0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteRoadTier(BinaryWriter writer, System.Collections.Generic.List<Vector2[]> paths)
|
||||||
|
{
|
||||||
|
writer.Write(paths.Count);
|
||||||
|
foreach (Vector2[] path in paths)
|
||||||
|
{
|
||||||
|
writer.Write(path.Length);
|
||||||
|
foreach (Vector2 p in path) { writer.Write(p.X); writer.Write(p.Y); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort short hash of the generator repo (reads res://.git directly, no
|
||||||
|
/// git invocation). Returns "" when unavailable — e.g. an exported build.
|
||||||
|
/// </summary>
|
||||||
|
private static string TryGetGitShortHash()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string gitDir = Path.Combine(ProjectSettings.GlobalizePath("res://"), ".git");
|
||||||
|
string head = File.ReadAllText(Path.Combine(gitDir, "HEAD")).Trim();
|
||||||
|
if (head.StartsWith("ref: "))
|
||||||
|
{
|
||||||
|
string refPath = Path.Combine(gitDir, head.Substring(5));
|
||||||
|
if (!File.Exists(refPath)) return "";
|
||||||
|
head = File.ReadAllText(refPath).Trim();
|
||||||
|
}
|
||||||
|
return head.Length >= 8 ? head.Substring(0, 8) : head;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,29 @@ namespace IslaApocalypse.Core
|
||||||
{
|
{
|
||||||
public Vector2 Position;
|
public Vector2 Position;
|
||||||
public TownTier Tier;
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
public class WorldBlueprint
|
public class WorldBlueprint
|
||||||
|
|
@ -25,6 +48,11 @@ namespace IslaApocalypse.Core
|
||||||
public List<Vector2[]> BranchRoads = new List<Vector2[]>();
|
public List<Vector2[]> BranchRoads = new List<Vector2[]>();
|
||||||
public List<Vector2[]> RuggedRoads = new List<Vector2[]>();
|
public List<Vector2[]> RuggedRoads = new List<Vector2[]>();
|
||||||
public List<Vector2[]> TrailRoads = 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. The Parser Utility
|
// 2. The Parser Utility
|
||||||
|
|
@ -33,18 +61,41 @@ namespace IslaApocalypse.Core
|
||||||
public static WorldBlueprint LoadMapData(string seedStr)
|
public static WorldBlueprint LoadMapData(string seedStr)
|
||||||
{
|
{
|
||||||
string filePath = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat");
|
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))
|
if (!File.Exists(filePath))
|
||||||
{
|
{
|
||||||
GD.PrintErr($"[MapDataParser] CRITICAL ERROR: Map file not found at {filePath}");
|
GD.PrintErr($"[MapDataParser] CRITICAL ERROR: Map file not found at {filePath}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
WorldBlueprint blueprint = new WorldBlueprint();
|
|
||||||
|
|
||||||
using (FileStream stream = File.OpenRead(filePath))
|
using (FileStream stream = File.OpenRead(filePath))
|
||||||
{
|
{
|
||||||
using (BinaryReader reader = new BinaryReader(stream))
|
using (BinaryReader reader = new BinaryReader(stream))
|
||||||
|
{
|
||||||
|
return LoadV1(reader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================================================
|
||||||
|
// 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!)
|
// 1. Header Validation (CRITICAL: Must read string first to align bytes!)
|
||||||
string header = reader.ReadString();
|
string header = reader.ReadString();
|
||||||
|
|
@ -122,7 +173,7 @@ namespace IslaApocalypse.Core
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GD.Print($"[MapDataParser] Successfully loaded Blueprint for Seed {seedStr}. Dimension: {blueprint.MapSize}x{blueprint.MapSize}.");
|
GD.Print($"[MapDataParser] Successfully loaded v1 Blueprint. Dimension: {blueprint.MapSize}x{blueprint.MapSize}.");
|
||||||
return blueprint;
|
return blueprint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
Tools/Scenes/RoundTripHarness.tscn
Normal file
6
Tools/Scenes/RoundTripHarness.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/RoundTripHarness.cs" id="1_rth"]
|
||||||
|
|
||||||
|
[node name="RoundTripHarness" type="Node"]
|
||||||
|
script = ExtResource("1_rth")
|
||||||
|
|
@ -180,9 +180,62 @@ public partial class MapGenerator : TextureRect
|
||||||
private void ExportMapData()
|
private void ExportMapData()
|
||||||
{
|
{
|
||||||
string seedStr = _noise.Seed.ToString();
|
string seedStr = _noise.Seed.ToString();
|
||||||
string filePath = $"user://MapData_Seed_{seedStr}.dat";
|
|
||||||
|
|
||||||
using (FileStream stream = File.Open(ProjectSettings.GlobalizePath(filePath), FileMode.Create))
|
// PRIMARY: the v2 tagged-section container (Core/Scripts/BLUEPRINT_FORMAT.md),
|
||||||
|
// under the name the server looks for.
|
||||||
|
string v2Path = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat");
|
||||||
|
BlueprintWriter.WriteV2(v2Path, BuildBlueprint());
|
||||||
|
|
||||||
|
// SAFETY NET: the legacy v1 format beside it, until the developer has lived
|
||||||
|
// with v2 across several regenerations. Removal is a future task.
|
||||||
|
ExportMapDataV1(ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}_v1.dat"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Packages the generator's internal arrays as a WorldBlueprint (plus the resolved
|
||||||
|
/// generation params) for BlueprintWriter. Arrays are shared, not copied.
|
||||||
|
/// </summary>
|
||||||
|
private WorldBlueprint BuildBlueprint()
|
||||||
|
{
|
||||||
|
var blueprint = new WorldBlueprint
|
||||||
|
{
|
||||||
|
MapSize = MapSize,
|
||||||
|
HeightMap = _heightMap,
|
||||||
|
BiomeMap = _biomeMap,
|
||||||
|
Highways = _highwayPaths,
|
||||||
|
BranchRoads = _branchPaths,
|
||||||
|
RuggedRoads = _ruggedPaths,
|
||||||
|
TrailRoads = _trailPaths,
|
||||||
|
FormatVersion = 2,
|
||||||
|
Params = new BlueprintParams
|
||||||
|
{
|
||||||
|
WorldSeed = _noise.Seed, // the RESOLVED seed — also names the file
|
||||||
|
MapSize = MapSize,
|
||||||
|
CraterRadius = _impactRadius,
|
||||||
|
DensityMultiplier = ConfigManager.DensityMultiplier,
|
||||||
|
ImpactCenter = _impactCenter
|
||||||
|
}
|
||||||
|
};
|
||||||
|
foreach (var town in _towns)
|
||||||
|
{
|
||||||
|
blueprint.Towns.Add(new TownLocation
|
||||||
|
{
|
||||||
|
Position = town.Position,
|
||||||
|
Tier = town.Tier,
|
||||||
|
IsHighwayNode = town.IsHighwayNode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================================================
|
||||||
|
// LEGACY v1 WRITER — the original "ISLA_V1" positional format.
|
||||||
|
// Kept intact as the dual-write safety net; removal is a future
|
||||||
|
// task. Do not extend it — new content belongs in the v2 writer.
|
||||||
|
// ===============================================================
|
||||||
|
private void ExportMapDataV1(string absolutePath)
|
||||||
|
{
|
||||||
|
using (FileStream stream = File.Open(absolutePath, FileMode.Create))
|
||||||
{
|
{
|
||||||
using (BinaryWriter writer = new BinaryWriter(stream))
|
using (BinaryWriter writer = new BinaryWriter(stream))
|
||||||
{
|
{
|
||||||
|
|
@ -237,7 +290,7 @@ public partial class MapGenerator : TextureRect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GD.Print("Binary Data Exported to: " + ProjectSettings.GlobalizePath(filePath));
|
GD.Print("Legacy v1 Binary Data Exported to: " + absolutePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- THE ORIGINAL MAP GENERATION CODE (Restored!) ---
|
// --- THE ORIGINAL MAP GENERATION CODE (Restored!) ---
|
||||||
|
|
|
||||||
156
Tools/Scripts/RoundTripHarness.cs
Normal file
156
Tools/Scripts/RoundTripHarness.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
using Godot;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The blueprint round-trip oracle (terrain-water task 02). Runs headless in seconds,
|
||||||
|
/// no generation, no road pass:
|
||||||
|
///
|
||||||
|
/// 1. load a known-good v1 blueprint (default: the preserved reference copy of seed
|
||||||
|
/// 1409879727) through the real parser,
|
||||||
|
/// 2. write it back out as v2 through the real writer (to a harness-only name — the
|
||||||
|
/// reference file is never overwritten),
|
||||||
|
/// 3. load the v2 file through the real parser,
|
||||||
|
/// 4. assert semantic equality: heights bitwise, biomes equal, towns equal in
|
||||||
|
/// position/tier/count, each road tier point-for-point.
|
||||||
|
///
|
||||||
|
/// Params and the per-town highway flag are EXCLUDED from equality — a v1 source
|
||||||
|
/// cannot supply them; they are verified in the full-generation acceptance run.
|
||||||
|
///
|
||||||
|
/// Run: Godot --headless --path <repo> res://Tools/Scenes/RoundTripHarness.tscn
|
||||||
|
/// Env overrides: HARNESS_V1_PATH (source file), HARNESS_V2_PATH (output file).
|
||||||
|
/// Exit code 0 = PASS, 1 = FAIL. Worth keeping permanently as a format regression test.
|
||||||
|
/// </summary>
|
||||||
|
public partial class RoundTripHarness : Node
|
||||||
|
{
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
string v1Path = OS.GetEnvironment("HARNESS_V1_PATH");
|
||||||
|
if (string.IsNullOrEmpty(v1Path))
|
||||||
|
v1Path = ProjectSettings.GlobalizePath("user://reference_1409879727/MapData_Seed_1409879727.dat");
|
||||||
|
string v2Path = OS.GetEnvironment("HARNESS_V2_PATH");
|
||||||
|
if (string.IsNullOrEmpty(v2Path))
|
||||||
|
v2Path = ProjectSettings.GlobalizePath("user://Harness_RoundTrip_v2.dat");
|
||||||
|
|
||||||
|
GD.Print($"[Harness] v1 source: {v1Path}");
|
||||||
|
GD.Print($"[Harness] v2 output: {v2Path}");
|
||||||
|
|
||||||
|
bool pass = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pass = RunRoundTrip(v1Path, v2Path);
|
||||||
|
}
|
||||||
|
catch (System.Exception e)
|
||||||
|
{
|
||||||
|
GD.PrintErr($"[Harness] EXCEPTION: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
GD.Print(pass ? "[Harness] RESULT: PASS" : "[Harness] RESULT: FAIL");
|
||||||
|
GetTree().Quit(pass ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool RunRoundTrip(string v1Path, string v2Path)
|
||||||
|
{
|
||||||
|
ulong t0 = Time.GetTicksMsec();
|
||||||
|
WorldBlueprint original = MapDataParser.LoadMapDataFromPath(v1Path);
|
||||||
|
ulong t1 = Time.GetTicksMsec();
|
||||||
|
if (original == null) { GD.PrintErr("[Harness] v1 load failed."); return false; }
|
||||||
|
GD.Print($"[Harness] v1 parse: {(t1 - t0) / 1000.0:F1}s (format v{original.FormatVersion})");
|
||||||
|
|
||||||
|
BlueprintWriter.WriteV2(v2Path, original);
|
||||||
|
ulong t2 = Time.GetTicksMsec();
|
||||||
|
GD.Print($"[Harness] v2 write: {(t2 - t1) / 1000.0:F1}s");
|
||||||
|
|
||||||
|
WorldBlueprint reread = MapDataParser.LoadMapDataFromPath(v2Path);
|
||||||
|
ulong t3 = Time.GetTicksMsec();
|
||||||
|
if (reread == null) { GD.PrintErr("[Harness] v2 load failed."); return false; }
|
||||||
|
GD.Print($"[Harness] v2 parse: {(t3 - t2) / 1000.0:F1}s (format v{reread.FormatVersion})");
|
||||||
|
|
||||||
|
return Compare(original, reread);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool Compare(WorldBlueprint a, WorldBlueprint b)
|
||||||
|
{
|
||||||
|
bool ok = true;
|
||||||
|
|
||||||
|
if (a.MapSize != b.MapSize)
|
||||||
|
{
|
||||||
|
GD.PrintErr($"[Harness] MapSize mismatch: {a.MapSize} vs {b.MapSize}");
|
||||||
|
return false; // nothing below is comparable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heights: bitwise-identical floats.
|
||||||
|
long heightDiffs = 0;
|
||||||
|
for (int x = 0; x < a.MapSize; x++)
|
||||||
|
for (int y = 0; y < a.MapSize; y++)
|
||||||
|
if (System.BitConverter.SingleToInt32Bits(a.HeightMap[x, y]) !=
|
||||||
|
System.BitConverter.SingleToInt32Bits(b.HeightMap[x, y]))
|
||||||
|
heightDiffs++;
|
||||||
|
if (heightDiffs > 0) { GD.PrintErr($"[Harness] {heightDiffs} height pixels differ bitwise."); ok = false; }
|
||||||
|
|
||||||
|
// Biomes: equal values (i32 -> u8 narrowing is documented and lossless for 0..255).
|
||||||
|
long biomeDiffs = 0;
|
||||||
|
for (int x = 0; x < a.MapSize; x++)
|
||||||
|
for (int y = 0; y < a.MapSize; y++)
|
||||||
|
if (a.BiomeMap[x, y] != b.BiomeMap[x, y])
|
||||||
|
biomeDiffs++;
|
||||||
|
if (biomeDiffs > 0) { GD.PrintErr($"[Harness] {biomeDiffs} biome pixels differ."); ok = false; }
|
||||||
|
|
||||||
|
// Towns: identical position/tier/count (highway flag deliberately not compared).
|
||||||
|
if (a.Towns.Count != b.Towns.Count)
|
||||||
|
{
|
||||||
|
GD.PrintErr($"[Harness] Town count mismatch: {a.Towns.Count} vs {b.Towns.Count}");
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for (int i = 0; i < a.Towns.Count; i++)
|
||||||
|
{
|
||||||
|
if (a.Towns[i].Position != b.Towns[i].Position || a.Towns[i].Tier != b.Towns[i].Tier)
|
||||||
|
{
|
||||||
|
GD.PrintErr($"[Harness] Town {i} mismatch: " +
|
||||||
|
$"{a.Towns[i].Position}/{a.Towns[i].Tier} vs {b.Towns[i].Position}/{b.Towns[i].Tier}");
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok &= CompareRoads("Highway", a.Highways, b.Highways);
|
||||||
|
ok &= CompareRoads("Branch", a.BranchRoads, b.BranchRoads);
|
||||||
|
ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads);
|
||||||
|
ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads);
|
||||||
|
|
||||||
|
if (ok)
|
||||||
|
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
|
||||||
|
$"{a.Towns.Count} towns, roads {a.Highways.Count}/{a.BranchRoads.Count}/" +
|
||||||
|
$"{a.RuggedRoads.Count}/{a.TrailRoads.Count} paths.");
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b)
|
||||||
|
{
|
||||||
|
if (a.Count != b.Count)
|
||||||
|
{
|
||||||
|
GD.PrintErr($"[Harness] {tier} path count mismatch: {a.Count} vs {b.Count}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < a.Count; i++)
|
||||||
|
{
|
||||||
|
if (a[i].Length != b[i].Length)
|
||||||
|
{
|
||||||
|
GD.PrintErr($"[Harness] {tier} path {i} length mismatch: {a[i].Length} vs {b[i].Length}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int p = 0; p < a[i].Length; p++)
|
||||||
|
{
|
||||||
|
if (System.BitConverter.SingleToInt32Bits(a[i][p].X) != System.BitConverter.SingleToInt32Bits(b[i][p].X) ||
|
||||||
|
System.BitConverter.SingleToInt32Bits(a[i][p].Y) != System.BitConverter.SingleToInt32Bits(b[i][p].Y))
|
||||||
|
{
|
||||||
|
GD.PrintErr($"[Harness] {tier} path {i} point {p} differs: {a[i][p]} vs {b[i][p]}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue