islaApocalypse/Core/Scripts/BlueprintWriter.cs
beezm c305fd6565 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>
2026-08-06 07:24:08 -04:00

165 lines
6.2 KiB
C#

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 "";
}
}
}
}