islaApocalypse/Core/Scripts/BlueprintWriter.cs
beezm cc6a24d2b9 fix: brush-spread deposition + deposit-cap governor; retune erosion for a drainage hierarchy (terrain-water task 18)
Deposition now spreads over the same cone brush as carving, and the per-cell
erosion ledger becomes a signed NET displacement ledger, so both caps are
measured from the height the pass found. Brush-spreading alone cut the spike
15.49 -> 6.12 m at task-17 dials, but NOT at hierarchy dials (long paths carry
far more sediment; a loaded droplet meeting a rise dumps min(rise, load) at
once -> 25.5 m). So deposition also gets governor 4, ErosionDepositCap
(default 6 m, <= 0 = unbounded), asserted on exit like the carve cap.

Dial defaults retuned for a drainage HIERARCHY: 250k droplets x 384 steps at
inertia 0.35 / evaporation 0.004, erode 0.12, carve cap 15 m. Diagnosis was
that lifetime 48 let a droplet travel at most 48 px on a ~3000 px island
radius, so paths could not overlap into trunks: the deepest task-17 features
were 43x36 px patches. Now 177 channel systems of 200+ cells at the 3 m
threshold, top one 204x279 px with 14 tributary tips; cells past 5 m up 8.6x
(4,948 -> 42,385) while the fine rills are retained. Verified descending, not
contour-locked: 0/155 components have drop/extent < 0.12 (median 1.13).

EROS body version -> 2 (deposit cap inserted after the carve cap); v1 payloads
are skipped whole by the existing rule. HydraulicErosion.VERSION now reads
BlueprintFormat.EROS_VERSION instead of restating it — the local copy had
already drifted and stamped a v2 body as v1, which readers decode with every
float shifted by one field.

Oracle green on both seeds (1280587109, 1512575962): 1_biomes/0_water
md5-identical erosion-ON vs OFF, BIOM/WBID bitwise equal. Flood guard 0 new
water px, 0 below-sea cells touched, crater zone untouched, island top
unchanged, carve field isotropic to 1.4% across the folded 45deg period.
Erosion pass 34 s at these dials.

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

287 lines
11 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));
if (bp.Erosion != null)
WriteSection(writer, BlueprintFormat.TAG_EROSION, w => WriteErosion(w, bp.Erosion));
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
writer.Write(d.EdgeAmpM); writer.Write(d.EdgeFreqIslands); // 2 × f32
writer.Write(d.EdgeSeedOffset); // i32
writer.Write(d.EdgeMaxShiftM); // f32
}
private static void WriteErosion(BinaryWriter writer, ErosionInfo e)
{
writer.Write(e.Version); // u16
writer.Write(e.DropletCount); writer.Write(e.Lifetime); // 2 × i32
writer.Write(e.BrushRadius); writer.Write(e.SeedOffset); // 2 × i32
writer.Write(e.CarveCapM); writer.Write(e.DepositCapM); // 2 × f32
writer.Write(e.SeaMarginM); // f32
writer.Write(e.Inertia); writer.Write(e.CapacityFactor); // 2 × f32
writer.Write(e.MinSlopeM); // f32
writer.Write(e.ErodeRate); writer.Write(e.DepositRate); // 2 × f32
writer.Write(e.Evaporation); writer.Write(e.Gravity); // 2 × f32
writer.Write(e.CraterExclFactor); // f32
}
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 "";
}
}
}
}