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)