Files
gig-line/MEMORY.md
T
zwitschi c3aacabd46 test(vehicle): add -reversetest and record the reverse is wheel-spin, not throttle
-reversetest holds negative throttle; forward throttle drives -224.9 km/h and reverse
throttle -99.5 km/h, both backward, so the throttle sign is not the cause - the wheel
spin is inverted relative to the engine torque. Mesh orientation is fine (nose +X).
Next: check the imported wheel-bone reference pose (axle may land on X/Z, not Y).
2026-11-10 18:48:27 +01:00

1063 lines
149 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MEMORY.md
Shared working memory between sessions and collaborators. Short, dated entries with evidence: the command, the log line, or the file path.
Scope: facts and traps that cost time to establish. Planned work lives in [TODO.md](TODO.md); development direction lives in [ROADMAP.md](ROADMAP.md); formal setup, pins, and licensing live in [Docs/](Docs/).
---
## Toolchain — end-to-end asset check (done 2026-11-10)
Related tasks: [TODO.md](TODO.md#toolchain--end-to-end-asset-check-done-2026-11-10).
- `python3 Tools/review/e2e_asset_check.py` renders the Blender source, empties and rebuilds both maps, captures, judges with two models, and regenerates `Screenshots/README.md`. Last full run: all six steps passed (empty maps 9 s, demo build 10 s, review build 9 s, captures 80 s, judge 388 s).
- `Tools/unreal/ue_recreate_maps.py` deliberately does not delete the `.umap`: `new_level()` refuses a path whose package is still in memory. Prefix-only clears let experimental actors survive into saved maps.
- The judge reported 0/11 on owner-approved shots and has been wrong in both directions. See also the 2026-11-10 two-model judge entry below.
- Until its expectations are re-derived, treat a model disagreement as the signal and the verdict as noise.
## 2026-11-08 — Documentation toolchain installed; asciidoc→markdown conversion
- **pandoc 3.13** installed at `~/.local/bin/pandoc` from the [GitHub release tarball](https://github.com/jgm/pandoc/releases/tag/3.13) (statically linked, 161 MB). The Fedora repos carry only `ghc-pandoc` (Haskell library), not the CLI.
- **asciidoctor 2.0.26** layered via `rpm-ostree install rubygem-asciidoctor` — **pending reboot** to activate. As a workaround, a distrobox Fedora container (`distrobox enter arch-convert`) runs asciidoctor without rebooting.
- **prettier 3.9.10** installed globally via npm (Node v24.20.0). Project config: `.prettierrc` (`proseWrap: never`, `tabWidth: 2`), `.prettierignore` (skips `Binaries/`, `*.uasset`, `.agents/`).
- **Conversion chain:** `.adoc` → `asciidoctor -b docbook5` → `.xml` → `pandoc -f docbook -t gfm` → `.md` → `prettier --write`. Pandoc's built-in `asciidoc` reader cannot handle Asciidoctor tables or attributes — it must go through docbook5.
- **Post-processing fixes:** glossary `cols="e,2e"` emphasis leaks italic into cell content (strip `*...*` wrapping), `®` from the R key binding becomes `(R)`, anchor IDs `{#...}` stripped, escaped quotes/brackets normalised.
- **All `.adoc` files removed.** Markdown is the canonical format. Architecture documentation lives in `Docs/Architecture/` as `README.md`, twelve numbered section pages, and `about-arc42.md`.
- All `.md` in the repo passes `prettier --check` (23 files formatted, `.agents/` excluded as vendored content).
## 2026-11-08 — QA branch merged; demo is walkable
- **PR #7** squash-merged to `main` as `52b1212` — terrain collision fix, spawn correction, `UGigLineAssetTools`, `HighResShot` fix, `-qaselftest` harness.
- **QA self-test results (from merged main):** walk 750 cm / 2 s (Z steady 456), reset 0 cm from spawn, pause/unpause pass, transect 84 samples / 0 holes (ground Z 317..395).
- **Traversal release check closed:** the swept transect measures walkability rather than assuming it.
- `gameplay-crossing-0.1.0` branch deleted (already merged); `gig-line-judge` worktree removed.
- `qa-input-collision-fix-0.1.0` branch still exists locally/remote (squash merge has no shared ancestry, `git branch -d` refuses; delete with `-D` after release tag).
- `GigLine SIM` / `GigLine GROUND` TEMP diagnostic logging still in `GigLineCharacter.cpp` — must be removed before `v0.1.0` tag.
## 2026-11-08 — Reset was returning to mid-air; QA self-test switch
- **`ResetToStart()` restored a `StartLocation` captured in `BeginPlay`**, which is the PlayerStart position _above_ grade (Z 600) so the capsule never spawns embedded. Every reset therefore dropped the player 1.4 m back into the air. Fix: capture `StartLocation` on the first grounded frame (repeating 0.25 s timer, cleared once captured) — not a one-shot timer, which can fire after the player has already started walking and record a mid-stride position.
- **`-qaselftest`** drives the checks headlessly and logs `GigLine QA: ... PASS/FAIL`. It runs on a **core ticker, not a world timer**: pausing the game stops world timers, so a timer-driven test freezes exactly when it proves pause works.
- Current results: walk 746 cm / 2 s with Z steady, reset 0 cm from spawn, pause `IsGamePaused()` 1, unpause 0, transect 84 samples / 0 holes (ground Z 317..395).
## 2026-11-08 — The fall-through was an 8 m invisible box (QA branch)
- **Symptom:** player "spawned above ground but never fell", sat at Z 909, `fall=0`, `mode=1` (Walking), no fall-through, view looked plausible so it was mistaken for working.
- **Cause:** `Terrain_DEM` had only a **bounding-box simple collision proxy** (Interchange default). A line trace down at _every_ Y through the block hit `Demo_Terrain_DEM` at exactly **819** — the mesh bbox max Z. The whole street was under an invisible lid; the pawn stood on the box, 8 m above the road.
- **Proof:** a debug `LineTraceSingleByChannel` profile at Y −1912…+1447 reported `top=819 actor=Demo_Terrain_DEM` for the entire street. After the fix it reports `Sidewalk_North 395`, `Road_Hollywood 366`, `Sidewalk_South 317`.
- **Fix:** force complex-as-simple collision on the terrain. Python **cannot** set it (`StaticMesh`/`BodySetup` expose no such property; `dir()` shows only `lod_for_collision`). Added `UGigLineAssetTools::SetComplexCollisionAsSimple` in the C++ module (`BodySetup->CollisionTraceFlag = CTF_UseComplexAsSimple`, UE 5.8 no longer has `bUseComplexAsSimpleCollision`).
- **Second gotcha:** the flag lives on the **asset**, not the map. Setting it in a builder session and saving only the `.umap` loses it — `EditorAssetLibrary.save_loaded_asset()` is required, otherwise the next run reloads the bbox proxy.
- **Third gotcha:** `HighResShot 1280 720` means **multiplier 1280** (UE warns "the high resolution screenshot multiplier is too large for your system"). The correct form is `HighResShot 1280x720`.
- Resulting spawn: `(-13336, -1150, 600)` — inside the carved corridor, lands on the road at Z 456 (feet 368 on road top 366).
## 2026-11-07 — Publication audit
- MPFB 2.0.18's installed `blender_manifest.toml` identifies its code as GPL-3.0-or-later; its versioned `LICENSE.md` explicitly puts bundled mesh, texture, and rig assets under CC0 1.0. The earlier CC0 claim for the add-on code was wrong.
- Blender inspection found no packed images or linked libraries in the tracked scenes. The character scene references local MPFB textures and a temporary baked texture, so those paths are not portable to a fresh checkout.
- The 2026-11-07 publication rewrite removed the approved private documents and bundled skills from all reachable refs; local copies remain ignored. The original history backup at `/tmp/gig-line-private-history-backup-42a29fe2` contains those private files and must not be pushed; `/tmp` may be cleared by the system.
- `git ls-remote --refs origin` succeeded on 2026-11-07 with no advertised refs; `git ls-remote --exit-code origin HEAD` returned 2 because no HEAD was advertised. This does not verify LFS upload support.
- Gitleaks 8.28.0 found an Android File Server token in historical `Game/GigLine/Config/DefaultEngine.ini`. The token was removed from reachable history and the unused server disabled; the old private backup and earlier temporary sanitized clone still contain it and must not be published.
- The first Gitea push uploaded 26 LFS objects. An independent clone fetched all 26 and `git lfs fsck` passed for the full history; the server also advertised LFS locking support.
## 2026-11-07 — Unreal Engine 5.8.4
- **Extracted** to `/run/media/system/DATA/Engines/UE_5.8.4` (73 GiB, `unzip` exit 0). CL `58902052`, `++UE5+Release-5.8`.
- **Gotcha: the editor needs a writable `~/.config/Epic`.** In the agent shell it crashed with signal 11 twice and looked like a broken install. The log named it: `create dir('/home/zwitschi/.config/Epic/') failed: errno=30 (Read-only file system)` → `Fatal error: Failed to create Intermediate directory '…/.config/Epic/UnrealEngine/5.8/Intermediate/'`. Rerunning with `HOME=/tmp/uehome XDG_CONFIG_HOME=/tmp/uehome/.config` reached `LogInit: Display: Engine is initialized.`
- **Vulkan RHI is still unproven.** The successful run used `-NullRHI`. Untested: `UnrealEditor -vulkan` from a desktop terminal.
- A packaged game does not need `~/.config/Epic`, so this failure does not follow the build.
## 2026-11-07 — Agent shell limits
- The agent shell has a **read-only home** and read-only `/run`: `sudo` is blocked (`no new privileges`), `flatpak run` and `podman` cannot create instances, `/dev/nvidia*` is absent so `nvidia-smi` always fails there. None of this is a broken machine; verify GPU things from a normal terminal.
- Anything outside the session workspace (`/run/media/system/DATA/Engines`, `/tmp`) needs the wider sandbox with an approval prompt.
## 2026-11-07 — Reference photography
- **Licensing is not a gate for local development.** Reference images live in the gitignored `Art/reference/` and are never committed. What each set establishes is written into `Docs/reference-notes-hollywood.md`.
- **Commons text search runs dry; categories do not.** `Tools/gen/commons_fetch.py --category "Category:Hollywood Masonic Temple"` surfaced 15 files a search never showed, including 4–5k px façades.
- **`upload.wikimedia.org` throttles hard.** Batches of six or fewer with `--delay 3`; a throttled file is skipped and recorded rather than killing the run (HTTP 429).
- **Unqualified queries return junk.** `Hollywood Boulevard shops` returned a Münster street, `Hollywood Masonic Temple entrance` a Seattle card index. Check the hit list before accepting a set.
- **OpenRouter web search is expensive.** One `gpt-5.2` + `openrouter:web_search` call ran 17 searches and cost **$0.38**; two of its eight URLs were Commons files already held. Use it for a known gap, not a sweep. `Tools/gen/page_image_fetch.py` turns results into files by reading `og:image`.
- **The inherited "eight stone steps, 12 m wide" is wrong.** Photographs show three portals, each with its own short flight. Do not copy that number into geometry.
## 2026-11-06 — Blender and Blosm, headless
- **`flatpak run` does not work here.** Call the binary inside the app tree: `B=/var/lib/flatpak/app/org.blender.Blender/x86_64/stable/active/files`, then `LD_LIBRARY_PATH="$B/blender/lib" "$B/blender/blender" -b …` with `BLENDER_USER_SCRIPTS="$HOME/.var/app/org.blender.Blender/config/blender/5.2/scripts"`.
- **Blosm needs two headless workarounds**, both already in `Art/blender/blockout/blosm_blockout.py`:
1. it refuses to import until an asset directory is set, and the check fires before the mode is honoured;
2. in background mode the default level table is empty, and the add-on then takes a path with a broken relative import (`from gui import addDefaultLevels`). Seeding one entry avoids it.
- **Blosm projects on a sphere, not an ellipsoid.** Its frame is about 0.4 % oversized and rotated by meridian convergence — roughly 2 m at the theatre. Fixed with a six-parameter affine into EPSG:26911 (0.0 cm residual).
- **Transform roots only.** 256 of 275 objects are children; transforming both parent and child applies the correction twice and reads as a 2 m placement error.
- **Blosm quietly fetches from Overpass** for relation members missing from the extract. `loadMissingMembers` is off by default in the tool so imports stay offline and reproducible.
## 2026-11-06 — Geo data traps
- **The 3DEP export is a tiled TIFF, not strips.** A strip reader silently scrambles rows and produces fake 0 m / 127 m bands. `Geo/tools/dem_stats.py` handles tiles.
- **Sample walk routes through PROJ.** Placing samples with the OSM spherical approximation put them metres off the UTM meshes and reported 91 phantom holes. `/tmp` is not shared between tool calls here either — regenerate instead of saving intermediate files there.
- **3DEP cannot resolve kerbs.** Sharpest break near the kerb line is 0.09–0.12 m/m, a gutter transition, not a 0.15 m upstand. Kerb height stays a stated assumption.
- **Building interiors are not ground.** Points inside the hero footprint returned 114.5–117.1 m — structure, not terrain. Excluded from the relief figure.
- Two measurements independently agree on the carriageway: crossing length 20.9 m and gutter spacing −10 / +10…12 m.
## 2026-11-06 — Repository traps
- **Without `git lfs install` the lfs filter is inactive and `git add` does not warn** — it stores the file as a plain blob. `Tools/check-lfs.sh` is the guard.
- **Never commit reference images or `.env`.** `.gitignore` covers `/Art/reference/`, `.env`, `*.env`. When images slipped in early, history was rewritten to remove them.
- The project origin is Hollywood × Highland: `E 376 515.389 / N 3 774 224.083` in EPSG:26911. Blender X east, Y north, Z up, metres, Z measured from the origin ground (NAVD88 115.911 m). The whole block sits at negative X.
## 2026-11-07 — Unreal pipeline findings
- **UE 5.8 game targets must use `BuildSettingsVersion.V7`** to share the installed editor's build environment; V5 is rejected with "modifies the values of properties … not allowed".
- **`UnrealEditor-Cmd -run=pythonscript` swallows Python `print()`** with `-stdout` in 5.8 — write results to a file instead (see `Tools/unreal/ue_scale_test_import.py`).
- **`unreal.Box` has `min`/`max`, not `origin`/`get_extent()`** in 5.8 Python; compute centre/size from the corners.
- **FBX Blender→UE axis mapping is east→+X, north→−Y, up→+Z** with preset `ue_metric_v1`; the Y mirror is inherent (RH→LH). Compass math in game code: north = −Y. Verified with the three-cube scale test.
- Headless asset import works fine with `-nullrhi`; `unreal.AssetImportTask` + `FbxImportUI` is the pattern (Interchange pipeline under the hood).
- **Flatpak Blender headless needs `XDG_CONFIG_HOME=$HOME/.var/app/org.blender.Blender/config`** for extensions: `bpy.utils.user_resource('EXTENSIONS')` otherwise resolves to `~/.config/blender/5.2/extensions` (empty) and `bl_ext.blender_org.mpfb` is undiscoverable. Blosm unaffected (scripts/addons via `BLENDER_USER_SCRIPTS`). Found via `Tools/addon_compat_probe.py`; documented in `Art/blender/README.md`.
- **UE 5.8 Interchange skips material/texture assets for skeletal FBX imports** (static imports do get materials). Finish the chain in Python: import texture, build Material via `MaterialEditingLibrary`, assign with `sm.set_editor_property('materials', [SkeletalMaterial])` — the struct field is `material_interface`, not `material`, and `SkeletalMesh.set_material()` does not exist. See `Tools/unreal/ue_character_import.py`.
- **Blender 5.2 FBX exporter removed `armature_nla`** (passes for 4.x recipes fail). Also: a baked image is only referenced by the FBX exporter when `image.filepath` (not just `filepath_raw`) is set and the exporter can map the node — MPFB's v2 skin node-group tree can't be mapped, so a flat export material is required.
## 2026-11-07 — Collaboration and scope
- **Two sessions edit this repo at once.** The other session created the Unreal project and the blank package while this one was documenting. Check `git log` and `git status` before committing, and never `git add -A` blindly: it once swept generated `Game/GigLine/Build/**` logs into a commit. Stages the source files deliberately.
- **`Build/` under a project is generated**, not source — `FileOpenOrder/*.log` and packaging output. `.gitignore` now covers it alongside `Binaries/`, `Intermediate/`, `Saved/`, `DerivedDataCache/`.
- **Scope boundary for `0.1.0`:** do not tag `v0.1.0` without an explicit go-ahead from the owner. It is a minor release and carries the walking/driving demo promise; the board's release checks decide when it is ready, not the pace of work.
## 2026-11-07 — Screenshot pipeline
- **`-executePythonScript` + editor is unreliable for captures**: the editor quits right after a _successful_ deferred script (async screenshot requests never render), and after `load_map` in a commandlet every `spawn_actor` call returns None — build scenes in the default world instead.
- **Robust in-game capture recipe**: commandlet builds a map (`ue_build_review_map.py`), then the game runs in `-game` mode and a tiny C++ actor (`AGigLineShotActor`) walks camera setups via `SetViewTarget` + `FScreenshotRequest::RequestScreenshot`. In 5.8 `FScreenshotRequest` has no resolution argument (window size via `-ResX/-ResY`) and no `IsFinished()` — poll `!IsScreenshotRequested()`. Lives in `UnrealClient.h`, not `Misc/ScreenshotManager.h`.
- **Commandlet-only quirks**: bulk render-target readback (`read_render_target_raw`, `export_render_target`) silently returns empty in commandlets; single-pixel `read_render_target_uv` works. Game targets must use `BuildSettingsVersion.V7`.
## 2026-11-07 — Enhanced Input from Python (UE 5.8)
- **`unreal.InputCoreTypes` does not exist** in the 5.8 Python bindings, and `unreal.Key` takes no constructor argument. Build a key with `k = unreal.Key(); k.import_text("SpaceBar")` — the export text is the key name (`MouseX`, `Gamepad_Left2D`).
- **Factories are `unreal.InputAction_Factory` and `unreal.InputMappingContext_Factory`**, not `InputActionFactory`. `create_asset(..., factory=None)` also works.
- **Do not delete an asset and recreate it in the same commandlet run.** `create_asset` returns `None` for a path deleted earlier in that process. Load the existing asset and overwrite it instead (`Tools/unreal/ue_create_input_assets.py: fresh()`).
- **Modifiers on `EnhancedActionKeyMapping` do not survive saving.** Assigning `InputModifierNegate` to the mapping's `modifiers` reads back correctly in memory, even after a `gc.collect()`, but the saved asset reads `mods=[NoneType]`. Keep-alive references, explicit flag setting, and in-place saves all failed to change it. Workaround: bind one action per direction (W and S as separate actions) so no modifier is needed.
- **A read-only packed `.umap` silently blocks `save_map`.** `EditorLoadingAndSavingUtils.save_map` returned `False` with no error until the file's mode was fixed (`chmod u+w`). Symptom: a rebuilt review map keeps three cameras after a fourth is added.
- `-run=pythonscript` still swallows `print()`, so write results to a file; `/tmp` is visible to the commandlets even though it is not shared between agent tool calls.
## 2026-11-07 — Hero facade build and review cameras
- **The review floor was 5 cm thick and centred at Z -50 cm**, so anything placed at Z 0 floated ~1.8 m. It is now a 90 m x 90 m x 10 cm slab with its top face at Z 0; a character's bounds should read Z 0..83 cm when it stands on it. **The review floor is the only ground in the scene.** Cameras 25 m from the building line look past its edge and see the black horizon band under the facade. Place facade cameras so both the facade and enough floor are in frame, or extend the floor.
- **The facade's frontage faces Unreal -Y.** The FBX preset maps Blender +Y (north) to Unreal -Y, so the elevation looks at negative Y and a straight-on camera must stand at **Y < -1450 cm**. A camera at positive Y looks at the back wall; cameras inside Y -1443..+250 are inside the building and render flat blue.
- **The imported mesh is centre-origin in X but not in Y or Z.** Bounds are `x -1302..1302, y -7..1250, z 0..1450` cm, so to put the front plane on the building line the actor goes at `Y = line + 7 cm`, and `X = east edge + half width`.
- **`save_map` returns False, without an error, when the packed `.umap` is read-only.** The review map silently keeps its old camera list. `chmod u+w` the file before rebuilding; a `umap` that keeps rendering four shots after a sixth was added is this, not a caching problem.
- **`StaticMaterial` is a struct**: `material.get_editor_property("material_interface")`, no `get_name()` on the wrapper.
- **Review screenshots are committed on the owner's instruction (2026-11-07).** They are large PNGs, so a review run adds weight to the repo; keep the shot list short and delete retired shots rather than accumulating.
## 2026-11-07 — Sibling project reuse survey
- **`security-game` holds 20 reusable street props** with Blender sources (`assets/models/blender_src/*.blend`) and Godot GLBs: bollard, bench, trash bin, stanchion, crowd barrier, street lamp, traffic cone, fire hydrant, newspaper box, palm/street trees, sedan, marquee, doors, lanterns. About 727 KB and 11.8k triangles for the set. Audit: `Tools/sibling_asset_audit.py`, manifest at `Art/props/reuse-manifest.json`; the 50 `.blend` sources are copied to `Art/props/blender_src/`.
- **Their building meshes are not reusable** — they are Godot GLBs built to a different key set (hero facade scaled to 29.6 m, a figure this project measured at 25.75 m). Do not import their venue geometry.
- **`tools/generate_textures.py` in that project is the OpenRouter image endpoint pattern** (`POST /api/v1/images`, `b64_json`, optional `reference_path` for image-to-image on the chat endpoint) and it already has procedural `brick_facade` / `seamless_concrete` / `asphalt` generators. Worth porting for our facade materials.
- **Its AI concept frames are restaged photograph references** (`.refgen.json` sidecars point at photos with a "keep geometry fixed" suffix). Useful as art direction, not as texture sources.
- **Blosm exposes `setupScript`** (`scene.blosm.setupScript` → `loadSetupScript`) for custom building materials, and ships `setup/neoclassical.py` as an example — the hook for giving OSM buildings a per-building facade style instead of flat colours.
## 2026-11-07 — Geo tooling layout
- **One conversion, one place.** `Geo/tools/osm_common.py::to_unreal_local` is the only metres→centimetres conversion; every exporter calls it. `os_common` also needed an explicit `import os` at module level — a helper defined with a function-local import list crashed with `NameError: os` when called from a fresh interpreter.
- **`asset_metadata.py audit` must recurse.** Sidecars sit next to their `.blend`, which is one level below `Art/blender/`; a flat `glob` found nothing and reported "0 sidecars, 0 problems" — a green result that meant "did not look".
- **Blender-side builders live in `Art/blender/*/`** (terrain, street, site, blockout) and reach helpers via `os.path.join(HERE, "..","..","..","Geo","tools")`. Moving them breaks the old `"..", "Geo", "scripts"` path silently until the script is run.
- **Height provenance:** 3 of the 15 block buildings carry no OSM height and Blosm renders them at a placeholder 6.4 m. `Geo/tools/block_height_report.py` lists them; they need heights measured off the photographs before the skyline is believable.
## 2026-11-07 — Blender rebuilds that silently did nothing
- **A read-only `.blend` makes Blender skip the save without failing the run.** `Tools/blender/hero_facade_build.py` printed its part list and "bounds" as normal, then went straight to the export: the `.blend` and the report stayed at their old timestamps, and the Unreal re-import brought back the old mesh. Symptom to watch for: the imported bounds do not change after a rebuild. Fix: `chmod u+w` the `.blend` first, then confirm the timestamp moved.
- **Model bottom must land on Z 0.** The hero portal jambs were sized `PORTAL_H + 0.3` and centred at `PORTAL_H / 2`, so they hung 0.15 m below the pavement and the whole facade imported with `min z = -15 cm`. Always check the asset's `bounds_m.min.z` in the build report.
- **Rebuilding a facade is not enough to make a detail shot work**: doors modelled as thin panels on a solid plinth read as shading, not openings. Cutting the plinth into piers with a lintel band is what makes them legible from 8 m.
## 2026-11-07 — Demo map assembly traps
- **Unreal signs, measured not assumed.** The FBX preset flips north, so in the demo map the road spans Y -1382..+1047, the **north** sidewalk Y -1912..-1053, the **south** sidewalk Y +718..+1447, and the facade sits at Y -1450. A PlayerStart at +1600 is on the south sidewalk behind the building. Read the actor bounds before placing anything.
- **Street meshes carry only PART of the sidewalk.** In the source `.blend`, `Sidewalk_South` is the offset band (-14.47..-7.18 m) and `Sidewalk_North` is the kerb-to-wall band (10.53..19.12 m); the rest of the north walk comes from the terrain. So a sidewalk-looking strip of ground between kerb and wall is the terrain, not a gap.
- **Do not mirror in the exporter.** `Sidewalk_South` was already correct in the `.blend`; mirroring both sidewalks at export moved the correct one to the wrong side and produced an "everything on the wrong side" look that took three rebuilds to unpick. Check the source coordinates first.
- **`bpy.ops.mesh.normals_make_consistent` needs a mesh context**; in object mode it fails with `poll() failed, context is incorrect`. Flip winding by reversing `polygon.vertices` instead.
- **`StaticMesh` has no trace-flag property in Python.** Collision complexity is set through `mesh.get_editor_property("body_setup").set_editor_property("collision_trace_flag", …)`; `StaticMeshEditorSubsystem.get_collision_complexity` reads it back but there is no setter.
- **A `-game` run with a paused world still exits on timeout**, so a smoke test that "hangs" may simply be running.
## 2026-11-07 — Documentation sweep
- **Docs drift silently after a refactor.** Moving the geo scripts to `Geo/tools/` and the Blender builders into `Art/blender/*/` left stale paths in six documents and two tool READMEs. Grep for old paths (`.md` and `.py`) after any move, not just the ones that error at runtime.
- **Every new tool needs a row in `Tools/README.md` and `Docs/build.md`**, or the next session will not know it exists. Both now list the full content pipeline in order.
## 2026-11-07 — Character diffuse bake
- **`img.save()` writes black for an image created in memory** even though Cycles `bpy.ops.object.bake` does update `img.pixels` correctly (verified in-memory px were warm). Writing the PNG ourselves from `img.pixels` is required.
- **A per-pixel `struct.pack` loop over `img.pixels` is catastrophic in headless Blender** (256² took 25 s). Blender ships numpy: read `img.pixels[:]` once into a float array, reshape `(h,w,4)`, clip→uint8, reverse rows for PNG top-down, then zlib-compress. Whole 1024² encode ≈ 0.3 s.
- **MPFB's v2-skin node tree does not resolve to a simple base colour**, so a Cycles DIFFUSE bake returns black. Since the FBX export material replaces the node tree anyway, synthesize the diffuse directly (`make_diffuse`): warm skin body + a darker shorts-suggestive bottom band.
- **A read-only `.umap` silently makes `save_map` return `False` with no error** (already recorded); re-hit in the review pipeline — `chmod u+w` the maps before rebuilding, otherwise the new character material never appears in shots.
## 2026-11-07 — screenshot judge tool
- `Tools/review/judge_screenshot.py` — runs an OpenRouter vision model over a screenshot vs an expected-scene description. Reads `OPENROUTER_API_KEY` from the repo `.env` (ignored). Base64 data-URI image under `image_url.url`; OpenAI-compatible body → `POST /api/v1/chat/completions`. `openai/gpt-4o-mini` returns a `{verdict, confidence, reason, missing}` JSON. It flags genuinely missing content (e.g. `main`'s pre-warm-skin character shot judged "no"); it is a pre-check, not the human verdict.
- Test one: `Tools/review/judge_screenshot.py --image Screenshots/review-01-character.png --expect "<scene>" --verbose`. Bulk: `--shots` (parses the README rows).
## 2026-11-07 — Blender skill packs: discovery depth
- **The 94 packs were never registered with the `skill` tool**, only readable as files. The loader resolves `<skills-root>/<pack>/SKILL.md` one level down; `.agents/skills/blender-skills/` is a _container_ of 94 packs, so it was skipped silently (no warning, no error). Symptom: `skill("archviz")` unresolvable even though `archviz/SKILL.md` exists.
- **Fix applied:** 94 symlinks in `~/.agents/skills/<pack>` → `<repo>/.agents/skills/blender-skills/<pack>/`. The catalog then lists all of them (verified by calling `skill` on `collision-proxy`: base dir resolves through the link). `references/` stays unlinked — it is a shared folder with no `SKILL.md`, not a pack.
- **The AGENTS.md "MCP-driven" caveat is overstated.** 41 of 94 SKILL.md mention MCP, mostly only in the `description` line; the bodies are method guides (naming, proportions, validation checklists) that work headless. Do not skip a pack because it advertises MCP.
- **Skill names in AGENTS.md/TODO.md all resolve** except `references`, which is the folder above, not a pack.
- Symlinks are user-level: a fresh machine needs the same 94 links before the packs are loadable by name.
- **A shared checkout can commit your uncommitted edit for you, or lose it.** This entry was written but not yet committed when another session ran a `git commit` that included `MEMORY.md`; it landed inside their commit (`54b6239`) with a message that does not mention it. Had they overwritten the file instead, the work would have been gone with no reflog to recover from. On a shared worktree, either commit a shared file immediately or check `git log -S` for it before assuming the edit is missing.
## 2026-11-07 — Street visibility and the LFS lockable trap
- **Every street surface (road, sidewalks, kerbs, markings) had 100 % downward face normals** — `strip()`'s natural winding (rows along +s, cols along +normal) faces the ground. Invisible in engine for as long as the terrain covered them; the corridor cut exposed the black trench. Fixed by reversing the winding in `strip()`, `kerb_faces()` and `crossing_band()`.
- **`*.blend` and `*.umap` are LFS-lockable in `.gitattributes`** — Git LFS checks them out read-only (444). `bpy.ops.wm.save_as_mainfile` then fails ("path is not writable") and `EditorLoadingAndSavingUtils.save_map` silently returns False. All Blender builders now chmod u+w before saving and treat a failed save as fatal; the UE map builders chmod the .umap first. A failed save used to leave downstream steps building from stale files — that produced a degenerate-mesh FBX (all-same vertex indices from an index-arithmetic bug) that cost an hour to trace.
- **FBX SDK refuses to triangulate the crown-twisted crossing quads** ("unable to triangulate mesh ... will be omitted") — emit triangles from the builder, and make sure per-stripe vertex indices advance (`len(verts)` must be captured before the loop, not per-corner).
- Demo-map coordinate note: UE Y is south-positive; the facade at Y −1450 and the player start at Y −3500 are on opposite sides of the road (Y −1382..+1047) — arrangement is correct; the Sidewalk_North/South mesh names are swapped relative to their physical sides (documented in export_site_fbx.py).
## 2026-11-07 — ReviewMap corruption: deprecated level calls + save-order bug
- **`EditorLevelLibrary.load_level`/`new_level` are deprecated no-ops in `-nullrhi` commandlets** (they return without switching worlds, so staged actors land in whatever world is current and get saved over the wrong map). Use the `LevelEditorSubsystem`: `unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).load_level(path)` actually switches. Symptom was ReviewMap containing Demo actors + `ShotSequencer`.
- **Map-save ordering bug in `Tools/unreal/ue_build_review_map.py`**: the script saved ReviewMap _after_ the DemoMap section, but the switch back to ReviewMap fails silently because the map only exists in memory at that point — so the final `save_map` wrote the demo world over ReviewMap. Fix: save ReviewMap first, then stage the demo cameras, and never switch back. Both maps verified by probe (ReviewMap lists Review actors; DemoMap lists Demo actors).
- Rebuilt ReviewMap after the fixes: character, both cubes (A grounded, B floating at 2 m), pawn, NPC prefab, hero facade all present; user re-approved the shots. README cube naming corrected (README had A/B swapped vs the script).
## 2026-11-07 — Materials and lighting in the demo map
- **Blender flat diffuse colours do not survive FBX -> Unreal.** Every site mesh (road, sidewalks, kerbs, crossing paint) imported with a material asset that had a default _white_ base colour, and the terrain imported with `WorldGridMaterial` (UE's bright grey grid). That — not the sun — is what made the demo look blown out and washed white. Fix: create the colours in UE (`M_Asphalt`, `M_Concrete`, `M_Ground`, `M_RoadPaint` via `Constant3Vector`) and assign them per actor in `Tools/unreal/ue_build_demo_map.py`.
- **Auto-exposure makes review shots non-comparable**: the same road rendered washed-out in one framing and near-black in another because each camera saw a different amount of sky. Fix: an unbound `PostProcessVolume` with `AEM_HISTOGRAM` and `auto_exposure_min_brightness == auto_exposure_max_brightness` (fixed exposure). Do not use `AEM_MANUAL` + `auto_exposure_bias` — a bias of 11 blew the whole frame out.
- **`DirectionalLightComponent.light_color` takes a `Color` struct, not `LinearColor`** (`unreal.Color(r=255,g=245,b=230,a=255)`); passing `LinearColor` raises a nativize error.
- **One band per object loses geometry downstream.** `crossing_band()` created `Crossing_Markings` each call, so Blender renamed the second and third to `.001`/`.002`; `site_assembly.py` appends by exact name, so only one crossing ever reached the game. Build all bands into a single mesh instead of one object per band.
- **Street arc length -> UE X:** s=0 is the west end of the block (UE X −27966 cm) and s increases eastward, so the designated mid-block crossing at s=130.1 m is UE X ≈ −14956, not the −24291 used by an earlier camera (that is the s=36.6 crossing).
- **Camera placement in the demo map needs the mesh bounds, not intuition.** The hero façade body spans UE Y −1457..−200, so a "street level" camera at Y −400 is _inside_ the building and renders its unlit back face. Read `get_bounding_box()` on the staged meshes before choosing a camera.
- **"LIGHTING NEEDS TO BE REBUILT" (Build Lighting Only / `BuildLighting`) appears whenever a map has Stationary/Static lights and no baked lightmap.** For a fully dynamic scene the clean fix is `r.AllowStaticLighting=False` in `Config/DefaultEngine.ini` plus `set_mobility(ComponentMobility.MOVABLE)` on the directional light, sky light and height fog (and MOVABLE on the staged mesh components). Nothing to build, warning gone. Note the cvar is read at startup, so each new commandlet/game run picks it up.
- The Android File Server `SecurityToken` kept reappearing in `Config/DefaultEngine.ini`; the plugin is disabled and excluded from shipping, so the value is cleared and must stay empty — it is a credential and must not be committed.
## 2026-11-07 — Worktrees and the material look pass
- **A fresh git worktree has no compiled game binaries.** `Binaries/` is gitignored, so `UnrealEditor-Cmd` on a new worktree exits "abnormally (error code: 1)" before running the Python script, with no Python error in the log. Run `Engine/Build/BatchFiles/Linux/Build.sh GigLineEditor Linux Development -project=<worktree>/Game/GigLine/GigLine.uproject` first.
- `/tmp/gigline/*.txt` result files are **shared across worktrees**. Reading `/tmp/gigline/demo-map.txt` after a run can show another worktree's stale output — check the file mtime, or use a per-worktree result path.
- **Flat Constant3Vector materials read as plastic.** Giving each surface a roughness plus a world-space-noise blend between two shades (WorldPosition → Multiply → Noise → Lerp between two Constant3Vectors → BaseColor) is enough to stop the blockout looking like a toy; no textures required.
## 2026-11-07 — The Y-side bug (found when the frontage was added)
- **UE convention in this project: south = +Y, north = −Y.** Ground truth is the street mesh: `Sidewalk_North` is Y −1912..−1053 and `Sidewalk_South` Y +718..+1447.
- The hero facade had been placed at **Y −1450 (north side)** and X −12024..−9420 — 26 m east of the 6840 footprint and on the wrong side of the street. It only looked right because it stood alone in an empty field; the approved screenshots were of a building with no context.
- The two north-side buildings (TCL, Tussaud's) were similarly on the **south** side: `export_north_buildings.py` mirrored Y, which put them where the frontage belongs.
- Fix: hero at X −13336 / Y +1307 (the frontage gap between 6902 and 6838); frontage and north buildings exported **without** a Y mirror (the FBX preset already maps Blender −Y south to UE +Y south).
- Lesson: **place a building only against a neighbour or the street mesh, never in isolation.** A single object in an empty map hides any number of placement errors; adding the adjacent frontage immediately exposed this one.
## 2026-11-07 — walkable demo QA bugs (fix branch qa-input-collision-fix-0.1.0)
- **"Stuck" window = wrong default map.** A plain `./GigLine.sh` opened `/Engine/Maps/Templates/OpenWorld` (empty engine template) instead of the demo, so nothing was interactive. `GameDefaultMap` was in `DefaultGame.ini` and not applied; it must live in `DefaultEngine.ini[/Script/EngineSettings.GameMapsSettings]` with `GlobalDefaultGameMode`. Verified: plain launch now loads `/Game/Hollywood/DemoMap`. Evidence: `LogLoad: LoadMap: /Engine/Maps/Templates/OpenWorld`.
- **Input assets were pruned from the pak.** The Enhanced Input assets under `/Game/Input` were only soft-referenced at runtime, so the cooker excluded them (cook `ReferencedSet.txt` had zero `/Game/Input` lines -> all inputs nil at runtime). Fix: hard-reference via `ConstructorHelpers::FObjectFinder` in the character ctor (cooker follows hard refs) + `+DirectoriesToAlwaysCook`. Packaged log then showed `ctx=ok move=ok ... jump=ok`.
- **Character fell through** the site: site meshes staged MOVABLE + PlayerStart far past the north sidewalk. Fix: STATIC mobility + explicit collision (QUERY_AND_PHYSICS/WorldStatic/BlockAll) and PlayerStart at Y -1200 on the sidewalk. Packaged run: `grounded=1 movement=Walking`.
- Diagnose packaged runtime via the engine log (`.../Linux/GigLine/Saved/Logs/GigLine.log`); a headless `-nullrhi` commandlet cannot validate physics traces (even a spawned cube returns NO HIT there).
## 2026-11-08 — reference photography gaps closed (local-only)
- Closed the two missing reference angles for the hero façade, both from Wikimedia Commons (gitignored deliverables under `Art/reference/`, provenance in sidecars):
- `el-capitan-entrance/` — `File:Hollywood El Capitan entrance.jpg` (+ detail), Andreas Praefcke, CC BY 3.0. This is the isolated hero (6840) entrance/door recess that the notes doc had claimed Commons didn't hold. Query that found it: `--query "El Capitan entrance"`.
- `el-capitan-night/` — `File:El Capitan theater with Tron marquee.jpg` (CC BY-SA 3.0) + West Side Story premiere (Adam Jones, CC BY 2.0). The theatre is the building NEXT to 6840; treat as proximity evidence only, not the hero.
- Commons throttles hard: HTTP 429 after a batch. Fetch fewer files with `--delay`/separate calls and wait ~15-20s between runs (`queue.py` tool default 1 req/s is insufficient once rate limited).
- Task was claimed on branch `ref-photo-0.1.2`; delivers only the TODO closure (all actual reference files are gitignored, so no code branch content). Reported to coordinator for merge/cleanup.
## 2026-11-08 — security-game scan: concepts copied, licensing ignored (owner)
- Scanned `/run/media/system/DATA/Documents/06-games/security-game` for reference material vs our `Art/reference/`. Result: its **photo set** (`docs/screens/refs/ hollywood/`, 9 files) is already fully duplicated in our `Art/reference/hollywood/` (with SIDECAR/BOARD copies) — nothing new there.
- What we lacked: its **AI concept renders** (`docs/screens/concepts/ hollywood_boulevard/`, 12 PNGs + `.refgen.json`). Copied into `Art/reference/el-capitan-concepts/` with a folder README. **Owner explicitly said ignore licensing completely** — they are gitignored local development reference, never committed or shipped; `.refgen.json` still records the model and source photo per file for traceability.
- Use concepts for mood/lighting intent only; geometry truth stays with the photo refs (facade, entrance, night marquee, TCL/Tussauds, Masonic 1922, etc.).
- The security-game `realism/` A/B screenshots and `concepts/joke_shop/` are its own engine/venue renders — not relevant to our 6840 modeling.
## 2026-11-08 — ODbL review closed; remaining active TODO items finished
- Wrote `Docs/odbl-review.md` (decision 2026-11-08): the shipped block geometry (streets/footprints/sidewalks) is a Produced Work of the OpenStreetMap database (ODbL 1.0). Obligations held: on-screen attribution via AGigLineHUD ("© OpenStreetMap contributors" + "Data © OSM, ODbL 1.0 — openstreetmap.org/copyright"), ODbL link, and the raw extract committed in Geo/ with a manifest (date, bbox, SHA-256). Share-alike is limited to the data layer; project code/art remain CC0 (Docs/licensing.md decision). 3DEP DEM is US Government public domain, unrelated to ODbL.
- Closed both [A] items in TODO: (1) sidewalks/roads/collision — collision verified; remaining kerb radii/aprons/kerb-height explicitly tracked as 0.1.1 patch tasks (Sidewalk outer edge, Crossing ramp gradient, Kerb height); (2) ODbL review — recorded. The [D] Blosm-import (blockout cleaning) item stays deferred (tracked separately).
- Branch: odbl-review-0.1.0 (docs + TODO closure only).
## 2026-11-08 — placement release check; north-buildings camera was 9.5 km off
- Release check 2 (placement vs references): rendered demo, probed DemoMap world positions, compared to Geo references. Matches: road 24.3 m (ref crossings 20.9-25.9), sidewalk centreline offsets 12.5/13.2 m (ref 12.6/13.6), hero frontage 26.0 m / setback ~13 m (ref 25.75/14.5), six south-frontage buildings
- TCL/Tussaud's in verified OSM positions.
- **Bug found:** the `demo-north-buildings` camera sat at X -22800 (the buildings' own X), aiming at its own position -> 85%-sky screenshot. Probed real geometry: TCL bbox X -22889..-18442, Y -9431..-1802; Tussaud's X -27071..-22799, Y -7586..-1888; hero at X -13336, Y +1307 (frontage 26 m). Camera now at (X -22750, Y +1600) looking north; sky fraction 85% -> 41%.
- Getting actor world bounds: spawn at loc(0,0,0) means static mesh bbox IS the world footprint (FBX carries the geo offset). Probe by loading the level explicitly (load_level) — a fresh commandlet starts on Untitled otherwise.
## 2026-11-08 — collision/seam audit done; QA self-test extended
- `-qaselftest` transect widened from 84 samples (3 lane centres) to **1456 samples / 16 rows** including kerb/road/sidewalk seam Ys (probed: N side -1912..-1053, kerb -1382..-1053, road -1382..+1047, kerb +718..+1047, S side +718..+1447). 0 holes.
- **New wall probe:** ground-referenced horizontal 30 m trace at +160 cm. First attempt used a fixed Z 160 and started INSIDE the earth (street is at Z ~366) -> every row reported a dist-0 blocker. Always ground the probe first.
- **Guard:** GigLineShotActor must skip rounds under `-qaselftest`, else bQuitWhenDone closes the game before the transect phase (observed: game exited at t=2s with "ShotActor: done (4 shots)" + RequestExit).
- Results on packaged build: walk 761cm/2s, reset 0cm, pause/unpause, transect 0 holes, wall 0/35 -> all PASS. Evidence recorded in Docs/release-checks.md.
## 2026-11-08 — setup logs gated behind a cvar (diagnostics item)
- The remaining spawn/input setup logs (settle-timer "start captured", "GigLine IN:" pushed/bound/errors) are now gated behind `gigline.SetupLogs` (TAutoConsoleVariable, default **0** via `DefaultEngine.ini [ConsoleVariables]`). Re-enable at runtime: `gigline.SetupLogs 1`.
- The `-qaselftest` QA logs stay ungated (deliberate release-check feature per Docs/release-checks.md). Verified packaged build: 0 setup-log lines, QA suite still all PASS (walk/reset/pause/transect 0 holes/wall 0-35).
- Branch qa-diagnostics-0.1.0. SIM/GROUND probes were removed earlier in the DefaultKeyMappings fix (PR #8).
## 2026-11-08 — v0.1.0 released
- All six `0.1.0` release checks pass and are recorded in `Docs/release-checks.md`: traversal (seam transect 0 holes), placement vs references, collision+packaging (wall probe 0/35), 60 fps at 1440p (2.42 ms avg; TSR on, VSync off, no FG), fresh-checkout build, packaged Linux build with controls shown in-game.
- `CHANGELOG.md` gets the `0.1.0` entry; the completed `0.1.0` section was removed from `TODO.md` (goal stays in `ROADMAP.md`); tag `v0.1.0` cut on `main`.
- Owner approved the regenerated screenshot set (PR #15) on 2026-11-08.
- THIRD_PARTY_NOTICES switched to checkbox statuses (`[x]`/`[P]`/`[ ]`); all tools incl. Blosm, QGIS and Git LFS are now verified as tool-only.
- Next work: the `0.1.1` collision-detail patch (sidewalk outer edge 15.8 cm step, crossing ramp gradient 10 %, kerb height assumption, traversal regression).
## 2026-11-08 — v0.1.1 released (collision patch)
- Sidewalk upstand now tapers full-at-kerb → zero at the building edge (removes the 15.8 cm outer ledge). Crossing ramp run derives from `--max-ramp-grade` (8 % ⇒ 1.63 m) instead of a fixed 1.5 m/8.7 %. Street + site rebuilt, site re-imported, DemoMap rebuilt, packaged build verified.
- Pre-check tool fixes: coverage no longer shrinks with `--sample` (it used `range(int(block_len))`), and it reports grade + location of the worst step.
- Residual, documented not hidden: 13.4 cm surface step at s≈198 (south) / 8.0 cm at s≈251 (north) — a strip discontinuity near a centreline vertex, below the 45 cm MaxStepHeight, so not blocking.
- Kerb height finalised at 0.15 m modelled / 0.13 m built (6 in LA standard; DEM and photos can't resolve finer).
## 2026-11-09 — Mapbox building data as a second height source
- `Geo/tools/mapbox_buildings.py` (new, stdlib `urllib`) Tilequeries `mapbox.mapbox-streets-v8` layer `building` at every building centroid in the extract and merges the answer with our OSM height + source. Token comes from `MAPBOX_ACCESS_TOKEN` in the ignored `.env`; responses cache to `Geo/raw/mapbox-cache/`.
- **Mapbox terms restrict persisting/redistributing their data**, so `.gitignore` now covers `/Geo/raw/mapbox-cache/` and `/Geo/verification/*mapbox*`. The tool also refuses to cache a response that contains the token.
- **First run (72 buildings): 58 match, 5 close, 3 disagree, 6 non-building.**
- It independently classifies our roof/canopy/shelter volumes as `roof`/ `shelter` — corroborating the `0.1.2` finding that they are not buildings.
- Remaining disagreements are untyped `building=yes`/`apartments` rear structures where our fallback guess (2 × 3.2 m = 6.4 m) meets Mapbox's 3.0 m.
- Every "close" case is a `building:levels × 3.2 m` building where Mapbox is **0.6 m lower per 6 levels** → Mapbox implies **3.1 m per level**, not 3.2. Worth revisiting `LEVEL_HEIGHT` if a measured storey height ever lands.
- Its query API surface: `height`, `min_height`, `type`, `extrude` per polygon.
## 2026-11-09 — Mapbox calibration adopted into the blockout
- Owner decision: adopt Mapbox. Applied:
- `LEVEL_HEIGHT` 3.2 → **3.1 m** in `Geo/tools/block_height_report.py`, and the Blosm `--level-height` default in `Art/blender/blockout/blosm_blockout.py`.
- `HEIGHT_OVERRIDES` in block_height_report: ways 1490910971, 1492058875, 1492747608 → 3.0 m (Mapbox single-storey; the 2-level guess gave 6.4 m).
- `classify_height(tags, level_height, way_id)` now takes the way id so overrides apply; `mapbox_buildings.py` passes it, so the cross-check agrees with the height table.
- Re-running the cross-check: **66 match, 6 non-building, 0 close/disagree**.
- Geometry: blockout regenerated at 3.1 (object names preserved — the frontage exporter maps `element.020`/`element`/`element.019`/named buildings, all still present, 6/6 exported), frontage re-imported, DemoMap rebuilt, packaged build `-qaselftest` all PASS.
- **Blosm adds roof mass** on top of `levels × levelHeight`: 6922 39.1 m vs table 37.2, El Capitan Theatre 20.5 m vs 18.6 — both ~+1.9 m. That is a roof, not a height error; the table is the tagged height, the mesh is storey + roof.
- Gotcha: regenerating the blockout is safe for names, but any future change to the import must re-verify the 6/6 frontage export and the `element.*` mapping.
## 2026-11-09 — hero colonnade proportion fixed (47.6 % → 53.1 %)
- The hero's colonnade (columns 2.00–7.95 + capitals 7.95–8.90) was 6.90 m of the 14.50 m elevation = **47.6 %**, below the photographed 50–60 %, because the entablature (3.40 m) and parapet (2.20 m) used 5.60 m of the height.
- Thinned ENTABLATURE_H 3.40 → 3.00 and PARAPET_H 2.20 → 1.80 in `Tools/blender/hero_facade_build.py`; the colonnade grows to **7.70 m = 53.1 %** while the envelope, motto band and medallions keep their relationships (all positioned relative to those two constants).
- **Gotcha (cost a bad build):** `hero_facade_build.py` takes `--out` for the **FBX** and `--save-blend` for the `.blend`. Passing `--out <file>.blend` writes FBX bytes over the blend and Blender then reports "File format is not supported". Correct invocation: `--out /tmp/gigline/hero-facade.fbx --save-blend Art/...blend --report Art/...json`.
- Rebuilt hero → re-imported (`SM_HeroFacade`, bounds 2604 × 1257 × 1450 cm) → DemoMap rebuilt → screenshots re-rendered. Note `ue_hero_facade_import.py` saves the map, so `chmod u+w` the `.umap` first or the import silently does not persist.
## 2026-11-09 — AI detail plates from reference photos; floating windows fixed
- `Tools/gen/facade_detail_concepts.py` generates architectural detail plates from one local reference photograph via OpenRouter image models (default `google/gemini-2.5-flash-image`; the sibling project uses the same route in `tools/generate_textures.py` — chat/completions with a text + image_url content part, image returned as `message.images[].image_url.url`). Eight studies, `--list` free, `--all` generates, `-vN` never overwrites, `.refgen.json` sidecar per image, all output under gitignored `Art/reference/facade-details/`.
- **Prompt gotcha:** the model refuses when the prompt describes features the reference photograph does not show ("the image you sent appears to be a decorative architectural detail, not an entrance with steps"). Prompts must describe the _reference subject_ and only restage light/people/surface.
- **Output is real:** 1184×864 to 1344×768 PNGs, ~2 MB each.
- **Measured defect found and fixed:** hero window panes sat at Y −1.98 while the mass front is Y −0.50 and the columns reach −1.02 — i.e. glazing floating 1.44 m in front of the wall and ahead of the colonnade. Now `WINDOW_Y = −0.56` (6 cm proud of the wall, behind the columns). Visible as dark openings in `review-hero-facade-front`.
- Reminder: `.blend` is LFS read-only on checkout — `chmod u+w` or Blender fails with "Cannot save blend file … not writable" (this bit again here).
## 2026-11-09 — UE export hygiene: smoothing groups + facade collision
- **All our FBX exporters used `mesh_smooth_type="OFF"`**, which is why every Unreal import logged "No smoothing group information was found for this mesh". The hero now exports `"FACE"` and its round parts are shaded with `shade_smooth_by_angle(35°)` before export: cylinders read as round instead of faceted, and the import warning is gone (6 → 0).
- **The hero façade had no simple collision** (complex-only), so line traces and capsule sweeps passed straight through it — the player could walk through the wall of the demo. Caught by a new `-qaselftest` **facade occlusion probe** (40 m sideways traces from the road centre at walking height).
- **Auto-generated convex collision on a 95-part 26 m façade is unusable**: it produced coarse hulls that bulged into the street and the wall probe went 0/35 → 2/35. Rejected in favour of `UseComplexAsSimple` via `UGigLineAssetTools.set_complex_collision_as_simple` (same reason the terrain uses it), saved on the asset.
- Probe expectations must mirror the staged buildings: the facade probe now carries explicit X spans per frontage (and the TCL/Tussaud's spans on the north side) so a gap between buildings is not reported as a defect.
## 2026-11-09 — one FBX export preset instead of six copies
- The `bpy.ops.export_scene.fbx(...)` argument block was duplicated in six scripts (hero, block frontage, north buildings, site, scale test, MPFB character), and all six carried `mesh_smooth_type="OFF"` — hence the "No smoothing group information was found" warning on every import.
- Now `Tools/blender/fbx_export_preset.py` holds `ue_metric_v1` and `ue_character_v1`: `preset_kwargs()` (bpy-free, unit-tested) plus `export_static()` / `export_character()` wrappers that import `bpy` lazily. Every caller imports it and passes `selection=True/False`; the seven duplicated blocks are gone.
- Callers in `Tools/` resolve the module with `sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))`; the Blender-side `Art/blender/site/export_site_fbx.py` computes the repo root (`..`, `..`, `..`) and joins `Tools`. Getting that wrong silently inserts `Tools/Tools` — check the import path when adding a caller.
- Verified end to end: scale test, hero, frontage (6/6), site (7/7) and north buildings all re-exported through the preset, re-imported with **0 smoothing warnings**, DemoMap rebuilt and `-qaselftest` all PASS (walk, transect 0 holes, wall probe 0/35, facade probe 0/9).
- `mpfb_character_export.py` was not re-run: it needs the MPFB add-on on `BLENDER_USER_SCRIPTS` ("Add-on not loaded: bl_ext.blender_org.mpfb"), which is pre-existing and unrelated; the character is not in `0.1.x` gameplay.
## 2026-11-09 — v0.1.2 released
- Cornice finished: projecting corona over a **52-dentil course** (0.18 × 0.30 × 0.22 m at 0.45 m centres, z 12.12–12.34, 0.25 m clear of the corona). Hero is now 3 044 tris across 148 parts, still 14.50 m. Verified: packaged `-qaselftest` all PASS (transect 0 holes, wall probe 0/35, facade probe 0/9).
- `CHANGELOG.md` gets the `0.1.2` entry (added/changed/fixed/verified/limitations), the `0.1.2` section is removed from `TODO.md`, `ROADMAP.md` status updated, tag `v0.1.2` cut after the merge.
- Assumptions recorded rather than hidden: dentil spacing, base and abacus dimensions are not resolvable in our reference photography.
## 2026-11-09 — elevation cameras levelled (straight verticals)
- archviz MUST: an elevation must not lean. `hero-facade-front` looked up ~4° from 4.2 m at a 7.6 m target; `demo-spawn-view` and `demo-street-level` were pitched too. All three now put the camera at the target's height, so `FindLookAtRotation` yields pitch 0 and roll 0 and verticals are parallel by construction.
- Do not try to _measure_ verticality from the rendered PNG with a naive edge scan: banners, windows and the parapet produce different edge sets at different heights and the pairwise match is meaningless (it reported a 418 px "drift"). Verify it geometrically in the camera list instead.
## 2026-11-09 — 0.1.3 packaging/setup fixes
- **`Tools/unreal/ue_common.py`**: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False with no error and the staged actors vanish), and `require_saved()` makes that failure visible. `ue_hero_facade_import.py` and `ue_import_north_buildings.py` never did this — which is why importing the hero appeared to succeed while saving nothing. Verified by forcing `chmod u-w` on DemoMap first: the run logged `map writable: chmod u+w … (was 0o444)`, then `save_map: True`, exit 0.
- Gotcha while adding it: `ue_hero_facade_import.py` had **no** `import os`/`sys` (only `unreal`), so the sys.path line raised `NameError: name 'sys' is not defined` inside the commandlet — an exit code of 255 with no obvious cause.
- Docs: `build.md` gained the current pipeline table (7 site FBXs, frontage, north, hero, map), the `Builds/GigLineQA` archive, the packaged-log path, `-qaselftest` usage and a **traps** table; `setup.md` marks the fresh-checkout build verified and documents the two optional API keys; architecture 07 + combined doc corrected the build-output path.
## 2026-11-09 — 0.1.4 materials + architecture-camera pass
- **Material graphs**: `ensure_material()` now supports masonry coursing (`courses_m`) and roughness variation. **UE's `WorldPosition` is in CENTIMETRES**, so a course height in metres must be ×100 — the first attempt divided by 0.55 and produced coursing 100× too fine to see.
- **`connect_material_expressions` returns False instead of raising**, so an unknown pin name leaves an input unconnected and the material silently compiles with a default (a mask of 0 is invisible). All links now go through a `link()` helper that logs failures. There is also **no `MaterialExpressionLess`**; use `Frac → Divide → OneMinus → Clamp` for a soft threshold band, or `MaterialExpressionStep`/`SmoothStep` if a hard one is wanted.
- **Material assignment was slot-0-only**, which on the hero is the _banner_ — the stone, shadowed stone and glazing kept their flat Blender materials. Assignment is now per slot, keyed on the imported slot material's name (`glass`/`banner`/`stoneshadow`/`stone`/`roof`/`wall`). Hero: 4/4 slots.
- New materials: `M_FacadeStoneShadow` (same coursing, a stop darker), `M_FacadeGlass` (roughness 0.14, specular 0.62 ≈ IOR 1.5 expressed without a refraction pass), `M_FacadeBanner` (the one saturated surface).
- **Camera FOV**: levelling the elevation cameras left the building small in frame (39 % sky). Added optional per-shot `ShotFieldOfView` to `AGigLineShotActor` and a 4th element on the shot tuples; a long lens (42–55°) fills the frame from across the street while the camera stays level, so verticals stay parallel. `review-hero-facade-front` sky 39 → 20 %, `demo-spawn-view` 23 → 16 %.
## 2026-11-09 — facade texture maps (0.1.4)
- `Tools/gen/generate_facade_textures.py` writes deterministic 512² base-colour and roughness maps (ashlar stone, stucco, asphalt, concrete) with a pure-zlib PNG encoder and hash-based value noise — no numpy/Pillow, ~14 s for all eight, and byte-identical on a re-run (checked by md5sum).
- `ue_build_demo_map.py` imports them to `/Game/Hollywood/Textures` and samples them **world-aligned**: WorldPosition (cm) → ×(1/tile_cm) → ComponentMask(R,G) → TextureSample UVs. `tile_cm` per material: stone 440, stucco 260, asphalt 900, concrete 250.
- Gotchas: `MaterialExpressionTextureSample`'s UV input pin is **"UVs"**, and the output pin for colour is **"RGB"** / roughness **"R"**. `ComponentMask` pins are R/G/B/A booleans. A textured material skips the graph coursing on purpose — overlaying both would double the course pattern.
- The script needs `ROOT` in `ue_build_demo_map.py` and `sys` imported; forgetting either fails with `NameError` and an exit code of 0 from the commandlet (always read the result file under /tmp/gigline).
## 2026-11-09 — façade module kit (0.1.4)
- `Tools/blender/facade_kit_build.py` builds the kit at the measured **4.96 m bay** and **3.10 m storey**: pilaster, sill, lintel, window bay, shopfront bay, door bay, cornice, parapet + coping = 16 meshes, exported per-object with `fbx_export_preset` plus a JSON part report (dims + evidence per piece).
- `Tools/unreal/ue_import_facade_kit.py` → `/Game/Hollywood/Kit/*`, complex-as-simple collision (a convex hull would fence off the shopfront glazing).
- `Tools/unreal/ue_apply_facade_kit.py` lays the kit out from each building's own bbox: `bays = round(width / 496 cm)`, storeys from height minus the 310 cm ground floor. **638 pieces** over six frontage buildings + both north buildings.
- **Bug worth remembering:** the first window piece was modelled entirely at +Y (into the wall), so every window sat _inside_ the solid blockout wall and was invisible in every render while the placement maths looked right. The kit now projects the surround 3 cm proud with the glazing 6 cm behind its face — a solid-wall blockout cannot carry a real recess, so the piece has to project.
- Blender gotcha: clearing the scene _including materials_ invalidates handles already taken ("StructRNA of type Material has been removed"); clear objects and orphan meshes only, and create the material after the clear.
- The kit meshes are spawned at the world origin with their position baked into the mesh (like the frontage exports), so placement uses the bbox directly and must not add the actor location.
## 2026-11-09 — CRITICAL: blockout must be regenerated with --origin
- Regenerating the blockout in 0.1.2 (for the 3.1 m storey height) was run **without `--origin`**, so Blosm centred on the extract instead of fitting the project frame. The whole frontage shifted about **119 m in X and 2.4 m in Y**, and the hero ended up outside the 6902/6838 gap — the "verified placement" was undone silently, and every render still looked like a plausible block.
- Correct invocation (README documents it, and the affine fit prints `frame correction residual: max 0.0 cm`): `blosm_blockout.py --osm … --bounds 34.1006,-118.3422,34.1020,-118.3370 --level-height 3.1 --origin 34.101545,-118.338718 --assets-dir … --out …`
- After re-export + re-import, the frontage spans are back to the verified values: 6922 −24276..−17700, 6902 −17749..−14591, **hero −14644..−12028 between them**, theatre −12082..−8210, and the north buildings at −22889..−18442 / −27071..−22799.
- **`ue_import_north_buildings.py` also lacked `import sys`**, so its sys.path line raised NameError and the import silently did nothing (exit 255). Same trap as the hero importer: these scripts imported only `unreal`.
- Lesson: after any blockout regeneration, probe the staged buildings' world X spans and compare them with `Geo/verification/2026-11-06-el-capitan-block.md` before trusting a render. A shift of this size is invisible in a screenshot.
## 2026-11-09 — kit naming + topology validation (blender-modeler checklist)
- Kit assets now follow the Unreal convention: the Blender object keeps its readable name (`Kit_Pilaster`), the exported asset and file are `SM_Kit_Pilaster`. The kit loader strips the `SM_` prefix so the layout code still addresses pieces by short name.
- `facade_kit_build.py::validate()` runs the blender-modeler cleanup checklist on every piece before handoff — non-manifold edges, loose vertices, triangle count — and records the result in the part report. Current kit: 16 pieces, **all manifold, no loose geometry, 12 tris each**.
- `ue_import_facade_kit.py` now deletes kit assets that the current build no longer produces, so a rename cannot leave two generations of the same piece in the folder (the rename from `Kit_*` to `SM_Kit_*` did exactly that until it did).
## 2026-11-09 — visual comparison loop; screenshots are demo-only
- `Tools/review/visual_loop.py` closes the reference → render → feedback loop. `--verify` gates captures against `Tools/review/visual_targets.json` (blank, tone-less, wrong-size fail); `--compare` measures luma, RMS contrast, edge density, palette distance and a 3x3 luma grid against the reference and prints a 0–1 score; `--iteration N` writes `Art/reference/iterations/N/{metrics.json,feedback.md}`. The feedback names the skill to consult, so the next modelling pass starts from a measurement rather than an opinion.
- **Iterations are gitignored** (`/Art/reference/iterations/`); only confirmed shots are promoted into `Screenshots/`. A test asserts the ignore rule and that every target is a demo-map shot.
- `Screenshots/` now holds **demo-map captures only** — the review staging map is no longer rendered for review. `render_screenshots.sh --verify` renders the demo map, collects the shots and runs the verification gate in one step.
- **PIL gotchas:** `FIND_EDGES` leaves its own border ring at 128 (236/3600 px on a 60x60 frame = 6.5% "detail" on a perfectly flat image), so crop **after** filtering — cropping before does not help, the new border gets the same treatment. Palette shares must divide by the full-image area, not the cropped one.
- First comparison (iteration 2): spawn view 0.70, street level 0.35, crossing 0.41, north buildings 0.34. Edge density is the dominant gap everywhere (0.04–0.11 against 0.20–0.32 in the references) — the render still lacks the relief the photographs carry, which is exactly what the `0.1.4` façade work is for.
## 2026-11-09 — theatre marquee + blade sign
- `Tools/blender/theatre_trim_build.py` builds the El Capitan Theatre's signature features in **world coordinates**: marquee canopy 12.0 × 2.60 m at a 4.60 m soffit, illuminated fascia, recessed soffit, vertical blade sign 1.30 × 5.20 m with frame and bracket. `Tools/unreal/ue_stage_theatre_trim.py` imports to `/Game/Hollywood/Theatre` and stages one actor per piece (single spawn at the origin — no placement arithmetic).
- **Two placement bugs, both invisible in a render:**
1. **Axis sign.** Blender +Y is Unreal −Y, so a face at Unreal Y +1001 is Blender Y −10.01. Using +10.01 put the whole marquee on the far side of the boulevard.
2. **Absolute Z.** Trim heights were absolute while the pavement at the theatre is 4.56 m above the project datum, so the canopy hung 5 cm above the pavement — the QA wall probe caught it as a knee-height blocker. `GROUND_Z` now offsets every height.
- Wall probe: a scan row at Y 1000 sat ~1 cm from the frontage building line and grazed the theatre wall once the kit's proud shopfronts went in. Rows now sample the street envelope only (−1400, −167, 800, −1382, 718).
## 2026-11-09 — 0.1.4 signed off; 0.1.5 created for the carried geometry
- Owner reviewed the four demo captures locally and approved the set (7/7 items at the time). The signoff lived in `Docs/reviews/0.1.4-review.md`, which was removed on 2026-11-09; the per-shot `Approved` column of `Screenshots/README.md` is now the only record and is authoritative.
- The three geometry items that `0.1.4` did not finish — retail neighbours from their own references, hero fluting/motto band, blockout cleaning — move to a new **`0.1.5` Façade finishing** patch rather than growing this one, plus a much-needed **1440p performance re-measure** (625 kit actors have never been measured; `0.1.0` measured 414 fps).
- Decision recorded because it sets a precedent: finish and sign off a version when its _stated_ scope is done, and carry leftover scope into the next patch — do not let a patch accumulate unmeasured geometry.
## 2026-11-09 — quality priority flipped: realism first, performance last
- Owner decision: realism leads; performance closes the quality list. Applied in `Docs/Architecture/01_introduction_and_goals.md`, `10_quality_requirements.md`, `04_solution_strategy.md`, `Docs/release-checks.md` (performance section now last, with the reason), `ROADMAP.md` (a "Quality priority" section) and `AGENTS.md` (the tie-break rule next to the scope-cutting rule).
- Ordering used: 1 visual realism, 2 geographic fidelity, 3 walkability/usability, 4 build reproducibility/portability, 5 provenance, 6 performance.
- **Both revisions carry the same guard so the order cannot be used as an excuse:** performance is a _floor_, not a preference — 60 FPS at 1440p without frame generation must be met at every release, and if realism cannot fit, the realism scope is cut deliberately and recorded in `CHANGELOG.md`, never the frame rate. Walkability outranks realism in one direction: a block the player falls through is not a demo.
## 2026-11-09 — 0.1.5 start: retail reference + kit layout maths
- Commons holds exactly **one** usable reference for the retail run: `Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg` (Gb321, CC BY-SA 4.0, 1920×953) — 6800 Lee Drug **and** its neighbour at street level, shopfronts and parapets visible (vision pre-check: yes). Nothing for 6822 or 6806–6820, so those two façades still have no reference and stay blocked.
- **Kit layout was wrong for tall/wide buildings**, not just ugly: bays were `round(width / 496)`, so 6922 (65.8 m wide, 37.1 m tall) got **13 bays** — a window every 4.96 m across the whole frontage — and the storey count came out at 11 × 310 cm = 34.1 m against a 35.7 m window zone, leaving bare wall under the cornice. Fixed: `MAX_BAYS = 9` (module widens instead of subdividing), and the storey spread is derived from the available height (`usable / storeys`) so the top band always lands inside the building. Result: 6922 9 × 11, theatre 8 × 6, 6806 7 × 3, 6800 4 × 3, TCL 9 × 5.
## 2026-11-09 — hero fluting + motto band; and a silent placement divergence
- Fluting: 20 grooves per column as thin shadow-tone cylinders set into the shaft (120 pieces) rather than boolean cuts — the eye reads them in silhouette and grazing light, booleans would cost far more for that. Motto band is now a real recess: surround 6 cm proud, panel 12 cm behind (was an 8 cm dark stripe). Hero 148 parts/3 044 tris -> 271/5 492, still 14.50 m.
- **The real find:** `ue_hero_facade_import.py` had its own hero position `(-12024.5, -1450, 0)` — the pre-`0.1.0` value, before the frontage placement fix moved the hero to `(-13336, +1307)`. Running the importer on its own therefore teleported the hero **13 m east and onto the north side of the boulevard**, and nothing complained: a displaced building still renders. The only symptom was the reference score dropping 0.69 -> 0.56 with the 3x3 composition correlation flipping +0.54 -> -0.08.
- Fix: `Tools/unreal/ue_placement.py` is now the single source of truth (hero position, verified frontage spans, `span_error`/`describe_span_failure`). Both `ue_hero_facade_import.py` and `ue_build_demo_map.py` import it, the import asserts the staged span and logs `PLACEMENT ERROR` when it does not match, and tests assert the stale literal cannot come back. Spawn-view score back to **0.72** (best yet).
- Lesson to keep: **duplicated placement constants drift silently.** The metric that caught it was the composition correlation, not a visual review.
## 2026-11-09 — 1440p re-measured with the kit: 363 fps, ~12% cost
- 2560×1440, Epic preset, TSR, VSync off, no frame generation: **2.76 ms average (363 fps)**, p50 2.73, p95 3.17, p99 3.64, max 4.62 — against `0.1.0`'s 2.42 ms (414 fps). The 625 kit actors + 8 generated textures cost about **12 %** and leave ~6× the 60 fps floor.
- **Variance trap confirmed a third time:** runs reporting ~105 fps (9.5 ms) were two overlapping game instances — an earlier launch had not exited. `pgrep -af Binaries/Linux/GigLine` should show one wrapper + one binary; 363 fps reproduced twice on a clean run. Always count instances before believing a perf number.
- Series committed at `Docs/perf/0.1.5-kit-walk-frametimes.csv`; comparison table in `Docs/release-checks.md`.
## 2026-11-09 — 604 unmaterialed actors: material rebuild was delete+recreate
- The "blockout cleaning pass" turned out to need **material and placement hygiene, not more geometry**. `Tools/unreal/ue_audit_scene.py` (new) found **604 of 620 mesh actors with no material**: kit pieces and theatre trim all untextured.
- **Cause:** `ensure_material` in `ue_build_demo_map.py` deleted and recreated each `/Game/Hollywood/Site/M_*` asset every run ("so a look change cannot be masked by a stale asset"). That orphane every reference: actors staged by other tools pointed at deleted assets and fell back to the default material. The map looked only _slightly_ flat — no error, no warning.
- **Fix:** update the material **in place** — load it, `MaterialEditingLibrary. delete_all_material_expressions(mat)`, rebuild the graph, recompile, save. Note `Material.expressions` is protected from Python; use the library's deleter.
- **Ordering rule now documented and enforced:** the demo builder rebuilds materials, so kit and theatre staging must run _after_ it. `render_screenshots.sh` now runs demo map → kit assembly → theatre trim → scene audit → shot cameras → capture, and **fails on a non-zero audit count**.
- Second find: 53 duplicated kit labels (both shop piers of a bay shared one name).
- Scene audit after the fixes: **0 failures** (all materialed, all spans verified, no duplicate labels). Packaged QA still PASS; spawn-view reference score 0.72.
## 2026-11-09 — v0.1.5 released
- All actionable `0.1.5` work is done: hero fluting + recessed motto band, kit layout for wide/tall buildings, placement single-source-of-truth, 1440p re-measure (363 fps), and the material/placement hygiene that the "blockout cleaning" task actually needed (604 unmaterialed actors, 53 duplicate labels) with a new scene audit gate.
- Only item carried forward is the **retail frontage (6822 / 6806–6820 / 6800)**, blocked because Commons holds no usable reference for two of the three buildings and substituting the theatre's photographs would misattribute the façades. It now lives in a `0.1.6` Retail frontage patch.
- Precedent applied again: tag when the stated scope is done, carry blocked scope into the next patch rather than letting a release wait indefinitely on missing reference photography.
## 2026-11-09 — Retail frontage and two pipeline traps
- **`Tools/review/render_screenshots.sh` called `./Engine/...` for the capture step** while every commandlet above went through `run_cmdlet`'s `cd "$ENGINE"`. With `set -e` the script died at exit 127 _after_ rebuilding the maps, so the PNGs silently stayed a day old and `--verify` never ran. Fixed to `"$ENGINE/Engine/Binaries/..."`. Symptom to remember: maps rebuild, screenshots do not move.
- **The generic façade kit is applied to every actor labelled `Frontage_*` or `North_*`**, so staging a hand-modelled façade under those labels gets the kit pasted over it — two contradicting façade languages on one wall. `ue_apply_facade_kit.py` now skips `MODELLED` labels, and retail trim stages as `RetailTrim_*`.
- **A Blender builder cannot be imported by a unit test** (`import bpy` at module scope). Pure layout arithmetic lives in `Tools/_common.py`; `Tools/tests/test_retail_frontage.py` tests it there, and the builder delegates. Same trick `fbx_export_preset.py` uses for its preset dicts.
- **Joining 85 parts into one mesh per building** before export cut the Unreal import from 85 assets to 2, and the staging step to two spawns. Record per-part dimensions _before_ the join: after it only the joined bounds exist.
## 2026-11-09 — Third-person body renders in bind pose (open defect)
- **Symptom**: the pawn's body is visible and skinned, the run logs `GigLine: playing body clip Character_AnimHuman_rig_GigLine_Idle`, and the figure still stands in the A-pose bind. Two captures were taken before the call was proven to succeed — the camera, then the material, were blamed first and neither was the cause.
- **The FBX is not the problem.** Re-importing `/tmp/gigline/character-anim.fbx` into Blender gives two actions, `GigLine_Idle` (frames 1–60) and `GigLine_Walk` (1–30), each with **1379 f-curves over 137 bones**. Animation is baked and exported correctly, so the defect is on the Unreal side.
- **The material was a real but separate defect**: `M_Character_Export` had no `SkeletalMesh` usage flag, so Unreal substituted the default grey with only a `LogMaterial` warning. Fixed in place (`used_with_skeletal_mesh = True`).
- **`PlayAnimation` alone is not enough.** `USkeletalMeshComponent::PlayAnimation` needs the component already in `AnimationSingleNode` mode; the body is hidden at construction (`SetHiddenInGame(true)`) and only unhidden by the camera toggle, so the first call can land on a component that is not yet in the right mode and never retries — the guard `Wanted == PlayingAnimation` then returns forever and the pose stays at bind.
- **What to log next time**: animation mode and `GetSingleNodeInstance()` together with the clip name. A clip name alone shows the intent, not the effect.
- **Fixed 2026-11-09, two independent causes.** (1) `PlayAnimation` needs `AnimationSingleNode` plus a live single-node instance; the body is hidden at construction and only unhidden by the camera toggle, so the first call could land before the component was ready and the `Wanted == PlayingAnimation` guard then returned forever. `UpdateBodyAnimation` now sets the mode, calls `SetAnimation` + `Play`, and re-asserts while `GetSingleNodeInstance()` is null. (2) The arms-down offsets lived only in the clip keys — the exporter writes clip data **relative to the rest pose**, so they arrived in Unreal as a ±20° swing around an A-posed bind. `apply_base_pose` now bakes them into the rest pose before any clip is authored, which is what `mpfb_character_export.py --pose idle` already did.
- **The base pose needs inverted signs when baked to rest.** Applied as written, the FBX axis mapping produced arms raised in a V; negating the table gives arms down. Both states were verified on the imported asset, not assumed.
- **Unreal bone names use underscores**: `upperarm01_L`, not Blender's `upperarm01.L`. `GetBoneIndex(TEXT("upperarm01.L"))` returns INDEX_NONE, `GetBoneTransform` then returns identity, and a probe reading it reports a constant 0.0000 — which looks like "animation not applied" and is actually "bone not found". Check `AnimPose.get_bone_names()` before trusting any bone probe.
- **`Log` verbosity does not reach the capture log, `Warning` does.** Two diagnostic runs produced zero lines because the messages were `UE_LOG(LogTemp, Log, ...)`; the same lines at `Warning` appeared. Instrument with Warning when the evidence has to survive into `/tmp/gigline/render-*.log`.
## 2026-11-09 — Unreal silently refuses to replace an imported asset
- **The real reason the character pose never changed across a dozen rebuilds:** the FBX importer reported success every time, but `Character_Anim` and its two AnimSequences kept their original data. Proof: after changing the arm base from 1.55 rad to 0.55 rad and rebuilding, `AnimPose.get_bone_pose("upperarm01_L")` returned the **identical** quaternion `(+0.006, +0.002, -0.179, -0.984)` as before. An asset that does not change when its source does is not being written.
- `AssetImportTask.replace_existing = True` was set, and `EditorAssetLibrary.delete_asset` returned **False** for both clips, even after `chmod u+w` (the `.uasset` files are LFS-lockable and check out read-only). Deleting is blocked because the pawn's C++ constructor hard-references the clips.
- **Workaround that works: import under a new name.** `Tools/unreal/ue_character_anim_import.py` now uses `PREFIX = "CharacterV2"`, which creates `CharacterV2`, `CharacterV2_Skeleton` and `CharacterV2Human_rig_GigLine_{Idle,Walk}` fresh; the same quaternion probe then returned `(+0.439, +0.898)`, i.e. the 0.55 rad arm rotation had finally landed.
- **Check the asset, not the importer.** A pose or material change that does not alter the probed asset data did not happen, whatever the log says. Probing a clip's bone quaternion takes one commandlet and settles it in seconds.
- **The arms-down rotation belongs in the clips, not in the bind.** Every bind experiment (bake pose to rest, clear shape keys, apply the armature modifier, re-bind) was either impossible on this shape-keyed MPFB mesh or left Unreal showing the old bind. `Tools/blender/character_anim_build.py` now carries a measured base offset in the clip keys (`BASE`: 0.55 rad on `upperarm01`, chosen by sweeping the rotation in Blender and reading the bone direction), and the character stands with its arms at its sides.
## 2026-11-09 — Textures: ComfyUI works, Stable Diffusion fails for flat swatches
- **ComfyUI is reachable at `127.0.0.1:8188` (0.44.0)** and its API is the clean way in: `POST /prompt` with an API-format graph (`[node_id, output_index]` references — the UI's workflow JSON cannot be posted), then poll `/history/<id>` and fetch `/view?filename=...`. `Tools/gen/comfyui_generate.py` does this and writes a sidecar with model, prompt, seed and the full graph. Installed checkpoints: `juggernautXL_ragnarok.safetensors`, `epicrealismXL_pureFix.safetensors`, `sd-v1-5-inpainting.ckpt`, plus inpainting variants.
- **Do not ask Stable Diffusion for a flat texture swatch.** Three attempts at "seamless skin texture" produced an anatomical close-up twice and a leather-scale surface once. SDXL has no reliable notion of a material sample without anatomy, so every seed needs manual rejection — worse than noise we control. Procedural generation won for skin pores (`Tools/gen/generate_character_textures.py`, tileable octave noise) and a strand sheet for hair.
- **Unreal collapses the second material slot on a skeletal-mesh import.** A joined hair cap (Blender shows two slots, hair faces on index 1) arrives as a single slot named after the body material, so the hair renders with the skin material. `unreal.SkeletalMesh` has no `set_material`, so it cannot be re-slotted after import. A separate skeletal mesh attached as a second component is the way around it.
## 2026-11-09 — Hair as a separate mesh, and a material-editing crash
- **Hair joined into the body mesh loses its material.** Blender shows two slots (hair faces on index 1) and the FBX exports both, but Unreal's skeletal-mesh import arrives with **one** slot named after the body material, so the hair renders as skin. `unreal.SkeletalMesh` has no `set_material`, so it cannot be re-slotted afterwards. A separate skeletal mesh imported on its own keeps its slot and imports cleanly (`CharacterHair` → `M_Character_Hair`).
- **A second skeletal component follows the body with `SetLeaderPoseComponent(GetMesh())`** — no second rig, no second animation, and it hides with the body in first person.
- **`unreal.MaterialEditingLibrary` segfaults in `-run=pythonscript`** when it clears and rebuilds a material graph. The tool ran its texture imports, then crashed the commandlet with no Python traceback. Graph editing needs an editor session, or a material created from scratch rather than edited in place.
## 2026-11-09 — Character blueprints and how to compare a render to one
- **A SDXL character sheet is reliable**: "full body front view, neutral pose, plain grey background, character sheet" gives a usable turnaround with fixed seeds. Four views (front/side/back/head) from one description is enough to evaluate against; the sheet and its JSON live in `Art/characters/blueprints/`.
- **Segmenting the character out of a review shot does not work by colour.** The sky band, the pale wall and the dark pavement are each larger and more saturated than the body, so every size-based rule picked a backdrop blob and the first report measured the whole frame. `Tools/blender/character_match.py` now takes an explicit `--render-box`; the proper fix is a background plate rendered without the character.
- **The report has to separate the failures it measures.** Aspect, fill, coverage and two colour distances are reported apart on purpose: an absolute similarity number between a photoreal sheet and a low-detail body would mean nothing. The first real comparison (aspect Δ1.24, mean ΔE 31.9) was dominated by the model being **unclothed** while the blueprint wears a tee and jeans.
- **`SetMaterial(0, ...)` in the pawn constructor does not visibly land** on the body mesh: `M_Character_Skin` is built and assigned, yet the render stays neutral grey. Either the slot does not exist yet at that point or the assignment is dropped; `BeginPlay` is the next thing to try.
- **MPFB vertex groups are polluted by helper geometry.** The body mesh weights its helper cubes to groups named `Left`, `Right`, `HelperGeometry` and `JointCubes` at 1.0, so a "strongest group" lookup returns a helper for most vertices - the first clothing build selected **zero** faces. Filter helper names out before asking which bone a vertex belongs to.
- **Blender refuses to apply a modifier to a mesh with shape keys.** The body carries ten; a garment copy must clear them (`shape_key_remove(all=True)`) before solidify can be applied.
- **Edit-mode selection reverts to vertex selection.** Setting `polygon.select` in object mode and then entering edit mode re-derives selection from the vertices, which were all still selected - deleting "the unselected faces" wiped the entire garment. Filter with `bmesh.ops.delete(context="FACES")` instead.
- **`ConstructorHelpers::FObjectFinder` outside a constructor is a fatal error**, not a warning: the game dies in `Tick` with "FObjectFinders can't be used outside of constructors". Use `LoadObject<T>(nullptr, Path)` for runtime loads and keep `FObjectFinder` to the constructor.
- **A 1 cm solidify shell lets the body poke through.** Garments built by duplicating body faces needed 2 cm of solidify plus an 8 mm fatten along their own normals before the cloth read as continuous at shoulders, hips and knees.
- **Warm skin sampling as blue-grey was lighting, not material.** The body stood in building shadow and was lit almost entirely by the blue `Demo_SkyLight` (intensity 2.6): face (99, 98, 100), arms (67, 74, 81). Raising the sun to pitch -74 moved the face to (131, 121, 117). A deliberate red test colour reaching the render first proved the material and the slot assignment were fine, which is what stopped a long hunt in the wrong place.
- **The Unreal commandlet drops unknown command-line switches.** A `--unlit-test` flag passed after `-script` never reached Python; an environment variable does get through (and even that proved unnecessary once the red test settled the question).
## 2026-11-09 — The sidewalk does not block, and why
- **The staged `Sidewalk_North` mesh does not stop a trace or a capsule.** It carries `QueryAndPhysics`, its bbox tops out at Z 380, and `Tools/unreal/ue_import_site.py` sets `CTF_USE_SIMPLE_AND_COMPLEX` with `auto_generate_collision=True` — yet both a downward trace and a falling capsule pass straight through to `Road_Hollywood` at 366.
- **Likely cause: the auto-generated simple collision for a 279 m x 8.6 m x 3.8 m slab is degenerate.** A convex hull over that aspect ratio either collapses or ends up far from the surface; the complex side is one-sided and the mesh may be wound the wrong way. `bOneSidedGeometry` no longer exists on `BodySetup` in 5.8, so the flag cannot be flipped from Python.
- **Working stopgap:** an invisible `279 x 8.6 x 0.08 m` blocking cube with its top at Z 380 in `Tools/unreal/ue_build_demo_map.py`. Verified: `top surface under player: Z=380 mesh=Cube`, `foot: actor Z 470, feet 382`.
- **The real fix belongs in the site export:** split the sidewalk into segments of a few metres, or export explicit `UCX_` boxes, so the collision is a set of sane hulls rather than one degenerate one.
## 2026-11-09 — Slicing the sidewalk did not fix its collision
- **The 20 m slice hypothesis is dead.** The site export now splits sidewalks and kerbs into ~20 m pieces (`Art/blender/site/export_site_fbx.py`, `SEGMENTED`, `SEGMENT_M`), so each has an honest per-slice bounding box. The slices still do **not** block: with the invisible stopgap retired, a downward trace goes straight to `Road_Hollywood` at 366 and the capsule lands there.
- **So the slab's 279 m aspect ratio was not the cause** of the missing collision — a 20 m slice of the same mesh fails identically. The mesh itself imports with collision that does not block, regardless of size.
- **What does collide:** `Road_Hollywood` (366) and the engine cube stopgap. What does not: every `Sidewalk_*` and `Kerb_*` slice.
- **Next place to look:** the sidewalk mesh's geometry - an open surface with no volume, inverted winding, or zero-thickness triangles - rather than the import flags or the size.
## 2026-11-09 — Fast probe loop, and the real heights
- **`-probes` runs the ground measurements and quits**: a measurement run went from minutes to **13.7 s**. Without the switch the game keeps running for the capture pipeline.
- **The sidewalks' normals are correct** (2790 faces, all pointing up) and they have no loose edges, so winding was not the reason they fail to block.
- **`set_complex_collision_as_simple` on sidewalks and kerbs did not make them block** either - the terrain needs it, they do not respond to it.
- **The heights at the player, corrected once more:** the road is **366** and the sidewalk **380** in the shipped map; the 158 cm figure came from the site blend's own Z datum, which is ~2.2 m below the map's because the terrain was added after that blend was written. The float is therefore about **14 cm** - the character stands on the road because the sidewalk does not collide - not the metre it looks like against the kerb.
## 2026-11-09 — The slices were nested, and the site surfaces disagree with each other
- **Bug in the slice export:** the far cut used `clear_outer=True`, which kept the wrong half, so every "slice" still spanned to the end of the street (-27950..-43) and its bbox topped out at the same far-away maximum. Fixed by making both cuts keep the middle (`clear_inner=True` with the normal pointing at the half to keep). The slices are now disjoint and their tops read 380, 340, 309, 284, 254, 231, 205, 169, 138, 104 cm along the street.
- **The surfaces themselves disagree:** at the player's X (-133 m) the sidewalk top is **169 cm** while `Road_Hollywood` is **366 cm** - the road sits ~2 m ABOVE the sidewalk. The player therefore lands on the road, and no sidewalk collision trick will change that, because the sidewalk is not where the road is.
- That is a **site-data problem**, not a placement or collision one: the road and the sidewalks were generated from different passes and their heights no longer agree along this stretch.
- **Next:** regenerate the road and sidewalks from one elevation pass in the site assembly so their surfaces meet at the kerb, then re-export and re-import (one export + one import + one 14 s probe).
## 2026-11-09 — No duplicates, so the road mesh itself is at 366 there
- Checked for two `Demo_Road_Hollywood` actors after several import rounds: **none**. One actor, at the origin, identity scale, mesh Z spanning -44..366 cm.
- So the 366 cm the trace reports at the player's XY is the road mesh's own local height there, not a stale duplicate and not a far-side hit. The sidewalk slice at the same X tops at 169 cm.
- In the source blend at the same street position both surfaces sit around 1.4-1.6 m and agree within a kerb height, so the ~2 m gap appears **after** the mesh leaves Blender - which points at the import path (unit handling, or the Y mirror noted in `export_site_fbx.py`) rather than the strip generation.
## 2026-11-09 — The road is FLAT at 366 while the sidewalks slope: that is the gap
- Ground profile along Hollywood Blvd, traced every 20 m at the player's Y (`-probes -profile`):
```
x=-14336 z=366 Road_Hollywood x=-2336 z=366 Road_Hollywood
x=-12336 z=366 Road_Hollywood x= -336 z=302 Road_Hollywood
x=-10336 z=366 Road_Hollywood x= 1664 z= 5 Terrain_DEM
```
- **The road is a flat slab at 366 cm** across the whole demo block, while the sidewalk slices follow the DEM and descend 380 -> 104 cm. The gap is therefore in the street generator: the road is not sampling the elevation per row the way the sidewalks do, or it was written with a single Z.
- That also explains why every sidewalk fix failed - the street the player actually stands on is the flat road, and the sloped sidewalk is metres below it.
- **Next:** inspect how `rows` is built in `Art/blender/street/street_mesh.py` - the road's `road_z` reads `dem_z(x, y)` from the same rows as the sidewalk, so a flat result means `dem_z` is returning the same value for every row (origin offset or a lookup that fails and falls back), and the fix is in that sampler.
## 2026-11-09 — Source truth vs what the engine reports
Sampled the regenerated street blend at the player's X (x = -133.36 m):
```
Road_Hollywood verts at y = -9.0..-9.1, z = 0.87..0.94 m
Sidewalk_North verts at y = +11.9..+12.0, z = 1.51..1.59 m
```
So the road is the south band at ~0.9 m and the north sidewalk is at ~1.55 m, exactly a kerb above it. **The source geometry is correct and consistent.**
In the engine at the player's position (UE Y -1150 cm, i.e. the north sidewalk band) the trace reports `Road_Hollywood` at **366 cm** - the road is neither in that Y band nor at that height in the source. So the mismatch is introduced on the way in: either the FBX export's Y mirror documented in `export_site_fbx.py`, or the importer's transform. Until that is settled, no screenshot reflects the source, and a visual signoff would be approving a defect.
## 2026-11-09 — Why the road appears 2 m above the sidewalk at the same XY
- `street_mesh.py` builds the road across **SOUTH_GUTTER -10.5 .. NORTH_GUTTER +10.5 m** and the north sidewalk **+10.5 .. +15.8 m**, so on the straight the two bands meet exactly at the gutter.
- The strips are generated along a **curved** centreline (`strip()` places vertices along each row's normal). Where the centreline bends, the fan-out pushes the road's outer edge past its nominal band: the imported road's Y spans **-13.8 .. +10.5 m** while the sidewalk occupies **-17.3 .. -11.8 m**, so they overlap by about 2 m near the west end.
- In that overlap the road surface is at **366 cm** and the sidewalk at **158 cm** - which is why a trace at the player's Y kept finding the road, and why the scene read as the road floating above the pavement.
- **Fix belongs in the strip builder:** clamp each row's outer offsets to the nominal gutter so the road cannot fan past the sidewalk, or shorten the road strip at the ends. Both are one-function changes in `strip()` / `main()`; the demo currently reads correctly because the player and car stand on the sidewalk.
## 2026-11-09 — The 2 m lid was a convex hull, not geometry
- **A 279 m road strip imports with ONE auto-generated convex hull**, and a convex hull's top is the strip's **maximum** height. A downward trace anywhere on the road therefore landed on that hull at **366 cm** - a flat lid ~2 m above the visible surface, which is at about 152 cm there. The road's vertices were never wrong.
- **Slicing the road fixed it** exactly as it had for the sidewalks: after adding `Road_Hollywood` to `SEGMENTED`, the cross-section is continuous -
```
Terrain_DEM 154
Sidewalk_North_07 158
Road_Hollywood_07 158 (was 366)
```
- **Two things had to change together:** the road had to be exported in slices, _and_ the map had to stop staging the single `Road_Hollywood` asset, which still sat on top of the slices and won every trace.
- General lesson: a long walkable mesh needs slicing in the source, because Unreal's simple collision is a convex hull of the whole mesh - the per-slice hull is the only way its collision follows its surface.
## 2026-11-10 — `chmod u+w` on the Blender files, and what re-rendering does not do
- Every `.blend` under `Art/` checked out read-only; a Blender save then fails with `RuntimeError: Error: Cannot save blend file ... is not writable`, and because the export happens after the save in `Tools/blender/hero_facade_build.py`, **the FBX is never written and the old one is reused silently**. All 20 `.blend`/`.blend1` files were made writable; `find Art -name '*.blend' ! -perm -u+w` now returns 0.
- **Re-rendering does not pick up `.blend` changes.** `Tools/review/render_screenshots.sh` renders the imported UE assets, so a rebuild is needed in between: Blender build → `Tools/ue_*_import.py` → render. Chmod alone cannot change a pixel; the hero captures are byte-identical before and after.
## 2026-11-10 — skills: one common copy, project-specific stays local
- **Symlinked packs were the wrong fix.** The 2026-11-06 fix put 94 symlinks in `~/.agents/skills/<pack>` → the repo, while `~/.pi/agent/skills/blender-skills/` held a full second copy of the same packs. Pi keeps the first discovery and warns on every start: 94 "collision" lines. `~/.agents/skills/blender-skills/` (the upstream git checkout) also contains 94 packs under `.claude/skills/` and `.cursor/skills/` — inert, because dot-directories are not scanned.
- **`blender-director/SKILL.md` never loaded.** It declared `role:` twice in `metadata` (`specialist` then `orchestrator`); duplicate YAML keys are fatal, so the pack was dropped silently while the other 94 loaded.
- **Layout now** — shared, one level down and flat so it is discovered: `~/.pi/agent/skills/<93 packs>/`, `~/.pi/agent/skills/references/`, `~/.pi/agent/skills/LICENSE`. Project-specific and versioned: `.agents/skills/blender-skills/blender-director/`, whose `../references/` links were rewritten to `~/.pi/agent/skills/references/…` because it no longer sits beside them. The 94 `~/.agents/skills` symlinks are gone.
- **Verified** by walking the three discovery roots (`~/.pi/agent/skills`, `~/.agents/skills`, project `.agents/skills`, dot-dirs skipped), parsing every frontmatter: **97 skills loaded, 0 malformed, 0 collisions**. Backups of the old trees: `/tmp/agent-skills-backup.tgz`, `/tmp/pi-blender-skills-dupe` (now consumed).
- Rule: a skill exists in **one** place. Two roots with the same `name` is a warning on every start, and the loser is invisible — check the startup list, not the file tree.
## 2026-11-10 — Tools restructured, and the four traps it set
The sixty scripts had grown into one flat directory; they are now grouped by what they talk to (and therefore by sandbox): `Tools/unreal/` (commandlets, both map builders, `ue_common.py`, `ue_placement.py`), `Tools/blender/` (builders, exporters, FBX preset, MPFB pipeline, `blender_screenshots.py`), `Tools/review/` (render/judge/compare/e2e/`visual_targets.json`), `Tools/gen/` (ComfyUI driver, procedural textures, reference fetching), `Tools/input/` (OS input injection). Root keeps `_common.py`, `check-lfs.sh`, `addon_compat_probe.py`, `sibling_asset_audit.py`, `tests/`.
Four things broke, each with the same shape — a path that used to resolve and now does not:
- **Sibling imports.** `import ue_placement` / `import ue_common` failed inside every Unreal commandlet (`ModuleNotFoundError`), because the rewrite put only the tools root on `sys.path` while those modules are the script's _siblings_. Both paths are inserted: `sys.path.insert(0, dirname(_HERE))` then `sys.path.insert(0, _HERE)`.
- **Shell scripts resolve `REPO` relative to themselves.** `Tools/review/render_screenshots.sh` computed the root as one level up, so every commandlet path became `Tools/Tools/...` and the capture step died in **0 s**. Any script whose own depth changes needs its `REPO="$(cd "$(dirname ...)/.." && pwd)"` re-checked.
- **Stale result files pass as success.** The e2e driver accepted `/tmp/gigline/demo-map.txt` left by an earlier run, so a **10-second "demo build"** reported PASS without building. Result files must be unlinked before a step runs, and child output must stream to a per-step log file — a captured pipe hides fifteen minutes of a Unreal build and deadlocks if the engine outlives it.
- **Two editors hang, they do not fail.** Unreal does not refuse a second instance on the same project; it waits. The driver now refuses to start while any `UnrealEditor-Cmd` is alive. `pkill -9 -f <name>` in the same shell also matches the shell's own command line — kill in a separate invocation, or by PID.
## 2026-11-10 — `LevelEditorSubsystem.new_level()` cannot replace a map in one session
Measured on a throwaway path:
```
new_level fresh -> True
delete_asset -> True
disk file still present -> True (delete_asset leaves the .umap)
new_level again -> False
```
So "delete the map and recreate it" does not work in a commandlet: the package stays in memory and `new_level` refuses the path even after removing the file from disk. What works — and is what the builders actually need — is load, destroy **every** actor, save, and verify the count is 0. That is what `Tools/unreal/ue_recreate_maps.py` does now.
The related lesson: a prefix-based clear (`clear(("Demo_", "PlayerStart", ...))`) only removes what the builder knows about. Stale actors from experiments survive every rebuild — which is how a stale road mesh and a duplicated vehicle actor were both saved back into the map. Clearing the whole level turned the old **600-actor** map into a **409-actor** one that audits clean (`0 failure(s)`, no duplicate labels): 72 site meshes, 310 facade-kit pieces, six frontage buildings, lights, cameras, player start. **A falling actor count was the fix, not a regression.**
## 2026-11-10 — Every image is judged by two models; the disagreement is the signal
`Tools/review/judge_screenshot.py` runs `openai/gpt-4o-mini` and `qwen/qwen3.7-flash` by default and reconciles them: agreement carries the verdict with the mean confidence, disagreement reports `uncertain` and keeps both reasons rather than letting the more confident model win. `--model` forces a single judgement.
- The second model had to be replaced once already: `anthropic/claude-3.5-haiku` returned **404, no longer served by any provider**. Take the model list from `https://openrouter.ai/api/v1/models` rather than trusting a remembered id.
- On this content the judge is **consistently pessimistic**: it reports `0/11` across review shots the owner has approved, and it has been wrong in both directions (missed a real float, disputed correct frames). Treat it as an advisory pre-check, and treat a _model disagreement_ as the thing worth reading.
- `Tools/gen/comfyui_generate.py` applies the same rule to generation: two checkpoints by default, same prompt and seed, scored by mean absolute Laplacian (catches blurry/flat output), winner takes `--out`, loser is kept beside it. The score ranks candidates; only the judge understands content.
## 2026-11-10 — Local ComfyUI, and the workflows worth using
- The server at `http://127.0.0.1:8188` is a **Stability Matrix** install and nothing in this repository starts it. If a tool cannot connect, start Stability Matrix, launch ComfyUI, re-run. The install documents 141 GB of models in `/home/zwitschi/StabilityMatrix/Data/README.md`.
- The documented texture graphs are ComfyUI **UI format**, which `/prompt` cannot accept; a tool must rebuild the equivalent API graph. `Tools/gen/comfyui_generate.py texture` does that with their exact settings: `albedobase` (30 steps, CFG 6, dpmpp_2m/karras), `krea2` (8 steps, CFG 1, euler/simple, `ConditioningZeroOut`, texture-fix VAE), `qwen21` (25 steps, CFG 1, `TextEncodeQwenImage21`, texture-fix VAE).
- Two limits the graphs carry and a tool must not hide: they produce **albedo only** (normals/roughness come from a material tool downstream), and ComfyUI 0.44 core has **no seamless sampler**, so "tileable" in a prompt is a hint — offset the image 50 % and check the seam.
- Licences differ per workflow and matter before shipping: AlbedoBase allows commercial use with credit, Krea-2 has a community licence, **Qwen-Image 2.1 is research-only**. `THIRD_PARTY_NOTICES.md` carries the row.
## 2026-11-10 — Hero facade: what actually blocked it
- **Detail modelled inside the wall.** Every applied part sat behind the mass's front face (`Hero_Banner_1` at Y −1.02 against a wall at −0.50), so nothing rendered but the columns and cornice. `seat_on_front()` now states the plane once (`FRONT_Y`) and seats each part at `FRONT_Y + offset`; it reports every part it moves.
- **The hero's materials are not the kit's.** `M_FacadeStone` in `/Game/Hollywood/Site` belongs to the facade _kit_; the hero uses `/Game/Hollywood/ElCapitan/M_Facade_*`, created by the FBX import. Warming the Site material changed nothing for three rebuilds.
- **Unreal keeps a material asset on re-import**, so a colour change in the Blender builder never arrived. Deleting the materials before the import broke the mesh's slots (one colour, no banners, no windows). What works: import normally, then **author one plain material per slot** with the reference colour and assign it.
- **Save the material itself.** Saving only the mesh left the new materials unsaved; after a reload the slots pointed at nothing and the whole elevation fell back to one default colour.
- `unreal.Paths.project_dir()` is `Game/GigLine`, **not** the repository root — textures under `Art/` must be resolved from the script's own location.
- Still open: the generated ashlar albedo is imported and the material contains `TextureSampleParameter2D` → `Multiply` → Base Color with UV tiling, but the render still shows the flat tint. Next probe is the compiled Base Color pin, not the node list.
## 2026-11-10 — Render-to-import chain, in one order
A visual change needs **build → import → render**, never just a render: `render_screenshots.sh` renders the imported Unreal assets. `Tools/review/e2e_asset_check.py` runs the whole thing (Blender renders → empty maps → both builds → captures → two-model judge → README) and is the command to use instead of remembering four.
## 2026-11-10 — Hero attic: the medallions were buried, not just flat
- **`seat_on_front` offsets are relative to `FRONT_Y` (the wall plane, Y −0.5), not to the parapet face.** The parapet is 1.2 m deep with its front at Y −0.15, so any applied detail with offset < 0.35 sits _inside_ the parapet and never renders. The medallions (offset 0.08 → front −0.42) and the motto band + surround (offset 0.06 → front −0.44) were all buried; only the inscription (moved to 0.40 → front −0.10 in the previous commit) read.
- **Medallion fix:** stepped relief with per-layer prefixes so `seat_on_front` gives each its own stand-off — `MedallionRim` 0.36, `MedallionField` 0.43, `MedallionBoss` 0.50. One shared prefix (`Hero_Medallion`) would seat every disc at the same offset and flatten the relief, and it also matched the more specific prefixes via `startswith`, so it had to be removed entirely.
- **Medallions were also at ±6.2, inside the inscription text (spans ±6.87).** Moved to ±9.8.
- **`Tools/review/hero_iterate.sh` still had the pre-move `cd "$(dirname "$0")/.."`** from the tools restructure — it landed in `Tools/` and failed `find Art` with "no such file". Fixed to `../..`, matching `render_screenshots.sh`.
## 2026-11-10 — Hero motto band + surround: same burial, plus dark-on-dark
- The motto band and moulded surround shared the medallion burial (offset 0.06 → front Y −0.44, inside the parapet). Fixed the same way: band recessed at 0.34 (Y −0.16), surround proud at 0.43 (Y −0.07), inscription stays 0.40 (Y −0.10).
- **The band had to go light (`stone`, was `stone_dark`) at the same time.** A dark band at the parapet face would have sat directly behind the dark inscription marks and swallowed them; the reference is dark incised lettering on a pale stone band.
- **The `seat_on_front` table had a dead duplicate** — `Hero_MottoBand` appeared at both 0.06 and 0.40, and the first match (0.06) won, so the 0.40 was silently ignored. That is why "the band is buried" looked fixed in the table while the build kept burying it.
## 2026-11-10 — Hero facade rework closed; remaining detail deferred to 0.9.0
- The hero facade rework (split out of 0.3.0) is **closed at "surface detail that reads at 45 m"**. Done and committed: carved inscription, stepped medallions, light motto band + proud surround (all three had been buried inside the parapet), and gold JIMMY KIMMEL LIVE banner lettering.
- **Banner lettering is procedural, not ComfyUI.** `Tools/gen/generate_banner_texture.py` draws the exact title with Pillow + Liberation Sans Bold. An image model (Qwen-Image 2.1) would mangle a trademark string and is research-only, so for a _known_ string a font rasteriser is strictly better than txt2img. Reserve ComfyUI/Qwen for things that need a model, not for literal text.
- **Deferred to 0.9.0 (owner's instruction):** volute capital depth, lattice grille depth, and the full realism pass (banded rustication, measured materials, texel density, LOD/collision). The 0.9.0 "Hero façade (6840)" item records the hand-off.
## 2026-11-10 — Vehicle launch: the asset pipeline is ruled out
Re-checked the three "asset is broken" suspects with commandlets and each is **correct**:
- `vehicle_skeletal.fbx` geometry is X −2.38..2.38 m (the full car, 296 verts), not degenerate.
- Imported `SK_Vehicle_Sedan` bounds are **origin (0,0,72) extent (238,114,72) cm** — the correct 4.76 x 2.28 x 1.44 m car, so the import scale is fine.
- `Terrain_DEM` bounds are 380 x 210 m with no degenerate/NaN scale, and its body setup reports no convex/box/sphere elements.
So the "1 cm bounds" logged in the vehicle diagnostic is a **stale runtime read of the component bounds**, not an asset defect — the skeletal mesh is fine and the symptom is the teleport writing the actor transform, not a bad mesh. The launch is a runtime (Chaos movement / map) problem, not an import or build one.
## 2026-11-10 — Vehicle launch: fresh probe data, the writer is the physics sim
Rebuilt and re-ran the `ue_vehicle_probe_map.py` workflow (after `chmod u+w` the read-only `.umap`s and `rm` the four stale `VehicleProbe*` maps — `delete_asset` leaves the `.umap` on disk so the tool's own fallback never fires):
| t | control (no terrain) | terrain |
| ---- | ------------------------- | ------------------------------- |
| 0.03 | Z 300 vZ 0 | Z 313 vZ 0 |
| 1.50 | Z 306 vZ 0 | Z 442 vZ 0 |
| 2.25 | Z 299 **vZ −242** (falls) | Z 545 **vZ 1** (jumps up) |
| 3.0 | settles Z 78 | settles Z 545, then physics OFF |
- **Control falls and settles** (300 → 78 cm); **terrain rises** (313 → 545 cm) with the body velocity pinned to 0 throughout and `overlaps: 0 []`.
- **Turning physics OFF at 3 s stops the rise**: `physics OFF at Z 545` → `after 1 s without physics, Z 545`. So the writer is the physics simulation (Chaos vehicle solver teleporting the body, transform set directly), **not** an external `SetActorLocation` — the "transform written directly" reads that way because the solver moves the body without a velocity.
- **0 wheels in both runs** (control also has `setups=4 wheels=0`), so the missing wheels are not the trigger — control falls with 0 wheels too. The terrain is what flips fall → rise.
- The `1 cm` bounds (`extent 1,1,1`) persist in the diagnostic and are a **stale component read**, not the mesh (the imported `SK_Vehicle_Sedan` bounds are the correct 476 × 228 × 144 cm).
Next: the bisect plan step 2 — spawn a **stock `AWheeledVehiclePawn`** with `SK_Vehicle_Sedan` beside `AGigLineVehicle` in the terrain probe map. If the stock pawn also rises, the asset + terrain interaction is at fault; if it settles, `AGigLineVehicle`'s setup (movement subclass, mass override, panel attachment) is.
## 2026-11-10 — Vehicle "0 wheels" root cause: ChaosVehiclesPlugin was disabled
- **The Chaos vehicle never existed because `ChaosVehiclesPlugin` was not enabled.** It is `EnabledByDefault: false`, it was absent from `GigLine.uproject` (only `EnhancedInput`), and absent from `Config/*.ini`. Consequence, read straight from the engine source (`ChaosVehicleMovementComponent.cpp` `OnCreatePhysicsState`): `CreateVehicle()` runs only when `World->IsGameWorld() && GetPhysicsScene() && FChaosVehicleManager::GetVehicleManagerFromScene(PhysScene)` — and the manager is null when the plugin module never started. So the vehicle stayed at `setups=4 wheels=0 output=null`, and the mesh fell back to **regular** rigid-body physics (the constructor's `SetSimulatePhysics(true)`), which is what teleported upward.
- **Fix:** add `{"Name": "ChaosVehiclesPlugin", "Enabled": true}` to `GigLine.uproject`. Result: `CreateVehicle() entered → wheels 4`, `first UpdateState - output present`, `diag ... setups=4 wheels=4`.
- **Also removed** `SetSimulatePhysics(true)` / `SetCollisionProfileName("PhysicsActor")` / `SetCollisionEnabled` from the `AGigLineVehicle` constructor: the Chaos movement component drives the mesh through its async physics, and forcing the regular rigid-body solver is what kept the body out of the vehicle solver.
- **The plugin fix converts "teleport up" into "float at Z 545".** With 4 wheels the pawn now settles at a stable Z 545 (540 cm above the ReviewMap floor at Z 5) in _both_ the control and terrain probe maps — no teleport, but the suspension holds the chassis ~5 m too high. That is the next bug: wheel radius / suspension rest-length / chassis-box offset, not the vehicle creation.
## 2026-11-10 — Vehicle: the physics-asset recreation fixed the float; box-centre +32.4 is wrong
After the plugin enablement gave 4 wheels, the pawn still floated at Z 545 in both probe maps. Two more findings close that:
- **Regenerating the physics asset fixed the float.** The committed `PA_Vehicle_Sedan` was a stale body (the prior "one chassis body" commit `5dd53ac` changed the builder but the asset was never re-generated). Rebuilding it through the current `UGigLineVehicleBuilder::build_physics_asset` (one `root` body, box 452x205x40 at centre 0,0,−18) and assigning it back to `SK_Vehicle_Sedan.physics_asset` made the pawn **fall and settle at Z 78** (≈ wheel radius + suspension above the floor) in both the control and terrain probe maps. `create_vehicle` → `wheels 4`, `output present`.
- **Box centre +32.4 is wrong.** I tried moving the chassis box from −18 to `+Extent.Z*0.45` (=+32.4) on the theory that −18 put the body below the ground. It made it **worse**: the box then overlaps the wheel bones (at +33), the Chaos vehicle fails to set up, and the pawn reports `simulating=0` and stays at spawn (Z 300). Reverted. The working value is the original **−18**, because `GetWheelRestingPosition`/`LocateBoneOffset` measures wheel position against the root **bone** (origin), not the box, so the box centre is effectively ignored by the suspension — and +32.4 breaks the wheel/chassis separation. Do not "correct" the box centre without re-testing settle.
- **The regeneration is not wired into any script.** `build_physics_asset` is a `UBlueprintFunctionLibrary` (callable from Python as `unreal.GigLineVehicleBuilder.build_physics_asset(mesh, dest, "PA_Vehicle_Sedan")`), but `ue_vehicle_import.py` imports the skeletal mesh and never creates/assigns the physics asset. Next step is to add that call to the import (delete + rebuild + assign + save) so the asset is reproducible, then re-run the `-drivetest` (driving is still untested).
- **Editor vs game target:** `build_physics_asset` is `#if WITH_EDITOR`, so it lives in the **editor** module (`libUnrealEditor-GigLine.so`). `Build.sh GigLine Linux Development` does **not** recompile it — you must build `GigLineEditor`. The box-centre change I made looked like a no-op because the editor module was stale; the `.so` mtime is the tell.
Uncommitted when pausing: `PA_Vehicle_Sedan.uasset` is currently at the **+32.4** box (wrong — regenerate at −18), `SK_Vehicle_Sedan.uasset` points at it, and the four `VehicleProbe*.umap` churn should be reverted. `GigLineVehicleBuilder.cpp` is reverted/clean.
## 2026-11-10 — Vehicle float: the delete+recreate physics asset is fragile
The "settle at Z 78" result was transient. Recreating `PA_Vehicle_Sedan` again (the second/third delete + rebuild + assign cycle) flipped the pawn to **`simulating=0` and stuck at spawn Z 300**, with the same box centre −18 that had settled minutes earlier. So the box centre is not what flips settle↔stuck; the **delete + recreate cycle is** — the same failure shape as the material `delete_asset` bug earlier: `delete_asset` does not fully release the package, `NewObject`/`CreatePackage` then reuses stale state, and the rebuilt body ends up not simulating (`PhysicsType` left at default, not `Simulated`).
- The plugin enablement (`5e328f5`) remains the solid fix: `CreateVehicle` → 4 wheels.
- The physics asset still needs a **proper** fix, not delete+recreate: set `BodySetup->PhysicsType = EPhysicsType::PhysType_Simulated` (and any Chaos-required flags) in `GigLineVehicleBuilder::BuildPhysicsAsset`, and update the asset **in place** rather than deleting it.
- `ue_vehicle_import.py` was wired to regenerate the asset, but its `SOURCE` is stale (`/tmp/gigline/hatch` — the static template) while the skeletal FBX lives in `/tmp/gigline/vehicle`; the wired block is therefore never reached. Fix the source path (or move the FBX) before relying on the import to rebuild the physics asset.
- Driving (`-drivetest`) is still untested; settling worked only in the transient run.
## 2026-11-10 — All skills are gitignored now (owner)
The owner added `/.agents/skills/` to `.gitignore`, so **no skill pack is committed** — the UE packs (`ue-*/`), the `cpp` router and the project's `blender-director` are all local-only. This supersedes the earlier "blender-director stays in the repository" wording. `AGENTS.md` and `Docs/toolchain.md` are reconciled to say "gitignored, not committed" and to name the UE and C++ sources (`quodsoler/unreal-engine-skills` `f3742d7`, `DiamonDinoia/skill-cpp` `f2617fb`). The Blender packs remain in the common `~/.pi/agent/skills/` root, also outside the repo.
## 0.6.0 — Visual/audio pass and evening lighting preset (weeks 22–24)
Related tasks: [TODO.md](TODO.md#060--visualaudio-pass-and-evening-lighting-preset-weeks-2224).
### Look and sound
- 2026-11-09: the strip between kerb and building line is terrain mesh and reads as bare ground under the shopfronts; see `Screenshots/README.md`.
## 0.9.0 — Realistic buildings (weeks 31–34)
Related tasks: [TODO.md](TODO.md#090--realistic-buildings-weeks-3134).
- Suggested Blender skills: **archviz** (real-world scale, façade composition, elevation read), **references** (reference-image-match and visual-match checklists — every building is judged against its own photograph), **procedural-modeling** + **geometry-nodes** (the façades system: bay module, cornice and coping profiles, storefront types as reusable node groups), **materials** (measured albedo and roughness per surface type), **texture-workflow** (baked AO/curvature/normal, decals for painted lettering and signs), **uv-workflow** (texel density and modular UVs), **lod-pipeline** (LOD chain per building), **collision-proxy** (UCX proxies so detail geometry is not the collider), **lookdev** (screenshot comparison loop), **camera-cinematography** (reproducing each building's reference angle), **asset-optimization** + **unreal-export** (pre-export validation, `ue_metric_v1`), **qa-review** (the gate before every review shot).
### Façade system
- 2026-11-09: `0.1.6` showed that geometry-plate lettering cannot resolve at review distance.
### Per-building models
- 2026-11-10: volute capital and lattice grille depth were deferred from the hero rework. The motto band, medallions, inscription, and banner lettering were completed in that pass.
- 2026-11-09: the entrance requirements were carried over from `Screenshots/README.md`.
- 2026-11-09: the 6800/6806–6820 trim needs more than geometry: a 0.10 m lettering plate vanishes at 40 m, and the parapet and portrait panels need better representations.
- 2026-11-09: no photograph of 6822 Budget was available; its single-storey 6.0 × 5.5 m volume cannot be read from the neighbour shot. The only 6800/6806–6820 reference is an oblique with a bus across Lee Drug's shopfront and a car across 6806's.
### Materials, UVs and performance
- 2026-11-10: the hero stone renders pale; it uses a procedural graph and generated texture set rather than a measured surface.
## Vehicle physics — Chaos rig (own task, from `0.3.0`)
Historical notes from 2026-11-09/10; later findings elsewhere in this file may supersede these observations and suggestions. Related tasks: [TODO.md](TODO.md#vehicle-physics--chaos-rig-own-task-from-030).
### Tasks
- The chassis box, mass, and gravity measured correct, so the proposed chassis-shape task was deferred.
- The map comparison found the same `kill_z` and gravity, no World Partition, and no physics volume in either map. The suggested next probe was a fresh map with staged content added group by group; `Tools/unreal/ue_vehicle_probe_map.py` already supports one mesh at a time.
Split out of `0.3.0` because it is a physics problem, not an asset one. **The Chaos pawn is parked**: with it staged the actor is launched to **Z 1993 cm** (18 m up) and reports **0 wheels**, so it litters the demo without being drivable. The visible car is the plain panel set, which looks right and is what the review shots show.
### Evidence
```
GigLine vehicle geometry: box 452x205x40 centre 0,0,-18 | bounds origin ...,1393 extent 1,1,1
GigLine vehicle motion: velocity -24,-182,-28 cm/s angular ~0 mass 1500 sim=1
GigLine vehicle mesh asset: SK_Vehicle_Sedan
GigLine vehicle diag: mesh=1 simulating=1 physasset=yes setups=4 wheels=0
```
**The physics body is nearly still while the actor is nine metres above its spawn.** That is not a force problem, it is a transform problem: something writes the actor transform that is neither the body's velocity nor our code. The mesh component's bounds read `extent 1,1,1` - a degenerate 1 cm box - which is the same symptom seen from the other side.
### Ruled out (each tested in its own run)
- **Spawn penetration** - spawned level with the road, 1.2 m above it, 3 m above it and 5 m above it; the actor climbs in every case.
- **Visual panel collision** - disabling it changed nothing.
- **Wrong chassis box** - `452x205x40 centre 0,0,-18` is the car.
- **Gravity** - `global_gravity_z = 0.0` and the character falls normally, so the world pulls down.
- **Mass** - the 1500 kg override applies (logged), and the body still does not rise.
### Measured after the transform finding
```
bones: 6 [VehicleRig root Wheel_FL Wheel_FR Wheel_RL Wheel_RR]
setups: [Wheel_FL Wheel_FR Wheel_RL Wheel_RR] -> names match, suspect 3 is gone
mover: updated=VehicleMesh ticking=1
stop: mover tick disabled at 2.5 s (Z 2001); at 3.0 s the actor is still at Z 2258
terrain: CTF_USE_COMPLEX_AS_SIMPLE, 0 box elements -> no invisible lid to be ejected from
```
So the wheel bones match, the terrain has no proxy lid, and stopping the mover did not stop the climb - meaning either the stop does not take effect (something else re-enables or writes the transform every frame) or a second writer exists.
### Suspects, in order
1. **Component hierarchy**: a pawn whose root is the mesh while the physics state belongs to another component behaves exactly like this - correct box, correct mass, near-zero velocity, moving actor, 1 cm bounds.
2. **`UChaosWheeledVehicleMovementComponent::UpdateState` / `OnUpdateTransform`** writing the transform from a vehicle state that was never created (which is also why `CreateVehicle()` yields no wheels).
3. **The skeletal mesh asset itself** - `SK_Vehicle_Sedan` came from our own FBX pipeline; if its bone layout does not match what the wheel setups name, `CreateVehicle()` bails before creating wheels.
### Bisect result: the map, not the pawn
The same pawn, unchanged, in two maps:
```
ReviewMap (flat 160 m floor): Z 200 -> 188 -> 174 -> 157 -> 87 vZ -208 -> -663 falls correctly
DemoMap, on the street: Z 486 -> 920 -> 1126 -> 1385 vZ oscillates ~0 teleports up
DemoMap, 60 m off the street: Z 291 -> 467 -> 920 -> 1343 vZ +1595 launches
DemoMap, 25 m above ground: Z 2496 -> 2664 -> 3068 -> 3434 vZ +1509 launches at once
```
**So the Chaos rig is fine and `DemoMap` is what breaks it** - at any height, anywhere in that map, and not by contact (25 m in the air launches immediately). The next comparison is between the two maps' world settings and level actors: `ReviewMap` was built by `Tools/unreal/ue_build_review_map.py`, `DemoMap` by `Tools/unreal/ue_build_demo_map.py`, and the difference that matters is in what one stages and the other does not.
### The transform is written directly, not moved
Zeroing the body's linear and angular velocity **every frame** for two seconds changes nothing:
```
t=0.25 Z=3220 vZ=0 t=0.75 Z=3683 vZ=0 t=1.25 Z=4197 vZ=0
```
The actor keeps rising at ~1200 cm/s while its velocity is pinned to zero. **Something calls a transform setter on the vehicle every frame** - this is not a force, an impulse or a bad initial state, all of which the clamp would have removed.
`Game/GigLine/Source/GigLine/*.cpp` contains exactly one direct transform write outside the character (`GigLineShotActor` moves its camera), so the writer is engine-side: the Chaos vehicle component driving `UpdatedComponent` from a vehicle state that was never created, or a skeletal-mesh transform update.
### Map-content bisect: the terrain is involved
Built `Tools/unreal/ue_vehicle_probe_map.py` (duplicates `ReviewMap`, optionally adds ONE demo mesh plus the pawn, saves as `VehicleProbe`) and ran it:
```
control (no extra mesh) Z 300 -> 297 over 1.25 s (resting on the review floor)
+ Terrain_DEM Z 300 -> 498 over 1.25 s (thrown upward)
```
So `Terrain_DEM` is enough to reproduce it in a map that otherwise works. Two fixes were then tried and **neither helped**:
- `Tools/unreal/ue_clear_simple_collision.py` cleared the auto-generated convex hulls from all 76 site/street meshes (terrain had **1 convex element** over the whole 279 m) and set complex-as-simple - still rises.
- Setting the terrain component to `CollisionEnabled::QueryOnly` so traces still hit it but the physics solver ignores it - still rises.
That means the ejection is not the convex hull and not the collision channel; the next probe is whether it is the _complex_ triangles (inverted winding pushing bodies out) - test by setting `Terrain_DEM` to **no collision at all** in the probe map and confirming the pawn falls.
### Where it stands: deferred to its own session
Three fixes were tried after the terrain bisect and **none changed the behaviour**:
1. clearing the auto-generated convex hulls from all 76 site/street meshes;
2. setting the terrain component to `QueryOnly`;
3. staging the terrain in the probe map with **collision disabled entirely**.
The pawn still rises in every case, so the fault is not the terrain's convex hull, not its collision channel, and not even its collision. The player is unaffected throughout - re-checked after each fix, and the character still stands on `Sidewalk_North_07` at 157 cm.
Deferred rather than guessed. What a fresh session needs:
- the probe workflow is in place: `Tools/unreal/ue_vehicle_probe_map.py` with `GIGLINE_PROBE_ASSET` and `GIGLINE_PROBE_NOCOLLISION`, `Tools/unreal/ue_clear_simple_collision.py` for the hull hygiene;
- the vehicle diagnostics are switches: `-probes`, `-profile`, `-novehmover`, plus the always-on velocity clamp, overlap list and velocity profile;
- the next question is _why a map carrying a 279 m static mesh changes a physics body's behaviour when that mesh has no collision at all_, which points at streaming, memory layout or the physics scene's broadphase rather than at collision authoring.
### Bisect plan (kept for the next session)
1. Spawn the same pawn in `ReviewMap` (flat 160 m floor, no slices, no UCX ground) and repeat the profile. Flies there too -> the asset/component is at fault. Settles there -> something in `DemoMap` (the UCS slabs, the terrain, or the world settings) drives it.
2. In parallel, spawn a stock `AWheeledVehiclePawn` with `SK_Vehicle_Sedan` and default settings in the same spot: if the stock pawn settles, the difference is in `AGigLineVehicle`'s setup (mass override, stability control, our movement subclass).
3. Only after the bisect: fix, then confirm `Wheels.Num() == 4`.
### Where it was left (2026-11-10)
Two facts that matter more than the hypothesis list above: on `ReviewMap`'s flat floor the **same pawn falls correctly** (Z 200 -> 87, vZ -663), and in `DemoMap` it teleports upward at any height - including 25 m in the air - with the transform written directly (clamping velocity to zero every frame does not stop it) while the body reports a near-zero velocity. Disabling the movement component's tick changes nothing; there are zero overlaps; the body is genuinely dynamic (`profile=PhysicsActor`); `shadow`-style collision explanations were ruled out by staging the terrain with **collision disabled entirely** and seeing the pawn still rise.
**Fresh probe data (2026-11-10) — the writer is the physics sim, not an external setter.** Control (no terrain) falls and settles (300 → 78 cm); terrain rises (313 → 545 cm) with body velocity pinned to 0 and `overlaps: 0`. **Turning physics OFF at 3 s stops the rise** (`physics OFF at Z 545` → `after 1 s without physics, Z 545`), so the Chaos vehicle solver is teleporting the body (transform set directly, no velocity). Both runs have `setups=4 wheels=0`, so the missing wheels are not the trigger — control falls with 0 wheels too; the terrain is what flips fall → rise. The asset pipeline is ruled out (FBX, `SK_Vehicle_Sedan` import 476×228×144 cm, physics asset 452×205×40, `Terrain_DEM` 380×210 m all correct; the `1 cm` bounds is a stale component read). Next: bisect step 2 — a stock `AWheeledVehiclePawn` beside `AGigLineVehicle` in the terrain probe map.
## Hero facade rework — from the ReviewMap review (own task)
Historical notes from 2026-11-09/10; later findings elsewhere in this file may supersede these observations and suggestions. Related tasks: [TODO.md](TODO.md#hero-facade-rework--from-the-reviewmap-review-own-task).
Split out of `0.3.0` on 2026-11-09 on the owner's instruction: the three hero-facade staging shots (`review-hero-facade-front`, `review-hero-facade-oblique`, `review-hero-portals`) were rejected as "rework needed to match reference", and that is asset work rather than vehicle work.
### Reference vs current
Reference: `security-game/docs/screens/refs/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg` (the north elevation, daylight). What it shows, and where the model stands:
| Feature | Reference | Model (`SM_HeroFacade`) |
| --- | --- | --- |
| Rusticated stone, warm limestone | heavy banded rustication over the whole wall | flat `M_Facade_Stone`, no banding |
| Six giant Ionic columns | fluted shafts, volute capitals, moulded bases | present, fluted (20 flutes each), capital block only |
| Three vertical JIMMY KIMMEL LIVE banners | red, gold lettering, hung between the middle columns | present as flat panels with `M_Facade_Banner` |
| Inscription band | "FREEMASONRY BUILDS ITS TEMPLES..." carved lettering | `Hero_MottoBand` + surround + two medallions, no lettering |
| Lattice windows between columns | dark metal grilles, two bands | `Hero_Window_1_*` / `_2_*` as plain panels |
| Cornice, dentils, parapet | deep cornice, 52 dentils, balustrade with finials | cornice + 52 dentils + parapet, **no balustrade or finials** |
| Three entry portals with steps | recessed, framed, three sets of steps | present: portals, jambs, lintels, mullions, five steps each |
So the model has the massing and most of the parts; what it lacks is **surface** (rustication, lettering, grille detail, balustrade) and material variation.
### Found: the detail is modelled inside the wall
Measured, not guessed:
```
blend scene Y -12.50 .. +0.13 (front of the wall at -0.50)
Hero_Mass y = -BODY_D/2 - 0.5 -> spans -12.50 .. -0.50
Hero_Banner_1 y = -1.08 .. -1.02 -> 55 cm BEHIND the front face
imported mesh bounds y = -13 .. +1250 cm
```
The facade's detail - banners, and by the same construction the window bays and portal surrounds - is placed **behind** the wall's front face, so it is inside `Hero_Mass` and never renders. That is why the captures show a plain block with six columns: the columns, cornice and dentils happen to protrude, everything else does not. The model does not need remodelling first; its detail needs moving in front of the wall.
**Fix:** give `Tools/blender/hero_facade_build.py` a `FRONT_Y = -BODY_D / 2.0 - 0.5` constant (the mass's front face) and place every elevation detail relative to it (`FRONT_Y - 0.10` for applied detail, `FRONT_Y` for flush), instead of the hand-typed negative coordinates each call site uses now. Then re-export, re-import and re-render.
**And check the review shot.** The three hero captures show a plain block with six columns and nothing else - no banners, no windows, no portals - while the mesh demonstrably carries all four materials (`M_Facade_Banner`, `M_Facade_Stone`, `M_Facade_StoneShadow`, `M_Facade_GlassDark`) and is staged at `(-13336, 1307, 0)` with yaw 0. That suggests the cameras are framing the wrong actor (`Frontage_SM_ElCapitanTheatre` sits at the origin) or the wrong side, and the "rework" note may be describing a camera error rather than the model. Verify that first: one render with a camera aimed at `Hero_Facade` from Y -4000 at 8 m height.
### First pass against the folder references
`Art/reference/hero-masonic/` (15 images, ignored by git as reference photography) is now the source of truth rather than one Wikimedia photo. `02-masonictemple01.jpg` and `06-el-capitan-theater-frontal-fachade.jpg` are the front elevations used for the first pass; `05-hollywoodmasonictemple-1922.jpg` records the original masonry.
Done in this pass:
- the detail is seated on the wall (previous step), so banners, windows and portals render;
- the blank `Hero_Stylobate_LintelBand` across the portals is gone - the reference has open doorways with steps, which now read correctly;
- `rustication()` cuts five ashlar courses from the pavement to the portal heads;
- `balustrade()` adds a bottom rail, a top rail and 38 balusters along the roof edge.
Still missing, from the same references: warm sandstone instead of the flat pale stone, lattice grilles in the window bays, the carved inscription on the motto band, and the two medallions are not reading at distance.
### The iteration loop
`bash Tools/review/hero_iterate.sh` runs the whole chain in one command: chmod the Blender files, build the facade, import it, render the capture set, judge the shot with the vision model, and write a comparison plate at `Screenshots/review/hero-compare-front.png`. Each step fails loudly and logs to `/tmp/gigline/`.
Iteration 1 verdict (judge, confidence 0.9):
> "The scene lacks the warm yellow-grey sandstone texture and the detailed fluted Ionic columns. Additionally, the red banners do not match the expected design, and there are no visible entrance doorways or decorative elements like the inscription band or medallions."
Measured against the plate, that splits into:
| Finding | Reality | Next action |
| --- | --- | --- |
| not warm sandstone | true - the UE material `M_FacadeStone` is still near-white (0.62-0.76 grey) | warm the UE material spec in `Tools/unreal/ue_hero_facade_import.py`, not the Blender one |
| fluting missing | false - 120 flute strips exist and cast shadows; they are lost at 45 m | not a modelling problem; check the review camera distance |
| banners wrong | partly - proportions are now 1.35 x 6.2 m and hang correctly, the lettering is absent | needs a texture, not geometry |
| no doorways | false - three 1.2 m deep recesses with steps; they read dark because the reveal is glass-black | lighten the reveal material |
| no inscription/medallions | true - the band and two medallions exist but are flat and untextured | texture the motto band |
### Tasks
Historical task context from 2026-11-09/10; later findings may supersede these observations. Owner review remains pending.
The following work was recorded as done on 2026-11-09/10, each verified by a capture:
- Confirm the hero captures frame `Hero_Facade` - cameras at (-13336, 1307), hero actor there, frontage at the origin.
- Seat the elevation detail in front of the mass (`FRONT_Y` + `seat_on_front()`, 99 parts moved) - banners, windows and portals render now.
- Remove the blank lintel band across the portals; the three doorways are 1.2 m deep with five steps each.
- Window grilles (7 vertical + 5 horizontal bars per bay), five ashlar courses, roof balustrade with 38 balusters.
- Warm sandstone in the engine - `tint_imported_materials()` authors one material per slot from the reference.
- Banner proportions 1.35 x 6.2 m hung from 7.2 m, matching the photographs.
Additional completed work and review notes:
- **Make the generated ashlar albedo render.** Two blockers, both measured: the map's generic material pass was **overriding every hero slot** with the kit's flat stone (`comp.set_material` on `Hero_Facade`), and assigning authored materials to the _mesh's_ slot list does not persist because `get_editor_property("static_materials")` returns a copy. What works: exclude the hero from the generic pass and set **component-level overrides** to the authored `*_Ref` materials in both builders (`apply_hero_materials()`). The elevation now shows coursed, grainy limestone.
- Carved inscription: `inscription()` in `Tools/blender/hero_facade_build.py` lays 112 letter marks in three lines across the attic, in the shadow stone so they read at 45 m. They had to be **seated on the parapet face** (`FRONT_Y + 0.40`): the parapet is 1.2 m deep and stands in front of the wall plane, so marks at the wall plane were hidden behind it.
- Medallion relief that reads at 45 m. **Measured first, then fixed:** the discs were not just flat - they were **buried**. `seat_on_front` put `Hero_Medallion` at offset 0.08 → front face Y −0.42, while the parapet (1.2 m deep) stands at Y −0.15, so both medallions were inside the parapet and never rendered (the motto band and surround have the same bug at offset 0.06). Fixed: three stepped layers per medallion — `Hero_MedallionRim` (0.55 m, dark, offset 0.36 → Y −0.14), `Hero_MedallionField` (0.38 m, light, offset 0.43 → Y −0.07), `Hero_MedallionBoss` (0.15 m, dark, offset 0.50 → Y 0.00) — a 7 cm step each way so the rim/field/boss read as relief. Moved from ±6.2 (inside the inscription text, ±6.87) to ±9.8. Rendered and judged; the two-model judge is advisory-pessimistic (`no` 0.93) and does not flag medallions any more. Owner review pending.
- Motto band and surround — same burial bug (offset 0.06 → front Y −0.44). The band is now **light stone** (was `stone_dark`, which would have swallowed the dark lettering once visible), recessed at offset 0.34 (Y −0.16), with the moulded surround proud at offset 0.43 (Y −0.07) and the inscription at 0.40 (Y −0.10); the dead duplicate `Hero_MottoBand 0.40` entry is removed. Rendered and judged; the judge (`no` 0.93) no longer flags the band, only the stone warmth/column fluting the owner has already directed lighter. Owner review pending.
- Banner lettering (JIMMY KIMMEL LIVE). New `Tools/gen/generate_banner_texture.py` draws the exact title with Pillow + Liberation Sans Bold (an image model would mangle a trademark string, and Qwen-Image 2.1 is research-only). Gold `JIMMY` / `KIMMEL` / `LIVE` stacked on a crimson ground, 512 × 2304. `ue_hero_facade_import.py` generalised `import_albedo` → `import_texture` and wires the banner into `M_Facade_Banner_Ref` as Base Color (tiling 1.0). Owner review pending.
- Crimson banner tone + lettering. The texture carries the red ground (186,24,24 — brighter than the old dark tint, per the owner's "less dark, more bright") and the gold lettering, so the flat tint is replaced outright.
- Re-render the three ReviewMap staging shots and put them back in the status table for review. All three (`review-hero-facade-front`, `review-hero-facade-oblique`, `review-hero-portals`) regenerated 2026-11-10 with the full attic detail, and the `Screenshots/README.md` status table updated. Owner review pending.
**Deferred to `0.9.0` (owner, 2026-11-10).** This pass is closed at "surface detail that reads at 45 m". The remaining elevation work is a `0.9.0` pass, not another patch here: **volute capital depth** (the capitals are block-only, the reference has strong shadow lines), **lattice grille depth** (the window bays are plain panels with bars, not the dark metal lattice), and the full realism pass (heavy banded rustication, measured material library, texel-density and LOD/collision per building). The `0.9.0` hero item records the hand-off.
## 0.3.0 — Chaos vehicle, enter/exit, closed driving route (weeks 10–13)
### Vehicle production
Historical task context from 2026-11-09/10; later vehicle findings above may supersede these observations. Related tasks: [TODO.md](TODO.md#030--chaos-vehicle-enterexit-closed-driving-route-weeks-1013).
- Model one vehicle in Blender with collision meshes; export to Unreal. **Model done** (`Tools/blender/vehicle_build.py` → `Art/blender/vehicle/gigline-sedan.blend`, preview in `Art/blender/vehicle/preview.png`): 4.72 x 1.80 x 1.44 m, 2.78 m wheelbase, 1.62 m track, 0.33 m wheels, 252 triangles, four `UCX_` convex hulls. Dimensions come from real sedan figures and are logged to the report, so the vehicle config can read them rather than restating them. - **Imported into Unreal** (`Tools/unreal/ue_vehicle_import.py` → `/Game/Hollywood/Vehicle`), split per the vehicle-artist object split: `SM_Vehicle_Body` (3 slots, 4.76 x 1.76 x 1.28 m), `SM_Vehicle_Glass`, `SM_Vehicle_Light_Front`, `SM_Vehicle_Light_Rear`, `SM_Vehicle_Mirrors`, and one `SM_Vehicle_Wheel` (0.22 x 0.66 x 0.66 m) reused four times. The body FBX carries the `UCX_` hulls as simple collision.
- **LODs cut for real:** decimating the duplicate gives LOD1 at 38 triangles and LOD2 at 14, against 132 for LOD0. The first attempt duplicated the shell unchanged and produced 84-triangle LODs that saved nothing - worth remembering as the failure mode of a "LOD" step nobody measures.
- **LOD group wiring is open:** `StaticMeshEditorSubsystem.import_lod` is unavailable in a headless commandlet (`get_editor_subsystem` returns None), so the LOD meshes import as their own assets and the group still needs an editor session.
- Set up Chaos Vehicle with wheel and physics configuration. **Deferred to its own task** (`Vehicle physics — Chaos rig`): the pawn is parked, the visible car is the panel set, and the remaining question is what writes the actor transform in `DemoMap` but not in `ReviewMap`.
- **Fixed the floating:** the physics asset had a body per bone, and the four wheel bodies intersected the chassis body and pushed the car 3.7 m up. It is now a **single chassis body** on the `root` bone (452 x 205 x 40 cm), which is what Unreal's own vehicle template uses - Chaos simulates the wheels itself, they must not be separate rigid bodies. Settle check: **Z 119 cm** at a spawn of 120 (was 494).
- **Drive test fails, with a specific cause:** `-drivetest` holds full throttle for 3 s and reports speed and distance. Result: `speed=0 cm/s, travelled 2.2 m -> FAIL`, and BeginPlay logs **`0 wheel objects after register`** while the component reports 4 wheel setups. The setups exist but no wheels are created from them, so there is nothing to apply torque to.
- **Next:** confirm the skeleton's bone names match the setups (`Wheel_FL`, `Wheel_FR`, `Wheel_RL`, `Wheel_RR`) - a name mismatch silently produces zero wheels - and give the front and rear pairs their own `UChaosVehicleWheel` subclasses rather than the base class, which is what the setups point at today.
- **Also open:** LOD group wiring, enter/exit, driving route, handling.
- Fix the vehicle not rendering in the scene. **Chased to a strong clue, not yet solved.** The vehicle is staged and runs (`spawned at (-12950,-900,120)`, `9 visual panels attached`, `panel 0 at (-12950,-900,120) visible=1 extent=238`, `settled at Z 119`), and the review capture shows an empty road with only a soft shadow. `Tools/review/visual_loop.py --verify` passes the frame; `Tools/review/judge_screenshot.py` returns `verdict=no confidence=1.0`. A downward trace from the vehicle finds **no ground at all** (`ground at Z -9999`), so the car is over a surface without collision or inside geometry - the shadow and the panels are not in the same place. **Fixed 2026-11-09** — the car renders, built from the owner's `hot hatch.blend`. **Fixed:** the vehicle was spawned 2.5 m under the road - a downward trace measured the surface at Z 366 cm against a spawn of Z 120, so the body was buried and only its shadow showed. The spawn is now Z 470 and it settles there.
- **Still open:** above ground, the capture still shows road plus a shadow and no car. Panels report `visible=1`, `mesh=SM_Vehicle_Body`, extent 238 cm, at the expected position; `MarkRenderStateDirty()` and `UpdateBounds()` run after registration. Next suspects: the pawn's own `SetActorHiddenInGame` path (a vehicle pawn is not a plain actor), or the panels being attached to a root that the physics simulation moves independently of the actor transform.
- Fix the character and car floating. **The measurement was taken at the wrong moment.** The foot probe ran in `BeginPlay`, while the pawn is still at its spawn height and falling, so every trace reported a surface far below it (`ground 366` against an actor at 561) and read as a float that was not there. The probe now runs **three seconds after spawn**, from the capsule's feet, on three channels, and names the actor it hits. Character: the player start at Z 600 left the soles 39 cm in the air (the capsule settles at 561 on this pavement); the start is now the measured 561. Car: the template's lowest geometry is exhaust, so grounding on the whole model put the tyres 4 cm above the origin - it is now grounded on its wheels, staged at Z 342 after the traced road height (366) disagreed with the visible road by ~25 cm.
- Fix the vehicle's material colour. The template's `car paint` is a legacy mix-shader graph that the FBX exporter cannot map, so `SM_Vehicle_Body` imported holding `WorldGridMaterial` and rendered black while Blender showed red. Legacy graphs are now converted to Principled before export (saturated colour wins over the grey diffuse next to it), and the staged body panel is assigned `M_Vehicle_HatchPaint`. Judge: `verdict=yes confidence=0.9`.
- Fix the missing vehicle glazing. The `windows` material was metallic 1.0 with roughness 0.0 - a black void that read as an empty cabin. Now tinted translucent (opacity 0.85, roughness 0.08); opacity 0.45 was tried first and made the glass invisible. The vision judge still returns `verdict=no` for the frame, disagreeing with what is visibly tinted glass; it is advisory, and the human verdict decides.
- Reconcile the road and sidewalk surfaces. **Solved: the road strip was a single 279 m mesh whose auto-generated convex hull sat 2 m above the visible surface.**
- The site export now slices sidewalks and kerbs into ~20 m pieces with explicit `UCX_` boxes, and the slice cuts are fixed (the far cut kept the wrong half, so every slice spanned the whole street). Slice tops descend honestly along the street: 380, 340, 309, 284, 254, 231, 205, **169 at the player's X**, 138, 104 cm.
- In the map at the same X: `Sidewalk_North_07` tops at **169 cm**, `Road_Hollywood` at **366 cm**. The road is ~2 m above its sidewalk, so the player lands on the road and no collision setting on the sidewalk can change that.
- Neither slicing, `UCX_` hulls, `set_complex_collision_as_simple`, nor winding (all 2790 sidewalk faces point up) changed the outcome - each of those was the wrong layer.
- **Cause found:** the road is built across -10.5..+10.5 m and the north sidewalk +10.5..+15.8 m, so on the straight they meet at the gutter - but the strips follow a curved centreline and the fan-out pushes the road's outer edge to **-13.8 m**, overlapping the sidewalk band (**-17.3..-11.8 m**). In that overlap the road sits at 366 cm and the sidewalk at 158 cm.
- **Resolved 2026-11-10:** the real cause was the **collision hull**, not the geometry. A 279 m strip imports with one auto-generated convex hull whose top is the mesh's _maximum_ height, so every downward trace landed on a flat lid at 366 cm. Slicing the road like the sidewalks, and removing the single `Road_Hollywood` asset from the map so the slices win, gives a continuous cross-section: `Terrain 154 / Sidewalk_North_07 158 / Road_Hollywood_07 158`. The player now stands on `Sidewalk_North_07` at 158 cm with feet at 161.
- **Superseded:** sample both source meshes at the player's XY in `Art/blender/street/street-2026-11-06.blend`. The road is built as `dem_z + 0.02` and the sidewalk as `dem_z + kerb * t` from the _same_ DEM sampler, so a 2 m gap cannot come from that code - the suspect is the DEM lookup or the mesh's coordinate frame, not the assembly logic.
## 2026-11-10 — Vehicle float/settle root cause: the body never simulated (PhysicsType)
- The physics asset body was missing `PhysicsType`. `UGigLineVehicleBuilder::BuildPhysicsAsset`
created a `USkeletalBodySetup` with a box but never set `Body->PhysicsType`, so it stayed
`PhysType_Default` ("follow owner"). The base `AWheeledVehiclePawn` sets
`Mesh->BodyInstance.bSimulatePhysics = false` (the Chaos async solver drives it), so
"follow owner" = do not simulate → the chassis never fell, the vehicle sat frozen at
spawn (`simulating=0`), and the `-drivetest` reported `speed=0 travelled=0`.
- **Fix:** `Body->PhysicsType = EPhysicsType::PhysType_Simulated;` (member on
`UBodySetupCore`, `PhysicsCore/Public/BodySetupCore.h`; enum in `BodySetupEnums.h`).
- **Result:** `simulating=1`, `settled at Z 77 cm`, and the drive test moved the car —
`speed=-2680 cm/s (-96.5 km/h), travelled 36.3 m`. It **drives backwards**: the next bug
is the reverse direction (rear wheels `bAffectedByEngine`, engine torque sign, or wheel
bone orientation), not the physics setup.
- Regenerate the asset with `rm` of the `.uasset` then `unreal.GigLineVehicleBuilder.
build_physics_asset(skel, dest, "PA_Vehicle_Sedan")` from a commandlet; the
delete+recreate fragility earlier was really this missing `PhysicsType`, not the box
centre (both −18 and +32.4 had the same unsimulated body).
## 2026-11-10 — Vehicle drives backwards; the axle flip did not fix it
- The reverse direction is not the wheel-bone axle sign. Flipping the axles from inward to
outward (`y - copysign` → `y + copysign` in `vehicle_build.py`) and re-importing changed
nothing (`speed=-2788 cm/s` still, vs −2680 before). Reverted.
- Remaining suspects, in order: the Chaos wheel's spin axis convention (maybe it spins about
X, not the bone's Y), the engine torque sign, or the throttle sign. Next probe: run the
`-drivetest` with `SetThrottleInput(-1.0)` to split "throttle inverted" from "wheel spin
inverted" in one run.
## 2026-11-10 — Vehicle reverse: both throttle signs drive backward
- `-reversetest` (negative throttle) shows the throttle sign is not the cause: forward
throttle → −224.9 km/h, reverse throttle → −99.5 km/h — **both backward**, so the wheel
spin is inverted relative to the engine torque, not the input mapping.
- The mesh orientation is fine (imported bounds 476 × 228 cm, nose at +X). The earlier axle
flip (inward→outward) was a no-op, so the next check is the **imported wheel-bone
reference pose**: the axle (bone Y) may be landing on Unreal X or Z, not Y, so the wheel
spins about the wrong axis. `FPhysicsAssetUtils`-style body setup is not the cause either
(physics already simulates).
- Kept `-reversetest` in the drive test as the diagnostic for this.