Lane B R37 (v8 wave 0 §0.1 + §0.4): THE KERB IS FOOTPATH — +0 draws, tris DOWN; the chunk seam is subscribable at last

§0.1 — ground.js adopts Lane A's corridor law. The road quad narrows from the whole
corridor to the carriageway and the EXISTING merged footGeos class extends inward to
meet it, consuming `vergeBand(e)[0]` (not `roadWidth/2` — per A's mid-round correction,
so the geometry and A's corrected gate read the same number).

MEASURED, fresh boots, port-isolated no-store servers (:8178 = HEAD control, :8177 =
treatment), 2560x1440:
  synthetic     ground 5 draws / 224 tris -> 5 draws / 216 tris
                walked spine, 102 steps @ 8 m: worst 276 draws BOTH; sum of draws
                20,081 BOTH (identical at every step); 8 bookmarks identical draws
  katoomba_real ground 4 draws / 9,932 tris -> 4 draws / 9,644 tris
                walk 120 steps: worst 100 draws BOTH; sum 7,703 BOTH
+0 draws is proven, not asserted. Tris go DOWN (-8 / -288): the charter's "~2k tris" was
an estimate against a class that already existed. Honest number, stated loudly.

Surfaces verified by RAYCAST, not by reading the code (offset from centreline, topmost hit):
  main@28  road 0-4.75 | kerb 5 | footpath 5.25-17.5     (band [5,14])
  main@24  road 0-5    | footpath 5.25-15.25             (band [5,12], real)
  side@12  road 0-3    | footpath 3.25-9.5               (band [3,6] — 72% of the corpus)
  lane@8   road 0-4    | footpath 4.25-9   DEGENERATE band [4,4] => pre-ruling geometry
  lane@4   road 0-1.75 | kerb 2 | footpath 2.25-5.25     (synthetic, also unchanged)
  arcade   footpath 0-6.5, NO road quad, NO kerbs        (1,102 corpus edges)
  RESIDENTIAL side@14  road 0-2.75 | kerb 3 | GRASS 3.25-6.75 | footpath 7-10.5
The lane case branches on `outer <= inner`, never on a band literal.

Posters: 14 of 14 stood inside painted bitumen before; 0 of 14 after. Not one poster moved
— the ground did.

ONE DEFECT FOUND AND FIXED IN THE ROUND. The first cut painted every intersection shut:
a 12.5 m footpath crosses the perpendicular CARRIAGEWAY where a 3.5 m one only ever
clipped a far corner. Screenshotted, rejected, and fixed by putting the carriageway on
top (ROAD_Y 0 -> 0.04 under the flag): roads run through junctions, footpaths stop at the
kerb they meet. Verified on the real graph's worst 4.3-degree junction.

?verge=0 ships permanently — the gate's falsifiability control. Proven EXACT, not merely
similar: ground vertex+uv hashes identical to the HEAD build (c4c72181 / c111d968 /
1a80ebcd / 12c1241b / fd0d3211), and the same detector goes RED on the default boot.

CLASSIC IS FENCED, NOT AMENDED (the R31 dig-flip pattern). ?classic=1 forces verge off, so
the covenanted town's render does not move on a render-side ruling. Proven twice: identical
ground geometry hashes AND identical ground-only rendered pixels at three poses
(79df268c / 91564001 / a09d1e52), while the default boot differs at all three. If Fable
wants classic to take the footpath too, that is an amendment ruling and a one-token change.

§0.4 — the chunk lifecycle seam. The producer has fired since R3 and Lane D's consumer has
existed since R3, but nothing could connect them: createChunkManager never handed `ctx`
back, so a consumer holding only PROCITY.chunks had nowhere to attach (measured on HEAD:
`!!chunks.ctx === false`). Now `chunks.onChunkBuilt(fn) -> unsubscribe()` for many
consumers plus `chunks.ctx` for the original single slot. Proven live: 136 build / 144
dispose events over a 40-step walk; payload {cx,cz,buildings,furniture,data} with
key === `${cx},${cz}`; a second consumer does not clobber the first; the ctx slot fires;
unsubscribe silences; a THROWING subscriber does not take the streamer down.
ZERO behaviour change when nobody subscribes: a 102-step walk recording
[draws, tris, chunks] per step hashes ceb1ec66 on BOTH builds.

Files: web/js/world/ground.js, web/js/world/chunks.js, docs/shots/laneB_r37_kerb/*,
docs/LANES/LANE_B_NOTES.md, B-progress.md. sim.js untouched (Lane D's).
This commit is contained in:
m3ultra 2026-08-03 16:40:43 +10:00
parent e4d77629c5
commit 47db478a51
8 changed files with 138 additions and 11 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

View File

@ -25,6 +25,34 @@ export function createChunkManager(plan, scene, ctx) {
const _colliders = []; // scratch, refilled each getColliders()
// ── the per-chunk lifecycle seam (ROUND37 §0.4) ──────────────────────────────────────────────
// The producer below has fired `ctx.onChunkBuilt` / `ctx.onChunkDisposed` since R3 and Lane D's
// sim has carried the matching `onChunkBuilt(key)` / `onChunkDisposed(key)` methods just as long —
// but nothing could ever connect them: `ctx` is the shell's local object and the manager never
// handed it back, so a consumer holding only the manager (which is all `PROCITY.chunks` is) had
// nowhere to attach. Two ways in now, both inert until someone uses them:
// • `chunks.onChunkBuilt(fn)` → returns an unsubscribe. MANY consumers, none clobbering another
// (the single `ctx` slot is last-writer-wins, and the v8 slate has several per-chunk systems).
// • `chunks.ctx.onChunkBuilt = fn` — the original single slot, still first to fire, and now
// reachable exactly as LANE_D_NOTES §35 documents it.
// Zero cost when nobody listens: the detail object is only allocated inside the guard, so a
// subscriber-free boot does the same two truthiness tests it has done since R3 and nothing else.
const builtSubs = [], disposedSubs = [];
const subscribe = (list, fn) => {
if (typeof fn !== 'function') return () => {};
list.push(fn);
return () => { const i = list.indexOf(fn); if (i >= 0) list.splice(i, 1); };
};
// A throwing subscriber must never take the streamer down with it — a half-built chunk leaks its
// groups and the player walks into a void. Report and carry on.
const fire = (fn, key, detail, what) => {
try { fn(key, detail); } catch (err) { console.warn(`[procity] ${what} subscriber threw for ${key}`, err); }
};
const emit = (slot, list, key, detail, what) => {
if (slot) fire(slot, key, detail, what);
for (let i = 0; i < list.length; i++) fire(list[i], key, detail, what);
};
function buildChunk(key) {
if (live.has(key)) return;
const data = index.get(key);
@ -41,14 +69,19 @@ export function createChunkManager(plan, scene, ctx) {
? buildings.colliders.concat(furniture.colliders) : buildings.colliders;
live.set(key, { buildings, furniture, colliders, cx, cz });
// optional lifecycle hook (Lane D/F per-chunk spawning, ambient, LOD — LANE_F_NOTES §8).
// Inert unless a consumer sets ctx.onChunkBuilt; citizens currently drive off plan.streets instead.
if (ctx.onChunkBuilt) ctx.onChunkBuilt(key, { cx, cz, buildings: buildings.group, furniture: furniture.group, data });
if (ctx.onChunkBuilt || builtSubs.length) {
emit(ctx.onChunkBuilt, builtSubs, key,
{ cx, cz, buildings: buildings.group, furniture: furniture.group, data }, 'onChunkBuilt');
}
}
function disposeChunk(key) {
const b = live.get(key);
if (!b) return;
if (ctx.onChunkDisposed) ctx.onChunkDisposed(key, { cx: b.cx, cz: b.cz }); // before the groups are freed
// fires BEFORE the groups are freed, so a consumer can still read what it is losing
if (ctx.onChunkDisposed || disposedSubs.length) {
emit(ctx.onChunkDisposed, disposedSubs, key, { cx: b.cx, cz: b.cz }, 'onChunkDisposed');
}
scene.remove(b.buildings.group, b.furniture.group);
if (b.buildings.doorMesh) {
const i = doorMeshes.indexOf(b.buildings.doorMesh);
@ -128,6 +161,13 @@ export function createChunkManager(plan, scene, ctx) {
return {
update, warmup, getColliders, setNight, dispose,
// §0.4 — the lifecycle seam, subscribable. `onChunkBuilt(fn)` → unsubscribe(); `ctx` is handed
// back so the original single-slot form works too. Keys are planutil chunk keys ("cx,cz"),
// which sim.js derives identically (sim.js:52).
onChunkBuilt: (fn) => subscribe(builtSubs, fn),
onChunkDisposed: (fn) => subscribe(disposedSubs, fn),
get subscriberCount() { return builtSubs.length + disposedSubs.length; },
ctx,
getDoorMeshes: () => doorMeshes,
get count() { return live.size; },
get pending() { return queue.length; },

View File

@ -8,11 +8,62 @@
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { resolveEdges } from './planutil.js';
import { vergeBand } from '../core/registry.js';
const FOOT = 3.5; // footpath width
const FOOT = 3.5; // footpath width OUTBOARD of the corridor (unchanged since v1)
const TILE = 5; // metres per texture tile
const OVERLAP = 1.5; // road segments overrun their nodes so intersections fill
// ── THE KERB IS FOOTPATH (v8 ruling 1, ROUND37 §0.1) ───────────────────────────────
// `edge.width` is the whole CORRIDOR — carriageway + verge — and this file painted all of it as
// bitumen from v1 to v7, which put the band registry.js calls "verandah'd footpath" (and that
// buildings.js already builds an awning over) under road texture: 14 gig posters, every bench and
// every ped lane stood in the road on the boot every player gets. Lane A's published corridor law
// (core/registry.js) is now the single source of truth for where the bitumen stops: the road quad
// narrows to the carriageway and the EXISTING merged `footGeos` class extends inward to meet it.
// Same merged meshes, same quad count ⇒ measured +0 draws (identical at all 102 steps of the walked
// spine) and tris that go DOWN, not up: 8 on the synthetic town, 288 on katoomba_real, because the
// only quads this adds are the ones the arcade case removes. The charter's "~2 k tris" was an
// estimate against a class that already existed; the honest number is a re-parameterisation.
//
// We consume `vergeBand(e)[0]`, NOT `roadWidth(e)/2`. Both `roadWidth` and `edge.width` are full
// widths — one of the carriageway, one of the corridor — and A names that collision as the reason
// this bug existed at all; `vergeBand`'s inner bound is the number A's own corrected gate reads, so
// the gate and the geometry cannot drift apart (Lane A, R37, verified over 31,039 corpus edges).
// The band is NOT one number: 9 m is synthetic-only (main @28 m). A real main is 24 m ⇒ 7 m, and the
// corpus's dominant edge is `side` @12 m — 22,415 of 31,039 edges (72%) — ⇒ 3 m. Never branch on a
// literal band; branch on the degenerate test (`outer <= inner`), which is exactly the class of bug
// this whole item exists to retire.
//
// `?verge=0` reverts to the pre-ruling geometry, permanently — it is Lane F's falsifiability
// control for the poster-clearance gate (the pre-change failure is known and counted at exactly 14).
// `?classic=1` forces it off (the R31 dig-flip pattern): the covenanted town's render does not move
// on a render-side ruling without an amendment ruling from Fable.
const VERGE = (() => {
try {
const p = new URLSearchParams(location.search);
const classic = p.has('classic') && p.get('classic') !== '0';
return !classic && p.get('verge') !== '0';
} catch (e) { return false; } // unparseable search ⇒ pre-ruling geometry, never a throw
})();
// Edges that keep a GRASS VERGE instead of pavement: the nature strip is period-correct in front of
// houses and nowhere else. An edge is residential if a house/yard lot fronts it, or if its fronting
// lots sit in a `residential` district. Real towns are 100% shop/mainstreet today ⇒ empty set ⇒ they
// pave, which is the intent.
function residentialEdges(plan) {
const set = new Set();
const districtOfBlock = new Map((plan.blocks || []).map((b) => [b.id, b.district]));
const kindOfDistrict = new Map((plan.districts || []).map((d) => [d.id, d.kind]));
for (const l of plan.lots || []) {
if (l.frontEdge == null) continue;
if (l.use === 'house' || l.use === 'yard' || kindOfDistrict.get(districtOfBlock.get(l.block)) === 'residential') {
set.add(l.frontEdge);
}
}
return set;
}
// Horizontal quad in XZ centred at (cx,cz), aligned to unit dir/perp, size len×wid, at height y.
// UVs baked to tile every TILE metres.
function hQuad(cx, cz, dir, perp, len, wid, y) {
@ -46,6 +97,18 @@ export function buildGround(plan, skins) {
const roadGeos = [], footGeos = [], kerbGeos = [];
const dir = new THREE.Vector3(), perp = new THREE.Vector3();
const grassy = VERGE ? residentialEdges(plan) : null;
// Surface heights are a painter's order, not elevations — the whole stack is 9 cm thick and every
// class is a flat quad, so what these numbers decide is who wins where two strips CROSS.
// Pre-ruling the footpath (0.02) sat above the road (0.0) and that was invisible: a 3.5 m path
// outboard of the corridor only ever crossed the far corner of a junction. At 12.5 m it crosses
// the whole of the perpendicular CARRIAGEWAY, and the first cut of this change painted every
// intersection shut — four roads arriving at a pedestrian plaza, screenshotted and rejected.
// So under the ruling the carriageway goes on top: roads run through junctions, footpaths stop at
// the kerb they meet, which is what an intersection actually looks like. Road-on-road overlap in
// the junction box is unchanged and still invisible (one skin, one y, uniform asphalt — R20).
const ROAD_Y = VERGE ? 0.04 : 0.0;
for (const e of edges) {
const dx = e.bx - e.ax, dz = e.bz - e.az;
const len = Math.hypot(dx, dz) || 1;
@ -53,13 +116,37 @@ export function buildGround(plan, skins) {
perp.set(-dir.z, 0, dir.x);
const cx = (e.ax + e.bx) / 2, cz = (e.az + e.bz) / 2;
const roadLen = len + OVERLAP * 2;
roadGeos.push(hQuad(cx, cz, dir, perp, roadLen, e.width, 0.0));
// footpaths flank the road
const off = e.width / 2 + FOOT / 2;
footGeos.push(hQuad(cx + perp.x * off, cz + perp.z * off, dir, perp, roadLen, FOOT, 0.02));
footGeos.push(hQuad(cx - perp.x * off, cz - perp.z * off, dir, perp, roadLen, FOOT, 0.02));
const half = e.width / 2; // corridor half-width (the v1 kerb line)
// A's law, as A publishes it: [kerb, corridor edge] measured from the centreline.
const [bandInner, bandOuter] = vergeBand(e);
// Where the kerb goes. `min(…, half)` is the inversion guard: no edge in the 31,039-edge corpus
// has a carriageway wider than its corridor, but a `side` under 6 m would, and an inverted band
// would flip a footpath quad inside out. A DEGENERATE band (`bandOuter <= bandInner` — all
// carriageway, no verge: 56 real `lane` edges, 2 synthetic ones, and NOT the [2,2] literal the
// brief carried) then lands on `bandInner === half`, i.e. the pre-ruling geometry, by arithmetic
// rather than by a special case.
const kerbHalf = VERGE ? Math.min(bandInner, half) : half;
// ARCADE (1,102 corpus edges): a covered pedestrian lane has no carriageway at all, so a road
// quad here is zero-area and the two kerbs land on top of each other at the centreline. Emit
// neither, and pave the whole corridor as ONE footpath quad (no centreline seam).
if (VERGE && kerbHalf <= 0) {
footGeos.push(hQuad(cx, cz, dir, perp, roadLen, e.width + FOOT * 2, 0.02));
continue;
}
roadGeos.push(hQuad(cx, cz, dir, perp, roadLen, kerbHalf * 2, ROAD_Y));
// Footpaths flank the road: from the kerb line out to the corridor edge + FOOT. Two bands keep
// their v1 outboard-only pavement — a DEGENERATE band (no verge exists to pave: the laneway
// case, tested, never assumed from a width) and a RESIDENTIAL edge, where the verge stays grass
// because a nature strip is what is period-correct in front of a house.
const inner = (bandOuter <= bandInner || (grassy && grassy.has(e.id))) ? half : kerbHalf;
const fw = half + FOOT - inner;
const off = inner + fw / 2;
footGeos.push(hQuad(cx + perp.x * off, cz + perp.z * off, dir, perp, roadLen, fw, 0.02));
footGeos.push(hQuad(cx - perp.x * off, cz - perp.z * off, dir, perp, roadLen, fw, 0.02));
// low kerbs between road and footpath
const koff = e.width / 2 + 0.12;
const koff = kerbHalf + 0.12;
kerbGeos.push(hQuad(cx + perp.x * koff, cz + perp.z * koff, dir, perp, roadLen, 0.24, 0.06));
kerbGeos.push(hQuad(cx - perp.x * koff, cz - perp.z * koff, dir, perp, roadLen, 0.24, 0.06));
}
@ -100,5 +187,5 @@ export function buildGround(plan, skins) {
kerbMat.dispose();
group.clear();
}
return { group, dispose };
return { group, dispose, verge: VERGE };
}