From e7de292b3d64607c1b14614921db9db1d7362277 Mon Sep 17 00:00:00 2001 From: beezm Date: Fri, 7 Aug 2026 15:24:33 -0400 Subject: [PATCH] feat: 0_height hillshade snapshot + TCRV blueprint section + harness (terrain-water task 05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0_height: hypsometric storm-ladder tint x Lambert NW hillshade over the curved heights, captured after topography in both curve modes — relief is finally visible. Renumbers nothing. TCRV (50 B, emitted when the curve is on): curve version + input knots + output bands, so blueprints are self-describing about the map their heights went through (H7 spirit). Parsed into WorldBlueprint.TerrainCurve (null = raw legacy profile); server treats it as metadata. Round-trip harness compares it when present. Co-Authored-By: Claude Fable 5 --- Core/Scripts/BlueprintFormat.cs | 1 + Core/Scripts/BlueprintWriter.cs | 12 +++++ Core/Scripts/MapDataParser.cs | 33 ++++++++++++++ Tools/Scripts/MapGenerator.cs | 73 ++++++++++++++++++++++++++++++- Tools/Scripts/RoundTripHarness.cs | 24 ++++++++++ 5 files changed, 142 insertions(+), 1 deletion(-) diff --git a/Core/Scripts/BlueprintFormat.cs b/Core/Scripts/BlueprintFormat.cs index 1aacec7..99d4597 100644 --- a/Core/Scripts/BlueprintFormat.cs +++ b/Core/Scripts/BlueprintFormat.cs @@ -32,6 +32,7 @@ namespace IslaApocalypse.Core public const uint TAG_WATER_BODY_IDS = 0x44494257; // "WBID" public const uint TAG_WATER_BODY_TABLE = 0x42544257; // "WBTB" public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF" + public const uint TAG_TERRAIN_CURVE = 0x56524354; // "TCRV" // WSRF quantization: u16, 0 reserved as the no-water sentinel. A real level L // (raw blueprint height units) encodes as 1 + round(L × 32768), so a genuine diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs index eb2ac5e..5762829 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -31,6 +31,8 @@ namespace IslaApocalypse.Core 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)); WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp)); WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp)); @@ -122,6 +124,16 @@ namespace IslaApocalypse.Core } } + 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.HMaxCal); + 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); + } + private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp) { int n = bp.MapSize; diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index a840ed6..67749d5 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -58,6 +58,20 @@ namespace IslaApocalypse.Core public Vector2 Centroid; // map pixels } + /// + /// The height-redistribution curve that shaped this blueprint's heights (v2 TCRV + /// section, terrain-water task 05). Null when the blueprint was generated with the + /// curve off (or predates it) — heights are then the raw legacy profile. Pure + /// metadata: the server never re-applies the curve; exported heights are already + /// curved. + /// + public class TerrainCurveInfo + { + public ushort Version; + public float T1, T2, T3, T4, HMaxCal; // input knots + public float Sea, OrangeCeil, RedCeil, PlateauLo, PlateauHi, PeakCap, TailSlope; // output bands + } + public class WorldBlueprint { public int MapSize; @@ -82,6 +96,9 @@ namespace IslaApocalypse.Core public ushort[,] WaterBodyIds; // 0 = no water, 1 = ocean, 2..N = lakes public List WaterBodies = new List(); public ushort[,] WaterSurfaceQ; // quantized levels — BlueprintFormat.DecodeWaterLevel + + // The curve that shaped HeightMap (v2 TCRV section); null = raw legacy profile. + public TerrainCurveInfo TerrainCurve; } // 2. The Parser Utility @@ -204,6 +221,7 @@ namespace IslaApocalypse.Core else if (tag == BlueprintFormat.TAG_WATER_BODY_IDS) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: false); else if (tag == BlueprintFormat.TAG_WATER_SURFACE) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: true); else if (tag == BlueprintFormat.TAG_WATER_BODY_TABLE) sectionOk = ParseWaterBodyTable(reader, blueprint); + else if (tag == BlueprintFormat.TAG_TERRAIN_CURVE) sectionOk = ParseTerrainCurve(reader, blueprint); else { // The property the redesign exists to buy: future sections (water, @@ -366,6 +384,21 @@ namespace IslaApocalypse.Core return true; } + private static bool ParseTerrainCurve(BinaryReader reader, WorldBlueprint blueprint) + { + var c = new TerrainCurveInfo(); + c.Version = reader.ReadUInt16(); + c.T1 = reader.ReadSingle(); c.T2 = reader.ReadSingle(); + c.T3 = reader.ReadSingle(); c.T4 = reader.ReadSingle(); + c.HMaxCal = reader.ReadSingle(); + c.Sea = reader.ReadSingle(); c.OrangeCeil = reader.ReadSingle(); + c.RedCeil = reader.ReadSingle(); c.PlateauLo = reader.ReadSingle(); + c.PlateauHi = reader.ReadSingle(); c.PeakCap = reader.ReadSingle(); + c.TailSlope = reader.ReadSingle(); + blueprint.TerrainCurve = c; + return true; + } + private static bool ParseRoadTier(BinaryReader reader, List into) { int pathCount = reader.ReadInt32(); diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index b622ca7..d62a542 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -98,7 +98,10 @@ public partial class MapGenerator : TextureRect // A future water stage slots in as another CaptureStage call at its own boundary. GenerateTopography(); - GD.Print($"{T()} Topography done (height + temperature)."); + GD.Print($"{T()} Topography done (height + temperature). TerrainCurve: {(_curveOn ? "v1" : "off")}."); + + DrawHeightStageTexture(); + await CaptureStage("0_height"); CalculateTrueOcean(); CalculateMainland(); @@ -247,6 +250,15 @@ public partial class MapGenerator : TextureRect TrailRoads = _trailPaths, WaterBodyIds = _waterBodyIds, WaterBodies = _waterBodies ?? new List(), + TerrainCurve = _curveOn ? new TerrainCurveInfo + { + Version = HeightCurve.VERSION, + T1 = HeightCurve.T1, T2 = HeightCurve.T2, T3 = HeightCurve.T3, T4 = HeightCurve.T4, + HMaxCal = HeightCurve.HMAX_CAL, + Sea = HeightCurve.SEA, OrangeCeil = HeightCurve.ORANGE_CEIL, RedCeil = HeightCurve.RED_CEIL, + PlateauLo = HeightCurve.PLATEAU_LO, PlateauHi = HeightCurve.PLATEAU_HI, + PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE + } : null, FormatVersion = 2, Params = new BlueprintParams { @@ -749,6 +761,65 @@ public partial class MapGenerator : TextureRect $"deeper than 0.01: {basinDepths.FindAll(d => d > 0.01f).Count}, deeper than 0.05: {basinDepths.FindAll(d => d > 0.05f).Count}."); } + /// + /// Paints the height-stage snapshot (task 05): hypsometric tint by storm-ladder + /// band × Lambert hillshade from the height gradient (NW light), over the CURVED + /// heights — this is the snapshot that makes relief visible. Runs in both curve + /// modes; with the curve off it shows the legacy profile under the same bands. + /// Pure numeric pass over the heightmap. + /// + private void DrawHeightStageTexture() + { + Image img = Image.CreateEmpty(MapSize, MapSize, false, Image.Format.Rgba8); + Vector3 light = new Vector3(-0.55f, -0.55f, 0.63f).Normalized(); // NW, ~39° up + + Color deepSea = new Color(0.07f, 0.15f, 0.32f); + Color shallowSea = new Color(0.25f, 0.45f, 0.65f); + Color green = new Color(0.44f, 0.62f, 0.36f); // orange band terrain: lowland green + Color tan = new Color(0.76f, 0.70f, 0.46f); // red band: tan + Color brown = new Color(0.55f, 0.41f, 0.28f); // shoulder + plateau: brown + Color white = new Color(0.97f, 0.97f, 0.98f); // peaks + Color grey = new Color(0.72f, 0.72f, 0.70f); + + int n = MapSize; + for (int x = 0; x < n; x++) + { + for (int y = 0; y < n; y++) + { + float h = _heightMap[x, y]; + float sea = GetSeaLevel(_tempMap[x, y]); + + Color tint; + if (h < sea) + { + float depth = Mathf.Clamp((sea - h) / 0.5f, 0f, 1f); + tint = shallowSea.Lerp(deepSea, depth); + } + else if (h < HeightCurve.ORANGE_CEIL) tint = green; + else if (h < HeightCurve.RED_CEIL) tint = tan; + else if (h < HeightCurve.PLATEAU_HI) tint = brown; + else + { + float t2 = Mathf.Clamp((h - HeightCurve.PLATEAU_HI) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU_HI), 0f, 1f); + tint = grey.Lerp(white, t2); + } + + // Lambert hillshade on the world-scale gradient (1 px = 1 m, height ×251 m). + int xm = x > 0 ? x - 1 : x, xp = x < n - 1 ? x + 1 : x; + int ym = y > 0 ? y - 1 : y, yp = y < n - 1 ? y + 1 : y; + float gx = (_heightMap[xp, y] - _heightMap[xm, y]) * 251f / (xp - xm == 0 ? 1 : xp - xm); + float gy = (_heightMap[x, yp] - _heightMap[x, ym]) * 251f / (yp - ym == 0 ? 1 : yp - ym); + Vector3 nrm = new Vector3(-gx, -gy, 1f).Normalized(); + float shade = Mathf.Clamp(nrm.Dot(light), 0f, 1f); + + Color c = tint * (0.35f + 0.65f * shade); + c.A = 1f; + img.SetPixel(x, y, c); + } + } + Texture = ImageTexture.CreateFromImage(img); + } + /// /// Paints the water-stage snapshot from the stage's own outputs (the biome grid /// does not exist yet at this point in the pipeline): ocean deep blue, lakes a diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index d2306a5..f547633 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -120,6 +120,7 @@ public partial class RoundTripHarness : Node ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads); ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads); ok &= CompareWater(a, b); + ok &= CompareTerrainCurve(a, b); if (ok) GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " + @@ -185,6 +186,29 @@ public partial class RoundTripHarness : Node return ok; } + private bool CompareTerrainCurve(WorldBlueprint a, WorldBlueprint b) + { + if (a.TerrainCurve == null && b.TerrainCurve == null) + { + GD.Print("[Harness] TCRV: absent in source — nothing to compare (and none reappeared)."); + return true; + } + if (a.TerrainCurve == null || b.TerrainCurve == null) + { + GD.PrintErr("[Harness] TCRV presence mismatch between source and reread."); + return false; + } + var ca = a.TerrainCurve; var cb = b.TerrainCurve; + bool same = ca.Version == cb.Version; + float[] fa = { ca.T1, ca.T2, ca.T3, ca.T4, ca.HMaxCal, ca.Sea, ca.OrangeCeil, ca.RedCeil, ca.PlateauLo, ca.PlateauHi, ca.PeakCap, ca.TailSlope }; + float[] fb = { cb.T1, cb.T2, cb.T3, cb.T4, cb.HMaxCal, cb.Sea, cb.OrangeCeil, cb.RedCeil, cb.PlateauLo, cb.PlateauHi, cb.PeakCap, cb.TailSlope }; + for (int i = 0; i < fa.Length; i++) + if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) same = false; + if (!same) { GD.PrintErr("[Harness] TCRV fields differ."); return false; } + GD.Print($"[Harness] TCRV equal (curve v{ca.Version})."); + return true; + } + private bool CompareRoads(string tier, List a, List b) { if (a.Count != b.Count)