feat: 0_height hillshade snapshot + TCRV blueprint section + harness (terrain-water task 05)

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 <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-07 15:24:33 -04:00
parent 67868638be
commit e7de292b3d
5 changed files with 142 additions and 1 deletions

View file

@ -32,6 +32,7 @@ namespace IslaApocalypse.Core
public const uint TAG_WATER_BODY_IDS = 0x44494257; // "WBID" public const uint TAG_WATER_BODY_IDS = 0x44494257; // "WBID"
public const uint TAG_WATER_BODY_TABLE = 0x42544257; // "WBTB" public const uint TAG_WATER_BODY_TABLE = 0x42544257; // "WBTB"
public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF" 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 // 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 // (raw blueprint height units) encodes as 1 + round(L × 32768), so a genuine

View file

@ -31,6 +31,8 @@ namespace IslaApocalypse.Core
writer.Write(BlueprintFormat.VERSION); writer.Write(BlueprintFormat.VERSION);
WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp)); 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_HEIGHTS, w => WriteHeights(w, bp));
WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(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) private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp)
{ {
int n = bp.MapSize; int n = bp.MapSize;

View file

@ -58,6 +58,20 @@ namespace IslaApocalypse.Core
public Vector2 Centroid; // map pixels public Vector2 Centroid; // map pixels
} }
/// <summary>
/// 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.
/// </summary>
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 class WorldBlueprint
{ {
public int MapSize; public int MapSize;
@ -82,6 +96,9 @@ namespace IslaApocalypse.Core
public ushort[,] WaterBodyIds; // 0 = no water, 1 = ocean, 2..N = lakes public ushort[,] WaterBodyIds; // 0 = no water, 1 = ocean, 2..N = lakes
public List<WaterBodyInfo> WaterBodies = new List<WaterBodyInfo>(); public List<WaterBodyInfo> WaterBodies = new List<WaterBodyInfo>();
public ushort[,] WaterSurfaceQ; // quantized levels — BlueprintFormat.DecodeWaterLevel 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 // 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_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_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_WATER_BODY_TABLE) sectionOk = ParseWaterBodyTable(reader, blueprint);
else if (tag == BlueprintFormat.TAG_TERRAIN_CURVE) sectionOk = ParseTerrainCurve(reader, blueprint);
else else
{ {
// The property the redesign exists to buy: future sections (water, // The property the redesign exists to buy: future sections (water,
@ -366,6 +384,21 @@ namespace IslaApocalypse.Core
return true; 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<Vector2[]> into) private static bool ParseRoadTier(BinaryReader reader, List<Vector2[]> into)
{ {
int pathCount = reader.ReadInt32(); int pathCount = reader.ReadInt32();

View file

@ -98,7 +98,10 @@ public partial class MapGenerator : TextureRect
// A future water stage slots in as another CaptureStage call at its own boundary. // A future water stage slots in as another CaptureStage call at its own boundary.
GenerateTopography(); 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(); CalculateTrueOcean();
CalculateMainland(); CalculateMainland();
@ -247,6 +250,15 @@ public partial class MapGenerator : TextureRect
TrailRoads = _trailPaths, TrailRoads = _trailPaths,
WaterBodyIds = _waterBodyIds, WaterBodyIds = _waterBodyIds,
WaterBodies = _waterBodies ?? new List<WaterBodyInfo>(), WaterBodies = _waterBodies ?? new List<WaterBodyInfo>(),
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, FormatVersion = 2,
Params = new BlueprintParams 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}."); $"deeper than 0.01: {basinDepths.FindAll(d => d > 0.01f).Count}, deeper than 0.05: {basinDepths.FindAll(d => d > 0.05f).Count}.");
} }
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary> /// <summary>
/// Paints the water-stage snapshot from the stage's own outputs (the biome grid /// 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 /// does not exist yet at this point in the pipeline): ocean deep blue, lakes a

View file

@ -120,6 +120,7 @@ public partial class RoundTripHarness : Node
ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads); ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads);
ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads); ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads);
ok &= CompareWater(a, b); ok &= CompareWater(a, b);
ok &= CompareTerrainCurve(a, b);
if (ok) if (ok)
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " + GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
@ -185,6 +186,29 @@ public partial class RoundTripHarness : Node
return ok; 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<Vector2[]> a, List<Vector2[]> b) private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b)
{ {
if (a.Count != b.Count) if (a.Count != b.Count)