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>
287 lines
11 KiB
C#
287 lines
11 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));
|
||
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 "";
|
||
}
|
||
}
|
||
}
|
||
}
|