islaApocalypse/Core/Scripts/BlueprintWriter.cs
beezm 3b6d166458 revert: the D8 drainage incision, whole (terrain-water task 10)
The task-10 draft's PASS B shipped and produced the canonical grid
artifact: thousands of straight, disconnected, pooling scratches. Per-
cell steepest descent on a regular grid can only route along its eight
neighbour headings, so at map scale the "channels" read as hatching,
not drainage. Reverted entirely rather than tuned -- no K, p or mask
setting fixes a directional basis. Rivers and erosion move to Phase C
as a hydraulic-erosion pass over FINAL terrain.

Removed: FlowAccumulation / IncisionWeight / EdgeBlend and the INC_*,
SEA_CLAMP, SHELF_INC_WEIGHT and CRATER_* constants; RunIncisionPass and
the pass-2b call; the six incision fields from TDTL (writer, parser,
harness). KEPT, untouched and developer-approved: pass A, the shelf
micro-relief skin, and its ShelfReliefAmp dial.

The crater carve folds back into the single pass-2 loop it came from,
carving two LOCALS written once each. That is what the code did before
the draft split it out, and it makes the aliased double-carve that the
split introduced (fix 1b98fb5) structurally impossible rather than
merely fixed -- there is no longer a read-modify-write to get wrong.

TDTL bodies are now versioned (BlueprintFormat.TDTL_VERSION = 2) and the
parser skips a body version it does not know instead of misreading the
longer v1 layout into plausible nonsense. The only v1 payloads that
exist are in the reverted batch's own tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 20:30:44 -04:00

268 lines
9.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));
if (bp.TerrainCurve != null)
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve));
if (bp.TerrainDetail != null)
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_DETAIL, w => WriteTerrainDetail(w, bp.TerrainDetail));
WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(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_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 WriteTerrainCurve(BinaryWriter writer, TerrainCurveInfo c)
{
writer.Write(c.Version);
writer.Write(c.T1); writer.Write(c.T2); writer.Write(c.T3); writer.Write(c.T4);
writer.Write(c.SpikeMax);
writer.Write(c.Sea); writer.Write(c.OrangeCeil); writer.Write(c.RedCeil);
writer.Write(c.PlateauLo); writer.Write(c.PlateauHi); writer.Write(c.PeakCap);
writer.Write(c.TailSlope);
// v4+ extension: shelf-modulation parameters.
if (c.Version >= 4)
{
writer.Write(c.BenchAmp); writer.Write(c.PlateauAmp);
writer.Write(c.ShelfSpanMin); writer.Write(c.ShelfSpanMax);
writer.Write(c.ElevFreqIslands); writer.Write(c.StrengthFreqIslands);
writer.Write(c.BenchSeedOffset); writer.Write(c.PlateauSeedOffset);
writer.Write(c.StrengthSeedOffset);
}
// v5+ extension: knot preset id + K5/K6.
if (c.Version >= 5)
{
writer.Write(c.PresetId);
writer.Write(c.K5); writer.Write(c.K6);
}
}
private static void WriteTerrainDetail(BinaryWriter writer, TerrainDetailInfo d)
{
writer.Write(d.Version); // u16
writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands); // 2 × f32
writer.Write(d.ReliefSeedOffset); // i32
}
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)
{
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 "";
}
}
}
}