# Asset Pipeline and Tooling Everything the game renders is produced or gated by the scripts below. Architecture-level contracts, data flow and failure fallbacks are summarized in [`architecture.md`](architecture.md#content-and-asset-pipeline-architecture); this page is the operational reference. Development-only outputs (AI concept sheets, Blender sources, raw AI fabrics) are gitignored; the audited game assets are committed. ## Image Asset Tools Install the Pillow dependency used by the existing texture generator and the asset tools: ```bash python3 -m pip install -r requirements-tools.txt ``` Generate a role-sized image (generation requires `OPENROUTER_API_KEY`): ```bash python3 tools/asset_pipeline.py generate --prompt "weathered red brick" --role tile --output assets/textures/brick_extra.png ``` Generated images are resized to the selected role profile. Non-square requests use the validated `openai/gpt-5-image-mini` source ratio and crop to the final role dimensions. Transparent glyphs can be recolored while preserving their alpha mask with `--foreground '#ffffff'`. Crop uses the image center by default; `--x` and `--y` select an explicit crop origin. Resize defaults to aspect-preserving `contain`; use `--fit cover` to crop or `--fit stretch` to distort to the exact dimensions. ```bash python3 tools/asset_pipeline.py crop assets/textures/source.png assets/textures/crop.png --width 256 --height 128 python3 tools/asset_pipeline.py resize assets/textures/source.png assets/images/scenes/scene.png --width 1920 --height 1080 --fit cover python3 tools/asset_pipeline.py seamless assets/textures/rough_tile.png assets/textures/tile.png ``` Audit accepts image files or directories. `--role auto` infers common roles from paths and filenames; explicit roles include `tile`, `texture`, `icon`, `card`, `banner`, `backdrop`, `background`, `overlay`, `app_icon`, and `screenshot`. ```bash python3 tools/asset_pipeline.py audit assets/textures python3 tools/asset_pipeline.py audit assets/images/scenes --role background --json python3 -m unittest discover -s tools/tests -t tools -p 'test_*.py' ``` The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks. ## Prop models Street furniture (bollards, benches, bins, planters, newspaper boxes, a hydrant) is real 3D geometry generated by `tools/model_pipeline.py` and written to `assets/models/generated/*.glb`. Godot imports each file as a `PackedScene`, and `WorldBuilder` instantiates it with a procedural fallback when the model or its import is missing, so the world still builds on a fresh checkout. ```bash # Print the catalogue (parts, triangle count, whether a model/concept exists) python3 tools/model_pipeline.py list # Rebuild the models (deterministic, offline) and validate them python3 tools/model_pipeline.py build python3 tools/model_pipeline.py audit # Optional: an image model supplies the colour palette per prop # (requires OPENROUTER_API_KEY; the repo .env is read automatically) python3 tools/model_pipeline.py concept python3 tools/model_pipeline.py concept --only bench --force ``` Each prop is a small list of boxes and cylinders, so `build` needs no network and the output is byte-identical between runs. `concept` asks an image model for a studio-style reference; `dominant_colors` and `assign_roles` then map that image's colours onto material roles (darkest → metal, warm saturated → wood, light grey → stone, green → foliage). glTF stores colours in linear space, so the tool converts the sRGB palettes before writing them. Re-run `build` and `godot --headless --path . --import` after changing a prop. See [ADR-0012](adr/0012-surface-materials-and-generated-props.md). Surface materials share one definition in `src/game/materials.gd`: named roughness/metallic profiles per surface, plus the environment tune-up (ACES tonemapping, glow, fog, colour grading) that the phase lighting applies. ## NPC character model NPCs are moving from the procedural capsule rig to a skinned, UV-mapped base character authored in Blender ([ADR-0013](adr/0013-blender-authored-npc-characters.md)). The active base is the CC0 Quaternius modular character, baked to the runtime contract ([ADR-0017](adr/0017-quaternius-npc-base.md)). `tools/npc_pipeline.py` builds `assets/models/generated/npc_base.glb` plus the per-slot material maps. `NpcAvatar` instantiates that skinned model, tints its five material slots from the seed-driven outfit, and drives the bones from the existing animation code; the procedural rig remains the offline fallback (see [ADR-0013](adr/0013-blender-authored-npc-characters.md) and [`blender-assets.md`](blender-assets.md)). ```bash # Print the frozen contract (slots, bone names, triangle budget) python3 tools/npc_pipeline.py list # Rebuild the skinned base mesh + neutral maps in Blender and validate it flatpak run org.blender.Blender --background --python "$PWD/tools/npc_pipeline.py" -- build python3 tools/npc_pipeline.py audit # Layer the AI clothing albedos (shirt/trousers) over the procedural weave, and # derive per-fabric outfit colours for the avatar palette python3 tools/npc_pipeline.py prepare python3 tools/npc_pipeline.py palette ``` `prepare` resizes the AI fabric tiles (kept locally in `assets/textures/npc/ai/`, which is gitignored) onto the 256² `cloth`/`pants` albedos; `build` bakes them into the `.glb` and regenerates the procedural normal + packed ORM maps at the same 256² size. Runtime fabric materials repeat the weave 4× (`NPC_FABRIC_UV_SCALE`) and skip the per-character baked AO, which otherwise magnified into large blotches; skin, hair and fixed gear keep AO. `palette` writes `assets/textures/npc/palette.json` using `model_pipeline.dominant_colors`. ## Blender asset library (loaded by the game) `tools/blender_assets.py` models a larger library procedurally in Blender for the v0.6.0 integration: **8 skinned NPC characters** (casual, jacket, coat, hi-vis vest, dress, hoodie, firefighter, guard), each a drop-in for `npc_base.glb` (same bones, joint positions and tint slots); **18 gear pieces** authored at head/hand/back/chest attach points (caps, hood, helmet, backpack, SCBA tank, lanyard, phone, cup, clipboard, umbrella, camera, sign, flashlight, wand, radio, …); and **20 props** (octagonal bollard and bin, slatted bench, hydrant, news box, planters, street lamp, street and palm trees, A-frame sign, wall lantern, cone, crowd barrier, stanchion, handrail, podium, double door, marquee, sedan). `NpcAvatar` selects a character by role/seed, tints the five contract slots, and attaches gear at the documented points; `WorldBuilder` loads the library props with colliders/avoidance and drives their emissive parts from the day/night cycle. The single `npc_base.glb` and primitive props remain offline fallbacks. Contract and integration notes: [`blender-assets.md`](blender-assets.md). Sourcing and migration plan: [`npc-model-sourcing.md`](npc-model-sourcing.md) records the 2026-11-05 direction (ADR-0017). Quaternius Ultimate Modular (CC0) is the primary NPC mesh and animation source, baked to the existing 13-core contract; the procedural generator is the fallback. Kenney/Khronos/MakeHuman remain evaluated references, not active bases. | Characters | Props | | --- | --- | | ![characters](../docs/blender_previews/sheet_npc.jpg) | ![props](../docs/blender_previews/sheet_props.jpg) | ```bash flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- build flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- build --head mh flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- render python3 tools/blender_assets.py sheet # docs/blender_previews/sheet_*.jpg python3 tools/blender_assets.py audit # no Blender needed python3 tools/blender_assets.py fit # gear-vs-body fit report python3 tools/blender_assets.py texels # per-slot texel density (px/m) of the characters # `build --head mh` selects the retained MakeHuman fallback head; the default is still `face`. godot --headless --path . --import ``` ### Intake inspection (`inspect`) `audit` reads accessor-local bounds and ignores node transforms, so it is the right release gate but a poor first look at a downloaded or newly authored GLB. `inspect` imports the file into Blender, clears parents and applies object transforms, then reports what the scene actually contains: ```bash # one asset, a batch, or an external download; repeat --file as needed flatpak run org.blender.Blender --background \ --python "$PWD/tools/blender_assets.py" -- inspect \ --file "$PWD/assets/models/blender/npc/npc_casual.glb" \ --file "$PWD/tmp/downloads/SomeCharacter.glb" # machine-readable, for intake scripts (write to a file: Blender prints a banner first) ... -- inspect --file "$PWD/tmp/x.glb" --json --out "$PWD/tmp/x.json" # force a contract family instead of inferring it from the path ... -- inspect --file "$PWD/tmp/x.glb" --category npc ``` It prints triangles (with the category budget), raw and seam-split vertex counts, Godot-space bounds with height/feet for characters, whether transforms had to be applied, mesh nodes and armatures, bone names, materials, UV layers, skinning and animation count. Hard contract failures (`issues`) fail the command; advisory findings (`warnings`) do not. The category is inferred from the path: `blender/npc` → `npc`, `blender/npc_gear` → `npc_gear`, `blender/props` and `generated` → `props`, anything else → `external` (measured but with no project budget or rig contract). Caveats: - The glTF importer adds a bone-display mesh (`Icosphere`, used as `pose_bone.custom_shape`). `inspect` excludes custom-shape helpers so they never pollute bounds or the unskinned list. - Bounds and bone rests are converted from Blender Z-up to Godot Y-up (`x, z, -y`), matching the contract bands and the audit's coordinate space. ### Intake adaptation (`adapt`) `adapt` is the mechanical first half of an external-mesh intake (Quaternius, MakeHuman fallback, downloaded GLB): import, apply transforms, ground the feet, weld seam splits, decimate to the character budget, and export a GLB. It does **not** map the five tint slots or rebuild the 13-core-bone `npc_base` armature (optional extra bones are allowed) — those stay a manual `retopology`/`rigging` step with a recorded second-rig contract if the source rig cannot be retargeted: ```bash flatpak run org.blender.Blender --background \ --python "$PWD/tools/blender_assets.py" -- adapt \ --file "$PWD/tmp/npc-research/quaternius/Casual.glb" \ --out "$PWD/tmp/npc-research/quaternius_adapted.glb" \ --target-tris 24000 # prints: adapted quaternius_adapted.glb: -> 24000 tris (bones=N, meshes=1) ``` Run `inspect` on the adapted GLB before and after, and keep the procedural generator as the fallback until a Quaternius variant passes `audit`/`fit` and the 24-NPC budget. - Numbers are Blender-side measurements. Triangles match `audit` on project assets; the seam-split vertex count can differ by ±1 because the importer rounds normals. `audit` stays the single final gate: it is stdlib-only, runs in CI, and matches what the engine imports. - `inspect` never writes or adapts the file; scale/grounding/weld/retarget work is the separate (not yet built) `adapt` step. - `--json` also captures the importer's INFO log in `stats.import_log` instead of printing it. Blender still prints its own version banner before the script runs, so intake scripts should use `--json --out ` rather than parsing stdout. ### Reference-anchored concept generation `tools/scene_concepts.py generate --reference ` anchors a concept frame on a real photograph: the photo is sent to the OpenRouter multimodal chat endpoint as a base64 data URL (`generate_textures.generate_image(reference_path=...)`) with a prompt suffix that pins the photographed geometry (column rhythm, band heights, signage placement, camera angle) while the prompt restages lighting, weather and people. Licence gate: the reference must be listed in its `docs/screens/refs//SIDECAR.md` row (which also supplies the licence and Commons page for provenance). A photo from anywhere else requires `--reference-unchecked`, which records `unchecked` in the provenance. Each anchored generation writes a `.refgen.json` sidecar next to the frame (`model`, reference file/licence/page, geometry suffix) so a concept frame can always be traced back to its evidence photo. Where a generated frame and its reference photo disagree, the photo wins for shape. ### Photo-anchored variant pass `tools/scene_concepts.py anchor --reference --context "..." --treatments day,night,weathered --out-dir docs/screens/concepts/` generates several restaged conditions (day, night, weathered) of one reference photo's framing, each with its `.refgen.json` provenance sidecar, so a concept board stays comparable with its photo board. The reference passes the same SIDECAR licence gate as `--reference` on `generate`. ### Local-only reference collection (`refcollect`) `tools/refcollect.py` downloads images from DuckDuckGo image search into `tmp/research//` for development reference only. It applies **no licence gate** (rows are marked `local_only` / `unchecked-local`); everything it fetches must never be committed or promoted into [`screens/refs/`](screens/refs/README.md) without the normal sidecar licence + privacy gate. This is the owner-authorised local-only override (TODO.md §3D model re-evaluation → A). ```bash python3 tools/refcollect.py search "Comedy Store Sunset Boulevard" --slug comedy-store --limit 20 python3 tools/refcollect.py sheet --slug comedy-store # local contact sheet (never committed) ``` Requires `pip install duckduckgo-search` (renamed `ddgs` upstream). DuckDuckGo rate-limits by IP (HTTP 202), so every request — the search itself and each image download — passes through a shared token bucket (`--rate`, default 0.5 req/s, and `--burst`, default 3) and a polite retry routine: transient statuses (408/425/429/5xx/52x) and network errors back off exponentially with full jitter, `Retry-After` is honoured, permanent failures fail fast, and the loop gives up after six attempts. Requests carry a full, coherent Firefox header set (`--user-agent` overrides the UA string); the UA alone is no longer enough to look like a browser. ### Reference-board research (`refboard`) `tools/refboard.py` searches, fetches and documents Wikimedia Commons photos of a real location for the [`screens/refs/`](screens/refs/README.md) boards. It is stdlib-only and never commits anything itself: downloads land in `tmp/`, and moving a file into a board folder is a deliberate manual step that goes through the sidecar licence gate. ```bash # search: keyword, geosearch (the reliable "photos of this exact place"), or category python3 tools/refboard.py search --source geo "34.10118|-118.34009" --limit 60 --months 12 python3 tools/refboard.py search "El Capitan Entertainment Centre" --source search python3 tools/refboard.py search "Hollywood Masonic Temple" --source category --json # fetch the usable rows into tmp/ (review-only rows need --include-review) python3 tools/refboard.py search ... --json > /tmp/manifest.json python3 tools/refboard.py fetch --from /tmp/manifest.json --dir tmp/research/ --max 12 # emit SIDECAR + README table rows from the fetched manifest python3 tools/refboard.py rows --from tmp/research//meta.json ``` Licence gate: `Public domain`, `CC0` and `CC BY` are committable with attribution; `CC BY-SA` rows are flagged `review` and stay research-only until the share-alike decision is written into the sidecar (`--allow-ccbysa` marks them committable after that review); anything else is skipped. `--months 12` flags recent captures so a "current reality" photo can be distinguished from the older geometry evidence. Network gotchas baked in: a descriptive User-Agent, batched `titles=` metadata queries and ~3 s download spacing to avoid Commons HTTP 429s. ### Texel density (`texels`) `texel_density()` and the `texels` command compute `resolution * sqrt(UV area / world area)` per material slot straight from the GLB, so the number does not depend on island packing. The NPC slot maps are 256², and cloth/pants are reported twice: authored density and effective density with the runtime 4× repeat from `Materials.NPC_FABRIC_UV_SCALE`. Current values and interpretation are in [`3d-model-review.md`](3d-model-review.md). ```bash python3 tools/blender_assets.py texels # all eight characters python3 tools/blender_assets.py texels --json # machine-readable python3 tools/blender_assets.py texels --only npc_coat ``` ## Local map development (planned) The local map workstreams (H–M) in [`TODO.md`](../TODO.md#3d-model-re-evaluation) are planned, not implemented; there are no map-generation commands yet. The runtime contract, provenance rules, budget/navigation gates and fallback are in [`architecture.md#venue-implementation-contract`](architecture.md#venue-implementation-contract). The detailed pipeline/task list lives in `TODO.md`. ## Confirmed game assets Everything below is audited and shipped in the game; development-only outputs (AI concept sheets, Blender sources, raw AI fabrics) are gitignored. | | | | --- | --- | | ![brick facade](../assets/textures/ai/game_ready/v2/picks/brick_facade.png) | ![sidewalk pavers](../assets/textures/ai/game_ready/v2/picks/sidewalk_pavers.png) | | ![asphalt](../assets/textures/ai/game_ready/v2/picks/asphalt.png) | ![awning](../assets/textures/ai/game_ready/v2/picks/awning_canvas.png) | | ![skyline day](../assets/textures/ai/game_ready/v2/picks/skyline_day.png) | ![skyline night](../assets/textures/ai/game_ready/v2/picks/skyline_night.png) | - **Surface textures:** the v2 picks above (brick, sidewalk pavers, dry and wet asphalt, awning canvas) plus their normal maps, loaded by `src/game/materials.gd` and `WorldBuilder`; the three skyline panoramas are switched per shift phase. The first-generation tiles and `assets/textures/skyline.png` are no longer loaded. - **UI art:** `assets/textures/ai/game_ready/icons/` (flashlight, door lock, wand, REC) and `assets/textures/ai/game_ready/cards/` (ID, wristband, ticket, credential, guest list), loaded by `src/game/ui/hud.gd`. - **Generated models:** `assets/models/generated/*.glb` — bollard, bench, trash bin, planter, newspaper box, hydrant, and the NPC base — plus their colour atlases. - **NPC material maps:** `assets/textures/npc/*_{albedo,normal,orm}.png` and `palette.json`. Audit them all: ```bash python3 tools/asset_pipeline.py audit assets/textures/ai/game_ready python3 tools/model_pipeline.py audit python3 tools/npc_pipeline.py audit ``` ## Optional interactive Blender MCP Status: **optional interactive tooling, not a build dependency.** Blender MCP is useful for exploring a local part scene, moving the viewport and iterating on a head/hand mesh without a manual UI walkthrough. The authoritative asset pipeline remains the headless CLI plus `audit`/`fit`/`inspect`; CI, tests and release builds must continue to work with no Blender process and no MCP server. This project does not vend the third-party addon or pin a runtime MCP dependency. Install it outside the repository and keep generated scenes under `tmp/`. ### What it adds With Blender MCP connected, an agent can: - query the current scene and object data; - run small `bpy` snippets through the MCP bridge; - capture a viewport screenshot for visual checks; - inspect or modify a local part scene before exporting a GLB. It does **not** replace: - `python3 tools/blender_assets.py audit/fit/inspect`; - `python3 tools/npc_proportions.py`; - the Godot capture and smoke gates; - the project's licence/provenance review. ### Prerequisites - Blender 5.2 LTS, installed as the `org.blender.Blender` flatpak (the project standard). - An MCP-capable client. - The upstream [BlenderMCP addon](https://github.com/ahujasid/blender-mcp) (`addon.py`) and its MCP server. - `uv`/`uvx` if you use the server's `uvx blender-mcp` launcher; a local install of the server package is equally valid. Check the upstream README for the current package/addon version and any command changes before installing. Do not copy the addon into this repository. ### Setup #### 1. Install the Blender addon 1. Download or clone the upstream BlenderMCP project. 2. In Blender: **Edit → Preferences → Add-ons → Install from Disk**, select `addon.py` from that checkout. 3. Enable **Interface: Blender MCP**. 4. Open the 3D View sidebar (N panel), find the **BlenderMCP** tab, and click **Connect to MCP server**. The default server listens on `localhost:9876`. The addon must be running inside the Blender process that has the scene open. Starting a second Blender instance does not connect the first one. #### 2. Configure the MCP client For Pi, add the server at user scope so it is not committed into the project: ```bash pi mcp add blender -- uvx blender-mcp pi mcp list ``` Then start/reload Pi and use `/mcp` to inspect the connection. Other clients use their normal MCP server configuration. A generic JSON example: ```json { "mcpServers": { "blender": { "command": "uvx", "args": ["blender-mcp"] } } } ``` For a client with a different config schema, use the equivalent server command and keep the server name `blender`. Restart/reload the client after adding it, then request the tool list and run a harmless `get_scene_info` before editing. #### 3. Connect through the flatpak Start Blender through the project's normal flatpak command, open the local part scene, and connect from the sidebar. If the client cannot reach the addon: - confirm the addon panel says connected; - confirm the client is launching the MCP server, not just a shell; - check port `9876` is not occupied; - if the flatpak sandbox is blocking localhost networking, grant network permission only for this interactive use, for example: `flatpak override --user --share=network org.blender.Blender` (review the permission change afterwards; do not add it to CI or build scripts). - restart both the MCP client and Blender after changing the addon or config. ### Recommended part-workflow The headless part pipeline is `tools/npc_parts.py` (see [`blender-assets.md#local-part-pipeline`](blender-assets.md#local-part-pipeline)). It builds, extracts, assembles, inspects and sheets the reusable head/face and hand parts and reuses the `blender_assets.py` builders, so there is no second face/hand implementation. The MCP steps below remain optional interactive exploration; the headless commands stay the build and audit path. 1. Create a local scene, for example `tmp/npc-parts/npc_casual/head.blend`, or open a copy of an exported part GLB. Never save over the tracked `assets/models/npc/npc_base.blend`. 2. Ask the MCP client for `get_scene_info`, then inspect the object/bone/mesh names it reports. 3. Use `execute_blender_code` for small, reversible edits. Prefer adding a new object or modifier over deleting existing geometry; keep a backup copy. 4. Use `get_viewport_screenshot` after camera/material changes to check the head/face/hand before exporting. 5. Export a **local** GLB under `tmp/npc-parts/`, not directly into `assets/models/`. 6. Run the normal gates on the exported file: ```bash flatpak run org.blender.Blender --background \ --python "$PWD/tools/blender_assets.py" -- \ inspect --file "$PWD/tmp/npc-parts/head.glb" --category npc --json python3 tools/blender_assets.py audit python3 tools/blender_assets.py fit python3 tools/npc_proportions.py report ``` 7. Only after the part is reviewed and the contract passes should it be assembled into a character and rebuilt through the headless pipeline. ### Safety and project rules - MCP is **not** part of `project.godot`, the build, CI, tests, release packages or runtime. - Do not commit `.blend` part scenes, MCP configuration, API keys or generated previews. The working set stays in `tmp/`; a part GLB is not a shipped asset. - Do not use MCP to bypass the contract, budgets, AO/UV rules or licence gate. - The 13 core bones must remain at their rest positions; extra optional bones are allowed only if `NpcAvatar` and the audit/tests still pass. - Prefer scripted headless changes for reproducible work; MCP is for the interactive exploration and review that is awkward in a one-shot script. - If MCP is unavailable, use the headless CLI and document the manual step; never make the project depend on MCP being present. ### Troubleshooting | Symptom | Check | | --- | --- | | Client cannot find `blender` | MCP client config path/schema; restart client; confirm server command exists | | `Connection refused` | Addon **Connect to MCP server** pressed; Blender still running; port 9876 | | Tools return stale scene data | Refresh scene/object query after edits; restart the connection if needed | | Flatpak cannot reach localhost | Check flatpak network permission; grant only for interactive use | | Changes look wrong in export | Re-import the GLB with `inspect`; check transforms, scale, UVs and materials | | Contract audit fails | Fix the mesh/bones first; do not edit the audit expectations around a broken asset | ## Realism pass: scene concepts, skylines and textures The skyline, surface tiles and phase lighting were rebuilt against AI concept frames. The concepts live in [`docs/screens/concepts/`](../docs/screens/concepts/README.md); the validated picks the game loads are in `assets/textures/ai/game_ready/v2/picks/` (day/dusk/night panoramas, brick, sidewalk pavers, dry and wet asphalt, awning canvas, each with a normal map). `tools/scene_concepts.py` generates and processes new candidates, promotes the validated ones to stable names, audits them, and scores captures against the concepts. It never overwrites an image: new files get a `-vN` suffix, and `process` skips existing candidates unless `--new`. ```bash python3 tools/scene_concepts.py generate --group scene # or backdrop / texture, --variants N python3 tools/scene_concepts.py process && python3 tools/scene_concepts.py audit python3 tools/scene_concepts.py promote # validated picks -> v2/picks/ godot --headless --path . --import # A/B a candidate without touching game code: the same cameras, textures swapped at runtime. godot --path . --script res://src/test/capture_realism.gd -- --out=res://docs/screens/realism//after godot --path . --script res://src/test/capture_realism.gd -- --out=res://docs/screens/realism//try \ --swap=res://assets/textures/ai/game_ready/v2/picks/brick_facade.png=res://assets/textures/ai/game_ready/v2/brick_facade-v2.png # Score before/after against the concepts (Pillow detail metrics + vision judge). python3 tools/scene_concepts.py realism --run docs/screens/realism/ --judge \ --model google/gemini-2.5-flash --repeats 3 ``` Judges default to `--model auto`: `tools/openrouter_judge.py` reads the live OpenRouter catalogue, keeps image-input models that support `response_format`, and ranks them by advertised token/image price. The run falls through the list on provider/model errors and aborts on an invalid key. Use `GIG_LINE_JUDGE_MODEL` or `--model ` for a pinned comparison. For an open-ended review of a single capture/reference (no structured verdict), `tools/describe_image.py` sends the image plus a question to a vision model and prints the answer. It is how the 2026-11-03 re-evaluation read the local-only reference frames and the prop/venue captures: ```bash python3 tools/describe_image.py docs/screenshots/12-joke-shop.png \ --question "Do the red curtain bays read as tied-back fabric or flat slabs?" ``` `capture_npc_review.gd` is the pre-build NPC gate: it stages all eight library character models at portrait, conversation and crowd distance on both promoted maps in day and night lighting, with the normal crowd hidden. It uses the review-only `NpcAvatar.character_override` to include the guard model, which has no spawned NPC role: ```bash godot --path . --script res://src/test/capture_npc_review.gd -- \ --out=res://tmp/npc-review ``` Contact sheets can be assembled from the files (the output is local QA and is not committed). The runtime applies small attach-space offsets to the lanyard and firefighter helmet so the badge clears the jacket and the brim does not sit over the eyes; these are `NpcAvatar.GEAR_OFFSETS`, not changes to the gear contract. `tools/npc_proportions.py` checks the eight character GLBs against the shared height/head/shoulder/hip/torso/leg/arm/foot guide; the current results are in [`npc-visual-review.md`](npc-visual-review.md#proportion-audit). It reads the exported GLBs and does not require Blender. `capture_npc_pose.gd` is the pose/deformation harness for the 3D re-evaluation: idle, walk-contact, walk-pass and seated poses of all eight variants, full-body plus shoulder close-ups. **It is currently blocked**: the applied pose is visible in the rig and bone values but not in the captured frames, so do not use its sheets as pose evidence. The reproduction and what was ruled out are in [`3d-model-review.md`](3d-model-review.md) §"Pose evidence is blocked". The baseline sheet tool accepts a custom view list, which is also how pose sheets are tiled: ```bash # baseline set (six views, eight variants) python3 tools/npc_baseline.py sheet --dir tmp/npc-baseline # pose set produced by capture_npc_pose.gd python3 tools/npc_baseline.py sheet --dir tmp/npc-pose \ --views idle,walk_contact,walk_pass,sit --out tmp/npc-pose/sheet_pose.jpg ``` `capture_npc_baseline.gd` is the 0.9.4 neutral baseline: eight characters × front/three-quarter/profile/back/full-body/crowd = 48 frames with a fixed camera, studio lighting and no venue/HUD/animation. The raw PNGs stay in `tmp/`; `tools/npc_baseline.py sheet` builds the committed contact sheet, and `tools/npc_baseline.py compare --before DIR --after DIR` builds the review comparison after a model change. See [`npc-visual-review.md`](npc-visual-review.md). ```bash godot --path . --script res://src/test/capture_npc_baseline.gd -- \ --out=res://tmp/npc-baseline python3 tools/npc_baseline.py sheet --dir tmp/npc-baseline ``` `capture_realism.gd` runs nine stages in this order: day, Hollywood surface close-ups, portrait, evening, late, Joke Shop day, Joke Shop surface close-ups, Joke Shop night, studio; `--stages=day,late` limits it while tuning; `--flashlight` turns on the guard lamp for night stages (used by the Desktop HQ A/B). The `portrait` shot freezes one NPC on open sidewalk and frames the face, which is the only capture that can judge avatar detail (`--portrait-type=N` picks a role). For the 0.9.5 texture audit, run the surface stages directly; the HUD is hidden for them: ```bash godot --path . --script res://src/test/capture_realism.gd \ -- --out=res://tmp/surfaces --stages=surfaces,surfaces_joke ``` The generated realism runs under `docs/screens/realism/` are **local, gitignored QA artifacts**; commit only the resulting decisions/docs, not the run folders. The 0.9.5 texture/UI audit checked actual screen sizes: all screen art is 1280×720, access cards are 256×128 and HUD icons are 64×64; no runtime texture was oversized or unused. The 24-NPC smoke now reports 168 meshes / 131,616 triangles / 6.94 ms. Compressed payloads are 24.55 MB of surface picks, 22.67 MB of screen art and 0.48 MB of NPC maps. The 0.9.5 realism judge ran once per shot with `z-ai/glm-5.3-flash` (`python3 tools/scene_concepts.py realism --run docs/screens/realism/2026-11-02-tex --judge`). Its mean `after` scores were overall 1.8, lighting 2.3, materials 1.8, texture detail 1.2, backdrop 2.0 and characters 1.2; the Pillow metric gap was 0.306. The judge still ranks the painted concept targets above the stylized build, particularly lit night facades and dense backdrop detail. That is expected: the pass validated close-range surfaces and promoted the texture repairs, not a photoreal renderer. The 0.9.5 before/after close-up sheet is [`blender_previews/sheet_texture_refinement_0.9.5.jpg`](blender_previews/sheet_texture_refinement_0.9.5.jpg); the texture-adoption run itself was the earlier 0.8-style candidate comparison, and its report remains local/gitignored. For the 0.9.6 integration checkpoint the same judge was pinned to `z-ai/glm-5.3-flash` and run once per shot on the 10-shot set: mean overall 2.1, lighting 2.4, materials 2.1, texture 1.7, backdrop 2.3, characters 1.8, Pillow gap 0.291. No `review` verdicts were produced by the screen judge, so there was no manual layout verdict to convert into a fix; the remaining realism comments are the accepted stylized gaps. Build release artifacts from a clean checkout or detached worktree. A normal working tree may contain ignored `tmp/` captures and old `build/` artifacts, and Godot can pack enough of those into exports to inflate a 0.9.6 package from ~160 MB to 1.6–3.1 GB. The 0.9.6 clean worktree produced 161.5 MB Linux, 197.1 MB Windows, 148.0 MB macOS, 97.8 MB Web and 241.5 MB Desktop HQ. The Forward+ A/B compared the Compatibility entrance with the optional Desktop HQ preset. The gear-fit run checked the sunglasses, bag and helmet against measured head/skirt bounds with `python3 tools/blender_assets.py fit`; it also fixed the portrait yaw and hid the focus overlay so the close-up shows the avatar. Regenerate a run locally with the capture and `realism --run ...` commands above; the reports and sheets are intentionally not part of the release repository. ## Screen design references Screen mockups and screenshot comparison use `tools/screen_designs.py`. It turns the screen descriptions in `docs/` (README captions, ADRs 0007–0010, `PLAN.md`, and `architecture.md`) into runtime reference art under `assets/images/ui/` (with a reference-only snapshot in `docs/screens/designs/`), then uses those images as the expected state to check the game's captures. Each screen is tagged `scene` (a realistic first-person 3D world view) or `ui` (an in-game panel overlaid on a softened scene), and the generator appends a shared art-direction suffix so the frames read as one coherent game. Requests use a 3:2 landscape source and crop to the 16:9 capture dimensions. ```bash # Export the built-in scenarios to editor-authorable .tres resources godot --headless --path . --script res://tools/export_venues.gd # List the catalogue (name, kind, whether a reference design exists) python3 tools/screen_designs.py list # Deterministic wireframes (offline, no API key); existing designs are kept # unless --force is passed, so an offline run never replaces an AI mockup python3 tools/screen_designs.py generate --draft python3 tools/screen_designs.py generate --draft --only wand --force # Realistic 3D scenes and in-game screen designs via OpenRouter # (requires OPENROUTER_API_KEY) python3 tools/screen_designs.py generate # UI-state gallery (menu, pause, settings, review, radio, wheel, crowd, responder) godot --path . --script res://src/test/capture_ui_states.gd -- --out=res://docs/screenshots # Prop gallery (studio line-up) for close-range prop checks godot --path . --script res://src/test/capture_prop_gallery.gd # Compare docs/screenshots/*.png against the designs python3 tools/screen_designs.py compare python3 tools/screen_designs.py compare --json # Ask a vision model whether each capture realises its design's screen and layout # (requires OPENROUTER_API_KEY; the repo .env is read automatically) python3 tools/screen_designs.py judge python3 tools/screen_designs.py judge --model openai/gpt-4o-mini --json python3 tools/screen_designs.py judge --only 03- # one capture python3 tools/screen_designs.py judge --dry-run # build requests, no network ``` The catalogue covers 21 screens: the thirteen scene captures plus the start menu, pause overlay, settings, shift review, radial menu, emergency radio, crowd panic, and responder arrival. All 21 have an AI reference in `assets/images/ui/` (snapshot in `docs/screens/designs/`) and a committed capture, so every entry is compared or judged against the running game. `compare` flags gross drift: wrong aspect ratio, undersized frame, near-blank frame, and large layout/palette/detail distance. It is a regression gate, not a semantic judge — pixel and perceptual metrics cannot decide whether a frame's composition is *right*. `judge` covers that gap: it sends each capture together with its concept mockup to a vision model and asks whether the capture realises the same screen and layout intent. Art style, fidelity, fonts, and placeholder text are explicitly ignored. Verdicts are classed as `match`, `review` (a mismatch below `--fail-confidence`, default 0.6 — expected when a concept mockup is more stylised than the game), or `diff` (a confident mismatch, which exits non-zero). Regenerate the designs after changing screen layout, then re-run `compare` after regenerating the game screenshots with `src/test/capture_screenshots.gd` and `src/test/capture_ui_states.gd`. For a one-command drift check, run `tools/check_screenshots.sh`. It renders the scene captures, UI-state captures and prop gallery into `docs/screenshots/` (using `xvfb-run` when no display is available) and then runs `screen_designs.py compare`.