PIXELPUNK JOURNAL / CHAPTER 04 Editorial evidence captured 23 September 2026. These are historical project notes. Status and next-step instructions in these excerpts belong to their original development session. Performance and test results quoted here are from those sessions, not tests performed by the journal build. ======================================================================== docs/visual-handoff.md | original lines 179–254 ======================================================================== **Texture contrast pass (23 September 2026, owner request, outside the feel plan):** Walls, bricks, paving and meadow read as materials at play distance instead of flat fills. Shader-only: four files changed, no GDScript, no test, no save field, and the `masonry_grid.gdshaderinc` contract is untouched, so bricks still land on the painted joints. Applied between Q1c and the `07-B0` snapshot in a Godot slot the feel-plan lead held open, so B0 and every later stage start from it. - **Bonded surfaces** (`masonry_body.gdshaderinc`, new `block_shade()` shared by bricks, setts and flagstones): - Per-block value spread: ±22% on walls (was ±8%), ±16 to 18% on paving. - A warm or cool cast per block, and 12% odd blocks at 0.72 to 1.22. - Joints never narrower than 0.55 of an art pixel (`fwidth` floor), so the bond survives the Harbour views; mortar at 0.52 of the block (was 0.78). - A ring one art pixel wide inside each joint: on walls +28% along the upper arris, −24% along the lower, ±8% on the sides; on paving +16% all round (worn edges). Wall faces are pillowed ±5%, lighter towards the top. - A second tone field about 4 m across (×0.90 to 1.08). The distance fade keeps 75% of the detail (was 35%). - Relief 2.4 cm on walls (was 1.0), 1.6 to 1.8 cm on paving. - Divided by 0.91, so a wall keeps its average value. - **Plaster** (`plaster_body.gdshaderinc`): broad tones over about 3 m (±12%) with a warm or cool cast; patched repairs (22% of 0.9 × 0.7 m bays, −11 to +9%, wandering edge); a one-art-pixel stucco speckle (±5%, faded by `detail_visibility`); relief about 3× deeper. - **Instanced bricks** (`surface_body.gdshaderinc`, kind 1): each brick keys its value (0.78 to 1.16), cast and 12% odd bricks on its own `MODEL_MATRIX` origin (`piece_origin`). Batched pieces on the brick material share one shade per batch. - **Meadow** (`ground.gdshader`): - Four greens (lush, sunny, straw where dry) on warped drifts a few metres across. - Turf clumps at 17 cm and 9 cm with random radii on a warped lattice: lit crowns, darker gaps, soil in the odd gap, rare daisy or buttercup flecks, 3.5 cm clump relief. - Detail fades out between 6 and 22 cm per pixel. - Earth gains pebbles and sand gains wind ripples. - **Not changed:** roof tiles, timber, metal, the planted-ground branch of masonry, the tonemapper. An AgX trial was darker and muddier at the current exposures and was not adopted. Files: the four shaders; `artifacts/texture-proto/live-backup/` holds the pre-change versions. `artifacts/texture-proto/v2/applied.diff` is the change, and `README.md` there covers the first prototype and the AgX trial. Tests: full suite after the change, 19 suites, 6,301 checks, 0 failures, identical to the pre-change count in `artifacts/feel/06-B2q/c-full-test.log`, and `--verify` gives `PIXELPUNK_INTEGRATION []`. Logs: `artifacts/texture-proto/suite/` (`counts.txt`, one `.log(.out)` per suite, `verify.log`, `import.log`). These are the steps of `tools/test.sh` with the logs redirected there. Measurements: a scratch copy of the 18:39 tree, old against new shaders, alternating runs, `--disable-vsync`, 1920x1080, `--smoke --lighting=day --time=13 --churn=100000` (`artifacts/texture-proto/v2/bench/`, `bench-summary.txt`, `bench.sh`). | Fixture | Old mean / p95 (medians) | New mean / p95 (medians) | |---|---|---| | Dense (`weather-pass9/dense.json`), 5 rounds | 12.95 / 13.93 ms | 13.01 / 13.33 ms | | Harbour day, 3 rounds | 7.39 / 6.85 ms | 7.38 / 6.59 ms | Draw calls are unchanged (2,035 and 792). The mean costs +0.06 ms (+0.5%) on the dense fixture. The dense p95 spread is wide (new 11.74 to 16.57 ms, old 12.50 to 14.09 ms) and shows no regression over five rounds. The meadow now does 18 cell lookups per fragment; no meadow-heavy fixture exists to isolate that. Captures (1600x1000, pixel size 2 unless noted): - Before: the scratch copy with the old shaders (`artifacts/texture-proto/v2/caps/before/`). - After: the live tree after the change (`artifacts/texture-proto/live/`: lane, close, night, wide, quarter) and the scratch copy (`v2/caps/after/`, including golden, styles and `lane-px1`). - Side by side, before left: `v2/compare/` (`close`, `lane`, `night`, and `full-*` 1:1 crops). - Mean luma of the whole frame: lane 71.99 → 71.77, night 46.36 → 46.39, close 53.52 → 55.20 (+3%, the lit arrises on a wall-filled frame). - No `SHADER ERROR` or `SCRIPT ERROR` in any log. Not done: - No orbit or zoom recording. Shimmer was judged from stills and a three-frame orbit of the first prototype (frame-to-frame difference +17%, part of which is simply more texture; `artifacts/texture-proto/compare/after-orbit.gif`). - Windows not run. - Journey frames change from here on for walls, paving and meadow. Counts must match `06-B2q` exactly; the lead captures step `06-T` to check. - Owner judgement pending on strength. The wall contrast was pushed deliberately at the owner's request; `block_shade`'s spread, lip and mortar constants are the knobs. ======================================================================== docs/visual-handoff.md | original lines 3139–3196 ======================================================================== ## Eighth-pass checkpoint — the metric face-coordinate contract, 16 September 2026 **The fault.** `masonry.gdshader` and `plaster.gdshader` each carried their own copy of the same line: `vec2 coord = top ? wp.xz : (abs(wn.x) > 0.7 ? wp.zy : wp.xy)`. An upright face was projected onto whichever world axis its normal leaned towards, so the coordinate advanced by the cosine of the wall's angle rather than by the distance travelled along it. A facade at 45 degrees drew bricks 1.41 times too wide; the sixteen facets of a round tower each drew a different width. `Masonry.surrounds()` had to place real bricks on that same grid, and did it by asking the same question of the world axes — with a documented surrender in the middle of it, "rotated walls fall back to a grid of their own", which meant the bricks it placed and the joints the shader painted behind them were two different grids. **The contract.** `shaders/masonry_grid.gdshaderinc` now holds one definition, and both shaders include it. A flat face is read in plan, x and z metres. An upright face is read in its own frame: distance along the face, and absolute height. The tangent is the outward normal turned a quarter turn about Y, so one metre along any wall advances the coordinate by exactly one whatever angle the wall stands at, and a tower's facets all draw the same brick. Both readings stay absolute rather than being measured from the object, because height must be for courses to line up between neighbours in a terrace, and distance along the wall must be for two coplanar walls in a merged cluster to continue one bond instead of meeting at a phase jump. The cost is that moving a building slides its bond, which is the behaviour this already had. The bond pitches live in the same file and the shaders no longer carry their own copies. **The GDScript half.** `Masonry.face_tangent()` and `Masonry.face_coord()` mirror the include, and `Masonry.STAGGER` and `Masonry.LEVEL` join `ROW` and `COLUMN`. `surrounds()` now takes the tangent from the face frame it was handed rather than guessing a world axis: `u = origin + scale * x`, where the frame's y and z axes contribute nothing to u and scale is plus or minus one for any unscaled frame. The shape of the arithmetic downstream did not change, only what feeds it. `rows()` was already the contract for courses and is unchanged apart from its comment. Worth knowing: `ClusterArchitecture.facade()` was already building its frame with exactly this tangent, so the fix is mostly the generator stopping to use the frame it was given. **What it is worth.** Measured, not asserted: with the pre-pass `surrounds()` restored and the new checks run against it, **161 of 387 surround bricks crossed a joint the shader paints**. After, none of 387 do, across 29 wall angles including the four cardinals, the diagonals, and the awkward angles taken from the Canal quarter's own scene file. On a sixteen-sided tower the spread in drawn brick width across facets falls from 41% to under a hundredth of a millimetre. **New suite.** `tests/test_masonry.gd`, **220 checks**, wired into `tools/test.sh` and `tools/Test.ps1`. It reads `masonry_grid.gdshaderinc` and fails if the shader's numbers drift from `Masonry`'s, which is the only thing holding one contract in two languages together. It also checks that neither shader still carries its own copy of the grid, that the tangent is unit, level and square to the wall at 37 angles, that a soffit gets a usable tangent rather than a division by zero, that paving on a slope lands on the same grid as paving beside it, and that a rebuild places every brick in the same place. The central check — no brick crosses a painted joint — was confirmed to fail against the pre-pass sources before it was trusted. **Verification.** Tests **1,399 / zero failures** (scene 451, building tools 400, clusters 198, lighting 130, masonry 220). Windowed integration `PIXELPUNK_INTEGRATION []`. No script or shader errors in any log. Canal quarter unchanged in every invariant across the change: 260 parts, 221 emitter sources, 126 reflection streaks, 16 local lights, 830 draw calls, `capture_failures []`, empty `support_error`, in Day, Golden, Night and at rain 0.9. **Frame cost: none measurable.** The fragment shader gained a normalize and a dot on every upright pixel, so it was measured on the channel built for it rather than argued about. Five interleaved dense benchmark pairs, alternating sources: after **11.278 / 11.722 / 11.296 / 11.291 / 11.521 ms**, before **11.262 / 11.290 / 11.693 / 11.284 / 11.433 ms**. The medians differ by 0.006 ms against a run-to-run spread of 0.44 ms in both sets, and in both sets the slow runs are the ones that drew more calls — draw calls varied 2881 to 2938 on the pre-pass sources as well, so that variation is the machine, not the change. Brick instances on the dense scene: 24,394 after against 24,405 before, 0.045% apart. **Smoke captures cannot measure this**: at 1600x1000 the quarter sits on the 120 fps vsync cap, so its 8.33 ms means nothing. **New fixture.** `artifacts/materials-pass8/rotations.json`, generated by `tools/make_rotations.gd` beside it, holds the cases the Canal quarter does not: a round tower, buildings at 45 degrees and at three awkward angles, a merged pair sharing a turned facade, and both shaded walls — stone masonry and plaster — since the quarter is canal style throughout and exercises only one of the two shaders that read the contract. Before and after captures of it are in `before-rotations`/`after-rotations` and the close pair `before-merged`/`after-merged`. On that fixture the surrounds gained 137 bricks (1,381 to 1,518) because bricks that the fallback grid clipped below the minimum width now land on whole cells; on the axis-aligned quarter the count moves by 9 in 1,523. **What the pictures do and do not show.** Mean luminance barely moves — quarter -0.06%, tower close-up +0.15%, fixture +0.01% — because this redistributes joints rather than changing how much light there is. The useful number is how much of the frame changed at all: 6.4% of pixels on the wide quarter, 12.2% on the tower close-up, 9.1% on the rotated pair. The change is confined to masonry and is largest where facets are angled, which is what it should be. **Known and deliberate.** Walls facing along world X now mirror their bond relative to before, because one consistent tangent convention cannot match a rule that was not consistent. Going round a building the bond now runs consistently, which it did not before. Paving on a slope is still read in plan, so setts run slightly short along the fall; that is the standard compromise and is well under a sett at the gradients this terrain reaches. `main.gd:450` uses the same 0.7 threshold to decide where a wall part may be placed; that is a different question that happens to share a number, and was deliberately not coupled to `Masonry.LEVEL`. **Exports.** Both rebuilt from these sources and the standalone Mac smoke re-run: `PixelPunk.app/Contents/MacOS/PixelPunk` `f2be5c0d`, `PixelPunk.exe` `2bac37ca`. Canal quarter at Night from the exported app gives 221 sources, 126 streaks, 16 lights, 260 parts, 0 selection and 0 reflection mismatches, `capture_failures []`, empty `support_error`. Import and both export logs are clean. `build/` matches source. **Not done.** Item 3's remaining half: this settles the coordinate that shader masonry and generated surround bricks share, which is what the item asked for, but the quoin and seam-filler courses in `ClusterArchitecture` run on a 0.32 pitch of their own and were left alone. Windows still carries no runtime evidence. **Next assistant's first job:** remaining work item 4, geometry-aware weathering — exposure beneath eaves and sills, ground-relative dampness, narrow dirt and moss masks — which can now be written against `masonry_face_coord` rather than inventing a fourth projection. Keep masks deterministic and refresh them when neighbours, roofs, or terrain change. ## Ninth-pass checkpoint — geometry-aware weathering, 16 September 2026 **The fault.** The damp band was measured from a fixed height near the canal surface: `(1.0-smoothstep(0.25,1.25,wp.y))`. Land here sits at `LAND_Y` 1.6, so every building on it was above the band entirely and showed no damp foot at all — the effect only ever reached quay walls and canal interiors. There was no shelter mask of any kind: an eave, a cornice, a string course and a window sill all left the wall beneath them looking exactly like the wall above them, and rain-streaking existed only inside the opening-damage band, which is a different thing that happened to be handy. **The contract.** `shaders/weathering.gdshaderinc` holds one definition, included by the masonry and plaster shaders, and mirrored by `Weathering` in GDScript. Two world-absolute metres travel per vertex in **UV2**: the height of the ground that piece of building stands on, and the height of the nearest thing overhanging it (`WEATHER_OPEN` where nothing does). Every mask is derived from those two and from `masonry_face_coord`; nothing samples a texture or looks at a neighbour, so it costs the same whatever the scene holds. Storing the soffit's own *height* rather than a shelter factor is what makes this cheap. A whole wall panel can carry one value and the gradient falls out of the fragment's own height, so a panel one floor tall needs no subdivision to show a dry band under the course above it. It also composes: a piece standing under both a cornice and a sill keeps the lower of the two, which is the one that actually shelters it. `Openings.cut()` carries the pair through the rectangle cutter and `_row()` gives the wall left under an opening that opening's own sill. **What the masks say.** Rain washes what it reaches, so grime is what is left where it cannot: a band under every overhang, and narrow run-off streaks leaving that overhang's edge and giving out a couple of metres below. Moss wants the opposite — the damp foot where the ground keeps the wall wet, and no roof keeping it dry. Both are narrow and patchy deliberately. A wash over a whole wall reads as a dirty texture; a few marks in the right places read as a building that has stood in weather. **Carrying the pair.** `MeshBatch` holds `ground` and `soffit` beside `transform` and stamps them onto every vertex of a weathered material; generators save, set, emit and restore exactly as they do with the transform. `MeshBatch.world_y()` turns a height in the frame being drawn into the world metres the pair holds. Only `stone` and `plaster` carry UV2 — `Weathering.WEATHERED` is the list — so nothing else pays the eight bytes a vertex. `loft()` takes an optional pair per cross-section, which is how a freehand wall climbing a bank keeps its damp foot on the ground instead of at an average; merlons, pillars and gateway lintels each take the ground beneath themselves. **Unplumbed geometry is unchanged.** The default pair is ground 0 and no overhang, and at that default the new dampness reduces exactly to the waterline band it replaced and the shelter mask to nothing. `tests/test_weathering.gd` asserts that equality at seven heights and four normals rather than leaving it as a claim. Anything a generator has not been taught about therefore looks precisely as it did. **New suite.** `tests/test_weathering.gd`, **189 checks**, wired into `tools/test.sh` and `tools/Test.ps1`. It reads the include and fails if the shader's numbers drift from `Weathering`'s, checks that neither shader keeps a copy of them, and checks the mask shapes — monotone, zero above an overhang, gone past its reach, nothing damp on a face lying flat. The decisive check is `geometry_checks()`: for every vertex claiming shelter it looks for a **downward-facing face of the same building at that height above it**, so a generator that invents a cornice fails. Confirmed to bite: moving the canal panel's recorded soffit 0.9 m up fails it on **439 of 1,666 sampled claims** along with three of the rotation checks. It also measures the old behaviour rather than asserting the new one is better — a house on land had **no** vertex above the waterline damp threshold before, and has a real damp foot now. **A bug it caught.** The first run failed on a freehand wall climbing a bank: 216 of 2,268 vertices sat more than half a metre from the terrain under them, because merlons and pillars took the mean ground of the run. On a 3.5 m bank that put merlons at the low end below their own ground, which would have drawn them fully damp. They now each take the ground beneath themselves. **Verification.** Tests **1,588 / zero failures** (scene 451, building tools 400, clusters 198, lighting 130, masonry 220, weathering 189). Windowed integration `PIXELPUNK_INTEGRATION []` with all thirteen artifacts. No script or shader errors in any log. Canal quarter unchanged in every invariant across the change: 260 parts, 221 emitter sources, 126 reflection streaks, 16 local lights, `capture_failures []`, empty `support_error`, in Day, Golden, Night and at rain 0.9. The dense scene generates **identical geometry** before and after — 800 surfaces, 3,732,150 vertices, the same eight material groups (`tools/surfaces.gd`). **Frame cost: none measurable — but the usual channel could not show that.** `--benchmark` advances the camera yaw by *delta time* and stops after a fixed frame count, so a slower run ends at a larger yaw, culls less and draws more. Its five interleaved pairs put the after runs 0.4 ms slower *and* drawing 50 more objects, and there is no way to tell from that which caused which. So the measurement was moved to a channel without the coupling: `artifacts/weather-pass9/dense.json` is the same 600-part, 4,096-cell scene written out with a fixed camera, driven through `--smoke --churn=100000` (a churn interval no run reaches, which buys the 600-frame limit and 491 samples without churning anything). Every run on both sides then reports **1,881 draw calls and 1,906 rendered objects**, so both sides draw the same frame. Six interleaved pairs at 1920x1080: after **12.304 / 12.369 / 12.415 / 12.416 / 12.780 / 13.167 ms**, before **11.926 / 11.947 / 12.287 / 12.407 / 13.130 ms** (one before run hit the known capture stall). Medians **12.416** and **12.287**, 0.13 ms apart against a run-to-run spread of 0.86 ms within the after set and 1.20 ms within the before set. Use this fixture, not `--benchmark`, for any change too small to swamp the culling. **What the pictures show.** Same cameras, Day, dry, in `before-*`/`after-*`: quarter -0.47% mean luminance and 7.4% of pixels changed; the canal-and-quay view `wall` -1.21% and 14.0%; the courtyard close-up `close` -0.57% and 8.1%; the rotated fixture -0.20% and 1.8%, its close pair `rotclose` -1.02% and 8.1%. `mask-close/courtyard.png` is the three masks drawn directly as red, green and blue, made by temporarily replacing `ALBEDO` in both shaders; it is the quickest way to see where the bands actually land and is not reproducible from the committed sources without that edit. **Known and deliberate.** A one-piece shell carries one overhang, so a round tower records the ring at its head and not the floor rings lower down; the same is true of a styled (Japanese, medieval, modern, Victorian) wall, which is emitted as a single rectangle a whole wall tall and so carries its eave but not its string courses — its window sills still cut their own bands out of it. Only the canal style subdivides its walls per floor and per bay, so only it shows a band under every course. A gable end is treated as sheltered below eave height rather than under the raking bargeboard. Brick and tile MultiMesh instances share one mesh and cannot carry a per-instance pair, so surround bricks and roof tiles do not weather with the wall behind them. Everything outside `stone` and `plaster` is untouched. **Exports.** Both rebuilt from these sources and the standalone Mac smoke re-run: `PixelPunk.app/Contents/MacOS/PixelPunk` `401d6a98` (universal, `codesign --verify --deep --strict` passes), `PixelPunk.exe` `c0935218` (PE32+ x86-64). Canal quarter at Night from the exported app gives 221 sources, 126 streaks, 16 lights, 260 parts, 0 selection and 0 reflection mismatches, `capture_failures []`, empty `support_error`. Import and both export logs are clean. `build/` matches source. **Windows was exported but not executed.** **Source hashes.** `scripts/weathering.gd` `da459bdf`, `scripts/mesh_batch.gd` `8351a07f`, `scripts/architecture.gd` `574a8ed0`, `scripts/openings.gd` `784afc44`, `scripts/cluster_architecture.gd` `cb8c89af`, `scripts/masonry.gd` `21f1fc80`, `shaders/weathering.gdshaderinc` `e65aa64b`, `shaders/masonry.gdshader` `f88e4c24`, `shaders/plaster.gdshader` `3bb78b30`, `tests/test_weathering.gd` `ad51295d`. Pre-pass copies of everything changed are in `artifacts/weather-pass9/before/`. ======================================================================== docs/visual-handoff.md | original lines 3359–3392 ======================================================================== ## Sixteenth-pass checkpoint — tilt-shift lens, 16 September 2026 **What was wrong.** Nothing was broken: the user asked for a toggleable miniature-lens look. The frame had no depth-of-field of any kind, and none can be built inside the 3D viewport without a second render of the world (the canvas pass has no depth buffer and the 3D screen copy holds only the opaque pass). Under the pitched, never-rolled orthographic camera the ground's depth follows screen y, so a blur that grows with the distance from a horizontal row is the photographic tilt-shift. **What changed.** To the plan in `docs/tilt-shift-plan.md`, steps 0–4 including the optional step 3, with the user reviewing after steps 1 and 2. `presentation.gdshader`, every 3D shader, `world_view.gd` and the profile are untouched. - *`shaders/tilt_shift.gdshader` (new).* A canvas shader reading `hint_screen_texture` (linear, no repeat) along one `direction`. `d = |SCREEN_UV.y - focus| / 0.5`, `spread = clamp((d - band) / (1 - band), 0, 1)` with `band` 0.15, `radius = max_radius * spread²` window pixels; under half a pixel it returns the source. Otherwise `taps = clamp(ceil(radius / 1.5), 6, 16)` either side at spacing `radius / taps`, weights `exp(-2 t²)` for `t = i / taps` (sigma half the radius), UVs clamped half a pixel inside the frame. - *`main.gd`.* `_ready` adds `BackBufferCopy` (`COPY_MODE_VIEWPORT`), the horizontal `ColorRect`, a second `BackBufferCopy` and the vertical `ColorRect` between `presentation` and `ui` (`lens_nodes`, full rect, mouse ignored, hidden). `apply_presentation` sets `max_radius` and `focus` on both rects and shows all four only while `tilt_radius` is above 0. `static tilt_radius(environment) = clamp(tilt_amount, 0, 1) * TILT_REACH * pixels` with `TILT_REACH` 7.8, 0 while the switch is off; `static tilt_focus_row(environment) = 1 - clamp(tilt_focus, 0.25, 0.75)`. `--tilt-shift=`, `--tilt-focus=`, and `tilt_shift`, `tilt_radius_px`, `tilt_focus` in `performance.json`. `--verify` gains `run_tilt_checks`: nothing drawn while off; the switch shows all four at the default radius; the amount and a pixel-size change set the radius; undo returns the pixel size, the amount and then hides the lens; redo restores it; the focus height moves both passes' row alone and undoes; the panel shows the switch on, the blur slider at 30 and the focus slider at 50 in 25–75; a running clock leaves radius and visibility alone; the final atmosphere undo leaves no `tilt_shift` and nothing visible. A new `tilt-inspector.png` shows the panel scrolled to the lens controls over the blurred scene. The in-game help's Scenery sentence names the lens. - *The settings.* `scene_state.gd`: optional `environment.tilt_shift` (bool), `tilt_amount` (number in [0, 1]) and `tilt_focus` (number in [0.25, 0.75]), else "Invalid tilt-shift lens."; restored as bool and floats; `DEFAULT_TILT_AMOUNT` 0.6, `DEFAULT_TILT_FOCUS` 0.5, `TILT_FOCUS_LOW`/`HIGH`. A fresh environment carries none of them, so scene version 2 is unchanged. `editor_ui.gd`: under Pixel size, a "Tilt-shift lens" `CheckButton`, a "Lens blur" slider (0–100, step 5) and a "Focus height" slider (25–75, step 5), both emitted divided by 100 through `change_environment`'s generic branch, one undoable edit each. `README.md` describes the three controls. - *Deviations from the plan.* (1) The plan's sequencing names the fourteenth pass; the fifteenth (sun path) came between, so this is pass 16, started with the live sources matching `artifacts/sun-pass15/after-src/`. (2) `--tilt-shift=` and the report key moved into step 1, which needed them for its review captures; `tilt_radius_px` and `tilt_focus` are extra report keys. (3) After the step 1 review the user asked for a 30% stronger blur: `TILT_REACH` is 7.8, not 6, so the default 0.6 at `pixels` 2 reaches 9.36 window pixels, not 7.2. (4) The plan's thirteen fixed taps ghosted thin lines at the stronger full blur (the rain came out as two or three parallel copies in a 2× crop at amount 1, 15.6 px, taps 2.6 px apart), which is the plan's own banding risk; rather than seventeen for one case, the tap count now follows the radius (6 to 16 either side, no more than 1.5 px apart until the cap; at amount 1 and `pixels` 4, 31.2 px, 16 taps at 1.95 px). (5) The lens nodes also hide at amount 0 with the switch on; the image is the same and it costs nothing. (6) The acceptance line "captures are byte-for-byte the previous ones" cannot be tested literally: two runs of the step 0 sources differ in 8.37% of pixels and the lens-off captures differ from step 0 in 6.6–8.8% (animated water, rain, sway), so the off path is held by mean luminance and band sharpness instead. (7) The orbit smoke passed `--rain=0`; the plan named no rain and the artwork scene's own is 0.45. (8) The bench added the step 0 sources as a third interleaved arm and was repeated with vsync off (below). (9) The README and the in-game help sentence are outside the plan's file area; both only describe the new controls. **Step 1 spike.** Canal quarter at 13:00 dry, an even 12 px blur over the whole frame (temporary `--tilt-spike`, removed afterwards). Mean luma step two pixels along / two pixels down in the middle tenth (`tools/sharpness.gd`): off 53.6 / 50.9; horizontal pass alone 14.0 / 34.1; vertical alone 40.5 / 13.6; both 10.1 / 11.2. Both passes flatten the horizontal detail far below the vertical pass alone, so the second copy sees the first pass: the Compatibility back buffer feeds both passes and the blur is round (`spike-montage.png`). **Suite.** `tests/test_tilt.gd` (new, 176 checks), in `tools/test.sh` and `tools/Test.ps1`: no lens key in a fresh or example environment and no blur without one; the switch accepts only bools, the amount only numbers in [0, 1] (rejecting -0.01, 1.01, strings, bools, arrays, NaN, infinity), the focus only numbers in [0.25, 0.75]; JSON round trip as bool and floats (a whole-number amount restores as a float) and saved again unchanged; undo and redo of the switch, the amount and the focus; the radius at `pixels` 1–4 and amounts 0, 0.3, 0.6 and 1, zero while off, the default when absent, clamped; the focus row `1 - height`, falling as the height rises, clamped, and independent of the radius; the shader is `canvas_item`, reads `hint_screen_texture`, declares `direction`, `focus`, `band` and `max_radius` with the default radius equal to the default amount at `pixels` 2, and its tap rule keeps every setting's spacing within 1.5 px below the cap and within 2 px at the widest. Two deliberate mutations were caught (the amount's range check removed: 8 failures; `MAX_TAPS` 8: the 2 px guard failed at 3.90) and reverted. No mutation was run against the `--verify` checks. **Verification.** All twelve suites on the final sources: scene 451, tools 400, clusters 198, lighting 149, masonry 220, weathering 189, water 116, time 158, foliage 97, wet 100, sun 72, tilt 176 — 2,326 checks, 0 failures. `--verify`: `PIXELPUNK_INTEGRATION []`, 17 artifacts, no script or shader errors. Lens off against step 0, Canal quarter from the artwork camera at 13:00 dry: **-0.00%** mean luminance; 8.83% of pixels differ, inside the same-source noise (8.37% between two step 0 runs); band sharpness identical to 0.2 (top 17.9/17.0, middle 53.6/50.9, bottom 18.2/31.7). The plaza close camera at 19:30 wet, off against step 0: +0.21%, where two runs of the final sources differ by -0.24% and the second one sits -0.03% from step 0 (`wet-noise.out`), so rain noise, not a change. Lens on at 0.6 against off: 13:00 dry -0.01%; plaza 19:30 wet +0.06%, middle 60.0 → 60.1, top 59.7 → 22.4, bottom 19.8 → 6.5 (horizontal detail; `step4-compare-plaza.png`). After the stronger reach at 13:00 dry: top 17.9 → 7.2, bottom 18.4 → 4.8, middle unchanged; 01:00 wet: top 12.1 → 3.4, bottom 29.9 → 7.1, middle 49.6 → 49.7 (`step1b-compare-1300.png`, `step1b-compare-0100.png`, 1:1 crops). The look was reviewed by the user at step 1 (stronger blur requested) and step 2 (go on). Focus height at full blur, 13:00 dry: at 0.7 the band 25–35% from the top keeps its full detail (47.1, off 47.1) and 65–75% drops to 15.7; at 0.3 the reverse (60.2 kept, upper 13.2), which is how `SCREEN_UV.y` was found to run down the frame (`step3-focus.png`). Orbit smoke, quarter at 13:00 dry, `--orbit=0.3 --tilt-shift=0.6 --quit-after 900`: six captures, `capture_failures []`, radius 9.36; in every frame the middle tenth keeps more detail than the top and bottom tenths (middle 51.1, 38.0, 32.4, 23.2, 22.8, 20.5 against top 6.8–13.6 and bottom 2.1–4.4); the middle's own figure falls as the orbit brings open water into the frame's centre. The stills are in `orbit/`; `step4-orbit.png` is a downscaled contact sheet in which the blur does not show. Six stills cannot show shimmer between frames. **Frame cost.** `artifacts/weather-pass9/dense.json` at `--time=13`, 1920×1080, `--churn=100000`, the fixture's own rain (0.35), three interleaved rounds, nothing else of ours rendering. `bench.sh` (vsync as the project sets it) ran the step 0 sources, the final sources with the lens off and with `--tilt-shift=0.6`; `bench-novsync.sh` repeated off and on with the engine's `--disable-vsync`. One stall in ten attempts and one in seven, each retried. | run | round 1 | round 2 | round 3 | draw calls | | --- | --- | --- | --- | --- | | before, vsync on | 19.44 / 20.83 | 19.33 / 20.83 | 19.45 / 22.53 | 2,035 | | off, vsync on | 19.27 / 20.64 | 19.52 / 20.83 | 19.21 / 21.97 | 2,035 | | on 0.6, vsync on | 19.16 / 20.00 | 19.42 / 20.83 | 19.00 / 21.78 | 2,037 | | off, vsync off | 18.05 / 20.83 | 18.45 / 20.00 | 18.52 / 21.04 | 2,035 | | on 0.6, vsync off | 18.35 / 19.44 | 18.84 / 20.12 | 18.82 / 22.22 | 2,037 | Mean / p95 ms. **Off matches before**: the same 2,035 draw calls and a mean 0.4% under before, within the spread. **On** adds two draw calls (the blur rects). With vsync on it shows no cost (the on means are the lowest), which says the measure hides it; with `--disable-vsync` the lens is slower in every round, +1.7%, +2.1% and +1.6% in mean (18.34 → 18.67 ms, +1.8%). The p95 moved -6.7%, +0.6% and +5.6% by round, 20.63 → 20.60 ms averaged: within the plan's 5% budget on average, over it in round 3 alone. These are one machine (Apple M3 Pro), one window size and three rounds; the cost scales with the window, not `pixels`. **Exports.** `bash tools/build.sh` and the pinned engine `--export-release Windows`, README and notices copied beside the exe. Mac `0f624c47` (codesign `--deep --strict` ok), Windows `d33f8a40` (PE32+ x86-64, exported, **not executed**; no Windows parity claim). The fifteenth-pass binaries (Mac `929219e0`, Windows `c9e9472b`) are in `build/pass15-backup/`; `build/pass13-backup/` and `build/pass14-backup/` are still there and all three can be deleted. `build/PixelPunk-Windows.zip`, the package made for the user's friend after the fifteenth pass, still holds the fifteenth-pass exe (`c9e9472b`) and has no lens. Standalone Mac smoke of the quarter from the artwork camera at `--time=19.5 --rain=0.45 --tilt-shift=0.6 --verify-selection`: Golden hour, `tilt_shift` 0.6, `tilt_radius_px` 9.36, `tilt_focus` 0.5, `sun_elevation` 16.24, 16 lights, 260 parts, 0 selection and 0 reflection mismatches, `capture_failures []`, empty `support_error`; its capture keeps the middle (66.7) and blurs the top and bottom (6.5, 6.2), so the export carries the shader (`standalone/`). **Limits.** The blur is a function of the screen row, not of depth: it is exact for the ground under this camera, but anything standing in the band is sharp only where it crosses the band, and the sky blurs like the ground. The focus row is placed by the author and does not follow the selection or the camera's target height. At full blur a faint fine vertical texture remains in the blurred near water at 2× zoom. The vignette is blurred with the scene rather than applied after the lens, and the edge rows sample the clamped edge; no darkening showed in the captures. The look was judged on the Canal quarter only, dry and wet, day and night. **Sources.** `main.gd` `949cd63b`, `editor_ui.gd` `62b1729c`, `scene_state.gd` `3febbd7e`, `tilt_shift.gdshader` `09eec09f`, `test_tilt.gd` `738bc4b9`, `test.sh` `0713ce65`, `Test.ps1` `eaf23235`, `README.md` `58b3cd9b`. Pre-pass copies in `artifacts/tilt-pass16/before/` (and the two docs in `before-docs/`), delivered copies in `after-src/`. Everything else under `artifacts/tilt-pass16/`: `before-*`, `spike-*`, `step1-*` (the plan's reach), `step1b-*` (the final reach; `step1b-first-taps/` holds the ghosted full-blur crop), `step2-panel/`, `step3-*`, `step4-*`, `orbit/`, `integration/`, `fixed-*`, `fixed-novsync-*`, `standalone/`, suite logs, `step0.out` … `evidence.out`, `bench-novsync.out`, `wet-noise.out`, and the scripts `capture.sh`, `luma.sh`, `pixdiff.sh`, `spike.sh`, `step0.sh`–`step3.sh`, `step1b.sh`, `bench.sh`, `bench-novsync.sh`, `evidence.sh`, `tools/` (`sharpness.gd` and `pixdiff.gd` are new).