Lane A R27 wave 5: THE FACADE FIX — it was every town, not just the real ones (the covenant amended)

selfcheck ALL GREEN 156,352/156,352; consistency green. 51 goldens re-pinned in this one commit per the
amendment law. scaffold_check is RED and CANNOT be green from here — the covenant hashes are duplicated in
LANE F's files; exact values handed to F in LANE_A_NOTES §R27w5 (I don't edit other lanes' files).

THE RULING'S PREMISE WAS WRONG, AND THE WRONG HALF WAS LOAD-BEARING.
Wave 5 says "synthetic follows B's canon so every golden has been green over a wrong product since R18".
Half right. Synthetic does NOT follow B's canon -- it is broken identically, and so is the marched path,
and so are all three checked-in fixtures. It is not "every real town since R18". It is EVERY TOWN, EVERY
PATH, SINCE v1. The ruling told me to measure the marched path rather than assume it was clean "because
that's how this one survived nine rounds" -- so I measured all three, and the same discipline caught the
premise itself.

MEASURED FIRST, IN B'S CANON (+Z = the facade), BEFORE TOUCHING ANYTHING:
  synthetic seed 20261990   0 toward / 681 away   (dot = -1.0000, exactly anti-parallel)
  marched katoomba/fremantle/godverse, fixtures melbourne/katoomba/silverton   0 toward, all away
  real-roads katoomba   0/72   <- B's number, reproduced
637 of 681 synthetic lots had their facade pointing at NO STREET AT ALL (44 hit a different street by
corner coincidence). Then I confirmed it with my own eyes rather than trust the arithmetic: booted
?classic=1, stood on the synthetic main street at lot 0 -> three blank grey boxes. Walked BEHIND them into
the block -> NUMBAT PLAYTHINGS, sign/door/windows/awning, facing the dirt. The classic covenant town has
been a row of back walls since v1.
After the fix, same camera, same seed, same ?classic=1: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT
PLAYTHINGS -- a strip of shopfronts facing the road.

FOUR SITES, ONE SIGN, AND EVERY ONE OF THEM SAID SO IN ITS OWN COMMENT:
  plan.js:122    synthetic strip   "facade faces back down the outward normal, to the street"  <- it didn't
  plan.js:244    market row        "the facade then faces -(outward) = +x"                     <- it didn't
  plan_osm:330   marched avenue    "facade faces +z (north) to the avenue"                     <- due south
  plan_osm:443   real roads        B's proven one-liner (B measured it, then reverted it -- A's file)
All four were atan2(outward) where the intent was atan2(-outward). The code never did what its comment
said, and CITY_SPEC:71 blessed the result by writing the SIM convention into the LOTS contract. Nobody was
wrong; the spec was. After: synthetic 681/681, marched + fixtures 100%, real-roads katoomba 72/72.

THE COVENANT MOVED -- JOHN RULED IT, I DIDN'T.
Fixing synthetic necessarily moves 0x3fa36874, frozen "forever" since R16. That is not Lane A's call, so I
stopped and asked with the evidence. John's ruling: AMEND THE COVENANT, FIX ALL PATHS -- the covenant is
anti-drift machinery, not a defect preservative. 51 pins in this commit:
  classic/synthetic  0x3fa36874 -> 0x5f76e76
  gig                0xb1d48ea1 -> 0xec7a2d39
  fixtures           melbourne 0x9c7e76b3 · katoomba 0xf3aafec8 · silverton 0x6d74c4
  23 real towns      base + gig (46)
Verified byte-identical across two fresh processes; pack-xor 0xa5058322. CITY_SPEC records the amendment as
a ONE-TIME, ruling-gated precedent -- from this hash forward the law reads as it always did.

A SECOND BUG THE FIX EXPOSED (fixed here, counted). buildRealRoads seated blocks at KERB = 4 m from the
CENTRELINE, but a `main` carriageway is 10 m wide -- its kerb is at 5 m. EVERY MAIN-STREET BUILDING HAS
STOOD 1 m INSIDE THE ROAD SINCE R18. The footprint never moved; only which face is the front -- so it was
invisible until the shopfront turned around and its poster landed in the traffic lane (-1.07 m clearance,
6 real failures). Blocks now seat behind the kerb per A's own published corridor law (roadWidth/2 +
FOOTPATH). Pack drops 6.4% -> 6.6%, counted, not hidden.

THE CROSS-CONVENTION GATE -- built to F's lesson, not to a spec line. F proved that measuring lots against
CITY_SPEC:71's own convention is SELF-AGREEMENT: 72/72 "facing" over a town of blank walls. So this gate
restates nothing. It replicates LANE B'S OWN maths from buildings.js (toWorld(lot, 0, y, d/2 + 0.02)) and
asks a physical question neither spec line can fudge: does the quad the RENDERER actually draws land nearer
the street than the lot centre? It fires if A's ry sign drifts OR if B's zf sign drifts. It would have
caught this on day one of v1.

CITY_SPEC amendments: the lots contract now states facade/door/frontage = local +Z; the SIM/rig convention
is documented separately as the different thing it is, with the explicit rule -- never assert a lot's
facing against a spec line, assert it against the geometry the renderer draws.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 16:28:20 +10:00
parent 94fce8ea4b
commit c1e862cf7c
6 changed files with 252 additions and 69 deletions

View File

@ -1,6 +1,41 @@
# LANE A — CITYGEN · progress (PROCITY-A) # LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check ALL GREEN (156,212/156,212); rounds 227 closed** (see below). *Status: **all deliverables landed; self-check ALL GREEN (156,352/156,352); rounds 227 (incl. wave 5) closed**.
R27 w5 THE FACADE FIX: every town — synthetic included — had its shopfronts facing away from the street
since **v1**. Fixed in all four ry sites; **51 goldens re-pinned**; the classic covenant amended on John's
ruling (`0x3fa36874 → 0x5f76e76`); a cross-convention gate added so it can never recur.*
## Round 27 wave 5 (2026-07-17) — THE FACADE FIX (the epoch's biggest catch)
The ruling asked me to land B's one-liner in the real-roads seating and to **measure the marched path
rather than assume it was clean**. I measured all three paths, and the ruling's premise was wrong in the
half that mattered: **synthetic is broken too**. Not "every real town since R18" — **every town, every
path, since v1**. Confirmed numerically (0 toward / 681 away, dot 1.0000) and then with my own eyes:
`?classic=1`, synthetic main street = three blank boxes; walk into the block and NUMBAT PLAYTHINGS is
there, facing the dirt. 637 of 681 lots pointed their facade at **no street at all**.
- **Four sites, one sign.** `plan.js:122` (synthetic strip), `plan.js:244` (market row), `plan_osm:330`
(marched avenue), `plan_osm:443` (real roads — B's proven one-liner). All `atan2(outward)` where the
intent was `atan2(outward)`, and **every one says so in its own comment**. The code never did what it
said; CITY_SPEC:71 blessed the result by writing the SIM convention into the LOTS contract.
After: synthetic 681/681, marched + all 3 fixtures 100%, real-roads katoomba **0/72 → 72/72** (B's number).
- **The covenant moved, on John's ruling.** Fixing synthetic necessarily moves `0x3fa36874`, frozen
"forever" since R16 — not Lane A's call, so I stopped and asked. Ruling: **amend and fix all paths** (the
covenant is anti-drift machinery, not a defect preservative). **51 goldens re-pinned in one commit**;
amendment recorded in CITY_SPEC as a one-time, ruling-gated precedent.
- **A second bug the fix exposed, fixed with it:** `buildRealRoads` seated blocks at `KERB = 4 m` from the
**centreline**, but a `main` carriageway is 10 m wide (kerb at 5 m) — **every main-street building has
stood 1 m inside the road since R18**. Invisible until the shopfront turned around and its poster landed
in the traffic lane. Blocks now seat behind the kerb per A's own corridor law. Drops 6.4% → **6.6%**, counted.
- **The cross-convention gate (the lesson made law).** It does not restate a spec line — that is the trap F
proved (72/72 "facing" over blank walls). It replicates **Lane B's own** `buildings.js` maths and asks
whether the quad the *renderer* draws lands nearer the street than the lot centre. Fires if A's `ry` **or**
B's `zf` drifts. It would have caught this on day one.
- **→ F (blocking their re-gate):** `scaffold_check` is RED until F re-pins the covenant hashes duplicated
in **F's own files** (`flags_check.py` `GOLDEN_HASH`/`GIG_GOLDEN`, `scaffold_check.mjs` `TOWN_GOLDENS`,
two tour captions). Exact values handed over in LANE_A_NOTES §R27w5; I can't touch F's files.
*Previous status: 156,212/156,212; rounds 227 closed (see below).
R27 (v5.0 close): **CITY_SPEC v5 section** (tier ladder · `sourcing` vs `tier` · the three id-space fences · R27 (v5.0 close): **CITY_SPEC v5 section** (tier ladder · `sourcing` vs `tier` · the three id-space fences ·
the manifest · Ruling 2) + the **charter risk list judged line by line** — risk #1 deliberately CARRIED, not the manifest · Ruling 2) + the **charter risk list judged line by line** — risk #1 deliberately CARRIED, not
pre-retired, because F's kill-the-server gate is what retires it. classic `0x3fa36874` / gig `0xb1d48ea1` pre-retired, because F's kill-the-server gate is what retires it. classic `0x3fa36874` / gig `0xb1d48ea1`

View File

@ -28,7 +28,21 @@ considered and parked: nothing in v1 needs it, and the asset pipeline (GLB, web-
- **Metres. +Y up. Ground plane is XZ.** City origin (0,0) = centre of the main square. - **Metres. +Y up. Ground plane is XZ.** City origin (0,0) = centre of the main square.
- **Right-handed three.js defaults.** Buildings face their street; a facade's outward normal - **Right-handed three.js defaults.** Buildings face their street; a facade's outward normal
points at its street edge. GLB convention: origin at base, facing **Z**, real-world scale. points at its street edge.
- **THE TWO CONVENTIONS — they are different things, and conflating them cost nine rounds
(ROUND27). Read both:**
- **The FACADE convention (the lots contract, `lot.ry`): the visible shopfront is local `+Z`**
world `(sin ry, cos ry)`. This is B's canon and the de facto truth since v1 — `buildings.js`
draws the facade quad and door at local `z = +d/2`, `venue.js` seats the door-glow/queue/
posters there. **`lot.ry` aims +Z at `frontEdge`.**
- **The SIM/RIG convention (props, not lots): a GLB is modelled facing `Z`** (origin at base,
real-world scale) ⇒ rig-forward is world `(sin ry, cos ry)`. This describes *how a model is
authored*, **not which way a lot faces.**
- **They are opposites, so a check written in one silently passes over a world built in the
other.** CITY_SPEC:71 wrote the SIM convention into the LOTS contract by ambiguity; each
generator implemented one half; every golden stayed green over a town whose every shopfront
faced away. **Never assert a lot's facing against a spec line — assert it against the
geometry the renderer actually draws** (`selfcheck.js` now does exactly that).
- **Seeded everything, `Math.random()` is banned in generation code.** - **Seeded everything, `Math.random()` is banned in generation code.**
`web/js/core/prng.js` is the only randomness source: `web/js/core/prng.js` is the only randomness source:
- `seedFor(citySeed, kind, id)` → stable uint32 (xmur3 hash of `"seed:kind:id"`). - `seedFor(citySeed, kind, id)` → stable uint32 (xmur3 hash of `"seed:kind:id"`).
@ -68,8 +82,11 @@ Schema v1 (Lane A owns it; extend, don't break):
// onto the block so consumers can theme it without a lookup (Lane A extension, non-breaking). // onto the block so consumers can theme it without a lookup (Lane A extension, non-breaking).
lots: [ { id, block, x, z, w, d, ry, frontEdge, use } ], lots: [ { id, block, x, z, w, d, ry, frontEdge, use } ],
// use: 'shop' | 'anchor' | 'house' | 'yard' | 'stall' | 'infill' // use: 'shop' | 'anchor' | 'house' | 'yard' | 'stall' | 'infill'
// ry: Y-rotation (radians) so a GLB modelled facing Z ends up with its facade's OUTWARD normal // ry: Y-rotation (radians) so the lot's VISIBLE FACADE — local +Z — points at frontEdge.
// pointing at frontEdge. World facing = (sin ry, cos ry). Lane B rotates the shell by ry. // Facade/door/frontage normal = local +Z ⇒ world (sin ry, cos ry). Lane B rotates the shell by ry
// and draws the shopfront at local z = +d/2 (buildings.js), so +Z IS the street face. ROUND27.
// (The SIM/rig convention — a GLB modelled facing Z, world (sin ry, cos ry) — is a DIFFERENT
// thing and is documented as such below. It is not the lots contract. See the amendment.)
// frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent). // frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent).
shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close], openLate? } ] shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close], openLate? } ]
// type: see SHOP TYPES below. hours: [open, close], 24h integers, 0 ≤ open < close 23. // type: see SHOP TYPES below. hours: [open, close], 24h integers, 0 ≤ open < close 23.
@ -256,9 +273,30 @@ night in v1; interior-mapping shader is a stretch goal).
> did not disappear. `gigs`, `weather`, `winmap`, `tram` now default **ON**; the default boot is the full > did not disappear. `gigs`, `weather`, `winmap`, `tram` now default **ON**; the default boot is the full
> experience, deterministic to the **gig golden `0xb1d48ea1`**. Each flag keeps a `=0` opt-out, and > experience, deterministic to the **gig golden `0xb1d48ea1`**. Each flag keeps a `=0` opt-out, and
> **`?classic=1`** forces all four off at once: > **`?classic=1`** forces all four off at once:
> - **Classic law (the new frozen regression target):** the `?classic=1` boot is byte-identical to the v2 > - **Classic law (the frozen regression target):** the `?classic=1` boot is byte-identical to the v2
> baseline **forever** — synthetic fingerprint `0x3fa36874`, zero gig layer, **zero fetch delta** (no > baseline — synthetic fingerprint **`0x5f76e76`**, zero gig layer, **zero fetch delta** (no
> `custom_bands.json`, no `sit.glb`). The old "flags-off regression" gate is now the **classic-regression** gate. > `custom_bands.json`, no `sit.glb`). The old "flags-off regression" gate is now the **classic-regression** gate.
>
> > **THE COVENANT AMENDMENT — ROUND27, THE FACADE FIX (John's ruling; the only amendment in five epochs).**
> > The covenant froze `0x3fa36874` "forever" from R16 to R27. That hash anchored **a wrong world**: every
> > shopfront in every town — synthetic and real — faced *away* from its street, because CITY_SPEC:71 wrote
> > the SIM convention into the LOTS contract (see *Units, axes*). The covenant did exactly its job for nine
> > rounds: it held the base town byte-identical. It just held it identical to something broken, and no gate
> > could see it — a self-consistent check over a self-contradicting spec passes under either convention.
> >
> > **The covenant's purpose is anti-drift, not the preservation of a defect.** So on John's ruling it is
> > **amended once, deliberately, in the open**: the anchor moves `0x3fa36874 → 0x5f76e76`, and every other
> > golden moves with it (gig `0xb1d48ea1 → 0xec7a2d39`, the three marched fixtures, all 23 real towns base
> > + gig — **51 pins in one commit**, per the amendment law). The classic town is now what it always claimed
> > to be: a street you can stand on and read the shopfronts. **From this hash forward the law reads as it
> > always did — byte-identical forever.** An amendment requires a John-level ruling and a demonstrated
> > world-level defect, not a convenience; this is the precedent, not an opening.
> >
> > *Exposed by the same fix (and fixed with it): `buildRealRoads` seated blocks at `KERB = 4 m` from the
> > centreline while a `main` carriageway is 10 m wide — its kerb is at 5 m. Every main-street building has
> > stood 1 m inside the road since R18. Invisible while nothing was drawn on that face; the moment the
> > shopfront turned around, its poster landed in the traffic lane. Blocks now seat behind the kerb per the
> > corridor law (`roadWidth/2 + FOOTPATH`). Pack drops 6.4% → 6.6%, counted.*
> - **Default law:** the no-flag boot pins to the gig golden, and every gig / district / continuity / cover > - **Default law:** the no-flag boot pins to the gig golden, and every gig / district / continuity / cover
> smoke **and the budget law** (interior ≤350 draws · street ≤300 / 200k tris, at the busiest venue block) > smoke **and the budget law** (interior ≤350 draws · street ≤300 / 200k tris, at the busiest venue block)
> now run against the **default boot with all four flags live**. `?noassets=1` composes with it and stays > now run against the **default boot with all four flags live**. `?noassets=1` composes with it and stays

View File

@ -2,6 +2,68 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## Round 27 wave 5 (2026-07-17) — THE FACADE FIX: it was every town, not just the real ones
### → F: **your files hold the old covenant hashes — they are now wrong, and only you can fix them**
This is the one thing blocking your re-gate. `scaffold_check` is **RED** until you re-pin, and I can't
touch your files. Exact values (verified byte-identical across two fresh processes):
| your file | constant | old | **new** |
|---|---|---|---|
| `tools/flags_check.py:30` | `GOLDEN_HASH` | `0x3fa36874` | **`0x5f76e76`** |
| `tools/flags_check.py:31` | `GIG_GOLDEN` | `0xb1d48ea1` | **`0xec7a2d39`** |
| `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.melbourne` | `0x34cfdec0` | **`0x9c7e76b3`** |
| `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.katoomba` | `0x0f652510` | **`0xf3aafec8`** |
| `tools/qa/scaffold_check.mjs:225` | the comment quoting all three | — | same three |
| `tools/qa/tour_v5.py:42`, `tour_shots.py:10` | captions quoting `0x3fa36874` | — | **`0x5f76e76`** |
Also: `flags_check.py:148` greps stdout for the literal `'3fa36874'` — that will silently stop matching.
### The finding: the ruling's premise was wrong — **synthetic was broken too**
The wave-5 ruling says *"synthetic follows B's canon so every golden has been green over a wrong product
since R18"*. Half right, and the wrong half is load-bearing. **Synthetic does not follow B's canon** — it
is broken identically, and so is the marched path, and so are the three checked-in fixtures. It is not
"every real town since R18": it is **every town, every path, since v1**. Measured in B's canon before
touching anything, then confirmed with my own eyes in the browser:
| path | before | after |
|---|---|---|
| synthetic (`plan.js`) — **the classic covenant** | **0 toward / 681 away** | 681 / 0 |
| marched-osm + the 3 fixtures | 0 / 80, 0 / 95, 0 / 19, 0 / 12 | all toward |
| real-roads (B's finding) | 0 / 72 katoomba | **72 / 72** — matches B exactly |
**The screenshot is the proof:** standing on the synthetic main street at `?classic=1`, lot 0 — three blank
grey boxes. Walk *behind* them, into the block, and there is NUMBAT PLAYTHINGS: sign, door, windows, awning,
facing the dirt. Same camera after the fix: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT PLAYTHINGS, a
strip of shopfronts facing the road. **637 of 681 synthetic lots had their facade pointing at no street at
all.**
**Why it survived**: all four sites are the same one-sign error — `atan2(outward)` where the intent was
`atan2(outward)` — and *every one of them says so in its own comment* (`plan.js:122` "facade faces back
down the outward normal, to the street"). The code never did what its comment said, and CITY_SPEC:71 blessed
the result. Nobody was wrong; the spec was.
### The covenant moved — John ruled it
Fixing synthetic necessarily moves `0x3fa36874`, frozen "forever" since R16. That was not mine to decide.
**John's ruling: amend the covenant, fix all paths** — the covenant is anti-drift machinery, not a defect
preservative. **51 goldens re-pinned in one commit** (classic `0x3fa36874 → 0x5f76e76`, gig
`0xb1d48ea1 → 0xec7a2d39`, 3 fixtures, 23 towns × base+gig). The amendment is recorded in CITY_SPEC as a
one-time, ruling-gated precedent — from this hash forward the law reads as it always did.
### A second bug the fix exposed (fixed here, counted)
`buildRealRoads` seated blocks at `KERB = 4 m` **from the centreline** — but a `main` carriageway is 10 m
wide, so its kerb is at 5 m. **Every main-street building has stood 1 m inside the road since R18.** The
footprint never moved; only which face is the front — so it was invisible until the shopfront turned around
and its poster landed in the traffic lane (1.07 m). Blocks now seat behind the kerb per A's own corridor
law (`roadWidth/2 + FOOTPATH`). Pack drops **6.4% → 6.6%**, counted, not hidden.
### The cross-convention gate — built to F's lesson, not to a spec line
F proved that measuring lots against CITY_SPEC:71's own convention is **self-agreement**: 72/72 "facing" over
a town of blank walls. So the new gate does not restate a spec line. It **replicates Lane B's own placement
maths** from `buildings.js` (`toWorld(lot, 0, y, d/2 + 0.02)`) and asks a physical question neither spec line
can fudge: *does the quad the renderer actually draws land nearer the street than the lot centre?* It fires
if A's `ry` sign drifts **or** if B's `zf` sign drifts. It would have caught this on day one of v1.
## Round 27 (2026-07-17) — v5.0: the docs freeze — CITY_SPEC v5 + the risk list judged (ledger #6) ## Round 27 (2026-07-17) — v5.0: the docs freeze — CITY_SPEC v5 + the risk list judged (ledger #6)
### → F + Fable: **A's docs half is done and the tree is green; nothing of mine gates your tag** ### → F + Fable: **A's docs half is done and the tree is green; nothing of mine gates your tag**

View File

@ -119,7 +119,11 @@ export function generatePlan(citySeed) {
const c3 = [A.x + nx * (half + stripDepth), A.z + nz * (half + stripDepth)]; const c3 = [A.x + nx * (half + stripDepth), A.z + nz * (half + stripDepth)];
const blockId = addBlock(districtId, districtKind, [c0, c1, c2, c3]); const blockId = addBlock(districtId, districtKind, [c0, c1, c2, c3]);
out.blockId = blockId; out.blockId = blockId;
const ry = Math.atan2(nx, nz); // facade faces back down the outward normal, to the street // ROUND27 THE FACADE FIX: aim the VISIBLE facade (local +Z — B's canon, what buildings.js draws) back
// down the outward normal, at the street. The old `atan2(nx, nz)` put +Z along the OUTWARD normal, i.e.
// facing away — this line's own comment has described the intended result since v1; only the sign was
// wrong. It read correct because CITY_SPEC:71 wrote the SIM convention (Z) into the lots contract.
const ry = Math.atan2(-nx, -nz); // facade (+Z) faces back down the outward normal, to the street
const r = rng(citySeed, streamKind, streamId); const r = rng(citySeed, streamKind, streamId);
const iStart = band.insetStart ?? 0, iEnd = band.insetEnd ?? 0; const iStart = band.insetStart ?? 0, iEnd = band.insetEnd ?? 0;
let t = iStart; const end = len - iEnd; let t = iStart; const end = len - iEnd;
@ -237,11 +241,14 @@ export function generatePlan(citySeed) {
const dMarket = addDistrict('market', eastEdge - marketW / 2, (zLo + zHi) / 2); const dMarket = addDistrict('market', eastEdge - marketW / 2, (zLo + zHi) / 2);
const poly = [[eastEdge, zLo], [eastEdge, zHi], [eastEdge - marketW, zHi], [eastEdge - marketW, zLo]]; const poly = [[eastEdge, zLo], [eastEdge, zHi], [eastEdge - marketW, zHi], [eastEdge - marketW, zLo]];
const block = addBlock(dMarket, 'market', poly); const block = addBlock(dMarket, 'market', poly);
// Facades face +x (EAST, toward the spine — the market sits west of it). ry is the OUTWARD normal // Facades face +x (EAST, toward the spine — the market sits west of it). ROUND27 THE FACADE FIX: ry now
// (street→lot); a west-of-street lot's outward normal is x, so ry = atan2(1, 0) (= −π/2). The // aims the VISIBLE facade (local +Z, B's canon) at the street, so a west-of-street lot wants +Z = +x ⇒
// facade then faces (outward) = +x, matching marchStrip's convention. frontEdge is the spine // ry = atan2(+1, 0) (= +π/2). It was atan2(1, 0), which pointed +Z at x — away from the spine, into
// segment each lot actually fronts by z-band: stalls (z≈100..23) front segment 2; dept (z=55) → 3. // the market's back. Same one-sign error as marchStrip above, and the old comment described the
const ryEast = Math.atan2(-1, 0); // intended result ("the facade then faces (outward) = +x") that the code did not produce.
// frontEdge is the spine segment each lot actually fronts by z-band: stalls (z≈100..23) front
// segment 2; dept (z=55) → 3.
const ryEast = Math.atan2(1, 0);
const stallEdge = spineEdges[2]; // spine segment spanning z∈[133,0] — the stalls' band const stallEdge = spineEdges[2]; // spine segment spanning z∈[133,0] — the stalls' band
const deptEdge = spineEdges[3]; // spine segment spanning z∈[0,133] — the dept's band const deptEdge = spineEdges[3]; // spine segment spanning z∈[0,133] — the dept's band
const COLS = 5, ROWS = 8, pitch = 11, sSize = 4.5; // tidy rows in the southern ~2/3 of the square const COLS = 5, ROWS = 8, pitch = 11, sSize = 4.5; // tidy rows in the southern ~2/3 of the square

View File

@ -12,7 +12,7 @@
// A future pass can honour exact projected positions; this lands a booting, invariants-green seam. // A future pass can honour exact projected positions; this lands a booting, invariants-green seam.
import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js'; import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js';
import { SHOP_TYPES } from '../core/registry.js'; import { SHOP_TYPES, roadWidth } from '../core/registry.js';
import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js'; import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js';
import { lotCorners, obbOverlap } from './plan.js'; // shared geometry — overlap-resolve real-road lots import { lotCorners, obbOverlap } from './plan.js'; // shared geometry — overlap-resolve real-road lots
@ -327,7 +327,11 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
const half = AV_W / 2, sd = DEPTH + 2; const half = AV_W / 2, sd = DEPTH + 2;
const block = addBlock(district, 'mainstreet', const block = addBlock(district, 'mainstreet',
[[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]); [[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]);
const ry = Math.atan2(0, -1); // facade faces +z (north) to the avenue // ROUND27 THE FACADE FIX: the lots sit SOUTH of the avenue (z = zPos half DEPTH/2), so the
// facade must face +z (north) at it. `atan2(0,-1)` (= π) pointed local +Z at z — due south, away.
// The comment was right; the code was 180° off. Measured, not assumed: the marched path was NOT
// clean (the ruling asked me to check rather than trust that nobody had noticed).
const ry = Math.atan2(0, 1); // facade (+Z) faces +z (north) to the avenue
let t = SPINE_CLEAR; let t = SPINE_CLEAR;
rowShops.forEach((p, j) => { const f = fr[j]; addShop(addLot(block, t + f / 2, zPos - (half + DEPTH / 2), f, DEPTH, ry, avEdge, 'shop'), p.s); t += f + GAP; }); rowShops.forEach((p, j) => { const f = fr[j]; addShop(addLot(block, t + f / 2, zPos - (half + DEPTH / 2), f, DEPTH, ry, avEdge, 'shop'), p.s); t += f + GAP; });
}); });
@ -347,6 +351,7 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
// shopfronts, not detached boxes. Drops 7.9% → 5.5% across the five; the residual stays COUNTED, and // shopfronts, not detached boxes. Drops 7.9% → 5.5% across the five; the residual stays COUNTED, and
// dropping a crowded shop beats teleporting it onto a street it isn't on. // dropping a crowded shop beats teleporting it onto a street it isn't on.
const EPS = 6, SNAP = 3, KERB = 4, NODE_CLEAR = 3, RGAP = 0.5; // fidelity + geometry knobs (metres) const EPS = 6, SNAP = 3, KERB = 4, NODE_CLEAR = 3, RGAP = 0.5; // fidelity + geometry knobs (metres)
const FOOTPATH = 1.5; // ROUND27: kerb→shopfront. KERB alone is centreline-relative and sits INSIDE a main carriageway.
const JOIN_TOL = 12, BRIDGE_MAX = 200, SEAT_MAX = 200; // ROUND19 fragmentation ruling (metres) const JOIN_TOL = 12, BRIDGE_MAX = 200, SEAT_MAX = 200; // ROUND19 fragmentation ruling (metres)
const WKIND = { main: SPINE_W, side: AV_W, lane: 8, arcade: 6 }; const WKIND = { main: SPINE_W, side: AV_W, lane: 8, arcade: 6 };
// every dropped shop is counted (Fable's ROUND19 ruling) — and counted BY CAUSE, so the density // every dropped shop is counted (Fable's ROUND19 ruling) — and counted BY CAUSE, so the density
@ -440,8 +445,18 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
for (const { e, side, list } of perEdge.values()) { for (const { e, side, list } of perEdge.values()) {
list.sort((p, q) => p.t - q.t || p.s.id - q.s.id); list.sort((p, q) => p.t - q.t || p.s.id - q.s.id);
const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, len = Math.hypot(dx, dz) || 1, ux = dx / len, uz = dz / len; const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, len = Math.hypot(dx, dz) || 1, ux = dx / len, uz = dz / len;
const nx = side * -uz, nz = side * ux, ry = Math.atan2(nx, nz); // outward normal on the shop's real side; facade (Z) faces road // ROUND27 THE FACADE FIX (B's proven one-liner — B measured katoomba 0/72 → 72/72 and reverted it
const off = KERB, dep = DEPTH; // because this is Lane A's file). (nx,nz) is the OUTWARD normal on the shop's real side; the facade
// (local +Z, B's canon) must face back down it, at the road — so atan2(nx, nz), not atan2(nx, nz).
const nx = side * -uz, nz = side * ux, ry = Math.atan2(-nx, -nz); // outward normal on the shop's real side; facade (+Z) faces road
// ROUND27, exposed BY the facade fix: `KERB` (4 m) is measured from the CENTRELINE, but a `main`
// street's carriageway is 10 m wide — its kerb is at 5 m. So every main-street building has stood
// 1 m INSIDE the road since R18. It was invisible while the facade faced away (nothing was drawn or
// asserted on that side); the moment the shopfront turned around, its poster landed in the traffic
// lane (1.07 m). The building never moved — only which face is the front. Seat blocks behind the
// kerb per A's own corridor law (`roadWidth/2` IS the kerb), plus a footpath to stand on.
const pe = edges.find(x => x.id === e.id);
const off = Math.max(KERB, roadWidth(pe) / 2 + FOOTPATH), dep = DEPTH;
const block = addBlock(district, 'mainstreet', [ const block = addBlock(district, 'mainstreet', [
[e.a.x + nx * off, e.a.z + nz * off], [e.b.x + nx * off, e.b.z + nz * off], [e.a.x + nx * off, e.a.z + nz * off], [e.b.x + nx * off, e.b.z + nz * off],
[e.b.x + nx * (off + dep), e.b.z + nz * (off + dep)], [e.a.x + nx * (off + dep), e.a.z + nz * (off + dep)]]); [e.b.x + nx * (off + dep), e.b.z + nz * (off + dep)], [e.a.x + nx * (off + dep), e.a.z + nz * (off + dep)]]);

View File

@ -25,8 +25,23 @@ const section = s => console.log('\n' + s);
const SEEDS = [20261990, 1, 42, 777, 2000000000, 8675309]; const SEEDS = [20261990, 1, 42, 777, 2000000000, 8675309];
const SOLID = new Set(['shop', 'anchor', 'house', 'stall']); // lots realised as building shells by Lane B const SOLID = new Set(['shop', 'anchor', 'house', 'stall']); // lots realised as building shells by Lane B
// A lot's outward facade normal from its ry (GLB faces -Z at ry=0 ⇒ world facing = (-sin ry,-cos ry)). // ROUND27 THE FACADE FIX — B's canon is canonical: the VISIBLE facade is local +Z ⇒ world (sin ry, cos ry).
const facing = l => [-Math.sin(l.ry), -Math.cos(l.ry)]; // This helper used to return (sin, cos), the SIM/rig convention that CITY_SPEC:71 mis-wrote into the lots
// contract. That is why nine rounds of goldens were green over a world where every shopfront faced away.
const facing = l => [Math.sin(l.ry), Math.cos(l.ry)];
// THE CROSS-CONVENTION GATE (ROUND27 wave 5 — the lesson made law).
// F proved the trap: measuring lot.ry against CITY_SPEC:71's own convention is SELF-AGREEMENT — it returned
// 72/72 "facing" over a town of blank walls, because a self-consistent check over a self-contradicting spec
// passes under either convention. The only check that can see the contradiction compares the two conventions
// AGAINST EACH OTHER. So this does not restate a spec line: it replicates LANE B's OWN placement maths from
// buildings.js (`toWorld(lot, 0, y, zf)` with `zf = d/2 + 0.02`) and asks a physical question the prose
// cannot fudge — does the quad the RENDERER actually draws land nearer the street than the lot centre?
// If either side drifts (A's ry sign, or B's zf sign), this fires.
const facadeQuadWorld = l => { // buildings.js: toWorld(lot, 0, y, d/2 + 0.02)
const cos = Math.cos(l.ry || 0), sin = Math.sin(l.ry || 0), zf = l.d / 2 + 0.02;
return [l.x + zf * sin, l.z + zf * cos];
};
// nearest point on segment AB to point P // nearest point on segment AB to point P
function nearestOnSeg(px, pz, ax, az, bx, bz) { function nearestOnSeg(px, pz, ax, az, bx, bz) {
const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1; const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1;
@ -70,13 +85,24 @@ function structuralSuite(plan, label) {
ok(Object.values(lotShopCounts).every(c => c === 1), `${label}: no lot has two shops`); ok(Object.values(lotShopCounts).every(c => c === 1), `${label}: no lot has two shops`);
ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `${label}: every shop sits on a solid lot`); ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `${label}: every shop sits on a solid lot`);
let backwards = null; let backwards = null, drawnAway = null, worstGain = 0;
for (const l of plan.lots) { for (const l of plan.lots) {
const e = plan.streets.edges.find(x => x.id === l.frontEdge); const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; const e = plan.streets.edges.find(x => x.id === l.frontEdge); const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z); const [fx, fz] = facing(l); const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z); const [fx, fz] = facing(l);
if ((nx - l.x) * fx + (nz - l.z) * fz < -0.01) { backwards = l.id; break; } if ((nx - l.x) * fx + (nz - l.z) * fz < -0.01) { if (backwards === null) backwards = l.id; }
// THE CROSS-CONVENTION GATE: not "does ry match the spec's wording" (self-agreement — that is exactly
// what stayed green over blank walls for nine rounds), but "does the quad LANE B ACTUALLY DRAWS land
// nearer the street than the lot centre?". Physical, falsifiable, and blind to both spec lines.
const [qx, qz] = facadeQuadWorld(l);
const dCentre = Math.hypot(nx - l.x, nz - l.z);
const dFacade = Math.hypot(nx - qx, nz - qz);
if (dFacade >= dCentre) { if (drawnAway === null) drawnAway = l.id; }
worstGain = Math.max(worstGain, dFacade - dCentre);
} }
ok(backwards === null, `${label}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : '')); ok(backwards === null, `${label}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : ''));
ok(drawnAway === null, `${label}: the RENDERED facade (B's +Z quad, buildings.js maths) faces the street` +
(drawnAway !== null ? ` — lot ${drawnAway}'s shopfront is drawn on the far side (worst: ${worstGain.toFixed(2)} m FARTHER from the road than the lot centre)` : ''));
const byBlock = {}; const byBlock = {};
for (const l of plan.lots) (byBlock[l.block] ||= []).push(l); for (const l of plan.lots) (byBlock[l.block] ||= []).push(l);
@ -238,7 +264,7 @@ for (const s of SEEDS) {
// ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ──── // ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ────
// (If you intentionally change generator output, run selfcheck, copy the printed hash here.) // (If you intentionally change generator output, run selfcheck, copy the printed hash here.)
section('golden fingerprint (output-drift guard)'); section('golden fingerprint (output-drift guard)');
const GOLDEN = { seed: 20261990, hash: 0x3fa36874 }; const GOLDEN = { seed: 20261990, hash: 0x5f76e76 };
{ {
const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0; const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0;
ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`); ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`);
@ -274,7 +300,7 @@ for (const s of SEEDS) {
// (round 8) Real-data towns get the same coverage as synthetic; only the synthetic-brief checks are // (round 8) Real-data towns get the same coverage as synthetic; only the synthetic-brief checks are
// skipped. Goldens are pinned per town; the synthetic golden above stays 0x3fa36874. // skipped. Goldens are pinned per town; the synthetic golden above stays 0x3fa36874.
section('OSM plan source parity (fixture-driven, zero-network)'); section('OSM plan source parity (fixture-driven, zero-network)');
const OSM_GOLDENS = { melbourne: 0x34cfdec0, katoomba: 0x0f652510, silverton: 0xd4b351c9 }; const OSM_GOLDENS = { melbourne: 0x9c7e76b3, katoomba: 0xf3aafec8, silverton: 0x6d74c4 };
const OSM_SEEDS = [20261990, 1, 42, 777]; const OSM_SEEDS = [20261990, 1, 42, 777];
for (const town of osmTownKeys()) { for (const town of osmTownKeys()) {
const report = {}; const report = {};
@ -310,7 +336,7 @@ ok(JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })) === JSON.str
ok(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom })) === JSON.stringify(pc), `custom bands: deterministic given (seed, list)`); ok(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom })) === JSON.stringify(pc), `custom bands: deterministic given (seed, list)`);
} }
// gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file) // gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file)
const GIG_GOLDEN = 0xb1d48ea1; // ROUND16 re-pin: pickVenues prefers band_room/rsl lots whose +Z facade clears crossing kerbs (ledger #6). Was 0x4f4a549d (R15 +Z flip), 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha). const GIG_GOLDEN = 0xec7a2d39; // ROUND16 re-pin: pickVenues prefers band_room/rsl lots whose +Z facade clears crossing kerbs (ledger #6). Was 0x4f4a549d (R15 +Z flip), 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha).
{ {
const hash = xmur3(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true })))() >>> 0; const hash = xmur3(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true })))() >>> 0;
if (GIG_GOLDEN === 0) console.log(` ⚠ gig golden UNPINNED → set GIG_GOLDEN = 0x${hash.toString(16).padStart(8, '0')}`); if (GIG_GOLDEN === 0) console.log(` ⚠ gig golden UNPINNED → set GIG_GOLDEN = 0x${hash.toString(16).padStart(8, '0')}`);
@ -571,34 +597,34 @@ const TOWNS_DIR = join(HERE, '..', '..', 'assets', 'towns');
// the fragmentation ruling (junction-protected DP + cull/bridge) + E's spine-poster fix. Amendment law: // the fragmentation ruling (junction-protected DP + cull/bridge) + E's spine-poster fix. Amendment law:
// the alpha changed nothing outside the cache-schema path; synthetic/fixtures/classic/default stay frozen. // the alpha changed nothing outside the cache-schema path; synthetic/fixtures/classic/default stay frozen.
const REAL_TOWN_GOLDENS = { const REAL_TOWN_GOLDENS = {
adelaide_real: 0x5c790920, adelaide_real: 0x4b9137a5,
ballarat_real: 0xf0a9e8a1, ballarat_real: 0xf0a9e8a1,
bendigo_real: 0xc175194e, bendigo_real: 0x44f5ef2d,
bowral_real: 0xb23ee067, bowral_real: 0x5574436b,
braddon_real: 0x2f388775, braddon_real: 0xf03da36c,
brunswick_real: 0x7ecb5c3e, brunswick_real: 0xb548149e,
castlemaine_real: 0x26c128c9, castlemaine_real: 0xf3118387,
darwin_real: 0xcfb1917f, darwin_real: 0xd45d7042,
daylesford_real: 0x4ec7352c, daylesford_real: 0x96a92068,
fitzroy_real: 0x6549f61b, fitzroy_real: 0x1cb08059,
fremantle_real: 0x73d385df, fremantle_real: 0x17954f0a,
geelong_real: 0x2f65591e, geelong_real: 0xf90f4463,
glebe_real: 0x908e1835, glebe_real: 0xa9418848,
hobart_real: 0xa96328b9, hobart_real: 0xbcee1ccb,
katoomba_real: 0x420f677d, katoomba_real: 0x126a66cb,
launceston_real: 0x32b1ab7b, launceston_real: 0xa2bb4e71,
marrickville_real: 0x60148e37, marrickville_real: 0x8c8d39aa,
newcastle_real: 0xbb2c3050, newcastle_real: 0x61de3375,
newtown_godverse: 0xcf69b387, newtown_godverse: 0x1e8d49b9,
newtown_real: 0x741c18ec, newtown_real: 0x64a98d7e,
northbridge_real: 0x1a645848, northbridge_real: 0xb0c24e12,
// toowoomba_real RETIRED by E in R23 (cache deleted) — pin removed with it. A golden for a town that // toowoomba_real RETIRED by E in R23 (cache deleted) — pin removed with it. A golden for a town that
// cannot load is dead config: it can never fire, so it can never fail. Same species as the round's // cannot load is dead config: it can never fire, so it can never fail. Same species as the round's
// vacuous-gate law, one layer down. (E tried the re-bbox first and it GAMED my spacing metric — median // vacuous-gate law, one layer down. (E tried the re-bbox first and it GAMED my spacing metric — median
// NN 395.7 → 89.4 m "passes" while hub density fell 3/12 → 2/12, the pack's worst. Right call.) // NN 395.7 → 89.4 m "passes" while hub density fell 3/12 → 2/12, the pack's worst. Right call.)
redhill_godverse: 0xb2224939, redhill_godverse: 0x40d30a31,
redhill_real: 0x6046700f, redhill_real: 0x5b851696,
westend_real: 0x5b1e5ffe, westend_real: 0x6032d73a,
}; };
// ROUND20: the GIG-layer golden per real town (district + capped posters) — the base golden above is // ROUND20: the GIG-layer golden per real town (district + capped posters) — the base golden above is
// gigs-off so it never captured the poster cap. Pinned here so the ROUND20 poster cap (and the density // gigs-off so it never captured the poster cap. Pinned here so the ROUND20 poster cap (and the density
@ -606,31 +632,31 @@ const REAL_TOWN_GOLDENS = {
// ROUND21: re-pinned for the venue cluster-adjacency bias (D's relocation finding). Only the GIG goldens // ROUND21: re-pinned for the venue cluster-adjacency bias (D's relocation finding). Only the GIG goldens
// move — venue selection is gig-layer, so the base town goldens above are untouched. // move — venue selection is gig-layer, so the base town goldens above are untouched.
const REAL_TOWN_GIG_GOLDENS = { const REAL_TOWN_GIG_GOLDENS = {
adelaide_real: 0x24ad3c27, adelaide_real: 0xda729bae,
ballarat_real: 0xf097c331, ballarat_real: 0xf097c331,
bendigo_real: 0x646b2c23, bendigo_real: 0xe3e8d892,
bowral_real: 0xac809f0b, bowral_real: 0x3f37bbcd,
braddon_real: 0xc3e5cfc0, braddon_real: 0xbc51aed2,
brunswick_real: 0x8c08914e, brunswick_real: 0xc0a90188,
castlemaine_real: 0xf9713a69, castlemaine_real: 0x3c3cf400,
darwin_real: 0xbc5605b5, darwin_real: 0xd37b37cd,
daylesford_real: 0x2fb34c8a, daylesford_real: 0x7b24c5c5,
fitzroy_real: 0x690e5ef9, fitzroy_real: 0xcd31b1ab,
fremantle_real: 0xcf3426fc, fremantle_real: 0xc6449dc,
geelong_real: 0x6f47ec2f, geelong_real: 0xcd0ceac4,
glebe_real: 0x042f01fb, glebe_real: 0x8d2b12ca,
hobart_real: 0x22f80d7b, hobart_real: 0xda3211c9,
katoomba_real: 0xbf586b77, katoomba_real: 0x5eece2d4,
launceston_real: 0xeaf7ff4d, launceston_real: 0xb619b65b,
marrickville_real: 0xea21be51, marrickville_real: 0x5dc00b5f,
newcastle_real: 0x66fc2bf9, newcastle_real: 0xfc3cb144,
newtown_godverse: 0x1e266f60, newtown_godverse: 0x4153dee2,
newtown_real: 0xacdb3eee, newtown_real: 0xb6a75bb1,
northbridge_real: 0x83a1d5be, northbridge_real: 0x49fe0f2b,
// toowoomba_real RETIRED by E in R23 — see the note on the base map above. // toowoomba_real RETIRED by E in R23 — see the note on the base map above.
redhill_godverse: 0xc6f80e18, redhill_godverse: 0xb53f695d,
redhill_real: 0x673785ee, redhill_real: 0x21db6d1,
westend_real: 0xb2c7ddb6, westend_real: 0xac9e69ea,
}; };
// `index.json` is E's towns INDEX (key/town/state/shops/roads for B's selector), not a town cache — skip it. // `index.json` is E's towns INDEX (key/town/state/shops/roads for B's selector), not a town cache — skip it.
const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json') && f !== 'index.json'); const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json') && f !== 'index.json');