Files
security-game/MEMORY.md
T
zwitschi 4bb2896819
Full build / Import + headless tests (push) Successful in 53s
Full build / Full desktop build + optional release (push) Successful in 4m27s
Verify the 0.9.6 integration checkpoint
2026-11-03 06:13:07 +01:00

77 KiB
Raw Blame History

MEMORY

Session memory for durable decisions, state, and gotchas. Keep concise, remove obsolete entries.

Decisions

  • The visual-alignment path/loader/panel/HUD work and the realism lighting pass are complete and recorded in DONE.md; remaining visual items are the realism follow-ups in TODO.md.

  • Character mesh cap (2026-11-02): the eight library variants now use 8,000 triangles / 8,000 glTF vertices per character (CHAR_MAX_TRIS/CHAR_MAX_VERTS in tools/blender_assets.py), up from 6,000/5,000. The frozen npc_base fallback keeps its 6,000/4,000 contract. This headroom is specifically for the remaining face, hand-grip and clothing realism work; re-run the 24-NPC and Web/Compatibility budgets as geometry lands. Do not add detail without measuring, and do not silently lower the cap back to 5,000.

  • PLAN.md is a compact product/design document, not an implementation log. Historical control and v0.3.0 specs were folded into its Presentation and Controls section; put new step-by-step work in TODO.md, not the plan.

  • Editable Blender sources under assets/models/blender_src/ are gitignored and untracked (46 .blend files removed from the index on 2026-11-01); the .gdignore marker stays tracked.

  • screen_designs.py judge default google/gemini-2.5-flash-lite worked on 2026-11-01 (7 match / 4 review / 0 diff); --model remains available.

  • capture_realism.gd parses --portrait-type=N; it was documented but ignored before the gear pass. Portrait captures need the requested NPC type to have spawned, otherwise the stage can finish with an empty frame; use the Blender character previews (render --only npc_coat) for deterministic gear checks.

  • Headgear: the firefighter helmet was lifted 2 cm after the brow/face pass; cap and hood were visually checked on npc_vest/npc_hoodie. The beanie is unused and shares the cap shell.

  • Gear fit is measured with python3 tools/blender_assets.py fit (stdlib, reads the GLBs): head width uses skin vertices weighted to Head in y 1.55–1.81; clothing radius is the cloth skirt in y 0.30–0.52 with a pants hip fallback. The command reports per-character gear boxes, head-band status and x/radial skirt clearance; test_blender_assets covers its shape.

  • Post-realism work is split by theme, not by date: finished items are archived in DONE.md, and TODO.md carries the 0.x gap register and milestone roadmap. New work belongs there, not in a revived historical plan document.

  • Graphics v0.6.0 plan (completed 2026-11-01; ADR-0013): NPCs move to Blender-authored skinned meshes. tools/npc_pipeline.py builds assets/models/generated/npc_base.glb; NpcAvatar keeps the procedural _build_body() fallback. Preserve Rig/Head, the limb node names, and the seed-deterministic outfit_for/layer_for/hand_item_for helpers. Blender = flatpak 5.2.0 LTS, sandbox cwd /app/blender → absolute paths for --python. CI stays offline (committed assets).

  • Engine: Godot 4.7.2 stable (official editor build, ~/.local/bin/godot). The system's preinstalled godot-runner cannot load projects from arbitrary paths because it was compiled without path overrides; use the official editor binary instead.

  • Language: GDScript. All cross-file references use explicit preload() constants (not class_name globals) so scripts compile in headless --script runs without the editor's class cache.

  • Test framework deviation: the project uses a custom minimal headless test runner (src/test/test_runner.gd) instead of GUT, because GUT needs the editor plugin. CI runs it with --headless.

  • Eventing: no autoload signal bus. ShiftCoordinator owns an EventBus instance (src/core/event_bus.gd) passed to subsystems; subsystems emit only when a bus is assigned. Autoloads at parse time are unreliable under --script.

  • Static signals are not available in Godot GDScript ("Expected func or var after static") — do not use them.

  • GDScript lambdas capture local variables by value; mutating a captured local inside a lambda does not affect the outer variable. Use member functions for accumulators in tests.

  • Shift length is the scenario's shift_duration_s (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.

  • RNG: NPC reactions and event scheduling are seedable for deterministic tests.

  • Mouse look (v0.7.3): guard look is handled in GuardController._input() (before GUI), not _unhandled_input; focus/click-back recaptures the cursor and the HUD is made mouse-transparent recursively. Regression tests cover yaw/pitch and HUD filters.

  • v0.9.0 production pass: CI/release, local dev and project now use Godot 4.7.2; the version-adaptive light falloff remains for older editors. Audio settings are master_volume/sfx_volume/ambience_volume; accessibility has reduce_motion and captions_enabled (HUD log is the caption surface). Budget smoke is 24 NPCs (6.94 ms headless). Coat/dress stride is scaled to 0.76 to reduce hem clipping. Localization remains English-table based; pseudo-locale/VO/music are post-0.10.

  • Radio submit fix (v0.8.1): RadioPanel.action_button now connects pressed for normal reports; button_down/up remains exclusive to emergency hold-to-confirm. Regression test covers both modes.

  • Campaign/v0.8.0: Campaign owns Hollywood → Joke Shop → TV Studio progression. campaign.json, shift_results.json, settings.json and versioned shift_state.json persist separately; pause saves a checkpoint, review Continue advances, Settings resets with confirmation. Difficulty keys are easy/normal/hard (ai_difficulty); scripted QA bypasses tutorial unless --tutorial is passed. Help is searchable and generated from InputBindings.REMAPPABLE.

  • Tutorial (0.8.0 work): docs/tutorial.md is the eight-beat design; src/game/tutorial.gd is the pure state machine, TutorialOverlay is the non-blocking card, and GameManager wires it with a safe visitor, suppressed spawns, persisted completion/skip and Settings replay. Scripted test/capture runs bypass it unless --tutorial is passed; smoke_tutorial.gd covers completion/replay/skip.

  • Pre-build NPC revisit (2026-11-02): the final-context harness is src/test/capture_npc_review.gd (8 library models, 3 distances, 2 venues, day/night). It found two P1 gear-fit issues: the lanyard badge was hidden under the jacket and the firefighter helmet brim sat over the eyes. Both are fixed at runtime with NpcAvatar.GEAR_OFFSETS (lanyard forward 4 cm, firefighter_helmet up 3 cm); the authored GLBs and the fit report remain asset-space measurements. The guard model is staged through the review-only character_override because it has no spawned NPC role. Signed off 2026-11-02: 96 final-context captures, no P0; lanyard/helmet P1 fixes; the backpack straps, caps, low-poly hands/faces and optional close-range flashlight wash were accepted or deferred. The 24-NPC budget is 6.94 ms, the gallery is 13 good / 0 bad, the screen-design judge is 13/13 and a clean portrait measured a 0.732 vision gap against the photoreal concept. No release build, version bump or tag was created at this gate.

  • NPC texture resolution (0.9.5, 2026-11-02): tools/npc_pipeline.py now generates 256² albedo/normal/ORM maps instead of 128² (AI shirt/trousers are resized to 256). The base and all eight library GLBs plus AO maps were rebuilt; texture files total ~0.5 MB, eight GLBs ~5.75 MB, and the 24-NPC smoke is 6.94 ms.

  • 0.9.5 texture/UI audit (2026-11-02): all 21 screen-art textures are 1280×720 and referenced by their screens; access cards are 256×128 and HUD icons 64×64, with no oversized or unused runtime texture. The 24-NPC smoke now reports 168 meshes / 131,616 triangles / 6.94 ms (50 ms budget). Compressed payloads are picks 24.55 MB, UI art 22.67 MB, NPC maps 0.48 MB. Legacy root textures remain only for generate_textures.py and the asset test.

  • Environment surface audit (0.9.5, 2026-11-02): capture_realism.gd gained surfaces (Hollywood pavers/asphalt/limestone/facade) and surfaces_joke (neighbour awning, canopy, pavers) stages with the HUD hidden. The Walk of Fame band now uses T_CONCRETE as dark terrazzo; the neighbour awning uses a neutral tint (0.62) and 4× UV so the red/cream stripes survive; the skyline shader fades its lower 42 m toward a per-phase haze colour. Brick/asphalt picks needed no replacement. No seams or broken UV scales found.

  • NPC fabric UV/AO (0.9.5, 2026-11-02): cloth/pants use Materials.NPC_FABRIC_UV_SCALE = Vector3(4,4,1), and NpcAvatar skips the baked per-character AO on those two slots. The 256² AO was magnifying into large blotches; the fabric now reads as a fine weave. Skin/hair/gear keep baked AO. Test test_fabric_uv_scale_avoids_the_baked_ao_pattern guards this. Current suite is 263/263.

  • NPC visual baseline (0.9.4, 2026-11-02): src/test/capture_npc_baseline.gd is the neutral-studio 8-variant × 6-view baseline (front, three-quarter, profile, back, full body, crowd). It uses a fixed orbit camera, mid-grey studio backdrop, key/fill lights, no HUD/crowd/animation; raw frames stay in tmp/, and tools/npc_baseline.py sheet builds the committed sheet_npc_baseline.jpg. Criteria and measurements are in docs/npc-visual-review.md. The first ranked gaps were: tourist photo prop duplicates the backpack silhouette; shallow/faceted face; backpack straps read as rigid bars. The tourist prop now hides outside photo/film poses and the backpack straps were refit as flat forward bands (GLB + AO rebuilt); the face remains P1. Before/ after sheet: sheet_npc_baseline_compare.jpg.

  • NPC proportion audit (0.9.4, 2026-11-02): tools/npc_proportions.py measures the exported GLBs (height, skin-head size, upper-chest shoulder silhouette, pants hip width, Spine-to-shoulder torso, Spine-to-ground leg, shoulder-to-hand arm, shoe foot length). All eight variants pass the stylized guide in docs/npc-proportion-audit.md; head ~0.190 (upper 0.22) and broad-build shoulders ~0.370/hip ~0.225 are near the upper bounds, not outliers. No skeleton/mesh rescale was needed; run the report after any body change.

  • NPC neck/shoulder transition (0.9.4, 2026-11-02): the body graph uses a fuller neck base (1.43 m, radius 0.074/0.072), a taller/thicker neck top and a shoulder root lowered from 1.39 to 1.37 m. A final triangulate + tiny-face delete in build_character removes zero-area triangles that the new neck branch exposed in jacket/hoodie. All eight GLBs and AO maps were rebuilt; npc_proportions.py still passes and sheet_npc_baseline_compare-body.jpg is the review image.

  • NPC face-depth pass (0.9.4, 2026-11-02): recessed the sclera to ey=-0.134 and added a thin upper lid; removed the separate jaw/cheek spheres and instead sculpt front head vertices (_shape_head_mesh) around a chin plate and cheekbones. This avoids dark AO intersection rings. Moved lips forward to stay on the reshaped surface. Latest review sheet: sheet_npc_baseline_compare-face.jpg; max character is now 4,754 tris / 4,574 verts.

  • NPC torso/hip transition (0.9.4, 2026-11-02): pelvis radius 0.165→0.168, waist 0.150→0.146, chest 0.170→0.168, upper chest 0.172→0.170. This is a subtle shirt taper, not a skeleton change; all proportions pass and sheet_npc_baseline_compare-torso.jpg records it. Max character is 4,754 tris / 4,584 verts.

  • NPC footwear pass (0.9.4, 2026-11-02): added ShoeToe/ShoeHeel spheres to the casual shoe and BootToe/BootHeel to responder boots; both stay above the sole/boot contact plane. Current counts are 4,482–5,474 tris / 4,260–5,494 verts under 8,000/8,000. Sheet: sheet_npc_baseline_compare-shoes.jpg.

  • NPC hand-item forward offset (0.9.4, 2026-11-02): NpcAvatar.HAND_ITEM_FORWARD = Vector3(0,0,0.04) is added in _attach_gear() for every GEAR_ATTACH hand item after the head/chest offsets. Hand gear is authored around the wrist proxy; without this, phone, flashlight, cup and clipboard were partly inside the palm. Fingers are still open, so true curl/thumb contact and umbrella/sign grips stay open. Comparison: sheet_npc_hands_grip_compare.jpg; regression assertions cover camera and flashlight positions.

  • NPC headgear clearance (0.9.4, 2026-11-02): after the face-detail pass, the 96 final-context frames confirm the cap, firefighter helmet, hood and sunglasses clear the refined brow/eyes; blender_assets.py fit stays clean, so no gear was rebuilt. The broad helmet brim is intentional turnout language, not a fit failure.

  • NPC face-detail pass (0.9.4, 2026-11-02): Nose tip radius reduced to 0.022 with ala wings, the two dark lip boxes became one shallow Mouth sphere (two blobs read as a moustache), and the ears gained a dark EarInner concha. All eight rebuilt; current counts are 4,226–5,218 tris / 4,119–5,240 verts under the 8,000/8,000 cap. Sheet: sheet_npc_baseline_compare-face-detail.jpg.

  • NPC hand pass (0.9.4, 2026-11-02): palm radius 0.046 at z .742, scale (.80,.62,.88); fingers are tube paths from z .758 to .681–.694 with tip spheres, thumb is a bent tube. The optional hand capture is capture_npc_baseline.gd -- --view=hand (8 frames, baseline stays 48). npc_firefighter is at 5,240 verts after the face-detail pass; the library cap is 8,000 (see the character-mesh-cap decision above), so grip/clothing detail still has headroom. Grip offsets remain open.

  • UI flow smoke (2026-11-02): smoke_ui_flows.gd boots the real scene and exercises inspection allow/deny, normal vs emergency radio, Help filtering, wand allow/deny and review return-to-menu. Keep this smoke in the 0.9.6 handoff; it complements the unit tests that only exercise panels.

  • Destructive actions (2026-11-02): SettingsPanel._open_destructive_dialog() owns one confirmation instance for reset/replay; repeated presses focus the existing dialog, OK emits once, Cancel is inert. Emergency send remains the RadioPanel hold path; _holding clears before submit so button-up cannot double-send. Tests cover both.

  • Font/layout limits (2026-11-02): HUD scale is clamped to 0.75–1.75 and only applies to HUD labels. Hud.set_target_label() measures the scaled font, widens the chip to 180–420 px and re-measures after a scale change; top labels clip. UiStyles.apply_button() sets clip_text + ellipsis so long localized labels cannot force a panel past the viewport. Do not replace these with Control.scale.

  • Modal close (2026-11-02): GameManager._close_top_modal() is the single Esc/right-click path for radial, inspection, wand, radio, timeline, Help, Settings, Pause and review. Settings remembers whether it came from the menu and restores it on apply/cancel; _refresh_freeze() recaptures the mouse and _show_start_menu() keeps it visible. Do not add a new modal without adding it to this close path.

  • UI focus (2026-11-02): src/game/ui/ui_focus.gd links visible/enabled controls into a wrapping ring; each modal sets default focus on open. Start menu uses its own focus-target list so Continue, venue rows and footer actions share one ring. GuardController._ensure_gamepad_bindings() adds A/B to ui_accept/ui_cancel so pad-only users can confirm/cancel; hidden Continue and disabled timeline buttons never enter the ring.

  • Button states (2026-11-02): src/game/ui/ui_styles.gd owns the five action-button states and font colours for the dark session/UI shell. Use UiStyles.apply_button() for every new Button; CheckButton/OptionButton keep the engine theme. Timeline Previous/Next disable at the ends; F1 tutorial skip is keyboard-only by design.

  • Version and roadmap renumber (2026-11-02): source was 0.9.3 at the UI polish milestone, 0.9.4 after the NPC pass, now 0.9.5 after the texture and material pass. Next work is 0.9.6 integration, then the 0.10.0 minor release candidate. TODO.md plans only 0.x patch/minor increments; no major-version scope is defined yet. The v0.9.5 tag is the release trigger; no tag was created for 0.9.3.

  • Priority change (2026-11-02): the map optimization, pre-build NPC revisit, 0.9.3 UI polish, 0.9.4 NPC visual optimization and 0.9.5 texture/material pass are complete and in DONE.md. 0.9.6 integration and 0.10.0 release work are next. The current source version is 0.9.6.

  • 0.9.6 integration (2026-11-03): no gameplay/content changes. A clean detached worktree at f5b8ef2 rebuilt Linux/Windows/macOS/Web/HQ without the dirty tree's ignored tmp//build/ payload; the in-repo build was 1.6–3.1 GB per artifact because those folders were packed, so always build release artifacts from a clean checkout/worktree. Verification: 263/263 tests, 101/101 Python, 14 smokes, 24-NPC 6.90 ms / 168 meshes / 131,616 triangles, gallery 21/0, screen judge 21/21, realism judge overall 2.1 / gap 0.291.

  • Release v0.9.5 (2026-11-02): commit 4bc6390 tagged v0.9.5; Gitea Actions run 922 passed checks and the full build and published seven assets (Linux, Windows, macOS, NSIS setup, AppImage, Web, Desktop HQ). Release page: https://git.allucanget.biz/zwitschi/security-game/releases/tag/v0.9.5. Do not reuse the tag; future fixes need a new patch tag.

  • Release v0.9.4 (2026-11-02): commit a57b86c tagged v0.9.4; Gitea Actions run 916 passed checks and the full build and published seven assets (Linux, Windows, macOS, NSIS setup, AppImage, Web, Desktop HQ). Release page: https://git.allucanget.biz/zwitschi/security-game/releases/tag/v0.9.4. Do not reuse the tag; future fixes need a new patch tag.

  • Hollywood landmark map (2026-11-02): the default venue is the real El Capitan Entertainment Centre block at 6840 Hollywood Blvd, not a generic brick entrance. Its reference/mapping notes live in docs/hollywood-boulevard-map.md; the map-budget test (test_hollywood_geometry_stays_inside_the_map_budget) currently measures 58,884 triangles across 409 meshes. The facade uses the shared smooth limestone (stone_steps) texture; the noisier granite pick was removed.

  • Light falloff gotcha (2026-11-02): in Godot 4.7 omni_attenuation = 0.0 is constant falloff, not physical falloff. It made the night facade a flat yellow wall and the street lamps wash the set. All local lights now use 1.0; the Hollywood facade was retuned with 0.50/0.28 washes, 1.3 sconces and 2.2 street lamps; the Joke Shop uses 1.8 sconces, 2.8 canopy downlights, 3.0 entrance, 1.35 ground uplights and 2.2 sign. Flashlight spot attenuation is also 1.0. Do not reintroduce 0.0 as a "modern" branch.

  • Joke Shop map (2026-11-02): the venue is now a 27 m Sunset Strip club based on The Comedy Store at 8433 Sunset Blvd, not the old brick alley. docs/joke-shop-map.md carries the OSM/Commons references and simplifications; docs/screens/concepts/joke_shop/ holds the new day/night/across-street frames. The map-budget test measures 22,680 triangles / 454 meshes. The hill behind the club is a custom hand-built ramp with an unshaded, fog-disabled shader: a rotated PlaneMesh was edge-on and scene fog erased it from the street camera. Local light names are JokeSign, JokeCanopy_*, JokeEntrance, JokeSconce_*, JokeGround_*; set_night_mode() owns their energies and the hill tint. The latest local realism judge reports a 0.454 reference gap on the night entrance; the concept is photoreal while the map keeps stylized materials, so texture/foliage detail stays with the 0.9.5 texture pass rather than the map milestone.

  • Docs validation (2026-11-02): python3 tools/check_docs.py requires a README only in directories containing tracked files, verifies local links and requires every tracked image in a folder to be linked from its README. Gitignored generated files (notably docs/screens/realism/) are exempt; the tool also has unit tests in tools/test_check_docs.py.

  • Documentation ownership (2026-11-01): docs/architecture.md §12 defines the source-of-truth map and consolidation rules. Obsolete/historical plan documents are removed rather than kept; current work belongs in TODO.md. README/development-status/Blender AO/Forward+ stale claims were corrected. The 0.x gap register and roadmap live in TODO.md.

  • Vision judge (v0.7.2): tools/openrouter_judge.py reads /api/v1/models, filters image-input models with response_format, and ranks by advertised prompt/image/completion prices. --model auto (default) uses the cheapest (z-ai/glm-5.3-flash on 2026-11-01) with fallbacks; explicit --model/GIG_LINE_JUDGE_MODEL wins. Auth errors abort, provider/model errors fall through. Full screen judge 13/13 match, 0 diff; A/B judge ~$0.006.

  • Desktop HQ preset (v0.7.1): packaging/desktop_hq/override.cfg switches a copied executable to Forward+; Materials.forward_plus_active() gates volumetric fog/SDFGI and light_volumetric_fog_energy, and Materials.modern_light_falloff() compensates for Godot 4.7's much harsher local-light falloff. LATE volumetric density is 0.020 with flashlight volumetric energy 3.0. tools/build_releases.sh packages the Linux/Windows binaries + override into gig-line-desktop-hq-<version>.zip; publish_release.sh uploads it when present. A/B report: docs/screens/realism/2026-11-01-forward-plus/ (entrance gap 1.568→1.187).

  • Godot toolchain (updated 2026-11-01): local ~/.local/bin/godot, CI and project.godot now use Godot 4.7.2; 4.3 remains only as a local backup binary. Export templates for 4.7.2 are installed under ~/.local/share/godot/export_templates/4.7.2.stable. Godot 4.7 writes *.gd.uid files; they are kept out of this clone's status via .git/info/exclude. CI downloads the binary/templates directly; Gitea Actions cache is currently unreliable (see CI cache evaluation below).

  • Blender invocation (v0.6.0): flatpak run org.blender.Blender --background --python /run/media/system/DATA/Documents/06-games/security-game/tools/npc_pipeline.py -- build. The flatpak cannot read /tmp; use absolute repo paths. assets/models/npc/.gdignore is required, otherwise Godot's editor tries to import the committed .blend and hangs during --import (also add .gdignore to local build/squashfs folders when scanning is slow).

  • Skinned NPC integration (v0.6.0): NpcAvatar._build_skinned_body() loads npc_base.glb and keeps _build_body() as fallback (use_skinned_model=false in tests). Bone pose rotation is the absolute local orientation, not an offset from rest: compose with get_bone_rest(i).basis or the whole body snaps to a broken pose. Proxies named Hip/Knee/ Shoulder/Elbow/Hand + Head marker preserve the old animation math; Rig/Head is the marker. Five material slots are tinted via SurfaceMaterials.npc_slot(); keep _rig.scale uniform on the skinned path. Budget: 16 avatars = 144 meshes, ~6.9 ms headless.

  • NPC facing (fixed after v0.5.0): the avatar's visual front is +Z (procedural eyes and the Blender model's face). Godot look_at() aims -Z, which made every NPC walk backwards. Use NpcAvatar.yaw_towards(dir) and set rotation.y; do not call look_at() on avatars. Head-turn offsets use +basis.z for the same reason. Covered by test_yaw_faces_the_movement_direction.

  • Image root (ADR-0014, 2026-11-01): assets/images/ is the runtime root for composed interface/scene art (ui/, scenes/); tiled PBR textures stay under assets/textures/; docs/screens/designs/ and docs/screens/concepts/ stay reference-only; QA captures stay in docs/screenshots/. When a mockup becomes runtime art, copy/process it into assets/images/ with a new name (no overwrites) and keep a fallback in code. Mockup copies: 21 design references live in assets/images/ui/; docs/screens/designs/ remains the reference-only copy. tools/screen_designs.py reads/writes the runtime root.

Handoff (read this first)

  • Status (2026-11-01, after the venue-geometry pass): v0.7.0 is released. Post-v0.6.0 the realism pass, the visual-alignment pass and the NPC face/hand pass all landed. Headless 229/229, Python tooling 87/87, tools/check_screenshots.sh 11 good / 0 bad, screen_designs.py compare 11 good / 0 bad, scene_concepts.py audit all GOOD, blender_assets.py audit exit 0, smoke_npc_budget OK (16 avatars, 6.94 ms of 50 ms). PLAN.md is compact; README/architecture track the current controls, asset pipeline, CI and release artifacts. The historical notes below remain useful for implementation gotchas; for current tasks use TODO.md.

  • Phase 2 — AI texturing (this agent, DONE 2026-11-01). Phase 1 assets were already in the tree (untracked) and are preserved. Generated six seamless AI clothing albedos into assets/textures/npc/ai/{shirt,trousers,jacket,vest, coat,dress}.png (512×512, --role tile, gpt-5-image-mini; audit 6 good). Gotcha: asset_pipeline.py generate defaults to GEN_SIZE=2048, which gpt-5-image-mini rejects (HTTP 400); pass --request-size 1024 for 1:1 roles. generate --role tile already applies make_seamless, so a separate seamless pass is redundant. Added to tools/npc_pipeline.py: prepare (layers AI shirt→cloth, trousers→pants albedos at 256px) and palette (writes assets/textures/npc/palette.json via model_pipeline.dominant_colors). write_slot_textures() now skips the albedo for slots with an AI override (normal/ORM stay procedural). Blender build re-run: GLB audit ok, 1204 tris / 2488 verts, AI albedo embedded for cloth+pants. Phase 2 completed and shipped with the v0.6.0 asset library. Import concurrency: a parallel agent is running timeout 300 godot --headless --editor --quit --path . and then checks assets/models/generated/npc_base.glb.import — treat that as the canonical import. My own godot --headless --path . --import hung with no output past the engine banner (two Godot instances contended on .godot); I killed the orphan (PID 2482934). Do not run a second import while that --editor --quit is active. Next: Phase 3 (NpcAvatar._load_model + slot tinting) consumes palette.json and the GLB; Phase 2's capture/judge item is blocked on that.

  • Git hygiene + tooling (2026-11-01): dev asset folders are now gitignored and untracked: assets/models/concepts/, assets/models/npc/ (Blender sources), assets/textures/npc/ai/ (raw AI fabrics), and the raw assets/textures/ai/* outputs (only assets/textures/ai/game_ready/ stays). Kept in the repo: assets/textures/npc/*_{albedo,normal,orm}.png + palette.json, assets/models/generated/* (props + npc_base.glb), and the authored assets/textures/* set. tools/asset_pipeline.py generate gained --variants N — generates N alternatives saved as <stem>-1..N.png (N=1 keeps the unsuffixed path). Python tooling tests still pass (exit 0).

  • Screenshots + docs (2026-11-01): regenerated the eleven README captures (capture_screenshots.gd + capture_prop_gallery.gd) — ten changed, the prop gallery is byte-identical (props unchanged). README gained an NPC character-model subsection, a "Confirmed game assets" section (linked game_ready textures, generated models, NPC maps), and refreshed dev-status / not-yet lines; docs/architecture.md Level 2 now lists npc_pipeline.py + asset_pipeline.py and the confirmed-asset locations.

  • Repo: /run/media/system/DATA/Documents/06-games/security-game (also reachable at /var/home/zwitschi/Documents/06-games/security-game). Godot: ~/.local/bin/godot (4.7.2). Tests: godot --headless --path . --script res://src/test/test_runner.gd → 177/177. Python tooling tests: cd tools && python3 -m unittest test_asset_pipeline test_screen_designs test_model_pipeline → 53. Session shell smoke: godot --headless --path . --script res://src/test/smoke_session_shell.gd → exit 0. Windowed control smoke: godot --path . --script res://src/test/smoke_focus_radial.gd → exit 0 (now also covers wheel cycling and right-click clearing).

  • main is in sync with origin. v0.2.2 tags the session-shell + world-follow-up commit; CI builds the portable binaries, NSIS installer, and AppImage on the tag push.

  • v0.3.0 (2026-10-31, working tree): rendering pass (src/game/materials.gd, phase-driven environment), generated .glb props (tools/model_pipeline.py, assets/models/generated/), aim-free selection with wheel cycling and Shift precision walk (ADR-0011/0012), and a Web export target. export_presets.cfg gains a Web preset with variant/thread_support=false (static hosting, no COOP/COEP); tools/build_releases.sh exports build/web/ and zips it to build/gig-line-web-<ver>.zip, and publish_release.sh uploads it. Covered by src/test/tests/test_web_export.gd.

  • Prop atlas (v0.3.1): model_pipeline.atlas_for() builds a per-prop power-of-two PNG with one 8×8 cell per palette role, plus a metallic/roughness atlas (G=roughness, B=metallic). The GLB merges all parts into one primitive with constant per-vertex UVs (cell centre) and a single material (baseColorTexture + metallicRoughnessTexture, NEAREST sampling). Colours are sRGB in the texture, so the old srgb_to_linear/_linear_rgba helpers were removed. audit_glb fails if a prop is not exactly one material or misses TEXCOORD_0/images.

  • Mockups complete (v0.3.2, updated 0.9.3): docs/screens/designs/ holds AI references for all 21 catalogue screens, and every entry now has a committed current-build capture. capture_screenshots.gd covers scenes 01–13, capture_ui_states.gd covers UI states 14–21, and capture_prop_gallery.gd covers the prop gallery. generate now calls _load_env_file().

  • Staged captures (v0.3.3, updated 2026-11-02): capture_screenshots.gd emits the scene gallery; states 7–10 switch to The Joke Shop at elapsed 620 s, retry the wand until it alerts, then jump to 760 s after staging for night lighting. LATE now has its own routine pool. Shot 12 closes any stale wand/inspection modal before the venue beauty shot (the wand fallback can leave hud.show_wand open without wand_open). The timeline needs recorder.close_all_open(), because to_dict() only lists closed incidents. capture_ui_states.gd adds 14–21; crowd panic stages a group 70 frames into the flee and responder arrival parks the responder with the door locked. check_screenshots.sh writes all captures to docs/screenshots/ before compare; judge is 21/21 match.

  • Prop collision (v0.4.0): _add_prop_model() fits a StaticBody3D from the model bounds (cylinder for bollard/bin/hydrant, box otherwise) and adds a NavigationObstacle3D; NPC agents enable avoidance and move with the safe velocity from velocity_computed. The navmesh stays a flat plane, so avoidance (not a baked mesh) routes guests around props. Placements live in PROP_PLACEMENTS with placement_problems() validation; variants come from variant_for(index). smoke_props.gd must be windowed because the guard reads captured-mouse input.

  • Realism pass (2026-11-01, completed + adopted): tools/scene_concepts.py (list/generate/process/promote/audit/realism) never overwrites an image (next_free_path adds -vN; process skips existing unless --new; promote keeps an existing pick). Raw AI output: assets/textures/ai/v2/raw/ (gitignored). Candidate pool: assets/textures/ai/game_ready/v2/ (gitignored) and the tracked runtime assets are .../v2/picks/{skyline_day,skyline_dusk,skyline_night,brick_facade,sidewalk_pavers,asphalt, asphalt_wet,awning_canvas}.png (+ _normal). Concepts docs/screens/concepts/ (committed, .gdignore, targets in its README). Runs docs/screens/realism/<run>/ (PNG captures gitignored; report.{json,md} + sheet.jpg committed). src/test/capture_realism.gd runs five stages (day 0 s / evening 620 s / late 760 s / joke_shop 700 s / studio 700 s; --stages= subset) and swaps textures by the material's original albedo path. Gotcha: in the SceneTree script, do not overwrite _phase after _begin_stage() sets done, or the run loops forever between stage/shoot (_queue empty). Gotcha: elapsed_s = 620 is Evening (dusk), 700+ is Late. Godot 4.7 StandardMaterial3D has no fog_enabled; the skyline is a ShaderMaterial with render_mode unshaded, cull_disabled, fog_disabled + ALPHA_SCISSOR_THRESHOLD so the panorama's own haze shows. Backdrop plane is 90 m tall with 2 UV repeats (~200×90 m per repeat, near the 2:1 panorama aspect). Legacy assets/textures/skyline.png is no longer loaded but stays on disk for the test_world asset check. Phase look: day sun 0.62 / exposure 0.6 / ambient 0.22; evening sun 0.12 / exposure 0.72 / dark violet sky; late exposure 0.95 / ambient 0.10 with the wet asphalt street and a bright skyline tint (1.5) so the night windows read. Stripes: stripe_period() (detrended autocorrelation) + align_stripes() (resample to whole periods) replace the edge crossfade for awning_canvas; audit_striped_tile() compares the wrap step with the harshest interior step. Judge: the default google/gemini-2.5-flash-lite mostly works but intermittently answers HTTP 400 "API key not valid" (one shot of ten on 2026-11-01); re-run or pass --model google/gemini-2.5-flash when it does. Scores are noisy (±1–2), so realism --repeats 3 averages and summarize() reports a like-for-like mean over the shots every set captured. Final: overall 0.8→1.0, backdrop 0.6→1.6, metric gap 0.444→0.348. screen_designs.py judge gained one retry for the same transient 400. Character portrait capture (2026-11-01): capture_realism.gd has a portrait stage (npc_portrait -> concept npc_portrait_day) because crowd shots cannot judge avatars. It freezes one NPC, parks the rest of the crowd 140 m away (a passer-by otherwise blocks the frame), moves the subject to an open sidewalk patch, turns it into the camera and stands the guard on the sunlit side. Needed because the NPC FSM re-writes rotation.y every physics tick (so the yaw must be set after set_physics_process(false)), the venue's morning sun leaves a face in shadow from most angles, and visible = false does not keep the crowd out of frame. --portrait-type=N forces a role; hooded roles (trespasser/violent/crisis) are skipped. Flashlight (2026-11-01, final): a tight SpotLight3D on Head: spot_range 20, spot_angle 14, light_energy 4.2, spot_attenuation 0.9, forward rotation_degrees = 0, starts invisible. A fake additive light shaft was tried twice (cylinder with a UV ramp, then a vertical-fade ShaderMaterial) and rejected both times: on GL Compatibility a first-person camera inside the cone renders it as a filled disc and the far ring shows, so Materials.light_beam() and the beam mesh are gone. Regression guard: test_flashlight_aims_a_tight_forward_spot in src/test/tests/test_door_and_flashlight.gd (lamp on the head, aims straight, cone ≤ 20°, starts off). Night captures are staged in LATE (elapsed_s 760), not dusk. NPC detail pass (2026-11-01): _face() now builds sclera + iris + upper lid per eye, brow ridge, cheekbones, nose bridge/tip, ears with lobes and split upper/lower lips; the new eye_white material is a fixed-colour slot, so _apply_outfit_materials() leaves it white. _hands_and_shoes() has four fingers plus a thumb and _clothing() adds Cuff tori. Budget work at the time: CHAR_MAX_VERTS 5000 (the library cap is now 8000; see the character-mesh-cap decision), body decimation 0.26, upper lids are boxes rather than spheres, and knuckle spheres/nostrils were cut to stay inside it. Orientation debugging: to check a GLB's front axis, compare the mean Z of the eye_white surface (face) and of the rubber sole verts (toes) — all 8 avatars are +Z, so any "they walk backwards" symptom is a yaw bug, not an export flip.

  • Blender asset library (v0.6.0, integrated 2026-11-01): tools/blender_assets.py (build/render in Blender; audit/sheet/list stdlib) writes assets/models/blender/{npc,npc_gear,props}/*.glb, manifest.json (integration source of truth), sources in assets/models/blender_src/ (.gdignored), and review sheets docs/blender_previews/sheet_*.jpg (per-asset PNG renders are gitignored). 8 characters, 18 gear, 20 props; notes in docs/blender-assets.md. Characters are drop-in for npc_base (same mesh node name, bones at the same rest positions, five tint slots); the npc_base fallback stayed under its frozen 6000/4000 budget because of body decimation (0.26) + smart_project at 80°: glTF vertex count includes UV-seam splits, so extra beveled detail quickly breaks the limit. The 2026-11-01 face/hand pass raised CHAR_MAX_VERTS to 5000 (tris 3,974–5,026) after measuring 16 avatars at ~6.9 ms of a 50 ms frame; on 2026-11-02 the library cap became 8000/8000 while npc_pipeline.MAX_VERTICES/MAX_TRIANGLES (base mesh) stayed frozen at 4000/6000. Gotchas: bisect_plane and thin bevels leave zero-area triangles (Godot warns "non-finite normal in LOD generation"); _clean() + a weld after bisect fix it, and the audit rejects them. Fixed-colour detail materials carry a 4×4 texture because _apply_outfit_materials() sets albedo_color = WHITE on unknown slots. Cycles CPU render ≈ 3 s/asset; Godot import of the library is clean. Integration: NpcAvatar.character_model_for() selects a variant by role/seed; only the five contract slots are tinted (detail materials untouched; npc_vest keeps its fixed hivis vest, firefighter/guard get dark uniform tints). Gear is attached at head/back/chest/hand and falls back to primitives; npc_hoodie skips the extra hood gear. WorldBuilder prefers assets/models/blender/props/ for the six replacements and the new street dressing, fits the same colliders/obstacles, and collects library emissives for set_night_mode(). LIBRARY_TRIANGLE_BUDGET = 60k (current ~27,986). Library props are excluded from PROP_PLACEMENTS only when they need dedicated slots (door/marquee/sedan/handrail/lantern). Gear fit (v0.6.1): fit measures head width (0.315 m), skirt/hip radius and gear bounds; sunglasses are 0.202 m, the bag's body inner face is 0.262 m (coat 0.225, dress 0.260), and the helmet is lifted 2 cm above the brow. test_npc_character guards head/back gear in Godot. Blender's GLB export is byte-nondeterministic for unchanged assets (padding/index order), so do not commit a full no-op rebuild; rebuild only the assets whose geometry changed.

  • Venue geometry (v0.6.1+, Joke Shop updated 2026-11-02): WorldBuilder.VENUE_SPECS is the per-venue table; WorldBuilder.new(root, id) / set_venue() select it, unknown ids fall back to hollywood_building. Dimensions, window/pilaster rows, stairs, street, nav bounds, guard spawn, feature flags and prop placements are read from the spec. GameManager.world_root holds only the world; _rebuild_world_for_venue() frees/recreates it on venue selection/cycling so guard/UI survive. Joke Shop now uses architecture: "comedy_store" with _build_comedy_store_building(), SunsetHillside, SunsetNeighbours and SunsetAcrossStreet; the old _build_alley_dressing() is gated off. TV Studio keeps the stone facade, bulb canopy, car paint and barrier placements. Gallery shot 12 now maps to joke_shop_entrance_night; shot 13 stays studio_stage_door_night.

  • Screen-design judge (2026-11-01): screen_designs.py judge could not run because OPENROUTER_API_KEY returned HTTP 400 "API key not valid". check_screenshots.sh still passed 13/13; the judge retry is a TODO follow-up.

  • Portrait capture: capture_realism.gd faces the Rig toward the camera (using the inverse look direction) and hides the blue FocusCircle; the old code used the wrong yaw and left the focus disc over the face. An explicit --portrait-type=N now overrides the hooded skip, but the day roster spawns none of types 5-7, so a hooded close-up is deferred.

  • v0.6.0 environment pass: tools/bake_ao.py bakes 256² AO maps per library asset (<name>_ao.png, red channel) and Materials.apply_ao()/SurfaceMaterials wire them in; tools/make_normal_maps.py derives tangent normals for the 1024² brick/concrete/asphalt textures. Forward+ boots on desktop but is rejected for v0.6.0 (project-wide renderer, Web must stay GL Compatibility). Headless 222/222, Python 69/69.

  • Prop pipeline (done): audit enforces 400 tris / 600 verts, ground contact, UV range, no degenerate tris, one material. capture_prop_gallery.gd writes 10-prop-gallery.png (3x2 studio line-up, back row on a platform) and the prop_gallery catalogue entry compares it.

  • NPC character pass (v0.5.0): Rig is articulated (Rig/Hip* -> Knee, Shoulder -> Elbow -> Hand); outfit_for adds layer/hair_style/hand_item; _idle_layer drives phone/clipboard/crossed and escalation gestures; _reaction_tag shows !!, ?!, !, ?; apply_lod swaps to a billboard impostor at 26 m (hysteresis 1.5 m); GameManager _refresh_queue assigns queue offsets. Perf: 16 avatars = 534 meshes, ~6.9 ms headless.

  • Player movement (v0.5.0): W was inverted (get_axis(forward, back) is negative for W and the code multiplied it directly); now forward * -input_dir.y. Step-up uses test_move + a raised copy (0.32 m). Guard emits footstep/landed/precision_changed; GameManager forwards them to AudioFeedback and the HUD. Gamepad actions are added at runtime by GuardController._ensure_gamepad_bindings; remaps persist via Save input_bindings and are applied by InputBindings.apply (key/button events only). Windowed smoke_movement.gd guards it.

  • Scheduled TODO pass complete: only the optional Future ideas remain (live replay, extra venues, voice audio, image-to-mesh). Grounding facts: the NPC rig is rigid capsules with a whole-limb swing, and guard_controller.gd uses one accel value. Each task should get its own windowed smoke or capture where it changes feel/looks.

  • Backlog complete: every planned TODO section is done and moved to DONE.md; TODO.md now only lists optional future ideas (live 3D replay, NPC LOD, more venues, real audio, prop atlas, AI mockups for the new catalogue entries, image-to-mesh props). GUT was consciously not adopted (custom runner covers it without an editor plugin).

  • Do not re-open override_access. Do not bind wand to W. Do not remove the radio easter egg.

UI / Presentation Decisions

  • Runtime UI/scene art is loaded through src/game/ui/art.gd (assets/images/ui/ then scenes/); missing files return null so panels keep procedural fallbacks.
  • Screen mockups with baked-in controls are used as backdrops plus an opaque ArtPanel cover, not as clickable full-screen skins.
  • Radio, wand, inspection, and body-camera timeline panels use their reference art as backdrops; forms sit on transparent, padded panels over the covered mockup controls.
  • HUD target names use a boxed chip under the crosshair; escalation has a five-segment bar below the text label. The body-camera REC/clock block starts at y=92 on the right so it clears the shift/rep/crowd text.
  • Staged captures in docs/screenshots/ are committed and were last regenerated after the realism and NPC passes; re-run tools/check_screenshots.sh (not a partial capture) before replacing them.
  • tools/check_screenshots.sh is the repeatable capture+compare smoke; both capture scripts accept --out=res://docs/screenshots. Judge verdicts on the staged set were 7 match / 4 review / 0 diff.
  • Venue selection used to leave guard.input_locked true and the mouse visible; _start_shift() must call _refresh_freeze() after starting the clock.

Current State

  • v0.3.0 pass (2026-10-31, historical; shipped): rendering, generated prop models, screen catalogue, aim-free selection. See ADR-0011 (selection) and ADR-0012 (materials/props).

    • src/game/materials.gd (SurfaceMaterials) is now the single source of surface recipes + configure_environment(). GameManager.PHASE_LIGHTING gained exposure/glow/fog_color/fog_density/fog_energy/contrast/saturation/ambient_energy. Renderer is GL Compatibility, so SSAO/SSIL are unavailable; glow+fog+ACES are used instead and unsupported settings are ignored rather than gated in code.
    • World: scaled brick (uv 22,10), framed windows with per-window night emission (i % 3 != 2 lit), stone base course, door trim/handles, curb, lane dashes (the old loop had Vector3(i * 0.0, …) and stacked every dash at x=0), street lamps use the metal profile. The two flat planters that clipped the steps were removed.
    • tools/model_pipeline.py builds assets/models/generated/*.glb (bollard, bench, trash_bin, planter_box, newspaper_box, fire_hydrant) from box/cylinder specs; WorldBuilder._add_prop_model() loads them with a procedural fallback and records loaded_models / fallback_props. Gotcha: glTF baseColorFactor is LINEAR — writing sRGB made every prop pastel; srgb_to_linear() is applied in build_glb. Gotcha: a new/changed .glb needs godot --headless --path . --import before ResourceLoader.exists() / load() sees it (test_generated_props fails otherwise).
    • model_pipeline.py concept (OpenRouter, reads repo .env) writes per-prop concept sheets to assets/models/concepts/; dominant_colors() centre-crops and weights by saturation (a grey studio backdrop otherwise wins), assign_roles() maps colours by hue/value. Delete a concept file to fall back to the catalogue palette.
    • NPC avatars: outfit_for(type, seed) (pure) gives shirt/trousers/hair/skin + accessory (cap, hood, backpack, sunglasses, lanyard) and height scale; _arms and _legs now swing (limb_swing) while walking. Keep Rig/Head named for focus.
    • Selection: NpcFocus.rank_indices (angle then distance) — pick_index = first. GuardController.cycle_target(direction) walks nearest-first nearby_candidates() and sets target_pinned, which makes _update_target() skip the cone. clear_target() sets focus_suppress_s = 0.75 so the cone does not instantly re-acquire the person. New input actions walk_slow (Shift), cycle_target_next / cycle_target_prev (wheel down / up). Right click with nothing open clears focus (Esc still pauses).
    • HUD: nearby_label + set_nearby_hint(count) show hud.nearby; hud.controls and hud.targeting mention the wheel and Shift.
    • tools/screen_designs.py: catalogue now 19 screens (added wand_screening, joke_shop_queue, studio_arrival, crowd_panic, responder_arrival, body_camera_timeline, escalation_hud), new list command, generate --only/--force (existing designs are kept by default so a draft run cannot clobber AI mockups), and judge --dry-run no longer raises KeyError: 'class'.
    • Screenshot tool leak: capture_screenshots.gd intermittently prints ObjectDB instances leaked at exit. Verified against a git worktree of HEAD~ — the baseline leaks more often (3/3 vs 1/3), so it is a pre-existing quit-timing artifact, not a regression. Normal boots are clean.
  • Access artifacts: src/core/access_loadout.gd builds from event override_access + venue policy. Spawn signal is now (profile, intent, npc_id, event). Recognition is not auto-attached to every guest. AccessVerifier.verify(..., already_screened=false) so Joke Shop scoring uses controller.screened. Joke Shop/Studio gained sidewalk/steps_end waypoints. Wand venues roll carried_items per spawn (seeded). Tests in src/test/tests/test_access_loadout.gd.

  • Wand shortcut is C, not W (W is walk). radio_emergency input map no longer includes W. hud.controls / hud.screen_first updated.

  • Control pass (historical; smoke-tested): cone focus via src/game/npc_focus.gd, accel/decel, gravity only airborne, input_locked freeze, Esc no longer on the guard. NpcAvatar in group npc; neck/hair/eyes/shoes; blue focus circle + Label3D name; set_focus_visual. src/game/ui/action_radial.gd doughnut (LMB/Space open, A/D/arrows, Space/click confirm, Esc/RMB cancel). Crosshair CanvasLayer. Emergency still R only. Number keys 1–6 when the wheel is closed.

  • Named input actions now exist and are used: action_menu, confirm, cancel (Esc + RMB), inspect (E), wand (C). The old unused interact action was removed.

  • Runtime fixes from the smoke: Head is +1.6 above the guard root (root sits on the floor); guard spawns in front of the door facing the street; building volume moved to z ∈ [-8,-4] so the door, awning, and marquee are visible; step treads rise toward the door; ShiftCoordinator pulls the event scheduler once per simulated second (was every physics frame → >1200 NPCs in seconds); GameManager.MAX_LIVE_NPCS = 16 caps live avatars.

  • Session shell (2026-10-31, historical): the game boots into StartMenu (src/game/ui/start_menu.gd, layer 20) instead of a live shift. GameManager._on_venue_selected() builds the scenario and calls _start_shift(); _shift_running gates input and _refresh_freeze(). Esc with nothing open now pauses (PauseOverlay, layer 18) → resume / restart / settings / quit-to-menu. SettingsPanel (layer 22) writes look_sensitivity + hud_font_scale through Save.save_settings() → user://settings.json; Save.load_settings() merges over Save.DEFAULT_SETTINGS on boot and GameManager._apply_settings() pushes them into the guard/HUD. Shift review "Run another shift" now calls _show_start_menu() (not a silent restart). V stays a debug venue bind. Smoke: src/test/smoke_session_shell.gd (menu → shift → pause → settings → menu).

  • World follow-ups closed (2026-10-31): skyline backdrop restored with T_SKYLINE (assets/textures/skyline.png, regenerated RGBA with a transparent sky) and an TRANSPARENCY_ALPHA_SCISSOR unshaded material (_skyline_mat); steps + landing are a StaticBody3D with per-tread BoxShape3D colliders; marquee font_size 160 / outline 24; focus circle (blue disc in front of the face) + name tag 48px; NpcAvatar.ephemeral/lifetime_s despawns routine traffic (authored events stay). Guard spawn height verified on the landing (guard_y=0.84).

  • PLAN verbs (2026-10-31, historical): the radial has 12 slices (Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio). eye_contact reuses the Level 1 reaction table via _apply_interaction. Recognition is a player decision: NPCController.player_recognized + IncidentRecorder.add_access_evidence, and GameManager._artifacts_for() prepends a RECOGNITION artifact only after the player declares it. The body camera is a real flag (GameManager.body_camera_on, Hud.set_body_camera), off by default, B toggles it, and a formal warning arms it and writes a body_camera incident event. Blocking also calls _station_between() to physically place the guard 0.9 m from the NPC toward the entrance. _request_reason() uses the authored reason context (falling back to seeded dialogue_lines) and stores the pick for the timeline.

  • NPC navigation was dead (fixed 2026-10-31): avatars never moved in the live game, for three stacked reasons. (1) agent.target_position was re-assigned every physics frame, which invalidates the path so get_next_path_position() keeps returning the current point — only set it when the target changes. (2) Nav path points sit on the navmesh plane (y=0) while bodies stand at y≈0.5, so a 3D "are we there" test always looked 0.5 m away and the horizontal direction was zero — compare horizontally. (3) path[0] is the old start point, so picking any point "far enough away" can pick the point behind you and oscillate — pick the first point that is closer to the goal than the current position. _nav_try_frames/_move_direct fallback is still there.

  • Crowd reactions listen on the EventBus: CrowdSystem.apply_event() emits event_bus.crowd_changed; its own changed signal is never emitted. GameManager._connect_crowd() therefore connects to coordinator.event_bus.crowd_changed (reconnect after every coordinator.setup(), which recreates both crowd and event bus).

  • Crowd presentation: NpcAvatar.start_rubberneck(at) (idle bystanders away from the door stop and face the incident for 12 s) and start_flee(from) (panic: walk away at 2.4 m/s, then free themselves). Someone awaiting_interaction within 3 m of the door is "engaged" and does not rubberneck. Use position (not global_position) for that distance check so it also works in headless tests outside the tree.

  • Perf: a busy 16-NPC entrance measured ~1.2 ms/frame (824 fps with vsync off, 800 samples), so LOD/culling is not warranted at MAX_LIVE_NPCS = 16. Re-measure before raising the cap.

  • Incident recording was dead: IncidentRecorder.start_incident() was never called, so every add_event/add_access_evidence silently no-opped and the review always showed 0 incidents. _begin_incident() now starts one per focused NPC (has_open_incident() guard), verbs call _ensure_incident(), and ShiftCoordinator._finish_shift() closes leftovers as unresolved before building the review.

  • Editor-ready venues (2026-10-31): tools/export_venues.gd (a SceneTree script) saves the three factory scenarios to content/venues/<id>.tres with ResourceSaver; the .tres files round-trip through ResourceLoader (events, profiles, policy, shift phases, door lines, locations). test_venue_resources.gd also fails if the exported files are missing or stale relative to the factory, so re-run the export after changing ScenarioFactory.

  • Vision judge (2026-10-31): python3 tools/screen_designs.py judge sends the capture plus its concept mockup to a vision model (DEFAULT_JUDGE_MODEL = google/gemini-2.5-flash-lite, override with --model; openai/gpt-4o-mini also works). Reads the repo .env via _load_env_file() (never overrides real env). Verdicts are classed match / review (mismatch below --fail-confidence, default 0.6) / diff (confident mismatch → exit 1) / unknown. --dry-run, --json, --only supported. The prompt explicitly ignores art style, because the references are stylised mockups; the live run gave 3 match, 3 review, 0 diff. Python tests: cd tools && python3 -m unittest test_asset_pipeline test_screen_designs.

  • String newlines (2026-11-02): content/strings.en.json needs one escaped newline (\n), not two. Two backslashes reached the HUD as literal \n in the shift status and wand panel; test_strings_format now guards this.

  • String formatting safety (2026-10-31): Strings.placeholder_count() counts % specs (ignoring %%), fmt() warns and returns the template when the arg count does not match (instead of raising an engine error), and validate() lints the table for trailing %, unsupported conversions, and mixed positional/sequential placeholders. Tests cover all three plus a formatting pass over every placeholder key with type-correct dummy args.

  • Authored scenario content (2026-10-31): events can now carry hint (a string key logged when the avatar spawns), extra_spawns (celebrity arrivals pull in tourists/influencers via _spawn_npc with kind: routine), and refuses_screening (copied to NPCController.refuses_screening; _toggle_wand_screening logs wand.refused, records a wand_refused event, applies a small prop hit, and does not open the panel). film_boundary is a new intent that maps to steps_end so the influencer stands past the posted line.

  • Accessibility (2026-10-31): escalation text is "<name> [###--] L3/5" via Hud.escalation_meter(level) (pure, tested) so the cue survives grayscale. HUD font scale uses add_theme_font_size_override on the core labels (set_font_scale), not Control.scale, which pushed the right-anchored clock label off-screen. Settings load before _build_ui(), so _ready() re-applies player settings after the HUD exists.

  • Audio (2026-10-31): AudioFeedback.start_crowd_bed() builds a 4 s looping AudioStreamWAV (one-pole-filtered noise + 68/103 Hz drone, seeded) and set_crowd_level(mood) maps 0..1 to -34..-12 dB. play_radio_open/close push short filtered-noise bursts. All procedural; with a dummy (headless) audio server the calls are no-ops. _free_player() returns null when _ready has not run (tests), and callers handle null.

  • NPC theater (2026-10-31): NpcAvatar.theater_pose() maps intent + arrived to idle/sit/photo/film/perform. Pure helpers crouch_for_pose, prop_lift_for_pose, sway_for_pose keep the math testable. Sit lowers the rig by 0.34 and swings the legs forward (negative X pitch; positive looked like kneeling). _photo_prop is set for influencer/tourist cameras, _sign_prop for protester/performer boards; their base Y values are captured at build time so the animation offsets from rest.

  • Body-camera timeline (2026-10-31): src/game/ui/event_timeline.gd flattens review.incidents.incidents[].events into one time-ordered list (flatten), formats each row with describe, and steps with Left/Right or the buttons. ShiftCoordinator.started_at_ms gives the review an absolute start for mm:ss stamps. GameManager._open_timeline hides the review dim (two stacked overlays made the screen nearly black) and _timeline_from_review restores it on close. Smoke: src/test/smoke_timeline.gd.

  • Radio dispatch was dead after the first shift: coordinator.setup() builds a new RadioSystem, so the responder_arrived connection made in _ready was lost on every venue switch/restart (responders never resolved anything). _connect_radio() now re-wires it next to _connect_crowd() after every setup. Same class of bug as the crowd signal.

  • Emergency aftermath: ShiftCoordinator.responder_outcomes / investigations feed the review. evacuation (emergency call, low danger, no weapon) locks the door, makes every NPC flee, marks them leave_requested, and applies a security_failure hit. An emergency placed while current_npc_truth has no weapon and danger_level <= 1 appends an investigation note, records investigation_opened, and applies false_report -6.

  • Skyline backdrop (2026-10-31): PlaneMesh defaults to a horizontal XZ plane — set mesh.orientation = PlaneMesh.FACE_Z or the "backdrop" renders as a band overhead. The skyline is now a ring of four 400x60 planes at 200 m (center y=30, so building bases meet the ground), TEXTURE_FILTER default, TRANSPARENCY_ALPHA_SCISSOR with threshold 0.5 (0.3 left faint mip dashes across the sky), unshaded with a lifted albedo (Color(2.6,2.7,3.0)) so it is not pure black. Ground is 500x500 so no sky gap shows under the buildings. The skyline.png import needs mipmaps/generate=true.

  • Asset gotcha: assets/textures/game_ready/ is not imported (no .import). Use the imported assets/textures/skyline.png path. Untracked duplicate folder should not be referenced.

  • Host sign-in follow-up is done: REQUIRE_SIGNIN_VIEW = false in /etc/gitea/app.ini (backup app.ini.bak-* alongside), Gitea restarted; anonymous repo page/API return 200 and release assets download in full. Reverse by setting it back to true and restarting.

  • Release publishing was broken since v0.2.2 (fixed 2026-10-31): publish_release.sh only built AUTH in the GITEA_TOKEN branch; the ~/.git-credentials branch set CRED and then used unbound ${AUTH[@]} under set -u, so every API call failed and the tag job ended "could not create or find release". The repo also has no GITEA_TOKEN secret (/actions/secrets is empty) and the runner has no git credentials. Fixes: the script sets AUTH=(-u "$CRED"); the workflow falls back to github.token (secrets.GITEA_TOKEN || github.token) — but github.token is not provided by this Gitea/act runner, so the env dump showed no token and the v0.2.10 tag build failed again. Final fix: publish_release.sh falls back to the local git extraheader that actions/checkout writes (git config --local --get-regexp '^http\..*\.extraheader$'), which carries the runner's basic-auth credentials for the same server. Verified locally by adding that extraheader and publishing v0.2.10 (all five artifacts). v0.2.9 and v0.2.10 are published; v0.2.4–v0.2.8 tags have no artifacts. Verify the CI path on the next tag.

  • CI publishing verified (2026-10-31): tagging v0.2.12 produced a release with all five artifacts automatically (no repo secret), confirming the checkout-extraheader fallback.

  • Host access (2026-10-31): root@192.168.88.30 now works from this workstation (key auth), host name gitea, Gitea 28.2.0. Maintenance applied: apt-get clean, journald capped to SystemMaxUse=300M (/etc/systemd/journald.conf.d/99-size.conf, journal ~133M), disk at ~7.6G free of 16G.

  • Host disk resolved (2026-10-31): the Gitea VM root filesystem was expanded from 7.8G to 16G (/dev/mapper/pve-vm--500--disk--0, now ~7.8G free). The pruned v0.2.0 and v0.2.9 releases were restored from the local build/ artifacts and verified (4 and 5 assets). git.allucanget.biz runs on this host as user zwitschi; root@192.168.88.30 is not reachable from this workstation (publickey denied; password auth closes the banner), so host maintenance must go through a zwitschi login with sudo.

  • Host disk full (2026-10-31, historical): the v0.2.11 tag build created release id 27 and logged the uploads, but they failed with HTTP 500 Attachments.Save: write … no space left on device (/dev/mapper/pve-vm--500--disk--0, 7.8G, 100%, 0 free). git push then failed too ("unpacker error"). Unblocked by deleting the superseded v0.2.0 and v0.2.9 releases via the API (~565 MB of assets), then publishing v0.2.11 locally with the fixed script (verified 5 assets). Remaining ~150 MB is tight — the next release needs host cleanup on 192.168.88.30: sudo journalctl --vacuum-size=100M (journal 837 MB), sudo apt-get clean (91 MB), clear /var/lib/gitea/data/attachments/tmp. SSH works as zwitschi; sudo -n needs a password. Releases now: v0.2.11, v0.2.10, v0.2.1 (5 assets each); v0.2.0 and v0.2.9 entries were pruned. PLAN.md + ADRs 0007–0010 still describe the intended UX.

  • Release v0.2.1 is LIVE (2026-10-31): CI run 1110 built the NSIS installer + AppImage and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push.

  • NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses -D defines (never /D), resolves relative -DICON/-DARTIFACT against the script directory so CI passes $PWD-absolute paths; installed exe must be space-free (gig-line.exe); ICO must be classic BMP entries (ImageMagick icon:auto-resize=48,32,16; PIL emits PNG-compressed ICOs NSIS cannot load).

  • appimagetool runs system appstreamcli if present (strict, exit 3 on warnings) — project metadata now passes --pedantic.

  • Distribution facts (verified via Gitea API + host creds): repo is public, account is admin; v0.2.1 has all 5 assets; the installers were initially missing from the release because publish_release.sh uploaded only portables — now fixed (uploads installer + AppImage; falls back to ~/.git-credentials when no GITEA_TOKEN).

  • Host sign-in: instance [service] REQUIRE_SIGNIN_VIEW=true blocks anonymous browse/download (not a repo/API setting). ~/.git-credentials holds the Gitea creds (basic-auth usable); .env only holds OPENROUTER_API_KEY.

  • Release v0.2.0 was LIVE: pushing the v0.2.0 tag made Gitea Actions run the build job and publish automatically (host has the GITEA_TOKEN secret). Tag page and /releases are public (HTTP 200). Caveat: anonymous asset downloads redirect to login on this host (host setting — tracked in TODO). Future releases = bump config/version, git tag vX.Y.Z, push. No local token needed.

  • Core simulation, three scenarios, v0.2.1 installers/AppImage, first-person prototype (inspect/wand/radio, shift review, Tab targeting, V venue cycle) are on local main. Tagged v0.2.1 was 57 tests; this working tree is 74/74.

  • NPC avatars now walk to per-intent waypoints (VenueScenario.waypoint_for_intent): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. NpcAvatar.setup takes an explicit door_position.

  • Returning-trespasser behavior: NpcProfile.return_probability (trespasser 0.6, mental-crisis 0.4, others 0). NPCController._maybe_schedule_return() moves a directed-away NPC to LOITERING_DISTANT; tick_return(delta) fires after 20-60s and transitions back to APPROACH. NpcAvatar._advance_fsm re-targets the door on return.

  • Perception pass: NPCController.perceive_guard(distance) returns {noticed, band, reaction} with bands unaware (>6m), aware (2-6m), near (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. GameManager._perception_pass() feeds real guard distance each physics tick and logs first-time notice.

  • Responder dispatch: RadioSystem tracks pending_calls and decrements arrival_delay_s in tick; emits responder_arrived when a call's delay elapses. RadioSystem.responder_outcome(call) maps danger/weapons → command/restraint/evacuation/arrest. GameManager._on_responder_arrived spawns a RESPONDER avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via _update_radio_cooldown.

  • Wand screening (Joke Shop): WandScreener classifies carried items as prohibited vs false-alarm vs clear. NpcProfile.carried_items is filled at spawn on wand venues. GameManager binds C to _toggle_wand_screening and A/D to decide_wand while inspect/wand is open. decide_access still blocks admission until controller.screened is true, and verify now takes that flag. Door-staff lines log through GameManager.

  • Design change (2026-10-31): the selectable dialogue tone-of-voice system was removed. Rules.Tone/TONE_NAMES, NpcProfile.tone_modifiers, and the tone param on NPCController.roll_reaction/apply_player_action no longer exist; NpcProfile.dialogue_lines is a flat Array[String]; GameManager uses _apply_interaction(level, action_name); the T key is gone. HUD tone_label removed and hud.tone_prefix string deleted from JSON.

  • Polish/accessibility: venue cycling (V cycles VENUE_ORDER and restarts the shift), aim-free targeting (Tab cycles nearby NPCs within NEARBY_RADIUS), color-coded escalation indicator (Hud.set_escalation), procedural audio (AudioFeedback node generates sine tones for confirm/radio/escalation/deny), and a centralized Strings table (src/core/strings.gd, lookup/fmt) backed by content/strings.en.json. NOTE: Strings.get was renamed to Strings.lookup because get collides with Object.get.

  • Newly added: LATE shift phase (start 700s) with a reachability test; PROTESTER archetype + events + colors; CALL_SOMEONE reaction; DoorState core class + L binding + visual indicator; flashlight (G, SpotLight3D, perception bonus at evening/late); persistence wired (Save.save_shift_result on shift end, history + best score on the review screen); NavigationRegion3D flat navmesh + NavigationAgent3D NPC paths (steps now visual-only); strings moved to res://content/strings.en.json (41 keys, loader keeps lookup/fmt); export_presets.cfg (Linux/Windows/macOS) + tools/build_releases.sh + docs/RELEASING.md; README Downloads section.

  • Run tests: godot --headless --path . --script res://src/test/test_runner.gd

  • Boot check: godot --headless --path . --quit-after 120

  • Screenshot tools: godot --path . -s res://src/test/take_screenshot.gd (single shot to user://screenshot.png); godot --path . -s res://src/test/capture_screenshots.gd (staged README gallery shots to user://screenshots/, copied to docs/screenshots/).

  • Release build verified locally: export templates for 4.3 installed at ~/.local/share/godot/export_templates/4.3.stable/; ./tools/build_releases.sh produced Linux/Windows/macOS artifacts for 0.1.0 in build/ and the Linux binary passed a headless smoke test. Publishing to Gitea still needs a token (see tools/publish_release.sh, docs/RELEASING.md).

  • CI: the repo now carries a Gitea Actions workflow at .gitea/workflows/build.yml (checks on push/PR; full export + artifact upload + optional release publish on tags and workflow_dispatch). A repo secret GITEA_TOKEN is required for tag releases. The old .github/workflows/ci.yml was removed so hosts scanning both paths do not double-run.

  • Venue cycling: V switches Hollywood Blvd → The Joke Shop → TV Studio and restarts the shift.

  • Venue door-staff dialogue: VenueScenario.door_staff_line(context, rng) and GameManager logging (wristband, ID/ticket, wand-check, refusal, prohibited-item). Hollywood uses a wristband line, the Joke Shop uses ID/ticket/wand lines, and the Studio uses ticket/credential/guest-list lines.

  • CI cache evaluation: Gitea Actions cache was tested for the Godot binary and trimmed 4.7.2 templates. Restores were inconsistent and saves timed out in CreateCacheEntry or were rejected as reserved by another job. The cache steps are disabled until the host cache service is healthy; keep direct downloads. GODOT_SILENCE_ROOT_WARNING=1 is set on jobs. Logs live in .gitea/logs/ after each run.

  • Visual polish (v0.2.0): phase lighting (GameManager.PHASE_LIGHTING + ProceduralSkyMaterial), street lamps + night mode in WorldBuilder.set_night_mode, marquee Label3D, crosswalk/planters/ trees, NPC per-seed variation. capture_screenshots.gd shoots day + fast-forwarded LATE night (6 shots in docs/screenshots/). Version bumped to 0.2.0; binaries rebuilt.

  • Assets pipeline: python3 tools/generate_textures.py produces all authored PNGs under assets/textures/ (≤1024px, pow2); regenerate after changing the script, then run godot --headless --import before tests. 57 headless tests guard the pipeline.

  • General image pipeline: python3 tools/asset_pipeline.py defaults to openai/gpt-5-image-mini, requests supported 1:1/3:2 sources, then fits each role's target. It provides crop, resize, tile edge blending, alpha-glyph recoloring, and role-aware audits. Screenshot auditing checks 1280x720 minimum, 16:9 aspect, and contrast; visual composition remains a manual check. Python tools require Pillow from requirements-tools.txt.

  • AI texture mode: python3 tools/generate_textures.py --ai generates textures via the OpenRouter image API (POST https://openrouter.ai/api/v1/images, model bytedance-seed/seedream-4.5 by default, base64 PNG in data[].b64_json). Requires OPENROUTER_API_KEY (a gitignored .env in the repo root holds it locally; source it with set -a && . ./.env && set +a). --prompt/--name/--model/--size override the default batch. Output is downscaled to ≤1024px. NOTE: seedream rejects sizes below 3,686,400 px (1920×1920), so the script requests GEN_SIZE=2048 and downscales — do not lower GEN_SIZE below 1920. Verified end-to-end 2026-10-31 (3 textures generated). Legacy --ai remains on Seedream 4.5 and still ignores background: transparent; do not use it for alpha-dependent assets. Gemini 3.1 Flash Image advertised broad ratios but the current provider rejected authentication. The validated role-aware model is GPT-5 Image Mini.

  • Runtime assets: all 15 loaded world/HUD/card textures now live in assets/textures/ai/game_ready/; each passes role audit. Generated tile edges are blended, icon alpha masks are recolored white where needed, and the vignette uses the existing deterministic generator because the API returned an empty transparent image. The six docs/screenshots/ captures were regenerated after wiring and pass screenshot audit.

  • Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob + attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam overlay (REC + shift clock). Screenshot set regenerated (docs/screenshots/, day + night).

  • Polish: wand scan cooldown (WAND_COOLDOWN_S + wand.recharge message).

  • NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio easter egg); check git status before committing and leave foreign changes untouched unless asked.

  • Screen-design references (tools/screen_designs.py, tests in tools/test_screen_designs.py): generate turns the screen descriptions in docs/ (README captions, ADRs 0007–0010, PLAN, architecture) into reference images under docs/screens/designs/ — --draft draws offline Pillow wireframes, --ai generates realistic 3D scenes / in-game UI via OpenRouter (default model openai/gpt-5-image-mini). Screens are tagged scene or ui; prompts get a shared art-direction suffix, and requests use a 3:2 landscape source cropped to 16:9 (not a square centre-crop). compare checks docs/screenshots/*.png for aspect/size/ blank-frame and coarse layout/palette/detail drift. Drift distances are warnings, not failures: a mockup never matches a real render, so semantic composition is a follow-up vision-model judge (tracked in TODO).

Known Issues / Notes

  • Reference cycles are fixed: FSM holds its owner via WeakRef, ShiftCoordinator is RefCounted, and persistence tests free the Save node — the suite exits with no "resources still in use" warnings (was ~13). If a future subsystem introduces a cycle, repeat the WeakRef pattern.
  • Shift length: GameManager uses the scenario's shift_duration_s (900s ≈ 10-15 min); tests and the screenshot tool override elapsed time directly.
  • Headless (dummy renderer) logs "Parameter m is null" from mesh_get_surface_count; it does not occur with a real GL driver and does not affect logic.
  • Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb.
  • Persistence tests write into the real user://shift_results.json (harmless local pollution of the demo history).
  • macOS export required rendering/textures/vram_compression/import_etc2_astc=true in project.godot (universal build demands ETC2 ASTC enabled).
  • Venue door_staff_lines are logged through GameManager (inspect / wand / refuse). They are not a separate dialogue UI.
  • Access loadout is implemented; do not re-fix override_access as if it were still unread.
  • Save.SETTINGS_FILE is declared and unused; settings persistence goes through Save.load_settings() / save_settings().
  • Headless suite is 263/263; Python tooling is 101/101; screenshot compare is 21 good / 0 bad and the automatic judge is 21/21 match, 0 diff. Remaining visual follow-ups are in TODO.md.
  • Typed spawn signal extra args: handlers must accept the 4th event: Dictionary or Godot will not call them (test_shift_coordinator_runs_a_shift failed until _on_spawn took four args).
  • GDScript inference trap that broke the main scene: a var typed as a base class (radial: CanvasLayer) exposes script members as Variant, so var x := radial.is_open is a parse error. Type it as the script type (ActionRadial) or annotate the local explicitly. --check-only --script cannot see the Save autoload; use a real boot (--quit-after 180) to verify a full-scene compile.
  • Do not snapshot right after changing a transform in the same frame: capture_screenshots.gd needs a state/frame gap or the capture uses the old camera orientation.
  • EventScheduler probabilities are per simulated second, not per call. Never poll it per frame.
  • Screenshot staging lives in src/test/capture_screenshots.gd (guard at z≈10.5 facing the door) and src/test/capture_ui_states.gd (UI states 14–21). tools/check_screenshots.sh renders all captures into docs/screenshots/; do not regenerate them by hand.

Open Questions

  • Whether the responder avatar should be playable/controllable or purely scripted.
  • Localization target languages beyond English.
  • macOS notarization approach for broad distribution.

Plan vs code

PLAN.md describes intended design; README.md and DONE.md describe what shipped. As of 2026-11-02 the playable build covers the three venues, tutorials/campaign/checkpoints, the 0.9.3 UI/accessibility pass, the 0.9.4 NPC visual optimization, the 0.9.5 texture/material pass, and the Blender + realism asset pipeline. TODO.md holds the remaining 0.9.6–0.10.0 work. The project uses a custom headless runner, not GUT, and Gitea Actions, not GitHub Actions.

Uncommitted Workstreams

  • None (2026-11-02). The 0.9.5 texture pass is committed, tagged v0.9.5 and published with seven assets; the workspace is clean apart from local/gitignored realism and capture output. Keep the rule from AGENTS.md: check git status before committing, and never revert another contributor's in-flight files.