fix: shoreline seam — water follows the RENDERED coastline (terrain-water task 15)

Closes the C1-polish carryover. The cause was confirmed by a read-only dump
BEFORE any code was written, per the filed note's discipline; the chat had
guessed twice from screenshots.

DIAGNOSIS. WBID is classified from the UNCURVED heightmap — the classify
path that keeps the biome/water oracle byte-identical through all of
Phase B — while the mesh renders the CURVED one. Outside the crater those
two agree EXACTLY, and provably so: the curve is identity at sea and
monotonic, so Apply(raw) < sea iff raw < sea. Inside the crater they do
not. The carve lerps two different bases toward one target --
classify from raw, rendered from Apply(raw) -- and Apply(raw) < raw
throughout the lowland band (the toe compresses raw 0.15..0.516 into
0.15..0.206). So the rendered surface sinks FASTER than the classify
surface, leaving an annulus that renders below the waterline while WBID
still calls it dry.

MEASURED, seed 1825907253, map-wide:
  wet columns                        32,799,866
  WATER-OVER-DRY (wet, mesh above Y)      1,101   scattered, not the story
  DRY-UNDER-WATER (dry, mesh below sea)  375,824   <- the seam
  ...of which inside the crater carve:      100 %
Dump across the gradient at z=905: at x=3800 the classify surface sits at
47.55 m while the mesh renders 22.38 m -- 25 m apart, no water drawn. The
ring is west of the Capitol, which is exactly the developer's "flush on
the right, lifted toward the left".

Neither of the task's two candidate causes, and worth saying so: the water
LEVEL is flat and correct at 37.65 everywhere it is drawn (rules out B),
and the per-cell flat sheet is a rounding error next to this (1,101 px vs
375,824). It is the filed note's classify-vs-render mismatch, with the
crater carve as the mechanism.

THE FIX. Presence now takes two clauses: the blueprint's classification,
OR the rendered ground actually being under the ocean's surface. The
second clause tests exactly what the mesh draws (exactSurfaceY) against the
OCEAN BODY'S OWN level from WBTB -- the runtime still derives nothing, it
only notices that the ground it is drawing is under a surface the
blueprint gave it. By the identity above that clause can only ever fire
inside the carve, which is precisely the flooded bay it exists for.

MEASURED after, same seed: 241,415 -> 358,576 water columns, 454 -> 648
chunks, 117,161 seam columns recovered in the loaded region. Each run now
reports its own seam count.

2D PIPELINE UNTOUCHED, verified rather than asserted: the only changed
file is the runtime chunk manager, and a regeneration is byte-identical to
task 13's in every section but PRMS (timestamp + git hash). All four
snapshot PNGs md5-identical.

Static flat sheet, no animation, no water DATA change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-10 00:56:34 -04:00
parent 3b5bc5e5d6
commit 69bf91b3d6

View file

@ -34,6 +34,12 @@ namespace IslaApocalypse.Server
private int _chunksWithWater = 0; private int _chunksWithWater = 0;
private float _waterMinY = float.MaxValue, _waterMaxY = float.MinValue, _waterMaxDepth = 0f; private float _waterMinY = float.MaxValue, _waterMaxY = float.MinValue, _waterMaxDepth = 0f;
// Shoreline seam (task 15). The OCEAN body's own surface level, from the
// blueprint's WBTB table; -1 when the blueprint carries no ocean. Columns the
// mesh renders below this but WBID calls dry are the seam, and are counted.
private float _oceanLevelRaw = -1f;
private long _seamColumnsRecovered = 0;
public override void _Ready() public override void _Ready()
{ {
@ -60,6 +66,13 @@ namespace IslaApocalypse.Server
$"vs config MapSize {ConfigManager.MapSize}."); $"vs config MapSize {ConfigManager.MapSize}.");
} }
// The ocean's surface level, for the shoreline-seam rule below. Taken
// from the blueprint's body table (type OCEAN), never derived here —
// the runtime does not compute sea level (D-033).
if (_blueprint.WaterBodies != null)
foreach (var body in _blueprint.WaterBodies)
if (body.Type == WaterBodyInfo.TYPE_OCEAN) { _oceanLevelRaw = body.SurfaceLevel; break; }
GD.Print("[Server] Blueprint loaded. Locating Capitol City..."); GD.Print("[Server] Blueprint loaded. Locating Capitol City...");
// 2. Find the Capitol in the parsed data // 2. Find the Capitol in the parsed data
@ -99,7 +112,8 @@ namespace IslaApocalypse.Server
GD.Print($"[Server] Water at rest: {_waterColumnsRendered} water columns across " + GD.Print($"[Server] Water at rest: {_waterColumnsRendered} water columns across " +
$"{_chunksWithWater} of {_activeChunks.Count} chunks" + $"{_chunksWithWater} of {_activeChunks.Count} chunks" +
(_waterColumnsRendered > 0 (_waterColumnsRendered > 0
? $"; surface Y {_waterMinY:F1}..{_waterMaxY:F1} m, depth up to {_waterMaxDepth:F1} m." ? $"; surface Y {_waterMinY:F1}..{_waterMaxY:F1} m, depth up to {_waterMaxDepth:F1} m; " +
$"{_seamColumnsRecovered} shoreline-seam columns recovered (mesh below the ocean surface, WBID dry)."
: " — nothing to draw here (check the blueprint carries WBID/WSRF).")); : " — nothing to draw here (check the blueprint carries WBID/WSRF)."));
// 5. Teleport the Camera to look down at our creation! // 5. Teleport the Camera to look down at our creation!
@ -268,30 +282,57 @@ namespace IslaApocalypse.Server
newChunk.ColumnBiomes[x, z] = columnBiome; newChunk.ColumnBiomes[x, z] = columnBiome;
newChunk.ColumnRoadMaterial[x, z] = roadSurface; newChunk.ColumnRoadMaterial[x, z] = roadSurface;
// --- WATER AT REST (task 13) --------------------------------- // --- WATER AT REST (task 13) + SHORELINE SEAM (task 15) ------
// The blueprint is the AUTHORITY on where water is; the runtime // The blueprint is the AUTHORITY on where water is and at what
// only draws it. WBID decides presence (it is the water stage's // level; the runtime only draws it. WSRF gives the level per pixel,
// own classification output, the same set the biome grid's // and the WBTB body table is the fallback if a column is flagged wet
// Ocean/Lake pixels form by construction), WSRF gives the level // but carries the WSRF no-water sentinel.
// per pixel, and the WBTB body table is the fallback if a column //
// is flagged wet but carries the WSRF no-water sentinel. // PRESENCE, though, takes two clauses. WBID was classified from the UNCURVED
// heightmap — the classify path that keeps the biome/water oracle
// byte-identical — while the mesh renders the CURVED one. Outside
// the crater those two agree exactly, because the curve is identity
// at sea and monotonic, so Apply(raw) < sea iff raw < sea. INSIDE
// the crater they do not: the carve lerps two different bases toward
// one target (classify from raw, rendered from Apply(raw), and
// Apply(raw) < raw throughout the lowland band), so the rendered
// surface sinks faster and leaves a ring that renders below the
// waterline while WBID still calls it dry. Measured on seed
// 1825907253: 375,824 such columns, 100 % of them inside the carve.
//
// So presence is decided by BOTH: the blueprint's classification,
// OR the rendered ground actually being under the ocean's surface.
// The second clause can only ever fire inside the carve (see the
// identity above), which is precisely the flooded bay it exists for.
if (_blueprint.WaterBodyIds != null) if (_blueprint.WaterBodyIds != null)
{ {
ushort bodyId = _blueprint.WaterBodyIds[globalX, globalZ]; ushort bodyId = _blueprint.WaterBodyIds[globalX, globalZ];
float levelRaw = -1f;
if (bodyId != 0) if (bodyId != 0)
{ {
float levelRaw = -1f;
if (_blueprint.WaterSurfaceQ != null) if (_blueprint.WaterSurfaceQ != null)
{ {
ushort q = _blueprint.WaterSurfaceQ[globalX, globalZ]; ushort q = _blueprint.WaterSurfaceQ[globalX, globalZ];
if (q != 0) levelRaw = BlueprintFormat.DecodeWaterLevel(q); if (q != 0) levelRaw = BlueprintFormat.DecodeWaterLevel(q);
} }
if (levelRaw < 0f) levelRaw = BodyLevel(bodyId); if (levelRaw < 0f) levelRaw = BodyLevel(bodyId);
}
else if (_oceanLevelRaw >= 0f
&& exactSurfaceY < _oceanLevelRaw * Constants.HEIGHT_SCALE)
{
// Rendered ground below the ocean's own surface level. The
// level still comes from the blueprint (the ocean body's
// WBTB entry) — the runtime derives nothing, it only notices
// that the ground the MESH draws is under that surface.
levelRaw = _oceanLevelRaw;
_seamColumnsRecovered++;
}
if (levelRaw >= 0f) if (levelRaw >= 0f)
{ {
// Same mapping as the terrain, so the sheet and the // Same mapping as the terrain, so the sheet and the seabed
// seabed cannot drift apart. // cannot drift apart.
newChunk.WaterSurfaceY[x, z] = Mathf.Clamp( newChunk.WaterSurfaceY[x, z] = Mathf.Clamp(
levelRaw * Constants.HEIGHT_SCALE, 2.0f, Constants.CHUNK_HEIGHT - 2.0f); levelRaw * Constants.HEIGHT_SCALE, 2.0f, Constants.CHUNK_HEIGHT - 2.0f);
// TRUE depth, from blueprint units — see ChunkData. // TRUE depth, from blueprint units — see ChunkData.
@ -306,7 +347,6 @@ namespace IslaApocalypse.Server
if (newChunk.WaterDepthM[x, z] > _waterMaxDepth) _waterMaxDepth = newChunk.WaterDepthM[x, z]; if (newChunk.WaterDepthM[x, z] > _waterMaxDepth) _waterMaxDepth = newChunk.WaterDepthM[x, z];
} }
} }
}
float hRight = surfaceRight - exactSurfaceY; float hRight = surfaceRight - exactSurfaceY;
float hFwd = surfaceFwd - exactSurfaceY; float hFwd = surfaceFwd - exactSurfaceY;