islaApocalypse/Core/Scripts/BlueprintWriter.cs
beezm f810ff44dc fix: shrink erosion's crater exclusion to the strike core; add FULL/FEATHER modes (terrain-water task 19)
Task 17's hard 1.2 x CraterRadius cutoff left a visible un-eroded disc with a hard
edge. Measured (radius dump, seed 1280587109): the carve writes only inside 0.80 x
(640 px) and its displacement is EXACTLY 0 beyond that, so the 640-960 px annulus
was 620,811 LAND cells of ordinary terrain held smooth for no geometric reason.
That annulus is now eroded — 98% of those cells are touched.

The protected core is the carve's own extent (CraterErosionCore, 0.80 x).
CraterErosionMode selects the transition: "full" applies full strength at the core
boundary (older-crater look), "feather" ramps 0->full out to CraterErosionFeather
(1.05 x, mirroring the detail pass) for a younger-crater look with no seam.
Implemented as a WEIGHT that scales carve and deposit amounts, not a skip, which is
what makes feather a one-liner. Default "feather" pending the gate.

Core = the carve radius is load-bearing, not tidy: the carve runs AFTER erosion and
scales height toward the sea target, so it amplifies any erosion delta inside its
radius. Measured at a 0.50 core: 79 cells newly below the rendered sea and 113,310
below-sea cells disturbed — the in-pass flood guard cannot see this because it
measures before the carve. At 0.80 the two passes touch disjoint cells and the
guarantee is exact again (0/0/0). A narrower core also reclaims nothing extra, since
the over-protected annulus lies entirely outside the carve. MapGenerator now owns
CRATER_CARVE_FACTOR as the single source of that 0.80 and warns loudly if the
configured core is narrower.

Verified both modes, seed 1280587109: 1_biomes/0_water md5-identical to erosion-OFF,
BIOM/WBID bitwise equal, 0 newly below/above sea, 0 below-sea cells modified, 0
cells modified inside the core, island top 457.65 m unchanged, isotropy 1.005/1.007.
Bay-to-ocean connection demonstrated explicitly by flood fill through rendered
water to the map's north border (2,122,935 cells), not merely argued from the sea
clamp.

EROS body version -> 3: craterExclFactor replaced by craterCoreFactor +
craterFeatherFactor + craterMode. Round-trip harness PASS with a real v3 payload.

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

289 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.CraterCoreFactor); // f32
writer.Write(e.CraterFeatherFactor); // f32
writer.Write(e.CraterMode); // u8
}
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 "";
}
}
}
}