6b31111cd215ff0d3cee5cfe9dfd97e69f6630b6
245
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fab70edd5a |
feat(ui): the stipple says WHERE the ground is broken, not just that it is (T-1194)
RimWorld's technique 4, the reference this ticket names, works by CONTRAST: the
Rockies and Appalachians carry dense hatching and the Great Plains carry none.
Ferrath's Global carried an even wash of dots over every landmass instead —
texture present, information absent.
The cause was a constant measured on the wrong rung. RUGGEDNESS_FULL_SCALE_Q = 4
comes from Region ("d4 2.38"), but the elev_q path it gates only ever RUNS at the
orbital rungs, where relief_q is flat and the stipple falls back to it. Ferrath
Global measures a mean 1-cell elev_q gradient of 0.99, so a coherent 4-cell
baseline reaches ~4 and saturates the constant exactly: every land cell read as
fully rugged.
It now normalizes against the canvas's own measured gradient — the same
self-calibrating shape T-1240 gave the hillshade, and for the same reason: one
constant cannot serve rungs whose sample spacing differs by four orders of
magnitude. A contrast curve rides on top, because even unsaturated the linear
reading puts ordinary ground mid-range and paints grain everywhere.
Measured on Global, before -> after: 1,679 -> 1,900 distinct colours, 145.90 ->
147.39 lum spread, and the pale uplands now stipple visibly denser than the
lowlands beside them.
Recorded because it cost two wrong turns: I first guessed saturation, then
talked myself out of it after measuring a 1-CELL gradient (0.99) against a full
scale meant for the 4-CELL baseline, and shipped a contrast curve alone — which
measurably did nothing (1,679 -> 1,698), because a curve cannot separate values
already clamped to 1.0. The gamma is kept; it does its job now that there is a
range to curve. The elev_q gradient joins relief_q's in the capture readout, so
the next person tuning this can read the number instead of guessing at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
9b146f9e1f |
fix(simulation): derive at the octaves a rung can actually reconstruct (T-1240)
Region rendered as fine uniform stucco while District and Quarter, on identical code, read as terrain. The cause was sampling: `min_wl_m` arrives as an LOD request and defaults to 0, so every invented octave contributed at every rung. MIN_WL_BANDS_M was meant to be the floor but is built from the rung's CELL SIZE (2 x DISTRICT_M), which stopped being the sample spacing at the D-255 extent inversion — a rung fixes EXTENT now and spacing falls out of the canvas size. The bands were off by roughly the cell count, and the served path never consulted them anyway. The cutoff is now derived from the resolved spacing, which is what this ticket asked for. Two things had to be measured rather than reasoned to get it right, and both corrected me. FIRST: the field was the culprit, not the renderer. I attributed the stucco to the client stipple painting noise onto a smooth field. Surfacing the terrain layer's own mean |relief_q gradient| in the capture readout settled it in one shot: Region 18.24 steps per cell — 144 m of relief between NEIGHBOURING cells — against District's 0.30 and Quarter's 0.07. The server was sending noise. That diagnostic ships here for the same reason `plane_variety` did in T-1213: a noisy field and a renderer inventing noise look identical, and one number separates them. SECOND: Nyquist is the wrong threshold. The first version floored at 2 x spacing, the aliasing limit, and Region barely moved (56.16 -> 59.73 lum spread, gradient still 18.24) because 2 samples per cycle is unaliased but renders jagged. The rungs that already worked say what the real bar is: District reconstructs its finest surviving octave at 34 samples per cycle, Quarter at 135. At 8x, Region goes to 1.08 gradient and 70.01 spread, and shows ridges and valleys. THE TRADE, taken deliberately and recorded in the tests: an 8x floor also truncates the coast warp's 2,048 and 1,024 m octaves at Region, the band T-1160 added for "one coastline at every rung". An earlier test here asserted that band must survive; it now asserts the opposite. Same reasoning as the relief: a 1,024 m coastline wiggle at 379.3 m per cell is 2.7 samples per cycle, so drawing it draws noise rather than coastline character — a rung cannot show shape finer than its own cell. The warp is amplitude-capped sub-pixel on the working grid, so what is lost is small. If a future pass wants the warp exempt, the fix is a relief-only floor threaded through derive_at_metres, NOT a lower multiple, which takes the stucco back. Global is exempt: its floor would be ~70 km and would truncate the whole warp band, and it needs none — the orbital derive leaves relief_q flat at 50. District (3.79 m spacing) and Quarter (0.948 m) floor below every octave in play and derive byte-identically, which their own test pins. Cache-safe by construction: the floor is a pure function of (rung, extent, body_radius), all three already in the step-canvas cache key. 0.4.12 is required anyway — this changes derived BYTES at Region, so a 0.4.11 entry holds a field this build would never produce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6d4c9e92c2 |
feat(ui): the copse and the rocky outcrop, drawn per cover class (T-1213)
D-258 invariant 2 asks the map to show the minority the orbital summary
suppressed — clearings, marsh, rock, scrub inside a cell that reads "forest" from
space. composition.rs goes to real trouble to invent it, and the conservation
harness measures it: over a District patch on Ferrath the tally is {Barren: 175,
Forest: 16209}, so 1.07% of that ground is exposed rock.
The renderer was averaging it back out. One test, `veg >= Scrub`, at one density
and one strength: Forest, Scrub and both Riparian classes drew the IDENTICAL
mark, and Barren drew none at all. A wood looked like scrub, and bare rock was
invisible by construction — on the rungs whose whole purpose is to show what the
summary hid.
Each class now has its own grammar, differing on the three axes a mark has:
density (how much of the class's ground carries it), strength (how far it moves
the base colour), and lattice (the block size marks are decided on — bigger reads
as a clump, smaller as grain). Rock LIGHTENS where everything else darkens, which
is the point rather than a flourish: bare stone catching the light is the one
cover type brighter than the ground around it, so it separates from vegetation by
sign alone and can never read as "denser plants". It gets its own ScatterField
salt so an outcrop does not preferentially land where a copse already did.
Tuned against captures, not guessed. A first pass gave Forest a 4x4 lattice at
52% density, which produced visibly axis-aligned dark SQUARES — a 4-cell block is
8 screen px at District — and read as an artefact laid over the hillshade. It
also mistook the background for a feature: Forest is 98.9% of this frame, so
marking half of it dark is not "the occasional copse", it is a second colour
layer. Pulled back to grain at a 2x2 lattice, and the occasional thing is now the
thing that catches the eye: the outcrops read as scattered pale clusters of
exposed ground, exactly the "occasional copse/tree/rocky outcropping" that was
missing.
District holds its form through the change (lum p1-p99 74.15, against 74.43
before the cover marks and 13.72 when the rung was flat).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8787ee1844 |
feat(ui): hillshade the deep rungs — form from light across slope (T-1213)
relief_q now reaches the renderer, and the first pass spent it on brightness:
lighten where the ground is high, darken where it is low. That moved the numbers
(District 13.72 -> 77.01 lum spread) and still looked like moss, because the eye
does not read landform from absolute brightness. It reads it from light falling
ACROSS a gradient — height-shading gives a rise and a fall the same tone, so no
ridge ever reads as a ridge.
So relief drives a proper hillshade: the local gradient of the field dotted with
a light from the upper-left. The light direction is not a free choice; lit from
the lower-right the brain inverts the read and valleys pop out as ridges.
THE SCALE IS MEASURED PER CANVAS, not fixed, and the first attempt at this failed
exactly the way this file already warned a fixed gradient constant would (see
RUGGEDNESS_BASELINE_CELLS: "the same 4-cell delta reads 21.86 at Region and 0.08
at District"). With a constant full-scale of 8:
Region 81.72 but District 77.01 -> 20.01, Quarter 42.56 -> 16.44
because at District's 3.8 m per cell neighbouring cells barely differ. The
terrain layer now measures each canvas's own mean |gradient| once per rebuild and
the hillshade normalizes against it, so one constant works at every rung.
Ladder (tooling/atlas-flatness, lum p1-p99), flat -> shipped:
Global 145.69 -> 145.69 unchanged; relief_q is flat 50 at orbital
Region 33.59 -> 54.30
District 13.72 -> 74.43
Quarter 11.01 -> 73.72
District and Quarter now read as terrain — ridgelines, valleys, and the stipple
organised into contour-like bands. Judged by eye on the captures, not by the
metric alone.
Stipple full-scale 25 -> 60. The old value was calibrated against a relief_q that
never arrived, so it was tuned to the elev_q fallback; with the real plane nearly
every land cell earned a mark and Region read as static (17,599 distinct colours,
more than twice Global's, for a quarter of the legibility). Form comes from the
hillshade now; the stipple is grain on top of it.
REGION IS NOT FIXED, and the cause is T-1240 rather than this change. It renders
as fine uniform stucco: a Region cell is 379 m of ground while the relief field's
content sits in the 128-1024 m band, so the field is at or below Nyquist and the
gradient the hillshade reads is aliasing, not slope. min_wl_m defaults to 0 on
the served path, so nothing truncates the octaves Region cannot resolve — which
is precisely what T-1240 proposes to fix. That ticket said the stale cutoff was
"currently inert"; it is now the thing capping Region, and T-1240 is updated
with the measurement.
Three tests, on direction rather than magnitude so tuning does not rewrite them:
a hill's west flank lit and east flank shadowed, a uniform field shading nothing,
and the canvas edge not drawing a rim. That last one is a bug this nearly
shipped: `_l8_value` returns 0 out of bounds and 0 on relief_q means MAXIMUM
HOLLOW, so sampling off-canvas posts a full-scale false gradient all the way
round the frame. The sample position is clamped instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3ec35b87c8 |
fix(client): the deep rungs were flat because relief_q fell off the wire (T-1213)
`relief_q` is the one field with signal below District — elev_q's 80 m steps
quantise sub-district detail away, which is precisely why relief_q was invented.
The server has encoded it since
|
||
|
|
086d9ed56e |
fix(client): bake the version into the build, so an export can invalidate its cache (T-1241)
current_schema_version() line-scanned res://../project.yaml at runtime. That resolves to the repo root in a dev run and to nothing in an exported build, so a shipped game got the "?.?.?" fallback every time. Since that tag is the Atlas disk cache's ONLY invalidation signal, every exported build stamped and compared the same sentinel: a canvas cached by one build would be served by every later build, forever. T-1239 is what that failure looks like once it happens. loading_screen.gd carried a byte-for-byte copy of the same function, so the version shown to the player was "?.?.?" in exactly the builds where a version string is worth showing. Both call sites now share client/scripts/build_version.gd, which reads application/config/version out of ProjectSettings — a value Godot bakes into the PCK, identical in the editor and in an export by construction rather than by luck. No file IO, no fallback branch. project.yaml stays the source of truth (CLAUDE.md); client/project.godot mirrors it. A mirror nobody checks would be worse than the bug it replaces -- the old code failed loudly everywhere, a stale mirror fails silently -- so tooling/check-client-version compares the two and the pre-push hook runs it unconditionally. Not gated on "were those files in this push": drift persists on main once introduced, and gating would let an existing drift ride along. The test this replaces asserted that current_schema_version() did not return its fallback, and passed -- in the one environment where the code under test worked. Three tests now pin the property that actually matters: a real version, sourced from the baked setting, matching project.yaml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
07ed2a47ab |
fix(client): the Atlas was replaying a cache from a build that no longer existed (T-1239)
Ferrath's Global map drew no rivers at native resolution: 375 courses arrived and 0 were drawn. The report suspected the D-261 length cull or the water truncation. Both were innocent, and so was the renderer. The client served the canvas from its own disk cache (T-1183). Every payload for GJ820Bc predated T-1237 ( |
||
|
|
43a267439b |
feat(client): ScatterField — reusable seeded scatter for client-side paint
Extracted from the T-1194 stipple, which was the first of a family: graffiti placement, cracks in textures, drifting cloud cover — presentation decisions that must look the same when the player returns to a place, and which the simulation has no opinion about and should not be burdened with. THE LINE IT DRAWS. It answers "how is this drawn", never "what is here". A cell's biome, a settlement's position, whether a wall exists — those are world data, derived once by the server and sampled everywhere (D-255(f) mechanism B), and the player eventually stands on them; inventing those here would put the map and the ground in disagreement. Stated on the class so the next consumer does not have to re-derive it: if the answer changes what is THERE it is not a ScatterField question; if it only changes how it is DRAWN, it is. Bit-identity with the server's Rust noise is explicitly NOT a requirement (Jeroen: "a seed is a seed and the functional intended outcome is repetition here"). Nothing here is compared against a server value or round-tripped through a save, so the contract is stability across sessions, not agreement across languages — which is precisely why paint belongs on this side: it buys visual density with no cross-language determinism burden. Seeded from GameState.world_seed, so two playthroughs scatter differently and one playthrough is stable forever. API: domain() resolves a name to a salt ONCE (the first consumer runs ~700,000 times per canvas rebuild, so the hot calls take an int, never a string); value/chance/pick/jitter for discrete marks; smooth() for continuous fields like cloud cover; an optional time axis for animation. Domains keep consumers uncorrelated — without them graffiti and cracks at the same wall coordinate would mark identical spots and read as one artefact. The tests pin the CONTRACT, not the numbers — freezing outputs would make any future improvement to the mixer a breaking change for no gain. They caught a real defect immediately: (-x, -y) collided with (x, y), because negated coordinates produce negated products and the sign-bit mask folded the pair together, mirroring every mark west and south of the origin onto its north-east counterpart. Not an edge case — the descent ladder's own anchor sits at y = -5,675,959. Fixed by zigzag-encoding coordinates before mixing. 1853 client tests, 0 failed (15 new). Global capture re-verified unchanged after migrating the stipple onto the service. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5eb394b36f |
feat(simulation): relief_q — a local relief signal the deep rungs can resolve (T-1213)
The District and Quarter rungs rendered as flat colour, and the cause was not the biome work everyone assumed. Measured on Ferrath through the production canvas builder: at District the mean |elev_q delta| between neighbouring gridunits is 0.02, and NOT ONE PAIR in a 1290x540 frame differs by 2. elev_q spans 0-100 across the body's whole 8 km elevation range, so ONE STEP IS 80 METRES. A District canvas covers 2,048 m of ground, where the rolling relief a walker navigates by is metres to tens of metres -- a fraction of a single step. The sub-district detail IS generated (invent_primitives' scatter and relief bands compute it) and then rounded away. Confirmed by running the diagnostic with the octave cutoff disabled: still 0.02. relief_q carries that same invented fine component against a scale chosen to resolve it: 0-100 about a flat 50, RELIEF_FULL_SCALE_M = 400 m either side, so 8 m per step -- ten times finer than elev_q. elev_q keeps its body-absolute meaning and the Atlas legend stays true. Measured effect, elev_q vs relief_q (distinct values / mean 4-cell delta): Region 49 / 2.38 -> 101 / 21.86 District 10 / 0.08 -> 35 / 0.35 Quarter 8 / 0.02 -> 19 / 0.06 FIXED metre scale, never per-canvas normalization: the value for a piece of ground must not depend on what else is in frame, or the same hillside changes tone as the viewer pans. And it excludes elev_pct deliberately -- this is the departure from the surrounding land, not height above sea level; including the base would re-introduce the body-scale dominance that makes elev_q unusable down here. 50 at the orbital rungs, which skip invent_primitives by design. Nothing is lost: Global and Region still have varied elev_q (101 and 49 distinct values), and the client takes whichever field carries signal via a max, with no rung-name branching. The client's ruggedness driver changes with it. It was an elev_q GRADIENT, which cannot work across rungs -- the same 4-cell delta reads 21.86 at Region and 0.08 at District, so any single full-scale constant either saturates one or vanishes on the other. relief_q states relief outright, so |relief_q - 50| is the answer directly and a fixed metre scale is immune to that by construction. An absent plane reads FLAT, not zero -- 0 on this field means maximum relief BELOW flat, so a payload without it would have stippled the entire map. That is reachable: the field is #[serde(default)] so old-shape payloads decode. Two colorize tests whose fixtures predate the plane caught it. 2004 server tests, 1838 client tests, 0 failed. clippy clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
566b566519 |
feat(ui): relief and vegetation texture over the terrain hue (T-1194)
RimWorld technique 4 from the reference map: texture as data, not decoration.
WHY IT WAS FLAT. The base layer reads hue from morphology and lightness from
elev_q. Below Global, morphology resolves to exactly ONE zone per canvas, so
the frame became a single colour whose only variation was a lightness ramp too
subtle to see. Measured on Ferrath at Region: 1 morphology zone, but 49
distinct elev_q values. The information was already on the wire and arriving —
the renderer was discarding it by expressing it in lightness alone.
TWO MARKS, NOT ONE SLIDER — the ticket's design question (b), settled by
looking at the reference rather than reasoning about it:
- RELIEF stipple: fine, dense, darker, keyed to RUGGEDNESS not height. The
reference's high flat plains carry none while its ranges are dense with it,
so the driver is the local elev_q gradient; a high plateau stays clean.
- VEGETATION blotch: coarser, softer, marked on a half-frequency lattice so
it reads as patches rather than a second speckle at the same pitch.
Inline in the existing per-cell loop (question (a)) and always-on, base layer
only (question (c)). The TMP/MST/VEG toggles are ANALYTIC reads — stippling a
temperature ramp would corrupt the quantity being read.
THE BASELINE IS MEASURED, NOT GUESSED, and the first attempt got it wrong: a
1-cell ruggedness delta samples mostly quantization noise, reads
near-identically everywhere, and rendered as uniform static over flat green —
grain, not structure. The gradient saturates by about 4 cells (Region: d1 1.40,
d4 2.38, d8 2.41, d16 2.51), so the baseline is 4 and the full scale 4.
Verified by capture at native resolution: on Global the stipple now
concentrates on rugged ground and leaves plains clean.
WATER TAKES NEITHER MARK, and gets a flat tone. An earlier version excluded
Lake alone and stippled the entire ocean — the one surface with no relief to
express. Both open-water zones are excluded now.
The ocean also stops shading by elev_q, which is the same argument T-1188
already made for lakes and never applied here: elev_q on a water cell is the
bedrock UNDER the water, not the surface, so shading the sea by it paints
seabed relief nobody can see. Near a coast that bedrock rises steeply and
quantizes hard, which is exactly where it showed — a pale, pixellated, broken
fringe hugging every shore (Jeroen, on the capture). One tone for the sea reads
as water and lets the coastline be the edge.
D-255(e)-legal throughout: texture-space dithering of already-derived per-cell
values, decided per server cell by a hash of its own coordinates and values —
no sample invented between cells, identical on cache hit and miss.
Two colorize tests updated: both asserted the old ocean shading incidentally
while testing zero-fill/no-crash. Property under test unchanged.
1838 client tests, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8eb5cb9ff7 |
feat(ui): the Atlas ladder bottoms out at Quarter (D-255, T-1213)
Quarter becomes the deepest navigable rung. Block and Chunk leave the ladder. The rule, from the amendment: the deepest Atlas rung is the one at which a screen pixel shows one subtile. At the uniform 2x2 px display ratio a 3440x1440 window gives 540 gridunits on the short axis, so Quarter's 512 m extent draws 0.948 m per gridunit -- about one voxel per gridunit and one 0.5 m subtile per pixel. Block (0.237) and Chunk (0.119) magnify beneath the finest datum that can exist, and measured as exactly that on Ferrath: one morphology zone, one vegetation class, an unbroken colour field. They were not missing a feature; there was nothing left to show them. CHUNK ITSELF IS UNTOUCHED. It remains D-243's 64 m stream/derive unit and is where Phase 5 derives first-person walkable content -- D-012's load-around-the-player is expressed in chunks. Block remains the 128 m generator planning unit. Both keep their enum variants, their extent_m answers and their wire vocabulary. What was retired is the claim that a MAP of one is worth looking at. DEEP_RUNGS moved with the floor, and this is the part worth reading twice. It is a mandatory D-255(d) hardening: a per-body retention cap bounding how much ground an exhaustive pan can hold resident at fine spacing. Left as [Block, Chunk] it would have guarded rungs no client can request -- dead code -- while the accumulation gap silently re-opened under Quarter, now the finest navigable rung at ~0.95 m per gridunit. It is now [District, Quarter]. A control that names its targets by rung has to follow the ladder when the ladder moves. Six tests pinned the old floor and were updated rather than deleted, since each was protecting a real property: the clamp tests now clamp at Quarter, and the disk-cache tests use Region for "shallow" (District is capped now) and Quarter for "deep". One new test pins the distinction the change turns on -- the retired rungs are absent from RUNG_LADDER but still present in RUNG_EXTENT_M, because viewability was retired, not vocabulary. Also removes the four capture scenarios for the retired rungs, including the two blank goldens that had been passing against blank captures. 1838 client tests, 0 failed. Server clippy clean, step_canvas suite green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4e503c3565 |
fix(simulation): a river course was one D8 hop, not a river (T-1237)
D-261 asked for contiguous river strokes, and the client got them by chaining hops back together after the fact. That could not work: each hop was warped independently, so a shared confluence point arrived as two points that no longer coincided -- 375 hops rejoined into 260 pieces, and Ferrath's Global map showed scratches rather than watercourses. The join belongs before invention, so it now happens on the server. river_course::build_paths walks the D8 cell graph into whole rivers from headwater to mouth, edge-drain, or junction with an already-walked river (including the joint cell, so a tributary visibly meets its trunk). step_canvas emits one course per river instead of one per cell, which also drops the per-hop warp and resampling -- a path's shape is the terrain's, so there is nothing left to invent. It is cheaper too: one point per river cell rather than three. The client's _chain_runs() and its endpoint index are deleted. Runs survive only for the reason D-261 gives them -- water splits a course, and a river crossing a lake is genuinely two strokes that must not be rejoined. Pinned by a real-terrain test on GJ380c rather than a synthetic graph, because the bug was caught by eye on real terrain: a lake must have an outflow that runs to sea level, and no such line existed. It asserts the property the eye was checking -- rivers are long, at least one reaches the sea, and every path is a contiguous walk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
474ab90663 |
feat(ui): rivers as cartographic strokes (D-261, T-1237)
Rivers now appear on the whole-body map for the first time. Five on Ferrath's
Global canvas, drawn as 5 px strokes that stop at the coastline.
Four rules, all client-side over existing server data, computed once on canvas
adoption rather than per draw:
- fixed 5 px screen-space stroke at every rung
- contiguous geometry through the river's own cells
- never drawn over water — ocean and lake end a run
- culled below 15 px of on-screen length (3x the stroke: below that a line
is a square, not a river)
TWO THINGS THE MEASUREMENT FOUND THAT THE RECORD DID NOT ANTICIPATE.
First, the cull unit was wrong. A server "course" is an EDGE of the river
network — the stretch between two confluences — not a river. Culling per
course culls per segment, so a long river assembled from many short edges
vanishes entirely. Measured on Ferrath Global: 375 courses, 180 surviving the
water clip, and ZERO surviving a per-course cull. Edges are now chained
end-to-end into rivers before the cull is applied, which also delivers the
other half of D-261's "contiguous": per-course contiguity only makes each edge
unbroken; joining is what makes a river read as one line rather than dashes.
After chaining, 5 rivers survive at Global — the "major systems only from
orbit" behaviour the record predicted, arrived at by a different route.
Second, and worse: uses_orbital_derive() still read `Global | Region` while
the client's mirror had said Global-only since 2026-07-26. The D-255 amendment
claims "Region left the orbital derive set... it now takes the full
courses-aware derive". That was implemented against the MIRROR and never
against the authority, so Region kept running envelope-only and carrying no
courses — the exact thing the amendment said it had stopped doing. Both test
suites stayed green for two days because neither compares itself to the other.
Fixed here, with a note on each side pointing at the other, since the two
cannot be cross-checked automatically.
Also removes the two gates that withheld courses from the orbital rung — the
reason the whole-body map had no rivers at all. Whether a course is worth
drawing is measured in screen pixels, which only the client knows, so the
server now supplies geometry at every rung and the client decides.
The capture harness reports "drawn" alongside "courses", because "375 courses
arrived" and "375 rivers are drawn" are different claims and conflating them
is what made an empty map look like a data problem.
Client suite 1836 / 1810 passed / 26 skipped. Server suite green.
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
|
|
dac64a8a40 |
fix(client): the Atlas map scrolled itself off the world
Jeroen: 'it scrolls out of screen automatically.' It did, with no input. _gui_input only fires while the pointer is over the Control, so _last_mouse_pos freezes at wherever it was last seen. Leaving the map ALWAYS means crossing an edge, so the frozen value is always inside the 24px edge margin — and the viewer went on believing the cursor was held there, panning forever. Moving the mouse elsewhere could not stop it, because 'elsewhere' generates no events this Control ever hears. NOTIFICATION_MOUSE_EXIT now resets to the same (-1,-1) sentinel the field is born with, making 'pointer is not over the map' and 'pointer has never been over the map' the same state. Neither should scroll, and there was already a test asserting the second case — the first had no equivalent. Considered and rejected: reading get_local_mouse_position() live instead of caching. It is arguably cleaner, but it cannot be injected in a headless test, so it would have traded a bug for the inability to prove the fix — and the existing edge-scroll suite drives _last_mouse_pos directly. This also explains the drift I had blamed on the capture harness (T-1236): same defect, and the harness was simply exercising it faithfully. Client suite 1833 / 1807 passed / 0 failed / 26 skipped. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
144d35d2a5 |
fix(client): size Global's cell count so the display ratio divides exactly
Jeroen: 'still not filling the screen... maybe we should calculate the depth that needs to be invented based on the canvas size somehow.' That is the fix. The old flow picked cells from the viewport, let the SERVER impose Global's 2:1 aspect, then fitted the returned extent into the drawable area with an INTEGER pixels-per-gridunit ratio. When that extent did not divide the area evenly, floor() dropped a whole step — and at ratio 2 the only step below is 1, i.e. half size. Worse, because the client fitted an extent it had not chosen, the result could fill NEITHER axis. Inverted: global_fill_extent() chooses the cell count FROM the ratio, already shaped 2:1, so cells * RATIO is the drawn size by construction and lands exactly on the binding axis. Verified across window shapes — 1920x1080 fills width, 2560x1080 fills height, 2560x1440 width, 3440x1440 height, 1280x720 width. Exactly one axis fills at every shape, which is the most a 2:1 equirectangular canvas can do in an arbitrary viewport; the other letterboxes. Global keeps its 2:1 aspect because it is 360 degrees of longitude by 180 of latitude — the aspect cannot follow the viewport without shearing the map. Tests pin the invariant directly: 2:1 preserved, never overflowing the drawable area, and never leaving slack on BOTH axes. Client suite 1832 / 1806 passed / 0 failed / 26 skipped. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
d36d458b74 |
fix(client): Global drew at half size — request and fit disagreed about the legend
Jeroen: 'that does not fit the viewport'. Correct — Global rendered a real map into roughly a quarter of the available area. _letterbox_scale_for() reserves LEGEND_COLUMN_PX before computing the Global fit, but _request_extent() sized the request against the FULL Control width. Because that fit is an INTEGER pixels-per-gridunit ratio, the disagreement does not degrade gracefully: at a 1920-wide window we asked for 960 gridunits but could only fit floor(1628/960) = 1 px each, so the canvas drew at HALF the intended scale with room to spare on every side. The request is now sized to the drawable area, so both sides agree: 814 gridunits at 2 px = 1628 px, plus the 292 px legend column = exactly 1920. Verified through the capture harness — canvas_scale went 0.5 -> 1.0. Only Global reserves the column, so only Global adjusts; the fixed rungs are untouched. Also corrects the regression test I wrote yesterday, which computed its expectation from the full viewport and so encoded the bug. Client suite 1830 / 1804 passed / 0 failed / 26 skipped. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
bdea719530 |
fix(simulation): Global was a 2x1 canvas — a sentinel outlived the extent inversion
Jeroen's Global map has been two coloured blocks all along. Not missing
hydrology, not a missing layer: serve_step_canvas_request zeroed Global's wire
extent, so every request arrived downstream as (0,0), clamped to (1,1), and
resolved to a 2x1 canvas.
That sentinel was correct when Global's size came from the body's region grid
and the client's extent field was meaningless. The D-255 extent inversion made
Global viewport-sized and this line silently outlived it. The commit titled
"size the Global rung to the viewport" was therefore correct and completely
unreachable — its tests passed by calling resolve_canvas_extent directly
rather than through the serve path, i.e. they tested the function that changed
instead of the path the data takes.
Two more places carried the same dead premise, both meaning the first canvas
ever built answered every later request and a resize could never take effect:
- GlobalTierCache keyed on body id alone. Now treats a size mismatch as a
miss, so the re-derive replaces it. Deliberately still ONE entry per body
rather than one per size: keying by size would make a tier that never
evicts accumulate an entry per viewport a player has ever used.
- The client's make_key collapsed Global's extent to a sentinel. Centre
stays collapsed — Global's canvas really is whole-body and origin-anchored
— but extent is now part of the key.
project.yaml 0.4.5 forces the 2x1 canvases already on disk to miss.
Verified through the capture harness, not by reasoning: server probe shows
req=(960,540) radius=6238.4 resolved=960x480, and Ferrath's Global now renders
continents, oceans, inland lakes and polar ice where it previously rendered
one solid rectangle.
Server suite green (45 binaries), client 1830 total / 1804 passed / 26 skipped.
Pair session with Jeroen, 2026-07-27.
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
|
|
1c45cd2ec8 |
fix(client): Global rung derived its canvas from a pre-layout viewport
Eyeballed on Lendel: the Atlas opened on a Global map that was literally two cells — one green, one blue — stretched across the window, reporting 19,598.512 km/gridunit, which is exactly half the body's circumference. Two bugs, both of which the D-255 extent inversion turned from harmless into fatal. enter() fires its first request BEFORE this Control is laid out, and a not-yet-laid-out size is not always exactly Vector2.ZERO — a few stray pixels sailed past the `== Vector2.ZERO` guard, so the viewer asked for a 2x2 gridunit canvas and the server's 2:1 fit floored it to 2x1. That never mattered while Global discarded the requested extent and took its cell counts from the body's region grid; the moment the request became the canvas size, a transient layout artefact became the map. Any viewport below a plausible panel size is now treated as not-laid-out. And Global was excluded from the refetch settle entirely, so a canvas born at the wrong size could never heal however the window was resized. That exclusion was correct when no viewport could change Global's extent. Global now takes the SIZE refit like every other rung, but still never the pan re-float — its canvas is whole-body and origin-anchored, and the server ignores `center` for it. Both have regression tests. The second asserts on _world_center rather than _view_offset, because _recompute_canvas_transform() legitimately re-centres the offset on any canvas adoption and would have made the test pass for the wrong reason. Worth noting for the class: no test written today could have caught this. Every one supplies an explicit viewport. The bug lived entirely in the gap between "scene loads" and "layout completes" — a seam a live launch exercises and a unit test does not. Also stages governance/README.md's pql-maintained record index (D-258). Pair session with Jeroen, 2026-07-26. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
9c8fcc2f95 |
feat(client): size the Global rung to the viewport, not the region grid
Global took its cell counts from `global_cell_counts()` — one gridunit per region — and discarded the requested extent entirely. On GJ380c that produced a 191x95 canvas built from a heightmap stored at 512x256: roughly seven times the available cells thrown away before anything was drawn. The count also shrank as REGION_M grew, so tuning the scale ladder silently degraded the opener, which is why the top of the ladder got worse rather than better as the ladder itself was refined. Global now fits the largest 2:1 canvas inside the requested extent. It cannot take its ASPECT from the viewport — the canvas is equirectangular whole-body, 360 degrees of longitude by 180 of latitude, and must stay 2:1 or the cells stop being square and the map shears — so the existing letterbox absorbs the remainder. A hostile extent is still clamped; sizing to the request is not trusting the request. Global also joins the deep display ratio, making the band uniform. At 5 px per gridunit a 1920 px window asked for 384 cells across a body whose heightmap holds 512x256 — discarding stored detail to save work already done. At 2 px it asks for 960, which is heightmap-native: nothing thrown away, nothing invented, and the same screen area filled either way. A body with no radius is not a sphere (asteroid belt, oort cloud) and has no equirectangular surface to fit. Those degrade to the region grid — a visibly degenerate 1x1 canvas — rather than a plausible-looking lie at whatever size the viewport happened to ask for. project.yaml 0.4.3 -> 0.4.4 invalidates the persisted step-canvas disk cache. District/Quarter/Block/Chunk kept identical 960x540 cell counts through the extent inversion, so their cache keys are byte-identical while a District canvas now covers 3.6 km of ground instead of 1,966 km — a warm cache would silently serve pre-inversion canvases. Global still rides the orbital derive, so it carries no courses yet; that is the next step and is deliberately separate, being a cost question over the whole body rather than a sizing one. Pair session with Jeroen, 2026-07-26. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
0a0419abcc |
feat(client): invert the Atlas rung relation — rung sets extent, not spacing
A rung used to fix the gridunit SPACING, with the canvas extent falling out of spacing x cell count. That is why the top of the ladder was unusable: at REGION_M spacing a viewport-sized canvas spanned ~251,658 km — six times around a rocky body — so the Region rung capped to the body and redrew the Global picture pixel-for-pixel. "Global and region look the same" was not a rendering bug; it was this relation, stated in metres. Inverted: a rung fixes the EXTENT and the spacing falls out of the canvas size. The shorter viewport axis spans exactly one cell of the rung's level, so a widescreen window shows more ground on the long axis rather than less on the short one. Every rung now shows the ground its name promises — Region 262x466 km, District 4.1x7.3 km — and the canvas cell count is viewport-driven and identical at every rung, so derive cost no longer varies with depth and resize is free. Consequences that fell out of the inversion rather than being chosen: - Region leaves the orbital derive set. It was envelope-only because at 251,658 km nothing finer made sense; at 262 km it is a genuine provincial map and takes the full courses-aware derive. Region having no rivers at all was much of why the top of the ladder read flat. It also joins the deep display ratio for the same reason. - The S2 station-spacing floor is deleted, not retuned. It guarded an O(1/spacing) blowup that the inversion makes structurally impossible (the canvas cell count is now constant across rungs, so stations-per-course is bounded however deep you scroll). Kept, it would do active harm in the opposite direction: a 2,048 m pitch across a 3.6 km District canvas places two stations and draws every river as a straight line. Station placement gets its own generator pass. - cap_extent_to_body is superseded and now a documented no-op. A canvas can no longer over-request a body by construction. The residual question — whether a rung's cell exceeds the whole body — is liveness, not capping, and is_rung_live_on_body() answers it by omitting the rung. Empirically it never fires on inhabited content: all six rungs are live on all 271 populated bodies with a radius. - snap_to_gridunit no longer truncates its multiplier to int. Post-inversion the deep rungs run sub-metre (Chunk ~0.12 m at a 1080 px short axis), where int(spacing) floors to zero and would collapse every request centre onto the origin. Both sides derive spacing from the same three inputs (rung, echoed cell extent, body radius) rather than one telling the other, so there is nothing to keep in sync beyond the constant table itself. Body radius already reaches the viewer via enter(); no wire change. Pair session with Jeroen, 2026-07-26. D-243/D-255 amendments to be backfiled. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
36482de5d6 |
fix(ui): Atlas fetch readout, and one settle timer for resize and pan
Two viewer fixes from the same pair session; they share step_canvas_viewer.gd so they land together. FETCH READOUT. A cold derive takes seconds and the map gave no honest sign of it. Root cause found while building the replacement: a Control paints its own _draw() BEFORE its children, so everything the viewer drew itself — the old DERIVING TERRAIN label AND the pending wash — was painted UNDER the terrain canvas, visible only when no texture existed at all, i.e. never in the slow-fetch case they existed for. That has been the state since the stepped viewer shipped. The readout now lives in its own overlay node added after the canvas: a centered implant-idiom panel, DOWNLOADING MAP DATA, indeterminate sweep, 250 ms grace so cache hits never flash it, held canvas still drawing beneath. Indeterminate by design — the server reports no derive sub-steps, and a progress fraction we cannot source would be invented. ONE SETTLE TIMER, THREE TRIGGERS. The viewer never re-requested a canvas on resize, so one derived for a smaller window letterboxed forever in a bigger one — the map not filling the frame. And the pan-edge refetch fired from inside the per-frame pan loop the instant its threshold was crossed, so a held edge-scroll issued a fresh request AND snapped the view on every frame past it. Both are the same event: the user is still moving. A shared one-shot timer now collapses them into a single request at the resting state, with a hard pan threshold that still fires immediately when the canvas edge is about to enter view (waiting there would show empty background), and drifting back inside the soft threshold cancels the pending request. Resize reaches this Control identically whether the OS window or a diegetic in-implant parent changed, so both sources are covered. Two new tests pin the soft path (schedules, does not refloat or snap) and its cancellation; the pre-existing threshold test was verified to still discriminate — its drift trips the new hard threshold — rather than passing vacuously. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
ae03ea2de7 |
perf(ui): Atlas deep rungs draw one gridunit per 2x2 px block
DISPLAY_RATIO_DEEP 1.0 -> 2.0, so a viewport-fit request at District/ Quarter/Block/Chunk asks for ~4x fewer gridunits (1920x1080: ~2.07M cells -> ~518K) and the server-side derive cost falls with it. Texel- exactness is preserved — the ratio stays a whole number of screen px per gridunit, so every source texel still lands on whole pixels; only the block size changes. Non-integer ratios are not an option here: they reintroduce exactly the sub-pixel blur D-255's texel-exactness exists to prevent. Judged live by Jeroen against the 1x1 build (pair session): the deep rungs read as crisp larger pixels rather than blur, and the speedup is substantial. Looks were the gate. Both transport tests that pinned the old literal now assert the RELATIONSHIP instead — deep rungs share the constant, viewport-fit divides by it — plus a new guard that the ratio stays whole, so the value remains tunable without editing tests that are not about it. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
a4d9a4fa09 |
fix(ui): PR #217 review fixes — derived legend offset, 960x540 goldens, non-overlap tests
The legend's reposition() now derives its Y from the header panel's MEASURED bottom plus a named 12px gap (new accessors on the viewer; deferred recompute so it reads settled layout, refreshed on header content change) — the old hardcoded 60.0 sat 17px inside the wrapped header's real 77px bottom, fusing the panels. Pixel-proven at the evidence center: bottom=77.0, legend top=89.0, gap=12.0 exact. All 13 goldens regenerated at the DOCUMENTED 960x540 (the 1280x720 rider was my own CLI flag, not a config; PIL-verified) with two-run byte stability. Two structural tests pin the header/legend relationship (T-1192 precedent), proven failing-first against the old constant (-17.0px reported), and the legacy test that pinned the literal 60.0 now asserts the derived relationship so it cannot re-enforce drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
41144f317f |
fix(ui): Atlas header gets its implant panel scrim — legible on any terrain (T-1197)
Root cause was not the suspected fade race — no animation exists anywhere under client/ui/implant (grep-proven, and settle+2 vs settle+90 captures were byte-identical). _build_screen_header() was the single ImplantHeader call site in the codebase that added the header bare instead of through ImplantPanel.add_component(), so its fixed light-gray text washed out against pale terrain (Quarter upland scatter, District olive) while reading fine on dark ocean. Wrapped in an ImplantPanel like every sibling — the theme's panel_bg scrim makes it legible everywhere. One committed golden (atlas_GJ820Bc_District) already carried the ghosting baked in, confirming the bug was static; all 13 goldens regenerated per the header change, two independent live-server runs byte-identical (T-1157 stability discipline). Verified live at the original evidence center: header crisp at settle+2/+30/+90. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9ac98542d1 |
fix(client): isolate viewer tests from the machine-shared atlas disk cache
user://atlas_cache/ is one directory for every worktree gate run, live capture driver, and real play session, and T-1183's Tier-2/3 lookup short-circuits BEFORE test_mode's silent-no-op IPC — so a warm shared cache delivers real canvases into tests written against 'nothing ever arrives'. Caught live: a concurrent GJ380c Global capture flipped the two before-any-canvas viewer tests in another worktree's push gate. Adds disk_cache_root_override on StepCanvasViewer (threads into StepCanvasRequest's existing test-injection-only seam; production leaves it empty) and routes every direct viewer construction in the viewer + legend suites through a _make_viewer() helper pinning an isolated root. First slice of T-1193's viewer test seam; screen-built viewers still share the default root (noted on T-1193). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
52304d3e37 |
fix(ui): PR #209 review round — current-screen guards, pending-aware settle, one body guard (T-971)
Every screen-targeted intent now routes through one _require_current_screen() check and returns the structured error shape instead of silently mutating an off-screen viewer (hoshe's finding: scroll_rung from the reach screen fired real IPC and reported ok). The reference driver's fixed 4-frame settle becomes is_pending()-aware with a 600-frame bound, the keep-waiting decision extracted as a pure testable function — restoring the proven eyeball-driver discipline. The terrain_reference guard moves into AtlasApp._on_body_selected(), the shared tail for double-click, Enter, AND the intent path — closing a pre-existing click/Enter divergence hoshe caught this PR formalizing; the intent layer pre-checks via the new SystemScreen.find_body() and reports structured errors for unknown ids and terrain-less bodies. after_test() resets AtlasAgentBridge.current_app (tyre's freed-pending footgun). Suites 58/58 + 14/14; full suite 3,638. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
34dbac9bf6 |
feat(ui): AtlasAgentInterface — observe/act named-intent control channel (D-226, T-971)
D-226 layer 4, rebuilt against the post-D-255 stepped Atlas after the Phase-1 reconciliation (the original intent list targeted the retired continuous-zoom viewer). Eleven intents, each backed by the exact production handler a click calls — select/open for systems and bodies (extracted shared by-id tails so click and intent paths are one code path), scroll_rung, reset_view, back, open/close_atlas, set_overlay — plus two new first-class capabilities: jump_to_center (the fixed-center revisit pattern proven by five eyeball drivers, via a new StepCanvasViewer.jump_to seam that reuses _scroll_rung's exact request tail — same extent cap, same cache keys) and get_current_canvas_summary (allocation-light reads off the raw wire dict, courses-by-class, draw-matched settlement dedup — no PNG decode). Contract shape: dumb AtlasAgentBridge autoload holding the app handle (untyped per the parse-order rule), all logic in the static AtlasAgentInterface class. observe() is side-effect-free: current state + a generic Control-walk affordance tree. In-process consumers only this ticket (documented); the committed reference driver (atlas_agent_driver.gd, InputSwallower + settle-until-ready from the T-1157 inventory) replaces the scratch eyeball drivers as the sanctioned headless-drive pattern. select_city/open_regional dropped with recorded rationale (no settlement hit-test affordance exists post-D-255) — diff on the ticket. 33 new tests across three suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
497f9dc94d |
Merge remote-tracking branch 'origin/main' into atlas-feature-names
# Conflicts: # CHANGELOG.md |
||
|
|
8da9670e0f |
feat(simulation): feature-name pipeline wired + legacy window_granularity u32 retired (T-1169, T-1159)
One commit for two tickets whose changes share the bridge/plugin plumbing files. T-1169 connects the three dormant feature-name pieces: atlas_feature_names populated at regen (17,891 rows — 15,190 mountain, 2,701 river — via populate_atlas_feature_names mirroring the city-names importer; systems.db regenerated, stamp fresh), attach_feature_names wired into the cascade's Topography block with name pools threaded DB-free through AnalyzeBody (D-225 pattern) and assignments stored on Layer1Output/BodyWorldState for future consumers, and a FeatureNamesRequest/Response read proxy as the bridge's 7th tagged envelope (D-236 pattern, both SimBridge impls). Client label DRAW is deliberately NOT here — implementation proved both river and mountain labels need a wire-carried position (the pool is position-free; course polylines aren't correlated with the named attractors by construction) — deferred to T-1195's single design pass. cascade_layer1 golden re-pinned (additive feature_names field). T-1159 retires the legacy u32 granularity field fully shadowed by window_granularity_v2: AtlasLayerRequest.window_granularity, DistrictWindowLayer.granularity echo, the u32::MAX sentinel, and resolve_window_granularity are gone server-side; client encode paths and the caller-less atlas_window_cache legacy key component dropped; msgpack fixtures regenerated; the T-1150 aliasing regression test now drives through the surviving enum field. The district_window carrier itself survives byte-compatible per D-255(c). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
686021bee8 |
fix(ui): PR #207 review round — crop-gated taper, true mitres, hybrid AA (T-1175)
Five findings from the araminta+hoshe round. Tapering now fires only on a TRUE upstream source: the course's first raw world point is tested against the canvas's own world bounds (conservative 1m epsilon) — exact detection because the server crop keeps one point beyond the window (layer_proxy crop_course_to_window lo = first_in-1, contract documented), so crop passthroughs draw the old flat full-width cut and never a false headwater. The averaged-normal joint is replaced by a real mitre (half_w/cos(theta/2) recovered trig-free via the bisector normal), clamped by a 2x mitre limit AND 0.45x the shorter adjacent segment — restoring true perpendicular width at bends (the 29% pinch at confluences is gone) and preventing the hairpin bowtie; the winding doc now states the actual bounded guarantee. Antialiasing restored via the hybrid: only the varying-width taper head draws as a ribbon; the constant-width ~85% of every course keeps the original antialiased draw_polyline (byte-identical for untapered courses), split at an interpolated arc-length point sharing position and width — junction capture evidence in .cache/screenshots/t1175-fix-round/. Flat-fill single-element color array. Suite 24 -> 48 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
40cf89c3cb |
feat(ui): river source tapering + width-grammar retune — map fluency pass (T-1175)
The single large polish pass Jeroen requested off the T-1170 captures, benchmarked against RimWorld's world-map fluency. Courses now draw as source-tapered ribbons: per-vertex width ramps from a hairline at the upstream source to full class width over 15% of the course's arc length (arc-length parameterized, not vertex-indexed, so point density doesn't change the read), built as one draw_polygon ribbon with mitred joins. Class width table retuned 0.9/1.4/2.2 -> 0.6/1.2/2.4: a clean ~2x per-class ladder so a tributary-joins-trunk confluence reads as a join, trunk held at its visually-proven weight, stream thinner per the benchmark's thin/consistent/restrained grammar. Opacity untouched (Araminta's T-1170 ruling stands). Coast-gradient item resolved as already-correct (the coastal transition zones + elevation lightness render the shoreline band; capture-verified) — no wire change. Stipple assessment filed as T-1194. Eight new taper-geometry tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
324a318b17 |
Merge remote-tracking branch 'origin/main' into atlas-latitude-fix
# Conflicts: # CHANGELOG.md |
||
|
|
374b195594 |
fix(ui): PR #205 review round — integer px-per-gridunit fit replaces fractional (T-1189, T-1192)
The fractional fit branch is deleted, resolving both review findings at the root: tyre showed its comments cited D-255 for an exception the record does not contain (the language came from the lead's ticket text, not governance), and hoshe showed it returned sub-1x for a canvas exceeding the viewport on one axis. Replacement: fit_scale_ratio() chooses the largest integer pixels-per-gridunit R fitting both legend-reserved axes, floored at 1 (over-viewport draws native and crops like every fixed rung) — the fine-grained integer lattice (GJ1c 1080p -> 9px/gu = 1593x792, ~98% width; 4K -> 20) that makes the fractional hatch unnecessary. center_offset() floors to whole pixels (half-pixel centering would blur the texel grid). Doc comments cite the real sanction (D-255 amendment 2026-07-25, this branch). Legend column constant is now canonical in transport, read directly by the legend (was an independently-typed literal); its test asserts real geometry. New regression test proves _global_body_extent clears on body switch and never caps another body's requests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dec5b0bc43 |
feat(simulation): lake_margin_q depth band — lake shorelines gain gradient vocabulary (T-1188)
Lake edges rendered as hard step-edges while ocean coasts got multi-tone transition bands: every coastal-transition morphology gate keys on ocean_fraction_q, definitionally 0 inside a lake basin (hypothesis (b) of the ticket; (a) disproven first — a shoreline-crossing sweep at 2048/512/128m plus a 10m fine sweep all land on the same continuous crossing, so positional refinement was never broken). New DistrictProfile.lake_margin_q (0-100 settled-hydrology depth band, from the same bilinear filled/elevation pair the lake test already samples; ceiling calibrated just above the observed p90 depth on GJ338Bd's 5,043 flooded cells), threaded through both derive paths onto EncodedStepCanvas (serde-default for shape tolerance) and down the client: protocol decode, terrain-layer plane, colorize shades Lake cells by depth band instead of elev_q (bedrock-under-water, the wrong signal). project.yaml 0.4.0 -> 0.4.1: the new wire field must invalidate the client disk cache via its version tag (T-1183's D-192 mechanism). Acceptance gates green with the new field (lossless round-trip, cache-hit==cache-miss, every rung). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c1a97166c5 |
feat(ui): cap rung extents to the body + fit/center the Global canvas (T-1189, T-1192)
The two client fixes for the Atlas frames Jeroen flagged. Region rung no longer requests more planet than exists: cap_extent_to_body() caps the viewport-fit extent at the body's own region grid (the Global canvas extent echo — cols=regions_per_equator, rows=cols/2), matched by gridunit SPACING not rung name, applied before the request/cache key is built; cold-start scroll-before-Global-echo requests uncapped and lets the server clamp (documented). Kills both the side-by-side continent repeat and the past-the-pole stripe smear. Global opener now fit-scales and centers through one shared letterbox mechanism (center_offset/integer_fit_scale/fit_scale, 75%-coverage integer-vs-fractional decision, NEAREST already forced on orbital rungs per D-255's escape hatch) also used for the Region cap's letterbox remainder. Legend column reserved before fitting — pinned to the legend's own width by a cross-constant test, never overlapping the canvas. Also fixes a latent crash: NOTIFICATION_RESIZED fires mid-_ready() before children exist; null guard in the resize path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
51fe35d00d |
fix(ui): harden step-canvas disk cache — PR #204 review round (T-1183)
Atomic index writes (tmp+rename), orphan-payload reconciliation sweep folded into the background sweep, payload shape guard mirroring the protocol's own width/height discriminator, size_bytes via get_position() instead of a full payload re-read, and a 64-bit SHA-256 payload filename (String.hash()'s 31-bit space made a silent wrong-map filename collision a ~1-in-16k event per cap-full body; migration self-heals via the orphan sweep). Tier-3 class doc rewritten to name the real D-253 seam — the glaciation/flooded_q wire fields exist but carry static values and no staleness signal crosses the wire; T-1190 tracks threading a wire TTL into put(sim_ttl_sec) when the driving clock lands — replacing the false 'no sim-state wire field exists' premise. Four new tests plus a _stub_canvas fixture helper centralizing the canvas shape contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6b76e365bb |
feat(ui): client disk cache — FileAccess tiers beneath the LRU (D-255, T-1183)
step_canvas_disk_cache.gd: the Tier 2/3 store per D-255(d) and the round-2 three-tier spec. Payload is the WIRE, pre-decode — store_var/ get_var round-trips the PNG-encoded PackedByteArrays natively, and Image.load_png_from_buffer never runs in this file. One composite key shared verbatim with Tier 1 (hash filenames for filesystem safety); per-body index.json with malformed-index recovery (rebuild-or-discard, never crash). Three independent eviction mechanisms, exactly as ruled: rung-0 Global carries a retention floor no sweep touches (now also threaded into Tier 1 per the T-1182 handoff); Tier 2 geometry is byte-valid forever and evicts only by time-since-last-visit (14d starting tunable, on body-open) and LRU byte budget (256 MiB/body, 5-min coarse timer) — two separate sweeps; Tier 3 sim-state TTL is wired and tested but has no production caller yet (no sim-state field exists on EncodedStepCanvas — the D-253 stub inheritance, documented). Hardening per D-255(d), both mandatory: per-body deep-rung cap (512 Block+Chunk entries, enforced synchronously in put(), floor- and budget-independent — the ticket sanctions count-or-quota; count chosen as the direct D-226(d) information-content proxy) and a schema/version tag on every entry (project.yaml version via the existing loading_screen line-scan idiom — D-192 co-ship makes the client version the wire-schema version; exact-inequality mismatch = miss + drop, NEVER decode, checked in both has() and get_canvas()). Integration: request_now() checks Tier 2 on a Tier-1 miss (synchronous promote), Ready responses write through to both tiers, Pending never writes; viewer runs the visit sweep on body-open + the background sweep on a 5-min timer. 30 new disk-cache tests + 7 request-integration + 3 sweep-wiring tests (restart persistence, sweep independence both directions, cap semantics, version-mismatch never-decode, corrupt-index recovery). Full client suite 3,440/3,440, 0 orphans; cold-parse clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
76d82a6a51 |
fix(ui): PR #203 review round — total retirement + bin decode + wired reset
Tyre finding: the orphaned AtlasViewer cluster is now actually deleted (atlas_viewer, atlas_marker_overlay, atlas_descend_geometry, atlas_legend_panel — a sixth orphan found beyond the review list — atlas_generation_proxy, atlas_generation_state; ~2,497 lines), with reachability re-verified across preload/class_name/res:// strings, every .tscn, and the standalone companion app. Test suites triaged, not blanket-deleted: 5 pure AtlasOverlayColors tests relocated into test_atlas_window_colors, the live type-identity regression guard relocated into test_step_canvas_viewer, dead coverage deleted. A real harness gap surfaced during diligence and RULED, not patched: visual_scenarios/visual_capture golden shots call retired continuous-zoom API — no shim (would resurrect what D-255 kills); inventory recorded on re-scoped T-1157 (gate-invisible, manual targets only). Hoshe finding 1: decode_png_field now detects the [Error, PackedByteArray] bin-shape from messagepack.gd explicitly — a genuine msgpack bin payload decodes correctly instead of silently collapsing to [0,0]; test built from a real round-tripped bin decode. Hoshe finding 2: the hard zoom-out reset is wired — ascend at rung 0 with a drifted view triggers _reset_to_global (the restored HARD condition), behavioral tests through the real input path. Notes folded: refloat + edge-scroll test coverage, legend smoke suite, Vector2i narrowing-safety comment with computed headroom. gdlint clean on touched files; full client suite 3364/3364. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d28d24fd26 |
feat(ui): step-canvas map component — RTT terrain + stepped zoom (D-255, T-1182)
The two-layer client rebuild per D-255(a)(b)(e), replacing the _canvas.scale continuous-zoom model with one viewer, one path, all six rungs: - step_canvas_protocol.gd: StepCanvasRequest/Response codec against the T-1181 wire contract — incl. the discovered png_bytes subtlety (rmp_serde without serde_bytes emits a msgpack int-array, not bin; decode repacks via PackedByteArray before load_png_from_buffer) and the extent-echo rule (read the server-clamped extent, never assume the requested one). - step_canvas/ component: transport (six-rung ladder, cursor-anchored scroll steps, edge-scroll/WASD pan with re-request on edge crossing, hard reset-to-Global), RTT terrain layer (Image.set_pixel colorize per the c1 measured ruling, texture.update reuse on step-cross, NEAREST coarse / LINEAR fine per rung), unscaled screen-space annotation sibling (courses + settlement markers at literal px), in-memory LRU cache (Tier 1; T-1183 layers the disk tiers beneath), request lifecycle (pending retry, staleness gate, extent echo). - Full _canvas.scale retirement in the same change: the zoom-scaled canvas model, the _zs compensation family, select_rung / MAX_COVERAGE_M / compute_tile_grid, the orbital-mosaic-vs-window two-path split, _view_zoom/_canonical_fit_zoom — 10 source files deleted; their 14 test suites deleted with them (T-1157 dead-goldens rule; replacement visual-capture coverage is re-scoped T-1157). - Surviving surfaces kept per the ticket: atlas_window_cache.gd's LRU shape (the ticket's named file atlas_window_tile_set.gd was the retiring orchestrator; the real LRU shape lives in atlas_window_cache.gd — cited in step_canvas_cache.gd), overlay colors, legend/overlay-bar chrome, AtlasViewer descend geometry. Determinism boundary per D-255(e): the client interpolates only within the closed server-supplied input set. 7 new gdUnit suites (164 cases) incl. a real extent-echo bug caught by its own test during implementation. Full client suite green (exit 0) with the live-gated suites running against a worktree server build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f9ab3088ab |
fix(ui): mouth-ring radius floored at 2x stroke — the radius-smaller-than-stroke draw_arc regime
Two separable findings from the live A/B (real driver): (1) the original zero-ring report was a viewport-framing crop — the ring fired all along at screen (1755,-191), above the frame under the COVER fit; recentering via set_view proved the path live. (2) The real bug once in-frame: at Quarter fit zoom the compensated ring radius (5.0/7.5=0.667 canvas) fell below the floored stroke (1.0 canvas) and draw_arc's stroke filled its own hole — a solid blob, not a ring. Bracket: 1x stroke=blob, 1.5x=hollow recovers, 2x=clean. Fix: zoom_compensated_ring_radius() floors the radius at 2x the paired stroke, wired through _zs_ring_radius() for both ring and halo arcs; verified live producing a clean hollow double-ring. The third member of the Godot sub-canvas-unit rasterizer family (width floor, stroke-vs-width sites, now radius-vs-stroke) — all recorded for the T-1176 render-mechanism discussion. 10 pin tests in the stroke-width suite; revert-verified (floor drop -> named failures); full client suite 3956/3956; gdlint clean. Tickets: T-1170 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
27fab8566a |
fix(ui): stroke widths floored against Godot's line-rasterizer hairline collapse (Araminta's pixel finding)
Her audit of the course captures found a uniform 1px alpha-255 line where trunk should draw 2.2 screen px. Live A/B bracket (20/1.5/1.1/ 0.6 forced widths + draw_line-vs-draw_polyline control) localized the cause: Godot's line rasterizer floors stroke widths below ~1.0 canvas units to hairline — the _zs arithmetic was correct (1.4/3.75=0.373 round-trips exactly); the value died at the driver. Fix: zoom_ compensated_stroke_width() (the _zs divide maxf'd at 1.0) via a _zs_stroke() wrapper on EVERY stroke-width site (course polyline, skeleton chords, mouth-ring arcs, basin boundary, attractor outlines — same latent class everywhere even where not yet visible); radius args proven unaffected and left on _zs. Honest degradation direction documented: at high zoom effective width grows rather than pinning. Class question resolved with printed ground truth: the original window is genuinely single-drawable-class (trunk course degenerate at 1 point); a confluence window confirms real multi-class rendering. The drive now prints per-course class/width/effective-px tables every run. New 14-test stroke-width suite (own file, line-cap split) pins the exact floor-engagement numbers and the PR#195-shape effective- width >= table-value invariant; revert-verified (floor drop -> 4 named failures). Full client suite 3942/3942; gdlint clean. Tickets: T-1170 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c31cc6220e |
feat(ui): T-1170 B3 — course polyline drawing at District/Quarter; clip restructured per Ruling 3g
_draw() splits into two independent gates: the Layer-1-gated skeleton path (Region chords, clip retained) and the NEW DistrictWindowLayer- gated course path — build_course_render_plan() (pure, render-free- testable) consumed by draw_polyline with _zs-compensated widths and opacities from the Araminta revisit tables; mouth double-rings at Mouth termini only (EdgeDrain/ContinuesBeyondWindow/None: three meanings, one presentation — draw to last point, stop, documented); zero water clip on the course path by construction (courses carry rung-consistent termini). CourseTerminus wire vocabulary kept re- pointable pending A2's real serde names; synthetic Ruling-3h fixtures mean the suites need zero changes when the server payload lands. Real gap found and fixed: window arrival never redrew the nature overlay after the first fit (one line in _on_window_ready — courses would miss every window swap post-pan). Water-clip header rewritten to RESTRUCTURED status (retired on course rungs; permanent at Region until Region goes windowed, T-1143 ruling 2). Revert-verified (visibility-gate bypass -> 4 named failures). geometry-nature 112/112, nature-overlay 58/58, viewer 78/78, zero collateral; full sweep 3928/3928 after the full-import bootstrap; gdlint clean (viewer 1016->1017, pre-existing overage rides T-1158). Tickets: T-1170 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9d7c01de02 |
feat(ui): T-1170 B1+B2 — visibility-table split; Region skeleton chords from downstream pointers
B1 (Ruling 5c): nature block split out of atlas_window_geometry.gd (954/1000 cap pressure) into atlas_window_geometry_nature.gd; RIVER_ CLASS_VISIBLE_BY_RUNG replaced by SKELETON_CLASS_VISIBLE_BY_RUNG (Region-only now) + COURSE_CLASS_VISIBLE_BY_RUNG (District trunk+ tributary; Quarter all three — the pre-announced Quarter-rivers- return) with width/opacity companion tables as Araminta's single revisit point; deliberately opposite unknown-tag fallbacks per reader (skeleton->full, course->empty), documented. B2 (Ruling 5a): Region dot-scatter upgraded to connected chords via river_downstream — D8 direction decode (0-7 into drainage.rs's (row,col) delta table, antimeridian wrap-aware), sentinel chain ends (MOUTH=8 ring-on-land, EDGE_DRAIN=9 no ring, TERMINAL=10 reserved, decodes like EDGE_DRAIN so the future endorheic server needs no client change). Pure build_skeleton_chords() split from drawing for render-free testability. Chord clip rule (3g pick): segment clips if either endpoint OR midpoint is drawn water — three-point catches both narrow-inlet and long-chord failure modes at one extra lookup; documented. Self-caught during build: first draft misdecoded the pointer as a river_cells INDEX; rewired to direction decode against A1's real convention before leaving the branch. Dual revert-verified (direction sabotage -> 6 named failures incl. the chain-threading pin; midpoint-drop -> exactly the 1 named clip test). Suites: geometry-nature 86/86, geometry 130/130, nature-overlay 24/24; full sweep 3892 with only the 6 known pre-existing garment/gait failures untouched by this batch; gdlint clean. Tickets: T-1170 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0b14cdb21d |
fix(ui): T-1172 round 2 — tile wrap resolution mirrored to the painter's own direction
Live round: the first clip removed only 133 pixels. Root cause (dossier- traced): resolve_morphology_zone wrapped the QUERY toward a tile's raw canonical center — the inverse of the painter's draw_col = nearest_wrap_image(tile_center, held_center) — so seam tiles tested containment against the wrong wrap-image and read real-but-wrong- location land cells. Fix mirrors the painter exactly: wrap the tile's own center toward held_center, test the (already held-wrapped) query against that. Index math itself was confirmed correct end-to-end and is now factored into shared AtlasWindowGeometry.cell_index_for_local_ offset() (full painter unification not applicable — the painter only iterates forward, never reverse-looks-up; documented). Post-fix: 1082 traced positions cross-checked against actual painted pixels, 0 real mismatches; regenerated captures show every dot/mouth on land. The one pre-existing test encoding the buggy direction as correct was replaced by positive+negative wrap-semantics tests (the negative one is the reliable revert discriminator) plus a wire-accurate seam-tile fixture with an honestly-documented proof limit (a single-tile fixture cannot distinguish the wrap directions; the multi-tile scan tests can). Also: two harness traps found and fixed in the lead's drive scratch — canvas_items stretch factor (root.size now matches the 1920x1080 base viewport) and mid-arrival snapshots (explicit is_fully_arrived wait; the round-1 faint result was partly a 4-of-6-tiles capture). 46/46 + 50/50 + five sibling suites regression-free; gdlint clean. Tickets: T-1172 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9914dc0d91 |
fix(ui): T-1172 — clip river dots/confluences/mouths against the drawn waterline
Jeroen's hands-on report: rivers continuing under the ocean. Tyre's ruling implemented: the skeleton is rung-independent, the drawn coast is rung-indexed (warp cutoff admits more octaves per rung), so reconciliation is a presentation-frame operation — a draw-time clip against the SAME per-cell morphology verdict the terrain painter used, at the rung on screen. New pure module atlas_window_water_clip.gd: cell resolution across both paths (single-window direct; tile mode selects the containing tile by each tile's OWN echoed n with nearest- wrap re-expression against that tile's canonical center — response-is- source-of-truth + wrap discipline reused, not reinvented). Strict drop (no snap); offshore mouths suppressed (return with real termini in T-1170 — retirement markers at the clip sites); basins untouched; fail-open wherever no composite data has arrived (the clip refines presentation, never gates data). The _pos split feeds the SAME wrap- resolved district to both draw position and clip test so they can never disagree about the wrap image. 49 new tests incl. antimeridian and mid-progressive-arrival fail-open; revert-verified with precise attribution (breaking water detection fails exactly the 3 water tests, fail-open/land tests stay green); smoke-suite stub crash under a real driver caught and fixed (headless skip-gating masked it). 7 suites regression-free; gdlint clean. Tickets: T-1172 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8a747e332c |
fix(ui): PR #195 review round — attractor stroke widths zoom-compensated (Tyre I1/I2/I3)
I1: _draw_attractor_shape's three stroke-width args (Confluence arc, Coastal/NaturalHarbor arc, Oasis spokes) were raw screen-space literals — Godot multiplies stroke widths by canvas scale exactly like radii, so at the Region orbital fit zoom the outlines rasterized at ~0.01px, the identical sub-pixel class the dot/ring compensation fixed, missed on glyph internals (and attractors are Region-only — precisely where it bites). Widths now arrive pre-compensated via a px_w param, keeping the primitive pure. Regression pin: a source-scan test asserting no draw_arc/draw_line in the function carries a bare numeric width (the draw-smoke suite documents its own vacuous-pass mode, so source-scan is the environment-independent gate); revert-verified by name. I2: D-226 visibility-direction sentence — Araminta's fade-down inversion recorded as pre-T-1170 with its single revisit point named. I3: class-header call-site claim corrected (enter() funnels through _enter_at_rung). Tickets: T-1156 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0366e8286f |
fix(ui): District trunk dot 2.0px/80% — Araminta's PR #195 capture objection
At 1.6px/60% the trunk dot under the mouth ring read as 'mouth glyph over plain terrain' — invisible without knowing where to look, underselling the ruling's own 'a major river crosses near here' intent. 2.0px/80% preserves the fade-down ladder vs Region's 2.2px/100% without reading as accidentally-erased. Doc comment records the revision provenance. Tickets: T-1156 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
60faf667a5 |
fix(ui): T-1156 live rounds — zoom-compensated marker sizes; toggle-redraw regression pin
Live round 2 (the real bug): every nature-overlay marker size was a raw screen-space constant drawn inside _canvas, whose scale IS view_zoom — at Lendel's orbital fit zoom (0.0063) a 2.2px trunk dot rendered at ~0.014px, invisible; the same code at District's 3.75 zoom produced the correctly-visible mouth ring, which is why one capture worked and the headline rung didn't. Fixed via AtlasWindowGeometry.zoom_compensated_ size() (pure, floor-guarded) wired through every radius/line-width; basin FILL points are positions and correctly stay unscaled. Suspect tile-mode-rung-detection was ruled out live (granularity_v2=Region confirmed in tile mode) but pinned with a named regression test anyway. +8 pure-function tests incl. a numeric pin of the pre-fix magnitude (<0.02px at orbital zoom); revert-verified by name. Draw-smoke suite documented as supplementary (the shared SubViewport background harness can pass vacuously under X11 BadMatch — the pure suite is the gate). Live round 3 (drive-script bug, no product change): the lead's scratch drive passed the button LABEL to set_overlay_visible() and the unknown- id guard silently no-op'd — but the chase banked a real pin: test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_ overlay (draw-counting spy per the cold-start precedent; is_queued_for_ redraw does not exist in this build). Revert-verified. Basins verified live: 7 Lendel watershed boundaries render at the ruling's alphas. Suites: viewer 76/76, geometry-nature 42/42, nature- overlay 22/22, zoom-ladder 50/50, no regressions across the cluster. Tickets: T-1156 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0d22e50e66 |
feat(ui): T-1156 — rivers/basins/attractors re-hosted onto the zoom ladder (nature overlay node)
New AtlasWindowNatureOverlay (Node2D on the viewer canvas, above terrain): self-connects to the shared atlas_layers_received broadcast (the established one-signal-N-consumers shape) and consumes the whole- body layer1 skeleton per Tyre's carrier ruling — no windowed wire touched. Coordinate chain layer1_pixel_to_world_m -> world_m_to_ district -> canvas-local lives in atlas_window_geometry as pure tested functions; vertical convention (row 0 = North pole, wy increases south) verified against the server's own pixel_to_world_m, pinned by pole tests and revert-verified (sign flip fails them by name). Araminta's per-rung presentation table implemented exactly: Region full skeleton (radii 0.9/1.4/2.2, confluence 3.5, mouth double-ring), District trunk-only 1.6px at 60% with mouths at full landmark styling, Quarter off until T-1170 course invention; retired palette reused verbatim; RVR on / BAS off / ATR off defaults; missing river_class falls back to trunk. 58 new tests (34 geometry, 22 lifecycle, 2 real- driver draw smoke actually rendered); 11 existing atlas suites regression-free. atlas_window_viewer.gd runs 16 lines past the advisory 1000-line cap on trivial wiring — accepted, rides T-1158's decomposition. Tickets: T-1156 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c9028af77e |
feat(ui): T-1161 — per-rung composite filter policy (Region NEAREST, District/Quarter LINEAR)
Araminta's ruling (PR #192 follow-up): filter keyed on rung IDENTITY via the window's own echoed granularity_v2 — Region (incl. the orbital tile mosaic, whose tiles are all Region-rung requests) samples NEAREST because GPU bilinear at 204.8 km/cell reads as smoothing-over-absence; District/ Quarter keep LINEAR where cell density earns the blend. One shared helper (_filter_for_granularity_v2) at both draw call sites; unknown/missing wire tags fall back to LINEAR (never trusted into NEAREST). COMPOSITE_ SMOOTH survives as the independent compile-time pipeline axis — the two-axes split is documented in the file header. Washes/border fades untouched per the ruling. Focused suite 44/44; revert-verified (helper hardcoded LINEAR -> exactly the three Region-NEAREST tests fail). Tickets: T-1161 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |