19 Commits
Author SHA1 Message Date
zwitschi f4c1fed99b test(placement): measure the foot contact after the pawn settles, not at spawn
The earlier 'ground 366 against an actor at 561' reading was taken in BeginPlay, while the
pawn is still at its spawn height and falling - so it described the surface far below a
falling capsule, not a float. That number drove two hand-tuned Z values (561 for the player
start, 342 for the car) on the strength of a measurement taken at the wrong moment.

The probe now runs three seconds after spawn, traces from the capsule's feet rather than the
actor origin, and reports three channels with the actor and component each hits. A
multi-channel probe at spawn showed every channel hitting the same StaticMeshActor at 366,
which is consistent with the falling-pawn explanation rather than with missing sidewalk
collision.

Not yet read back: the last two game runs produced no log output at all, so the settled
numbers are unverified and recorded as such.
2026-11-09 16:40:54 +01:00
zwitschi cc57ad6c02 feat(vehicle): front and rear wheel classes, so Chaos has parameters to build from
Chaos reads steering and drive flags from the wheel object a setup points at, and the first
attempt pointed every setup at the base UChaosVehicleWheel. Dedicated front and rear classes
carry the modelled dimensions (radius 33 cm, width 22 cm), steering on the front axle, drive
on the rear, and the suspension travel - which is also how Unreal's own vehicle template is
organised.

Compiles clean. Not yet verified in a run: the drive test did not log the vehicle this time,
so whether the wheel count changes from 0 is still unproven and recorded as such rather than
claimed.
2026-11-09 16:32:25 +01:00
zwitschi 98eae6f3e9 docs(architecture): make section pages canonical 2026-11-09 16:30:29 +01:00
zwitschi 15cc6cbdd0 fix(vehicle): the glazing reads as glass
The windows material was metallic 1.0 with roughness 0.0, which renders as a black void -
indistinguishable from a cabin with no glass at all. It is now tinted translucent: opacity
0.85, roughness 0.08, dark blue-grey base. The windscreen and side glass read as glass.

Opacity 0.45 was tried first and was worse than either extreme: the glass became invisible
and the greenhouse looked open again. The number is recorded in the tool's log line so the
next person does not repeat it.

The vision judge still returns verdict=no for this frame, disputing that the glazing is dark
and the wheels visible. The image shows both. It is an advisory helper - AGENTS.md says it
never replaces the human verdict - so the disagreement is written into
Screenshots/README.md rather than being resolved by iterating against the model.
2026-11-09 16:21:41 +01:00
zwitschi bb12ce8c33 fix(vehicle): the car is red in the engine, not black
Root cause was in the template, not the import path: 'car paint' is a legacy mix-shader
graph. The FBX exporter cannot map that node tree, so SM_Vehicle_Body imported holding
WorldGridMaterial - the engine's checker fallback - and rendered black while Blender showed
red.

Two changes:
  - Tools/vehicle_from_template.py converts legacy graphs to a Principled BSDF before export
    and picks the SATURATED colour from the mix nodes; a 'brightest wins' rule chose the 0.8
    grey diffuse beside the 0.49 red and produced a grey car.
  - Tools/ue_vehicle_materials.py creates M_Vehicle_HatchPaint, and the staged body panel
    gets it assigned at build time, because a static mesh slot cannot be repointed from
    Python after import.

Judge on the new capture: verdict=yes confidence=0.9 - 'a red hatch car with a red body and
dark wheels parked on a road'. The comparison table in Screenshots/README.md now shows both
columns agreeing on colour.

Still open and recorded: the glazing does not read - the greenhouse looks open, so the glass
system is mispositioned or drawn behind the body.
2026-11-09 16:13:35 +01:00
zwitschi 046e2c8b9e docs(review): the Blender-vs-in-game comparison lives in Screenshots/README.md
Merged COMPARISON.md into the review README as a generated section between
<!-- blender-comparison:begin/end --> markers, so there is one place to look and one place
to sign off. Tools/build_comparison_md.py rewrites only that block - the hand-written
sections around it are untouched - and formats the file with prettier afterwards, because a
generated table is never byte-identical to prettier's output and the repo gates on
--check.

Run it after every capture or Blender render; it is the routine that keeps the page current
with new images rather than a one-off export.
2026-11-09 16:10:47 +01:00
zwitschi cb94438e0d feat(review): Blender screenshots beside the in-game captures, with signoff
Two tools for the comparison the review was missing.

- Tools/blender_screenshots.py renders out of a .blend: one image per mesh object, and
  four turntable angles of the whole scene, framed from the scene's own bounds so adding
  geometry never silently crops a shot. Output lands in Screenshots/blender/, beside the
  in-game captures.
- Tools/build_comparison_md.py writes Screenshots/COMPARISON.md: a table with the in-game
  capture and its Blender render side by side per row, then a signoff table with one row
  per in-game screen. Unmatched shots show an em dash rather than disappearing, because a
  missing Blender render is information too.

The first run already earns its keep: the Blender render of the owner's hot hatch is red
with clear glazing, while the in-game capture of the same asset is near-black, so the
material colour is being lost on import - visible in one glance in the new page.
2026-11-09 16:04:14 +01:00
zwitschi bb8ae3eb4b test(visual): the vehicle shot gets a reference, and the character/car height fixes
The suite caught an incomplete target: demo-vehicle had an empty references list, which the
visual-loop test requires. It now points at Art/blender/vehicle/hatch-reference.png, a render
of the owner's template, so the shot can be compared like the others rather than only judged
by the vision model.
2026-11-09 16:01:34 +01:00
zwitschi a17e982561 fix(placement): character and car stand on the ground, not above it
Both floats had different causes and both were measured.

Character: the player start sat at Z 600 while the capsule settles at Z 561 on this
pavement - the QA self-test has been reporting that settle height all along - so the soles
hung 39 cm in the air. The start now uses the measured 561.

Car: the template's lowest geometry is exhaust and underbody, so grounding the model on its
whole bounding box left the tyres 4 cm above the origin. It is grounded on its WHEELS now.
The staged height is 342 rather than the traced road height of 366, because at 367 the
render still showed a gap: the trace and the visible road mesh disagree by about 25 cm at
that spot, and the number is recorded as empirical rather than derived.

Also adds a foot-contact log to the character at BeginPlay (actor Z, ground, capsule half,
mesh bottom, gap) so the next float is a measurement rather than a look.
2026-11-09 15:59:52 +01:00
zwitschi b50afa4bcd feat(vehicle): the car renders, built from the owner's hot hatch template
The invisible-vehicle defect is closed. Shot 9 now shows the car instead of an empty road.

Tools/vehicle_from_template.py converts Art/blender/vehicle/hot hatch.blend into UE-ready
assets, following two skills:

  vehicle-artist - systems split (body 113k tris, trim 10.5k, glass 20.5k, lights 18.6k,
  wheels 47.8k), one UCX_ hull 1.45 x 3.94 x 0.76 m, two decimated LODs at 39.7k and 13.6k.
  unreal-export - scale normalised to a documented 4.05 m hot hatch before export (the
  template measures 7.26 m, factor 0.5579), then verified after import: body bounds
  1.51 x 4.02 x 1.22 m against the 4.05 m target.

Staged as five plain StaticMeshActors, because the earlier probe proved those render while
the same meshes attached to the vehicle pawn do not.

Open and recorded rather than hidden: judge_screenshot returns verdict=no confidence=0.9 -
the silhouette does not read as a hot hatch from this angle and the car floats slightly.
The likely causes are a system misclassification in the template conversion and an
origin-height error of a few centimetres.
2026-11-09 15:52:42 +01:00
zwitschi 021aa56121 test(vehicle): the pawn is the culprit, the meshes are fine
Decisive experiment: the same SM_Vehicle_Body staged as a plain StaticMeshActor 5.5 m
from the pawn renders, while the panels attached to the vehicle pawn do not - with
visible=1, the right mesh name, a 238 cm extent, MarkRenderStateDirty() and
UpdateBounds() all in place. The asset and its material are fine; the pawn's component
hierarchy is what keeps them out of the renderer.

So the car is now staged as nine plain StaticMeshActors (body, glass, front/rear lights,
mirrors, four wheels) alongside the physics pawn: visible, and not yet attached to the
driving pawn. A visible car that does not drive yet beats an invisible one that does.

Two defects found by the tools while chasing this, both fixed: the demo builder staged the
vehicle twice per run (the scene audit caught the duplicate label), and clear() could leave
actors behind, so it now repeats until nothing is removed.
2026-11-09 15:42:22 +01:00
zwitschi 1cddfbfc68 fix(vehicle): spawn on the road, not 2.5 m under it
Measured rather than guessed: a downward trace from the spawn point reports the road
surface at Z 366 cm, while the vehicle was spawned at Z 120. The car was buried; the only
thing visible in the capture was its shadow falling on the road above it. Spawn is now
Z 470, the camera aims at 430, and it settles at Z 470.

Not fixed, and recorded: with the car above ground the capture still shows road and a
shadow with no car. Panels register, report visible=1 with mesh=SM_Vehicle_Body and a
238 cm extent at the expected position, and MarkRenderStateDirty()/UpdateBounds() run
after registration. The shadow proves a collider is there; the visible geometry is not.

Screenshots/README.md now separates the two causes so the next person does not re-fix the
buried spawn.
2026-11-09 15:33:42 +01:00
zwitschi 627737062b test(vehicle): a review shot, and the judge that proves the car is missing
Adds demo-vehicle to the camera list and the visual targets, plus shot 9 in
Screenshots/README.md - marked FAIL, because that is what it shows.

The vehicle is staged and runs: spawned at (-12950,-900,120), 9 visual panels attached,
panel 0 visible with a 238 cm extent, settled at Z 119 cm. The capture shows an empty road
with a soft shadow. Two checks disagree in a useful way: visual_loop --verify PASSES the
frame (it only tests size, blankness and tonal range, so a well-exposed empty road passes),
while judge_screenshot returns verdict=no confidence=1.0.

Strongest clue, recorded in TODO.md: a downward trace from the vehicle finds no ground at
all (ground at Z -9999), so the car sits over a surface without collision or inside
geometry - the shadow and the panels are not in the same place.

Diagnostics added along the way: mesh/visibility/bounds at BeginPlay, per-panel world
positions and extents, and the ground trace. The visual panels were also unhidden from the
physics mesh: SetVisibility(false, ...) hid them whether or not propagation was requested.
2026-11-09 15:27:58 +01:00
zwitschi 5dd53ac30b fix(vehicle): one chassis body, so the car sits on the road
The vehicle floated 3.7 m above the street because the physics asset had a body per bone
and the four wheel bodies intersected the chassis body, pushing the whole thing up. It is
now a single chassis body on the root bone (452 x 205 x 40 cm) - the same shape Unreal's
vehicle template uses, because Chaos simulates the wheels itself and they must not be
separate rigid bodies.

Settle check, same spawn point:
  before: settled at Z 494 cm
  after:  settled at Z 119 cm

Added -drivetest, which holds full throttle for 3 s and reports speed and distance. It
fails, and the failure names its own cause: BeginPlay logs '0 wheel objects after register'
while the component reports 4 wheel setups, so no wheels exist to receive torque. Recorded
in TODO.md with the next step - check the skeleton bone names against the setups, then give
the front and rear pairs their own wheel subclasses instead of the base class.
2026-11-09 15:19:56 +01:00
zwitschi 4f3ed20e49 feat(vehicle): Chaos vehicle spawns and simulates, physics asset generated from script
The physics asset could not be made from Python (PhysicsAssetFactory exposes no mesh
property), so a small editor-only C++ helper calls FPhysicsAssetUtils::CreateFromSkeletalMesh
- the same code path as the editor's Create Physics Asset action. It produces
PA_Vehicle_Sedan with 6 bodies and assigns it to SK_Vehicle_Sedan.

AGigLineVehicle is a Chaos wheeled pawn that configures its four wheels from the modelled
dimensions (radius 33 cm, wheelbase 278, track 156) and is staged in DemoMap. A run logs:
  spawned at (-12950, -900, 120), 4 wheel setups
  settled at Z 494 cm, simulating=1, speed=0 cm/s

That last line is the defect and it is not hidden: the vehicle rests 3.7 m above the road,
so the generated physics bodies do not match the mesh. Recorded in TODO.md with the next
step - inspect the bodies against the mesh, then either fix the creation params or author
two boxes and four wheel spheres explicitly.
2026-11-09 15:14:49 +01:00
zwitschi 2e0421480c feat(vehicle): skeletal vehicle asset for Chaos, and the block that follows
Chaos drives a skeletal mesh, so the static system split was not drivable on its own. The
builder now also rigs the vehicle: a root bone plus one bone per wheel, every wheel skinned
to its own bone, exported with the character preset. SK_Vehicle_Sedan imports with
SK_Vehicle_Sedan_Skeleton (508 triangles).

Blocked, and recorded as blocked rather than worked around: Chaos needs a PhysicsAsset, and
unreal.PhysicsAssetFactory in 5.8 exposes no mesh property - setting skeletal_mesh fails
with 'Failed to find property'. Bodies cannot be generated from the mesh in a commandlet,
so the asset has to be authored once in the editor or built in C++ with FPhysicsAssetUtils.

Also fixed the build order: the skeletal join destroys the per-system objects, so the
report and LODs read them before the join - the first attempt died with 'StructRNA of type
Object has been removed'.
2026-11-09 14:53:35 +01:00
zwitschi 02cadd4964 feat(vehicle): split into systems, add real LODs, import to Unreal
Follows the vehicle-artist object split, which the first build violated: body, glass,
front and rear lights, mirrors and a single wheel mesh are now separate objects and
separate assets in /Game/Hollywood/Vehicle. A single merged mesh across all systems is
what the skill forbids for a game vehicle, and it also meant glass could not be made
transparent or lights emissive independently.

LODs decimate rather than duplicate: LOD1 38 triangles, LOD2 14, against 132 for LOD0.
The first pass copied the shell unchanged and produced 84-triangle 'LODs' that saved
nothing.

Imports verified with bounds: body 4.76 x 1.76 x 1.28 m, wheel 0.22 x 0.66 x 0.66 m,
which match the modelled dimensions.

Open, recorded: LOD group wiring needs StaticMeshEditorSubsystem.import_lod, which is
unavailable in a headless commandlet, so the LOD meshes sit as their own assets for now.
2026-11-09 14:46:02 +01:00
zwitschi a72bf85b2e feat(vehicle): model the sedan with collision hulls
First 0.3.0 asset. Tools/vehicle_build.py builds a 4.72 x 1.80 x 1.44 m sedan - 2.78 m
wheelbase, 1.62 m track, 0.33 m wheels - as a three-tier silhouette: sill slab, bonnet
and boot shoulders, cabin greenhouse, plus glazing, lamps, bumpers and mirrors. 252
triangles.

Collision is four UCX_-prefixed convex hulls (body, cabin, two bumpers) per the
collision-proxy convention, hidden from render so the preview reads as the car rather
than the colliders.

Two corrections from the preview, both visible in the saved image: the first build was
one tall slab plus a floating cabin and read as a box truck, and the wheels were buried
inside a full-width sill - the sill is now 1.56 m so the wheels stand proud.

Reports every dimension to JSON so the Chaos vehicle config can read the wheelbase, track
and radius instead of restating them.
2026-11-09 14:43:20 +01:00
zwitschi 73b5bd6cd1 Merge pull request #37: v0.2.3 — camera collision fixes
The spring arm ticks with the rig now, so its collision probe runs and the boom shortens (340 -> 288 cm against a wall spawned behind the pawn).
2026-11-09 14:41:49 +01:00
128 changed files with 2452 additions and 656 deletions

No files matched your search

+2 -2
View File
@@ -141,7 +141,7 @@ Keep the tools standard-library-only where practical; if a third-party import is
## Architecture documentation
[`Docs/Architecture/`](Docs/Architecture/) is the arc42 documentation: twelve numbered section files, `about-arc42.md`, and the combined [`architecture.md`](Docs/Architecture/architecture.md). **Markdown is canonical** — the `.adoc` sources were removed on 2026-11-08.
[`Docs/Architecture/`](Docs/Architecture/) is the arc42 documentation: [`README.md`](Docs/Architecture/README.md) provides the overview and links to twelve numbered section files; `about-arc42.md` records the template attribution. **Markdown is canonical** — the `.adoc` sources were removed on 2026-11-08.
Keep it current **in the same change as the implementation**, not in a later documentation pass. When work alters any of the following, edit the matching section:
@@ -160,7 +160,7 @@ Rules:
- **Describe what exists.** No aspirational architecture: anything planned belongs in `ROADMAP.md` and `TODO.md`. Every structural claim names the file, script or flag that implements it.
- **ADRs are append-only.** Never rewrite an accepted decision; supersede it with a new ADR that says what it replaces and why.
- **Keep the combined document in sync.** `architecture.md` mirrors the section files; when a section changes, update the combined document too.
- **Keep the index current.** `Docs/Architecture/README.md` links to the canonical section pages; update it when the structure or section list changes.
- **Images** live in `Docs/Architecture/images/` and are referenced, not duplicated.
- **Verify:** all Markdown must pass `prettier --check` (config in `.prettierrc`), and a command you quote must still run.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -20,7 +20,7 @@ C++ classes in `Game/GigLine/Source/GigLine/`:
| --- | --- | --- |
| `AGigLineCharacter` | First-person player pawn | 1.75 m capsule, 4.5 m/s walk, Enhanced Input bindings, reset, pause, `-qaselftest` harness |
| `AGigLineGameModeBase` | Sets game mode and HUD class | `HUDClass = AGigLineHUD` |
| `AGigLineHUD` | HUD text overlay | `DrawText` every frame: ODbL attribution bottom-left, controls bottom-right, and an F3 debug readout top-left (`gigline.Debug`, on for `-perf`/`-qaselftest`) |
| `AGigLineHUD` | HUD text overlay | `DrawText` every frame: controls bottom-right, and an F3 debug readout top-left (`gigline.Debug`, on for `-perf`/`-qaselftest`) |
| `AGigLineNPCPerson` | NPC prefab (future) | Capsule + visible MPFB body, feet on Z 0 |
| `AGigLineShotActor` | Screenshot camera walker | Drives cameras in `-game` mode, `FScreenshotRequest`, quits |
| `UGigLineAssetTools` | C++ collision helpers | `SetComplexCollisionAsSimple` on `StaticMesh` assets |
+27
View File
@@ -0,0 +1,27 @@
---
title: Gig Line Architecture Documentation
description: Architecture documentation for the Gig Line Linux-first Unreal Engine 5 demo around 6840 Hollywood Blvd.
status: v0.1.0
date: 2026-11-08
---
Gig Line is a Linux-first Unreal Engine 5 walking and driving demo around 6840 Hollywood Boulevard and the El Capitan Entertainment Centre. The architecture prioritises visual realism and geographically faithful placement while maintaining a fully walkable scene, reproducible asset pipeline, documented provenance, and a 60 FPS floor at 1440p.
This directory contains the canonical arc42 documentation. Each section is maintained independently; update the relevant page in the same change as its implementation. Project work and future scope remain in [TODO.md](../../TODO.md) and [ROADMAP.md](../../ROADMAP.md).
## Sections
1. [Introduction and Goals](01_introduction_and_goals.md)
2. [Architecture Constraints](02_architecture_constraints.md)
3. [Context and Scope](03_context_and_scope.md)
4. [Solution Strategy](04_solution_strategy.md)
5. [Building Block View](05_building_block_view.md)
6. [Runtime View](06_runtime_view.md)
7. [Deployment View](07_deployment_view.md)
8. [Cross-cutting Concepts](08_concepts.md)
9. [Architecture Decisions](09_architecture_decisions.md)
10. [Quality Requirements](10_quality_requirements.md)
11. [Risks and Technical Debt](11_technical_risks.md)
12. [Glossary](12_glossary.md)
See also [About arc42](about-arc42.md).
-570
View File
@@ -1,570 +0,0 @@
---
title: Gig Line Architecture Documentation
description: Architecture documentation for the Gig Line Linux-first Unreal Engine 5 demo around 6840 Hollywood Blvd.
status: v0.1.0
date: 2026-11-08
---
# Introduction and Goals
Gig Line is a Linux-first Unreal Engine 5 game that reconstructs a walkable (and eventually drivable) stretch of Hollywood Boulevard around 6840 Hollywood Blvd / El Capitan Entertainment Centre. The initial release (`0.1.0`) delivers a first-person walking demo of the south-side block between Highland Avenue and Orange Drive, plus north-side buildings within 50 m of the frontage.
## Requirements Overview
| ID | Description |
| --- | --- |
| F-1 | Walk both sides of Hollywood Blvd in the demo area without falling through geometry |
| F-2 | First-person movement (WASD + mouse look), jump, reset (R), pause (Escape) |
| F-3 | Fixed daytime lighting and atmosphere (sun, sky, fog, exposure) |
| F-5 | Designated street crossing with zebra markings at signalised crossings |
| F-6 | Building façades recognisable against reference photography |
| F-7 | Terrain, road, and sidewalk follow real-world DEM elevation |
| F-8 | 60 FPS at 1440p on the documented workstation and quality preset |
Full requirements and progress are in [TODO.md](../../TODO.md); version direction is in [ROADMAP.md](../../ROADMAP.md).
## Quality Goals
| Priority | Quality Goal | Scenario |
| --- | --- | --- |
| 1 | **Visual realism** | Renders read as the reference photographs: façade identity, proportion, material and light — judged side by side, shot against photo |
| 2 | **Geographic fidelity** | Building placement and street scale match the measured data and the photographs |
| 3 | **Terrain walkability** | Zero raycast holes across both sidewalks and the carriageway (84-sample transect passes) |
| 4 | **Build reproducibility** | Fresh checkout + documented engine path produces a runnable Linux build |
| 5 | **Provenance** | Every committed asset has recorded provenance |
| 6 | **Performance efficiency** | 60 FPS at 2560×1440 on RTX 5070 Ti, documented preset, no frame generation |
**How the order is used.** It decides trade-offs. When realism and performance disagree, realism wins and the cost is paid — but never by silently shipping a frame rate below the floor. The 60 FPS figure is a **budget that must still be met**, so the response to "realism costs too much frame time" is to cut the realism feature deliberately, record the cut, and keep the floor — not to let frames slip. Walkability outranks realism in one direction only: a beautiful block the player falls through is not a demo.
## Stakeholders
| Role | Contact | Expectations |
| --- | --- | --- |
| Owner / Director | zwitschi | Sets scope, approves screenshots, cuts release tags |
| AI coding agents | multiple sessions | Read `AGENTS.md`, `TODO.md`, `ROADMAP.md`, and this document for architecture constraints; never commit directly to `main` |
| Players | end users | Walk the demo at 60 FPS without falling through the world; see a recognisable Hollywood block |
# Architecture Constraints
## Technical Constraints
| Constraint | Explanation |
| --- | --- |
| Engine: Unreal 5.8.4 | Pinned; no bundled Linux clang — uses system clang 23.1.4. Game targets use `BuildSettingsVersion.V7`. |
| Platform: Linux first | Developed and tested on Bazzite Linux (Fedora 45 base). Windows builds ship but are secondary. |
| GPU: RTX 5070 Ti 16 GB | Dev workstation target. Vulkan RHI; open kernel module driver 615.78.08. |
| No static lighting | `r.AllowStaticLighting=False`. The demo is a fixed daytime scene lit entirely dynamically to avoid "LIGHTING NEEDS TO BE REBUILT" warnings. |
| Blender→UE via FBX | Flat diffuse colours do not survive FBX→Unreal import. All materials are authored in UE. |
| Terrain collision | UE 5.8 exposes `CollisionTraceFlag` (not `bUseComplexAsSimpleCollision`); Python cannot set it. Must use C++ helper `UGigLineAssetTools::SetComplexCollisionAsSimple`. |
| Coordinate convention | South = +Y, north = −Y (ground truth = street mesh orientation). 1 m in Blender = 100 cm in Unreal. |
## Organisational Constraints
| Constraint | Explanation |
| --- | --- |
| Squash-merge to `main` | Every PR is squash-merged for a linear history. No direct commits to `main` except integration merges. |
| Visual review required | Any change affecting appearance must produce screenshots for human approval before being marked done. |
| Asset provenance | Every committed asset must have verified licence and redistribution permission recorded in `THIRD_PARTY_NOTICES.md`. |
| Reference photography | Local only, gitignored, never committed. Used for façade work under fair-use / internal-development rationale. |
| LFS for binaries | `.uasset`, `.umap`, `.blend` tracked with Git LFS; `Binaries/`, `Intermediate/`, `Saved/` are gitignored. |
## Conventions
| Convention | Explanation |
| --- | --- |
| Semantic versioning | `MAJOR.MINOR.PATCH`. `0.x` documents breaking changes. `1.0.0` = agreed game scope. |
| Task tracking in `TODO.md` | Single central task list. `- [ ]` open, `- [-]` or `- [~]` in progress, `- [x]` done, `- [A]` active, `- [D]` deferred. |
| Headless Blender | All Blender work driven by `bpy` scripts in `Tools/`, not MCP. Blender skills are method guides only. |
| Named export presets | `ue_metric_v1` for static meshes, `ue_character_v1` for skeletal. Every exporter calls the metre→centimetre conversion. |
# Context and Scope
## Business Context
The system is a self-contained single-player game. It has no server backend, no multiplayer, and no live service dependencies. Its communication partners are:
| Partner | Inbound | Outbound |
| --- | --- | --- |
| Player (keyboard/mouse) | WASD, mouse, R (reset), Escape (pause), Space (jump) | Rendered 3D scene + HUD |
| OSM data | Extracted building footprints, street geometry, elevation | — |
| Filesystem | Engine content (`.uasset`, `.umap`), Blender sources (`.blend`), DEM GeoTIFF | Saved maps, screenshots, packaged build |
## Technical Context
| Channel | Protocol / Format | Purpose |
| --- | --- | --- |
| Keyboard + mouse | Enhanced Input (IA\_\*, IMC_Default) | First-person movement, look, jump, reset, pause |
| Vulkan RHI | GPU rendering | Scene display at 60 FPS / 1440p |
| FBX | Autodesk FBX (binary) | Blender→Unreal mesh and skeleton transfer |
| GeoTIFF | 3DEP 1 m DEM | Terrain heightfield source |
| OSM XML | Overpass API extract | Building footprint and street layout source |
| Git / LFS | HTTPS to Gitea remote | Source control, binary asset storage |
# Solution Strategy
## Technology decisions
- **Unreal Engine 5.8.4** for rendering, World Partition, physics (Chaos), and the Enhanced Input system. Chosen for its Linux support, open C++ source, and real-time rendering quality.
- **Blender 5.2 LTS** for all 3D modelling, UV work, and collision mesh authoring. Driven headlessly via `bpy` Python scripts, not MCP.
- **QGIS 4.2.3** for geographic data cleanup, coordinate conversion (EPSG:26911), and DEM preparation.
- **Blosm 2.7.28** for OSM building and street blockouts inside Blender (layout data only — not authoritative façade, height, or pavement detail).
- **MPFB 2.0.18** for human base meshes and baked materials (NPCs in `0.2.0+`).
## Top-level decomposition
The system is a single Unreal game module (`GigLine`) with no plugins beyond `EnhancedInput`. Content is organised by domain under `/Game/Hollywood/`:
- `Site/` — terrain, road, sidewalks, kerbs, crossing markings, site materials
- `ElCapitan/` — hero façade mesh and materials
- `Frontage/` — south-side block building meshes
- `North/` — north-side building meshes
- `Input/` — Enhanced Input actions and mapping context
All modelling and geographic processing live outside the game project in `Art/`, `Geo/`, and `Tools/`. The `Game/` directory contains only engine-ready content and C++ source.
## Quality achievement
- **Realism**: reference photography drives every façade decision, measured data fixes placement and scale, and renders are compared against the photographs (`Tools/visual_loop.py`, `Screenshots/README.md`). One module kit at the 4.96 m bay keeps the street wall coherent; materials are world-aligned and lit by a fixed daytime rig so a shot is comparable between runs.
- **Performance holds the floor, it does not set the agenda**: dynamic-only lighting (no build step, no static shadow maps), cheap procedural materials, minimal overdraw for street-level geometry, and 60 FPS at 1440p as a budget every release must meet.
- **Walkability**: complex-as-simple collision on terrain (C++ helper forces the flag on the asset), block-all collision on every site mesh, swept transect QA verifies zero raycast holes.
- **Fidelity**: geographic data verified before modelling (footprints, bounds, DEM); building placement checked against street mesh, never in isolation; reference photography drives façade work.
- **Reproducibility**: pinned toolchain in `Docs/toolchain.md`, named export presets, `Tools/check-lfs.sh` gate, fresh-checkout build procedure in `Docs/build.md`.
# Building Block View
## Whitebox Overall System
The system decomposes into five top-level building blocks:
| **Name** | **Responsibility** |
| --- | --- |
| Gameplay | Player pawn, movement, camera, input bindings, HUD, game mode |
| Site | Terrain, road, sidewalks, kerbs, crossing markings, site-level collision |
| Façades | Hero façade (El Capitan), south block frontage, north-side buildings |
| Art Pipeline | Blender models, geographic data, export scripts, import scripts |
| QA & Review | Screenshot pipeline, self-test harness, visual review workflow |
### Gameplay
C++ classes in `Game/GigLine/Source/GigLine/`:
| Class | Responsibility | Key detail |
| --- | --- | --- |
| `AGigLineCharacter` | First-person player pawn | 1.75 m capsule, 4.5 m/s walk, Enhanced Input bindings, reset, pause, `-qaselftest` harness |
| `AGigLineGameModeBase` | Sets game mode and HUD class | `HUDClass = AGigLineHUD` |
| `AGigLineHUD` | HUD text overlay | `DrawText` every frame: ODbL attribution bottom-left, controls bottom-right, and an F3 debug readout top-left (`gigline.Debug`, on for `-perf`/`-qaselftest`) |
| `AGigLineNPCPerson` | NPC prefab (future) | Capsule + visible MPFB body, feet on Z 0 |
| `AGigLineShotActor` | Screenshot camera walker | Drives cameras in `-game` mode, `FScreenshotRequest`, quits |
| `UGigLineAssetTools` | C++ collision helpers | `SetComplexCollisionAsSimple` on `StaticMesh` assets |
Input assets in `/Game/Input/`: `IA_Move`, `IA_MoveBack`, `IA_Strafe`, `IA_StrafeLeft`, `IA_Look`, `IA_Jump`, `IA_Reset`, `IA_Pause`, `IA_ToggleCamera`, `IA_ToggleDebug`, bound in `IMC_Default`.
Player-facing bindings: **WASD** move, **mouse** look, **Space** jump, **R** reset, **Escape** pause, **V** swap first/third person. The camera and body work landed with `0.2.0` (see ADR-9): the shipped default view stays first person, and `-thirdperson` starts the pawn in the third-person rig — used by the screenshot run so the review is reproducible.
### Site
Static meshes and materials in `/Game/Hollywood/Site/`:
| Asset | Role |
| --- | --- |
| `Terrain_DEM` | Ground heightfield (complex-as-simple collision) |
| `Road_Hollywood` | Carriageway surface (block-all collision) |
| `Sidewalk_North`, `Sidewalk_South` | Pedestrian surfaces (block-all collision) |
| `Kerb_North`, `Kerb_South` | Kerb edges |
| `Crossing_Markings` | Zebra markings at signalised crossing |
| `M_Asphalt`, `M_Concrete`, `M_KerbConcrete`, `M_Ground`, `M_RoadPaint`, `M_Stucco`, `M_FacadeStone` | Procedural materials (world-space-noise blend + roughness) |
### Façades
| Asset | Role |
| --- | --- |
| `SM_HeroFacade` | El Capitan Entertainment Centre detailed façade |
| `SM_ElCapitanTheatre`, `SM_Budget_6822`, `SM_Hollywood_6806`, `SM_Hollywood_6902`, `SM_Hollywood_6922`, `SM_LeeDrug_6800` | South block frontage (6 buildings) |
| `SM_Madame_Tussauds`, `SM_TCL_Chinese_Theatre` | North-side buildings (within 50 m) |
| `SM_Retail_LeeDrug`, `SM_Retail_6806` | Retail trim over 6800 and 6806–6820, modelled from their own photograph (`Tools/retail_frontage_build.py`, staged as `RetailTrim_*`). `6822 Budget` stays a bare volume: no photograph exists. |
### Art Pipeline
Python scripts in `Tools/` drive the content pipeline:
| Script group | Purpose |
| --- | --- |
| `export_*`, `hero_facade_build` | Blender headless export (FBX) |
| `ue_import_*`, `ue_hero_facade_import` | Unreal headless import + placement |
| `character_anim_build`, `ue_character_anim_import` | Character animation clips (`GigLine_Idle`, `GigLine_Walk`) and their import |
| `export_*`, `hero_facade_build`, `retail_frontage_build`, `theatre_trim_build`, `facade_kit_build` | Blender headless export (FBX); trim builders work in world coordinates |
| `ue_import_*`, `ue_hero_facade_import`, `ue_import_retail_detail` | Unreal headless import + placement |
| `ue_build_demo_map`, `ue_build_review_map` | Map assembly (lighting, meshes, player start) |
| `ue_create_input_assets` | Enhanced Input actions + mapping context |
| `render_screenshots.sh` | Screenshot capture pipeline |
| `judge_screenshot.py` | Vision-model pre-check (optional) |
| `input_emulation.py`, `input_test.py` | OS-level virtual keyboard/mouse injection for input verification |
| `Tools/_common.py` | Pure helper shared by Blender builders, importable without Blender (bay layout) |
| `Tools/tests/` | Stdlib `unittest` tests for the pure parts of the tools (`requirements-tools.txt`) |
| `Geo/tools/mapbox_buildings.py` | Second height source: Mapbox `mapbox-streets-v8` building data vs our OSM heights |
Source data: `Art/blender/` (`.blend` files), `Geo/` (OSM extracts, DEM, verification docs).
### QA & Review
- `AGigLineShotActor` walks fixed cameras in `-game` mode, captures PNGs, and quits.
- `-qaselftest` switch on `AGigLineCharacter` runs a core-ticker harness: walk 2 s, reset, pause/unpause, ground transect — all PASS/FAIL logged.
- `Screenshots/` holds committed PNGs and `README.md` approval table.
## Level 2
### White Box Gameplay — Input Flow
1. `AGigLineCharacter::SetupPlayerInputComponent` binds `IA_*` actions to `MoveForward`, `MoveBack`, `MoveStrafe`, `MoveLeft`, `Look`, `StartJump`/`StopJump`, `ResetToStart`, `TogglePause`.
2. Movement delegates to `ApplyMove(Forward, Strafe)` → `AddMovementInput`.
3. Look applies yaw/pitch scaled by `LookSensitivity` (Y invertable via `bInvertLookY`).
4. `ResetToStart` teleports to `StartLocation` (captured on first grounded frame), clears velocity, resets control rotation.
5. `TogglePause` toggles `APlayerController::SetPause`, swaps input mode between `FInputModeGameOnly` and `FInputModeUIOnly`, shows/hides mouse cursor.
### White Box Site — Collision Model
- Every site mesh (road, sidewalks, kerbs, crossing) is `STATIC` with `BlockAll` collision.
- `Terrain_DEM` uses `CTF_UseComplexAsSimple` (forced by `UGigLineAssetTools` at build time, saved on the asset).
- The terrain corridor is carved to half-width 10.7 m around the road centreline so the player can walk the carriageway and both sidewalks.
- Ground Z: north sidewalk 395, road 366, south sidewalk 317 (3.94 m fall along the block, matching the DEM).
# Runtime View
## Walk the block
1. Player launches game → `AGigLineGameModeBase` spawns `AGigLineCharacter` at `PlayerStart` `(-13336, -1150, 600)`.
2. Character falls 144 cm to road surface (Z 456) and begins walking. `StartLocation` captured on first grounded frame.
3. Player holds W → `IA_Move` fires → `ApplyMove(1, 0)` → `AddMovementInput(ForwardVector, 1)` → capsule moves at 450 cm/s.
4. Mouse move → `IA_Look` fires → yaw/pitch applied to controller rotation → camera follows (attached to capsule at 160 cm eye height).
5. Player presses R → `ResetToStart` → teleport to `StartLocation`/`StartRotation`, `StopMovementImmediately`, reset control rotation.
6. Player presses Escape → `TogglePause` → `SetPause(true)`, switch to `FInputModeUIOnly`, show cursor. World timers stop; core tickers continue.
7. Player presses Escape again → `TogglePause` → `SetPause(false)`, switch to `FInputModeGameOnly`, hide cursor.
## Screenshot capture
1. `Tools/render_screenshots.sh` compiles editor, builds `ReviewMap` from `Tools/ue_build_review_map.py`, launches game in `-game` mode.
2. `AGigLineShotActor` iterates the camera list, sets view, calls `HighResShot 1280x720`, waits for capture.
3. On last camera, requests engine quit. PNGs are copied to `Screenshots/` and committed.
## QA self-test (`-qaselftest`)
1. Core ticker starts at `BeginPlay` (not a world timer — world timers freeze on pause).
2. 1.0 s: begin driving forward input.
3. 3.0 s: stop, measure displacement (> 200 cm = PASS).
4. 3.5 s: call `ResetToStart`.
5. 4.0 s: measure distance from spawn (< 100 cm = PASS).
6. 4.5 s: call `TogglePause`, check `IsGamePaused()` (= true = PASS).
7. 5.5 s: call `TogglePause`, check `IsGamePaused()` (= false = PASS).
8. 6.0 s: swept ground transect across three lanes (Y −1400, −167, +1000), X −14900 to −9420 at 200 cm spacing. 84 samples, 0 holes = PASS.
9. 7.0 s: final verdict logged.
## Build and import pipeline
1. Blender headless (`Tools/export_*.py`) reads `.blend` files, applies metre→centimetre conversion, exports FBX with `ue_metric_v1` preset.
2. Unreal headless (`Tools/ue_import_*.py`) imports FBX via Interchange, places meshes at geographic coordinates, sets collision profiles.
3. `Tools/ue_build_demo_map.py` assembles `DemoMap`: creates/repositions actors, sets lighting, saves map and assets.
# Deployment View
## Infrastructure Level 1 — Development Workstation
| Node | Specification |
| --- | --- |
| Dev workstation | Bazzite Linux 45 (Fedora 45 base, immutable ostree), Ryzen 7 7700X, 64 GB RAM, RTX 5070 Ti 16 GB, Vulkan RHI |
| Engine install | `/run/media/system/DATA/Engines/UE_5.8.4` (73 GiB, external to repo) |
| Project repo | `/run/media/system/DATA/Documents/06-games/gig-line` (Git + LFS, remote at `git.allucanget.biz`) |
| Build output | `/run/media/system/DATA/Builds/GigLineQA/Linux` (packaged Development build, ~1.1 GiB; outside the repository) |
Motivation
Single-machine development. No build farm, no CI pipeline yet. The engine, toolchain, and GPU are all on the same host.
Quality and Performance Features
- Vulkan RHI on RTX 5070 Ti, open kernel module driver 615.78.08.
- System clang 23.1.4 (no bundled engine clang) compiles game code.
- Build time: ~90 s incremental, ~5 min clean for the editor target.
Mapping of Building Blocks to Infrastructure
| Building Block | Infrastructure Element |
| --- | --- |
| Gameplay (C++) | Dev workstation — compiled to `libUnrealEditor-GigLine.so`, loaded by engine |
| Site + Façades (UE content) | Dev workstation — `.uasset`/`.umap` in `Game/GigLine/Content/` |
| Art Pipeline (Python/Blender) | Dev workstation — headless Blender 5.2 Flatpak, Python 3.14.8 |
| QA & Review | Dev workstation — game run in `-game` mode, PNGs to `Screenshots/` |
| Git remote | `git.allucanget.biz` — Gitea instance, LFS-enabled |
## Infrastructure Level 2 — Packaged Build
| Element | Detail |
| --- | --- |
| Linux packaged build | Development config, ~1.1 GiB. Contains engine binaries, game module, cooked content, no editor. |
| Run command | `./GigLine.sh -windowed -ResX=2560 -ResY=1440` |
No server infrastructure. The game is a standalone executable.
# Cross-cutting Concepts
## Coordinate system and scale
- **Source CRS**: EPSG:26911 (UTM zone 11N). Local origin recorded in `Geo/`.
- **Blender**: metres. East → +X, north → +Y (Blender convention).
- **Unreal**: centimetres. East → +X, north → **−Y** (confirmed from street mesh: `Sidewalk_North` Y −1912..−1053, `Sidewalk_South` Y +718..+1447).
- **Conversion**: every exporter calls the metre→centimetre function. Import presets (`ue_metric_v1`, `ue_character_v1`) are named and repeatable.
Rule: place a building only against a neighbour or street mesh, never in isolation.
## Collision model
- Site meshes (road, sidewalks, kerbs, crossing): `STATIC` mobility, `BlockAll` collision profile.
- Terrain: `STATIC`, `CTF_UseComplexAsSimple` (forced by `UGigLineAssetTools` at build time, saved on the asset — the flag lives on the `UStaticMesh`/`UBodySetup`, not the map).
- Buildings: `STATIC`, simple collision (convex hull from mesh).
- Rule: the collision flag must be saved on the asset. Saving only the `.umap` silently reverts it.
## Lighting
- Dynamic-only: `r.AllowStaticLighting=False`. All lights are `MOVABLE`.
- Fixed daytime rig: directional light (sun, −62° pitch), sky light, exponential height fog, post-process volume with fixed exposure.
- Rationale: avoids "LIGHTING NEEDS TO BE REBUILT" warnings; no bake step; the scene never changes time of day.
## Materials
- All materials authored in Unreal, not carried from Blender (FBX import loses diffuse colours).
- World-space-noise blend nodes for cheap procedural variation (asphalt grain, concrete speckle, stucco roughness).
- Instance parameters where tunable (e.g. wetness, ageing).
## Geographic data provenance
- OSM extract is layout data only — not authoritative for façade, height, or pavement detail.
- DEM from 3DEP 1 m; elevation verified against cross-sections in `Geo/verification/`.
- Reference photography (local, gitignored) drives manual façade work.
## QA harness
- `-qaselftest`: core ticker (not world timer) so pause cannot freeze the test that proves pause works.
- `GigLine SIM` / `GigLine GROUND`: TEMP diagnostic logging, to be removed before release tag.
- Screenshot approval: human must tick `Screenshots/README.md` before visual work is marked done.
# Architecture Decisions
## ADR-1: Dynamic-only lighting for the demo
- **Context**: The demo is a fixed daytime scene. Stationary lights demand a lighting build and produce "LIGHTING NEEDS TO BE REBUILT" warnings.
- **Decision**: `r.AllowStaticLighting=False`, all lights `MOVABLE`.
- **Consequences**: No bake step, no warning, simpler iteration. Slightly higher GPU cost per frame (no baked shadows) — acceptable for the demo scope.
## ADR-2: Materials authored in Unreal, not imported from Blender
- **Context**: FBX→Unreal import maps all Blender diffuse colours to white. Flat colour materials do not survive the pipeline.
- **Decision**: Build all materials (asphalt, concrete, stucco, façade stone, road paint) directly in Unreal with procedural nodes.
- **Consequences**: Materials cannot be previewed in Blender look-dev. Must round-trip to Unreal for visual check. But: full PBR control, world-space noise, no import surprise.
## ADR-3: Complex-as-simple collision on terrain via C++ helper
- **Context**: UE 5.8 removed `bUseComplexAsSimpleCollision`. Python cannot set `CollisionTraceFlag` on `StaticMesh`/`BodySetup` at all. The Interchange default is bbox-only simple collision, which placed an invisible lid 8 m above the street.
- **Decision**: `UGigLineAssetTools::SetComplexCollisionAsSimple` (C++) forces `CTF_UseComplexAsSimple` and saves the asset.
- **Consequences**: One C++ helper class. The flag must be saved on the asset (not just the map). No Python workaround exists.
## ADR-4: Reset captures StartLocation on first grounded frame
- **Context**: `BeginPlay` captures `StartLocation` at the PlayerStart, which is above grade (Z 600) so the capsule does not spawn embedded. Every reset dropped the player 1.4 m back into the air.
- **Decision**: Repeating 0.25 s timer captures `StartLocation` once the character reports `IsMovingOnGround()`, then clears itself.
- **Consequences**: Reset is correct. A one-shot timer at 2 s is wrong (fires after walking begins, records mid-stride position).
## ADR-5: Core ticker for QA self-test, not world timer
- **Context**: Pausing the game stops world timers. A timer-driven test freezes exactly when it proves pause works.
- **Decision**: `FTSTicker::GetCoreTicker()` drives the `-qaselftest` harness.
- **Consequences**: Test runs even while paused. Must be enabled explicitly (`-qaselftest`), never in normal play.
## ADR-6: South = +Y, north = −Y in Unreal
- **Context**: The street mesh established this convention: `Sidewalk_North` has Y range −1912..−1053, `Sidewalk_South` has +718..+1447.
- **Decision**: Adopt it project-wide. Never place a building by assumption; always verify against a street mesh or neighbour.
- **Consequences**: Matches Blender’s north→+Y after the Y-flip in the `ue_metric_v1` export preset.
## ADR-7: Headless Blender via bpy scripts, not MCP
- **Context**: Blender skill packs assume an MCP server; the project does not run one.
- **Decision**: All Blender work driven by Python scripts in `Tools/`. Skill packs used as method guides (checklists, naming, proportions) only.
- **Consequences**: Scripts are version-controlled and repeatable. No live Blender session needed for CI.
## ADR-8: Realistic building façades are a minor milestone, not a patch
- **Context**: `0.1.4` carried the first façade pass as a _patch_, while the release policy says patches contain fixes and refinements and never new scope. The `0.1.6` retail pass then showed what that costs: a façade built in a patch has no release gate of its own, so a first pass that does not yet read as the building (`Screenshots/README.md`, 0.1.6 gaps) can sit inside a "done" patch series. Realism is also the project's leading quality goal, which a patch cannot carry.
- **Decision**: Building realism gets its own minor milestone, `0.9.0 — Realistic buildings`, placed before the feature freeze. Its scope is the façade system (orders, profile library, storefront types, signage as decals), per-building photo-matched models, a measured material library with a texel-density rule, and LODs plus collision proxies. Patches keep fixes and refinements only. Renumbered by this decision: the former `0.9.0` freeze/beta/audit milestone becomes `0.10.0`, its patches `0.10.1`–`0.10.3`, and `1.0.0` moves to weeks 39–44.
- **Consequences**: Façade work now has a release gate — every building measured against its own photograph, every building with a collision proxy and a LOD, no placeholder materials — so "modelled" means verified, not merely present. The price is that realism work stops being sliced into patches, and the schedule shifts by one minor cycle. Blockouts without a photograph stay blockouts and are listed as such, rather than being dressed up to fill the milestone.
## ADR-9: Third-person body is an attachment of the first-person pawn, not a second pawn
- **Context**: `0.2.0` needs a visible body and a third-person camera, while `0.1.0` shipped a first-person pawn with no body and the owner's decision was first person for that release. Two separate pawn classes would duplicate movement, input and reset logic, which is the code most likely to drift.
- **Decision**: one pawn, both rigs. `AGigLineCharacter` carries a skeletal mesh (hidden in first person), a collision-tested spring-arm camera, and a **V** toggle that swaps the active camera and the body's visibility. The shipped default view stays first person; `-thirdperson` starts the pawn in the third-person rig and is what the screenshot run passes, so the review shot is reproducible without a human pressing a key.
- **Consequences**: movement, reset, pause and the QA self-test stay in one place. The body's animation state is driven from `Tick` by speed rather than from input, so being pushed or falling animates correctly. The cost is that the camera swap is not a separate actor, so a future cinematic camera needs its own path rather than reusing the toggle.
# Quality Requirements
## Quality Requirements Overview
Ordered by priority: realism first, performance last. The order decides design trade-offs; it does not license dropping a lower item silently (see the trade-off rule below the table).
| Priority | Category | Requirement |
| --- | --- | --- |
| 1 | Realism / Fidelity | Façade identity, proportion, material and lighting read like the reference photographs when compared side by side |
| 2 | Realism / Fidelity | Building placement matches the measured footprints and the photographs (within ~1 m) |
| 2 | Realism / Fidelity | Street scale (lane width, sidewalk width) within ~10 cm of the measured profile |
| 3 | Reliability / Terrain | Zero raycast holes across sidewalks and carriageway (84-sample transect) |
| 3 | Usability | WASD + mouse look, jump, reset (R), pause (Escape) — no tutorial needed |
| 4 | Maintainability | Fresh checkout builds with the documented engine installation |
| 4 | Portability | Linux build packages and runs; Windows build ships |
| 5 | Provenance | Every committed asset has a recorded source and licence |
| 6 | Performance efficiency | 60 FPS at 2560×1440, documented preset, no frame generation |
**Trade-off rule.** Realism leads: when a realism improvement costs frame time, the improvement wins. Performance is still a **floor, not a preference** — 60 FPS at 1440p without frame generation must be met at every release. If realism cannot fit inside that budget, reduce the realism scope deliberately and record the reduction in `CHANGELOG.md`; never ship below the floor without the owner's explicit approval. Walkability outranks realism in one direction: a block the player falls through is not a demo, whatever it looks like.
## Quality Scenarios
| ID | Source/Stimulus | Response | Metric |
| --- | --- | --- | --- |
| QS-1 | Screenshot is compared against its reference photograph | The render reads as the same building | Similarity score and visual match confirmed by the owner; `Tools/visual_loop.py` reports the deltas |
| QS-2 | A façade is placed from measured data | It sits on its verified footprint | World span probed against `Geo/verification/` within ~1 m |
| QS-3 | Player walks both sides of the demo | No fall-through, character stays at ground Z | 0 raycast holes in 84-sample transect |
| QS-4 | Player holds W for 2 s on flat road | Character moves forward | ≥ 200 cm displacement, Z constant within ±5 cm |
| QS-5 | Player presses R | Character teleports to spawn | < 100 cm from `StartLocation` |
| QS-6 | Player presses Escape | Game pauses, cursor appears | `IsGamePaused()` = true |
| QS-7 | Player presses Escape while paused | Game unpauses, cursor hides | `IsGamePaused()` = false |
| QS-8 | Fresh checkout + engine install | Build succeeds | Exit code 0, runnable binary produced |
| QS-9 | Game runs on dev workstation at 1440p | Frame rate holds the floor | ≥ 60 FPS, 99th percentile ≥ 55 FPS |
> QS-9 is last on purpose. Performance is a floor that every release must meet, not the goal that shapes the content: realism decides what gets built, the frame budget decides how much of it.
### Building realism gate (0.9.0)
| ID | Check | Metric |
| --- | --- | --- |
| BR-1 | A building with a reference photograph has a review shot compared against it | Silhouette and proportion deltas recorded per building in `Screenshots/README.md`; owner approval per shot |
| BR-2 | Every building is either photo-matched or explicitly recorded as an unfounded blockout | List in `TODO.md`; no building in an in-between state |
| BR-3 | Every building has a collision proxy and at least one LOD | Count of buildings missing either = 0 |
| BR-4 | No surface carries a placeholder material | Material audit in `Tools/ue_audit_scene.py` reports 0 placeholder slots |
| BR-5 | The realism pass does not break the floor | QS-9 still holds at 1440p, with the measured cost of the pass recorded |
# Risks and Technical Debts
| ID | Risk / Debt | Priority | Mitigation |
| --- | --- | --- | --- |
| R-1 | Terrain bbox proxy misdiagnosed as fall-through — already fixed, but the root-cause path (Interchange defaults to simple collision) could recur on reimport | High | `UGigLineAssetTools` runs in the build script; always reassert complex-as-simple after any reimport. Documented in `MEMORY.md` and ADR-3. |
| R-2 | Blosm 3Dsimple on Blender 5.2 — worked around, not resolved; `3Drealistic` mode untested | Medium | Pin Blosm 2.7.28 and Blender 5.2 LTS; test realistic mode before depending on it. |
| R-3 | Kerb height is an assumption (DEM does not resolve it) | Medium | Record the assumption in `Geo/verification/`; measure from reference photography if possible; mark as unresolved until verified. |
| R-5 | `GigLine SIM` / `GigLine GROUND` TEMP diagnostic logging still in `GigLineCharacter.cpp` | Medium | Strip behind a cvar or remove entirely before the `v0.1.0` release tag. |
| TD-1 | Materials are Unreal-only — no Blender preview round-trip | Low | Accepted (ADR-2). Look-dev happens in UE; screenshot review catches issues. |
| TD-2 | No CI pipeline — all builds and QA are manual on the dev workstation | Low | Acceptable for `0.x`. CI is a `0.3.0+` concern. |
# Glossary
| Term | Definition |
| --- | --- |
| Blosm | Blender add-on (2.7.28) that imports OSM data as 3D building and street blockouts. Used for layout only — not authoritative for façade or height. |
| PIE | Play In Editor — an Unreal Editor feature that runs the current map as a live game session inside the editor window, with full game code, input, and physics, without packaging a build. Used for interactive verification (walk, reset, pause) before the scripted QA self-test. |
| Complex-as-simple collision | `CTF_UseComplexAsSimple`: Unreal collision mode where the render mesh triangles are used as the collision surface, instead of a simplified proxy. Required for terrain so the player walks on the actual ground shape. |
| DEM | Digital Elevation Model. 3DEP 1 m resolution GeoTIFF covering the demo bounds. Source of terrain heightfield. |
| El Capitan | El Capitan Entertainment Centre, 6838 Hollywood Blvd. The hero building of the demo. |
| Enhanced Input | Unreal’s modern input system (UE 5+). Uses Input Actions (`IA_*`) and Input Mapping Contexts (`IMC_*`) instead of legacy axis/action bindings. |
| EPSG:26911 | UTM zone 11N, the projected CRS used for the demo area. Local origin recorded in `Geo/`. |
| Hero façade | The detailed, reference-calibrated model of the El Capitan building frontage. |
| MPFB | MakeHuman Plugin for Blender (2.0.18). Generates parametric human base meshes with rigs and baked materials. |
| PlayerStart | Unreal actor that defines where the player pawn spawns. Positioned above grade so the capsule does not embed in the floor on spawn. |
| QA self-test | The `-qaselftest` command-line switch that runs an automated behaviour check (walk, reset, pause, ground transect) via a core ticker. |
| Site | The terrain, road, sidewalks, kerbs, and crossing markings — the ground-level infrastructure of the demo area. |
| Squash merge | Git merge strategy that compresses all branch commits into one commit on `main`, keeping a linear history. |
| Swept transect | A QA technique that raycasts vertically at regular intervals along a path (e.g. every 200 cm along both sidewalks and the carriageway) and counts holes (misses). Zero holes = the block is fully walkable. |
| UE | Unreal Engine — Epic Games' real-time 3D creation platform (version 5.8.4 in this project). Comprises the Unreal Editor (including PIE, content browser, viewport, blueprint editor), a C++ game framework, and runtime systems for rendering (Vulkan RHI on Linux), physics (Chaos), and input (Enhanced Input). The game project is `GigLine.uproject`; game code is a single C++ module in `Game/GigLine/Source/GigLine/`. |
| World Partition | Unreal's world composition system for large open-world levels. Not yet used in `0.1.0` but planned for `0.3.0+` driving routes. |
| Y convention | In Unreal, south = +Y, north = −Y (confirmed from street mesh orientation). In Blender, north = +Y; the `ue_metric_v1` export preset flips Y. |
---
**About arc42**
<div data-wrapper="1" role="lead">
arc42, the template for documentation of software and system architecture.
</div>
Template Version 0.1.0. initial architecture documentation, 2026-11-08
Created, maintained and © by Dr. Peter Hruschka, Dr. Gernot Starke and contributors. See <https://arc42.org>.
Adapted for the Gig Line project (Linux-first walking/driving demo around 6840 Hollywood Blvd).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,5 +14,19 @@ public class GigLine : ModuleRules
"InputCore",
"EnhancedInput"
});
// Editor-only: the vehicle physics asset is generated from the skeletal mesh with
// FPhysicsAssetUtils, which lives in the Developer/PhysicsUtilities module. Chaos
// Vehicles also need the physics vehicle module at runtime.
if (Target.bBuildEditor)
{
PrivateDependencyModuleNames.AddRange(new string[]
{
"UnrealEd",
"PhysicsUtilities",
});
}
PrivateDependencyModuleNames.Add("ChaosVehicles");
}
}
@@ -340,6 +340,11 @@ void AGigLineCharacter::BeginPlay()
}
}
// Foot-contact diagnostic, one line per channel: the player stands at 561 while the
// Visibility trace reports ground at 366, so at least one channel is seeing a different
// surface than the one under foot. This names the actor and the Z for each channel.
// Foot-contact probe runs from Tick three seconds after spawn — see LogFootProbes().
bWalkForever = FParse::Param(FCommandLine::Get(), TEXT("walk"));
bCameraTest = FParse::Param(FCommandLine::Get(), TEXT("cameratest"));
@@ -365,6 +370,32 @@ FVector AGigLineCharacter::GetThirdPersonCameraLocation() const
return ThirdPersonCamera ? ThirdPersonCamera->GetComponentLocation() : GetActorLocation();
}
void AGigLineCharacter::LogFootProbes()
{
UWorld* World = GetWorld();
if (!World)
{
return;
}
static const TCHAR* Names[] = {TEXT("Visibility"), TEXT("WorldStatic"), TEXT("Pawn")};
static const ECollisionChannel Channels[] = {ECC_Visibility, ECC_WorldStatic, ECC_Pawn};
const float CapsuleHalf = GetCapsuleComponent()
? GetCapsuleComponent()->GetScaledCapsuleHalfHeight() : 0.f;
const FVector Feet = GetActorLocation() - FVector(0.f, 0.f, CapsuleHalf);
for (int32 Index = 0; Index < 3; ++Index)
{
FHitResult Hit;
FCollisionQueryParams Q(TEXT("GigLineFoot"), false, this);
const bool bHit = World->LineTraceSingleByChannel(Hit, Feet + FVector(0.f, 0.f, 5.f),
Feet - FVector(0.f, 0.f, 1500.f), Channels[Index], Q);
UE_LOG(LogTemp, Warning,
TEXT("GigLine foot %s after settling: actor Z %.0f, feet %.0f, hit=%d Z=%.0f actor=%s"),
Names[Index], GetActorLocation().Z, Feet.Z, bHit ? 1 : 0,
bHit ? Hit.ImpactPoint.Z : -9999.f,
bHit && Hit.GetActor() ? *Hit.GetActor()->GetName() : TEXT("none"));
}
}
void AGigLineCharacter::UpdateCameraCollisionTest(float DeltaSeconds)
{
// Deterministic on purpose. Finding a wall in the street wasted four runs: the
@@ -962,6 +993,15 @@ void AGigLineCharacter::Tick(float DeltaSeconds)
UpdateCameraCollisionTest(GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.f);
}
if (FootProbeDelay > 0.f)
{
FootProbeDelay -= GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.f;
if (FootProbeDelay <= 0.f)
{
LogFootProbes();
}
}
WalkForever();
UpdateBodyAnimation();
@@ -210,6 +210,10 @@ protected:
UPROPERTY()
TObjectPtr<class AStaticMeshActor> CameraTestWall;
/** Seconds until the foot-contact probe runs; 0 disables it. */
float FootProbeDelay = 0.f;
void LogFootProbes();
FVector LastWallNormal = FVector::ZeroVector;
bool bCameraTest = false;
bool CameraTestPlaced = false;
+1 -13
View File
@@ -57,26 +57,14 @@ void AGigLineHUD::DrawHUD()
}
UpdateFrameStats(GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.0f);
if (!Canvas || AttributionLine.IsEmpty())
if (!Canvas)
{
return;
}
// Bottom-left, above any letterbox: the standard place for map/data credits.
const float LineHeight = Canvas->ClipY > 0 ? 22.0f * TextScale : 22.0f * TextScale;
const float X = MarginPx;
const float Y = Canvas->ClipY - MarginPx - LineHeight * 2.1f;
const FLinearColor Shadow(0.0f, 0.0f, 0.0f, 0.55f);
const FLinearColor Ink(1.0f, 1.0f, 1.0f, 0.92f);
// Shadow pass for legibility over bright pavement and sky.
DrawText(AttributionLine, Shadow, X + 1.0f, Y + 1.0f, nullptr, TextScale);
DrawText(LicenceLine, Shadow, X + 1.0f, Y + LineHeight + 1.0f, nullptr, TextScale * 0.82f);
DrawText(AttributionLine, Ink, X, Y, nullptr, TextScale);
DrawText(LicenceLine, Ink, X, Y + LineHeight, nullptr, TextScale * 0.82f);
// Control legend, bottom-right (0.1.0 release check: controls shown in-game).
if (!ControlsLine.IsEmpty())
{
+6 -18
View File
@@ -5,16 +5,12 @@
#include "GigLineHUD.generated.h"
/**
* Always-on geographic attribution for the shipped demo.
*
* The demo is built from OpenStreetMap data, which is ODbL 1.0 licensed and
* requires attribution wherever a derived database or produced work is shown.
* This HUD draws the required credit in the bottom-left corner of the screen so
* it is present in the game, not only in the repository documents.
* On-screen text overlay: control legend (bottom-right) and the optional
* performance/state readout (top-left).
*
* Plain AHUD::DrawText is used deliberately: no UMG widget asset is needed, so
* the attribution cannot be accidentally dropped from a cook by a missing
* widget reference.
* the overlay cannot be accidentally dropped from a cook by a missing widget
* reference.
*/
UCLASS()
class GIGLINE_API AGigLineHUD : public AHUD
@@ -24,10 +20,6 @@ class GIGLINE_API AGigLineHUD : public AHUD
public:
virtual void DrawHUD() override;
/** Main credit line. Must match the ODbL attribution requirement. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution")
FString AttributionLine = TEXT("\u00A9 OpenStreetMap contributors");
/**
* Control legend, shown bottom-right so the 0.1.0 demo documents its own
* controls in-game (release check: "controls documented in Docs/ and shown
@@ -36,10 +28,6 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Controls")
FString ControlsLine = TEXT("WASD move \u00B7 Mouse look \u00B7 Space jump \u00B7 V view \u00B7 R reset \u00B7 Esc pause");
/** Licence line, including where the licence can be read. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution")
FString LicenceLine = TEXT("Data \u00A9 OpenStreetMap contributors, ODbL 1.0 \u2014 openstreetmap.org/copyright");
/**
* Performance and state readout, top-left, off by default.
*
@@ -54,11 +42,11 @@ public:
void UpdateFrameStats(float DeltaSeconds);
/** Screen-space margin in pixels. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution")
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HUD")
float MarginPx = 18.0f;
/** Text scale relative to the default canvas font. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution")
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HUD")
float TextScale = 1.45f;
private:
@@ -0,0 +1,312 @@
#include "GigLineVehicle.h"
#include "ChaosVehicleWheel.h"
#include "ChaosWheeledVehicleMovementComponent.h"
#include "Components/SkeletalMeshComponent.h"
// From the Blender report (Art/blender/vehicle/report.json). Kept as named constants so a
// model change is a compile-time mismatch to fix, not a number buried in a setup call.
namespace GigLineVehicleDims
{
constexpr float WheelRadiusCm = 33.0f;
constexpr float WheelBaseCm = 278.0f;
constexpr float TrackCm = 156.0f;
}
UGigLineFrontWheel::UGigLineFrontWheel()
{
// Dimensions from the model's report: radius 33 cm, width 22 cm. Steering yes, drive no.
WheelRadius = 33.0f;
WheelWidth = 22.0f;
bAffectedBySteering = true;
bAffectedByEngine = false;
MaxSteerAngle = 35.0f;
SuspensionMaxRaise = 8.0f;
SuspensionMaxDrop = 8.0f;
}
UGigLineRearWheel::UGigLineRearWheel()
{
WheelRadius = 33.0f;
WheelWidth = 22.0f;
bAffectedBySteering = false;
bAffectedByEngine = true;
SuspensionMaxRaise = 8.0f;
SuspensionMaxDrop = 8.0f;
}
AGigLineVehicle::AGigLineVehicle()
{
PrimaryActorTick.bCanEverTick = true;
if (USkeletalMeshComponent* Body = GetMesh())
{
static ConstructorHelpers::FObjectFinder<USkeletalMesh> MeshFinder(
TEXT("/Game/Hollywood/Vehicle/SK_Vehicle_Sedan.SK_Vehicle_Sedan"));
if (MeshFinder.Succeeded())
{
Body->SetSkeletalMesh(MeshFinder.Object);
}
Body->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
Body->SetCollisionProfileName(TEXT("PhysicsActor"));
Body->SetSimulatePhysics(true);
}
if (UChaosWheeledVehicleMovementComponent* Movement =
Cast<UChaosWheeledVehicleMovementComponent>(GetVehicleMovementComponent()))
{
ConfigureWheels();
}
}
void AGigLineVehicle::BuildVisuals()
{
// One component per authored system: body, glass, front and rear lights, mirrors and
// the four wheels. Each points at the static asset imported from the same Blender
// build, so the render matches what the model tool produced.
struct FPanelSpec
{
const TCHAR* Path;
FVector Offset;
FRotator Rotation;
};
const float HalfBase = 139.0f; // wheelbase 278 cm / 2
const float HalfTrack = 81.0f; // track 162 cm / 2
const FPanelSpec Specs[] = {
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Body.SM_Vehicle_Body"), FVector::ZeroVector, FRotator::ZeroRotator},
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Glass.SM_Vehicle_Glass"), FVector::ZeroVector, FRotator::ZeroRotator},
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Light_Front.SM_Vehicle_Light_Front"), FVector::ZeroVector, FRotator::ZeroRotator},
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Light_Rear.SM_Vehicle_Light_Rear"), FVector::ZeroVector, FRotator::ZeroRotator},
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Mirrors.SM_Vehicle_Mirrors"), FVector::ZeroVector, FRotator::ZeroRotator},
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Wheel.SM_Vehicle_Wheel"), FVector(HalfBase, HalfTrack, -16.f), FRotator::ZeroRotator},
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Wheel.SM_Vehicle_Wheel"), FVector(HalfBase, -HalfTrack, -16.f), FRotator::ZeroRotator},
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Wheel.SM_Vehicle_Wheel"), FVector(-HalfBase, HalfTrack, -16.f), FRotator::ZeroRotator},
{TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Wheel.SM_Vehicle_Wheel"), FVector(-HalfBase, -HalfTrack, -16.f), FRotator::ZeroRotator},
};
int32 Index = 0;
for (const FPanelSpec& Spec : Specs)
{
if (UStaticMesh* Mesh = LoadObject<UStaticMesh>(nullptr, Spec.Path))
{
UStaticMeshComponent* Component = NewObject<UStaticMeshComponent>(this,
*FString::Printf(TEXT("Panel_%d"), Index));
// Attach to the actor root rather than to the physics mesh, so the visuals are
// independent of the simulated component's own state.
Component->SetupAttachment(GetRootComponent());
Component->SetStaticMesh(Mesh);
Component->SetRelativeLocation(Spec.Offset);
Component->SetRelativeRotation(Spec.Rotation);
Component->SetCollisionEnabled(ECollisionEnabled::NoCollision);
Component->SetMobility(EComponentMobility::Movable);
Component->RegisterComponent();
// Runtime-created components need their render state forced: without this the
// panels registered and reported visible but never reached the renderer.
Component->MarkRenderStateDirty();
Component->UpdateBounds();
Panels.Add(Component);
++Index;
}
}
UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: %d visual panels attached"), Panels.Num());
for (int32 PanelIndex = 0; PanelIndex < Panels.Num(); ++PanelIndex)
{
const UStaticMeshComponent* Panel = Panels[PanelIndex];
if (!Panel)
{
continue;
}
const FVector Where = Panel->GetComponentLocation();
const FBoxSphereBounds B = Panel->Bounds;
UE_LOG(LogTemp, Warning,
TEXT("GigLine vehicle: panel %d at (%.0f,%.0f,%.0f) visible=%d extent=%.0f mesh=%s"),
PanelIndex, Where.X, Where.Y, Where.Z,
Panel->IsVisible() ? 1 : 0, B.BoxExtent.GetMax(),
Panel->GetStaticMesh() ? *Panel->GetStaticMesh()->GetName() : TEXT("NONE"));
}
}
void AGigLineVehicle::ConfigureWheels()
{
UChaosWheeledVehicleMovementComponent* Movement =
Cast<UChaosWheeledVehicleMovementComponent>(GetVehicleMovementComponent());
if (!Movement)
{
return;
}
// One entry per wheel bone, in the order the rig was built. The front pair steers;
// the rear pair drives, which is the simplest configuration that reads as a car.
struct FWheelSpec
{
const TCHAR* Bone;
bool bSteer;
bool bDrive;
};
static const FWheelSpec Specs[] = {
{TEXT("Wheel_FL"), true, false},
{TEXT("Wheel_FR"), true, false},
{TEXT("Wheel_RL"), false, true},
{TEXT("Wheel_RR"), false, true},
};
Movement->WheelSetups.Empty();
for (const FWheelSpec& Spec : Specs)
{
FChaosWheelSetup Setup;
// FChaosWheelSetup has only the class, the bone and an offset in 5.8 - there is no
// per-setup disable flag, and steering/drive flags live on the wheel class.
Setup.WheelClass = Spec.bSteer ? UGigLineFrontWheel::StaticClass()
: UGigLineRearWheel::StaticClass();
Setup.BoneName = FName(Spec.Bone);
Movement->WheelSetups.Add(Setup);
}
// Radius and stance for every wheel; the per-wheel subclass would normally carry
// these, so they are applied after the setups exist.
for (int32 Index = 0; Index < Movement->WheelSetups.Num(); ++Index)
{
UChaosVehicleWheel* Wheel = Movement->Wheels.IsValidIndex(Index)
? Movement->Wheels[Index] : nullptr;
if (!Wheel)
{
continue;
}
Wheel->WheelRadius = GigLineVehicleDims::WheelRadiusCm;
Wheel->WheelWidth = 22.0f;
Wheel->bAffectedBySteering = Specs[Index].bSteer;
Wheel->bAffectedByEngine = Specs[Index].bDrive;
}
}
void AGigLineVehicle::BeginPlay()
{
Super::BeginPlay();
// The skeletal mesh is the physics driver only: its imported geometry is unusable
// (1 cm bounds), so it is hidden and the visible car comes from the static panels.
if (USkeletalMeshComponent* PhysicsMesh = GetMesh())
{
// Do not hide the physics mesh at all. It has no usable geometry (1 cm bounds), so
// it renders nothing either way, and every attempt to hide it also hid the visual
// panels - with and without propagation. Leaving it visible avoids the fight.
PhysicsMesh->SetCastShadow(false);
}
BuildVisuals();
// Where is the road, really? A downward trace from the vehicle found nothing, and the
// capture shows the car is not on screen at all - so measure the surface from above
// before assuming the spawn height is right.
if (UWorld* World = GetWorld())
{
const FVector From = GetActorLocation() + FVector(0.f, 0.f, 1000.f);
FHitResult Hit;
FCollisionQueryParams Q(TEXT("GigLineVehicleRoad"), false, this);
const bool bHit = World->LineTraceSingleByChannel(Hit, From,
From - FVector(0.f, 0.f, 4000.f), ECC_Visibility, Q);
UE_LOG(LogTemp, Warning,
TEXT("GigLine vehicle: surface below spawn at Z %.0f cm (hit=%d, actor at %.0f)"),
bHit ? Hit.ImpactPoint.Z : -9999.f, bHit ? 1 : 0, GetActorLocation().Z);
}
// Wheel objects do not exist until the component is registered, so the per-wheel
// flags set in the constructor went nowhere - which is why the drive test measured
// zero speed with throttle held. Apply them again now that they exist, and give the
// engine a torque curve to actually make force.
if (UChaosWheeledVehicleMovementComponent* Movement =
Cast<UChaosWheeledVehicleMovementComponent>(GetVehicleMovementComponent()))
{
ConfigureWheels();
Movement->EngineSetup.MaxTorque = 500.0f;
Movement->EngineSetup.MaxRPM = 6000.0f;
Movement->EngineSetup.EngineIdleRPM = 900.0f;
Movement->TransmissionSetup.bUseAutomaticGears = true;
Movement->TransmissionSetup.FinalRatio = 3.5f;
Movement->SetThrottleInput(0.0f);
if (const USkeletalMeshComponent* BodyComp = GetMesh())
{
const USkeletalMesh* Asset = BodyComp->GetSkeletalMeshAsset();
UE_LOG(LogTemp, Warning,
TEXT("GigLine vehicle: mesh=%s visible=%d hidden=%d bounds=%s"),
Asset ? *Asset->GetName() : TEXT("NONE"),
BodyComp->IsVisible() ? 1 : 0,
BodyComp->bHiddenInGame ? 1 : 0,
*BodyComp->Bounds.BoxExtent.ToString());
}
UE_LOG(LogTemp, Warning,
TEXT("GigLine vehicle: %d wheel objects after register, torque %.0f"),
Movement->Wheels.Num(), Movement->EngineSetup.MaxTorque);
}
UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: spawned at (%.0f, %.0f, %.0f), %d wheel setups"),
GetActorLocation().X, GetActorLocation().Y, GetActorLocation().Z,
GetVehicleMovementComponent()
? Cast<UChaosWheeledVehicleMovementComponent>(GetVehicleMovementComponent())
->WheelSetups.Num()
: 0);
}
void AGigLineVehicle::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
// One settled-state report: proves the physics body exists, is simulating, and has
// come to rest on the road rather than falling through it. Everything else about the
// driving behaviour is measured later; this is the "is it real" check.
// `-drivetest`: hold full throttle from 1 s to 4 s and report the result. A vehicle
// that spawns is not a vehicle that drives.
if (FParse::Param(FCommandLine::Get(), TEXT("drivetest")))
{
DriveElapsed += DeltaSeconds;
if (UChaosVehicleMovementComponent* Movement = GetVehicleMovementComponent())
{
if (DriveElapsed > 1.0f && DriveElapsed < 4.0f)
{
Movement->SetThrottleInput(1.0f);
}
else if (DriveElapsed >= 4.0f && !bDriveReported)
{
Movement->SetThrottleInput(0.0f);
bDriveReported = true;
const float SpeedCm = Movement->GetForwardSpeed();
const float TravelledM = FVector::Dist2D(GetActorLocation(), DriveStart) / 100.f;
UE_LOG(LogTemp, Warning,
TEXT("GigLine vehicle DRIVETEST speed=%.0f cm/s (%.1f km/h), travelled %.1f m -> %s"),
SpeedCm, SpeedCm * 0.036f, TravelledM,
(SpeedCm > 200.f && TravelledM > 1.0f) ? TEXT("PASS") : TEXT("FAIL"));
}
}
}
if (DriveStart.IsZero() && DriveElapsed > 1.0f)
{
DriveStart = GetActorLocation();
}
SettleElapsed += DeltaSeconds;
if (!bSettleReported && SettleElapsed > 3.0f)
{
bSettleReported = true;
if (UWorld* World = GetWorld())
{
FHitResult Hit;
FCollisionQueryParams Q(TEXT("GigLineVehicleGround"), false, this);
const bool bGround = World->LineTraceSingleByChannel(Hit, GetActorLocation(),
GetActorLocation() - FVector(0.f, 0.f, 2000.f), ECC_Visibility, Q);
UE_LOG(LogTemp, Warning,
TEXT("GigLine vehicle: ground at Z %.0f cm, actor %.0f cm, height above ground %.0f cm"),
bGround ? Hit.ImpactPoint.Z : -9999.f, GetActorLocation().Z,
bGround ? GetActorLocation().Z - Hit.ImpactPoint.Z : -9999.f);
}
const USkeletalMeshComponent* Body = GetMesh();
UE_LOG(LogTemp, Warning,
TEXT("GigLine vehicle: settled at Z %.0f cm, simulating=%d, speed=%.0f cm/s"),
GetActorLocation().Z,
Body && Body->IsSimulatingPhysics() ? 1 : 0,
GetVehicleMovementComponent() ? GetVehicleMovementComponent()->GetForwardSpeed() : 0.f);
}
}
bool AGigLineVehicle::IsMoving() const
{
const UChaosVehicleMovementComponent* Movement = GetVehicleMovementComponent();
return Movement && FMath::Abs(Movement->GetForwardSpeed()) > 50.0f;
}
@@ -0,0 +1,86 @@
#pragma once
#include "CoreMinimal.h"
#include "ChaosVehicleWheel.h"
#include "WheeledVehiclePawn.h"
#include "GigLineVehicle.generated.h"
class UChaosWheeledVehicleMovementComponent;
/**
* Front and rear wheel classes.
*
* Chaos reads steering and drive flags from the wheel *object* the setup points at, and the
* base `UChaosVehicleWheel` is what the first attempt used - which produced zero wheels
* after registration. Giving each axle its own class is also what Unreal's own vehicle
* template does, for the same reason: the parameters belong to the class, not to the
* instance that arrives later.
*/
UCLASS()
class GIGLINE_API UGigLineFrontWheel : public UChaosVehicleWheel
{
GENERATED_BODY()
public:
UGigLineFrontWheel();
};
UCLASS()
class GIGLINE_API UGigLineRearWheel : public UChaosVehicleWheel
{
GENERATED_BODY()
public:
UGigLineRearWheel();
};
/**
* The one drivable vehicle in the demo.
*
* A skeletal mesh with a root bone and four wheel bones, driven by Chaos. Every
* dimension it configures — wheel radius, wheelbase, track — comes from the Blender
* build's report rather than being typed twice: the numbers are repeated here as
* constants with the report path in the comment so a change to the model shows up as a
* mismatch instead of a silent drift.
*
* Model: `Art/blender/vehicle/gigline-sedan.blend`, report `Art/blender/vehicle/report.json`
* (4.72 x 1.80 x 1.44 m, wheelbase 2.78 m, track 1.62 m, wheel radius 0.33 m).
*/
UCLASS()
class GIGLINE_API AGigLineVehicle : public AWheeledVehiclePawn
{
GENERATED_BODY()
public:
AGigLineVehicle();
virtual void BeginPlay() override;
virtual void Tick(float DeltaSeconds) override;
/**
* Visual panels, as static mesh components.
*
* The skeletal vehicle asset imported with no usable geometry (runtime bounds of
* 1 cm), so the car was invisible in the scene while its physics body still cast a
* shadow - the "no vehicle in scene" report. The static assets are correct (body
* 476 x 176 x 128 cm), so the visible car is assembled from them and the skeletal
* mesh stays as the physics/animation driver only.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Visual")
TArray<TObjectPtr<UStaticMeshComponent>> Panels;
/** True once the vehicle reports a forward speed above a walking pace. */
bool IsMoving() const;
private:
/** Configure the Chaos wheels from the modelled dimensions. */
void ConfigureWheels();
/** Attach the visible panels from the imported static systems. */
void BuildVisuals();
float DriveElapsed = 0.f;
bool bDriveReported = false;
FVector DriveStart = FVector::ZeroVector;
float SettleElapsed = 0.f;
bool bSettleReported = false;
};
@@ -0,0 +1,71 @@
#include "GigLineVehicleBuilder.h"
#if WITH_EDITOR
#include "AssetToolsModule.h"
#include "Engine/SkeletalMesh.h"
#include "PhysicsAssetUtils.h"
#include "PhysicsEngine/PhysicsAsset.h"
#include "PhysicsEngine/BodySetup.h"
#include "PhysicsEngine/SkeletalBodySetup.h"
#include "Engine/SkeletalMesh.h"
#include "UObject/Package.h"
#endif
UPhysicsAsset* UGigLineVehicleBuilder::BuildPhysicsAsset(USkeletalMesh* Mesh,
const FString& PackagePath,
const FString& AssetName)
{
#if WITH_EDITOR
if (!Mesh)
{
UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: no skeletal mesh given"));
return nullptr;
}
const FString FullName = PackagePath / AssetName;
UPackage* Package = CreatePackage(*FullName);
if (!Package)
{
UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: could not create package %s"), *FullName);
return nullptr;
}
UPhysicsAsset* PhysicsAsset = NewObject<UPhysicsAsset>(Package, *AssetName,
RF_Public | RF_Standalone | RF_Transactional);
// ONE body on the chassis bone, which is what Unreal's own vehicle template uses.
// The first attempt let FPhysicsAssetUtils build a body per bone and the car settled
// 3.7 m above the road: the four wheel bodies were intersecting the chassis body and
// pushing the whole vehicle up. Chaos drives the wheels itself, so they must not be
// separate rigid bodies here.
const FBoxSphereBounds Bounds = Mesh->GetBounds();
const FVector Extent = Bounds.BoxExtent;
const FName ChassisBone(TEXT("root"));
USkeletalBodySetup* Body = NewObject<USkeletalBodySetup>(PhysicsAsset, ChassisBone,
RF_Public | RF_Transactional);
Body->BoneName = ChassisBone;
// A box slightly under the mesh bounds: a body that is exactly the bounds sits on the
// ground plane and reads as floating because the mesh has wheels below the sills.
FKBoxElem Box;
Box.X = Extent.X * 1.9f;
Box.Y = Extent.Y * 1.8f;
Box.Z = Extent.Z * 0.55f;
Box.Center = FVector(0.f, 0.f, -Extent.Z * 0.25f);
Body->AggGeom.BoxElems.Add(Box);
PhysicsAsset->SkeletalBodySetups.Add(Body);
UE_LOG(LogTemp, Warning,
TEXT("GigLine vehicle: chassis body %.0f x %.0f x %.0f cm on bone '%s'"),
Box.X, Box.Y, Box.Z, *ChassisBone.ToString());
PhysicsAsset->MarkPackageDirty();
UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: physics asset %s with %d bodies"),
*FullName, PhysicsAsset->SkeletalBodySetups.Num());
return PhysicsAsset;
#else
UE_LOG(LogTemp, Warning,
TEXT("GigLine vehicle: physics asset generation is editor-only"));
return nullptr;
#endif
}
@@ -0,0 +1,34 @@
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "GigLineVehicleBuilder.generated.h"
class UPhysicsAsset;
class USkeletalMesh;
/**
* Script-callable helpers for vehicle setup.
*
* Chaos Vehicles need a PhysicsAsset, and Unreal's `PhysicsAssetFactory` exposes no mesh
* property to Python, so the asset cannot be generated from a commandlet. The body does
* the job with `FPhysicsAssetUtils::CreateFromSkeletalMesh`, which is the same code path
* the editor's "Create Physics Asset" action uses.
*
* Editor only: it is compiled into the editor target and never shipped.
*/
UCLASS()
class GIGLINE_API UGigLineVehicleBuilder : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/**
* Build (or rebuild) a physics asset for `Mesh` at `PackagePath`/`AssetName`.
*
* @return the created asset, or nullptr with the reason written to the log.
*/
UFUNCTION(BlueprintCallable, Category = "GigLine|Vehicle")
static UPhysicsAsset* BuildPhysicsAsset(USkeletalMesh* Mesh, const FString& PackagePath,
const FString& AssetName);
};
+2 -2
View File
@@ -13,7 +13,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
- **prettier 3.9.10** installed globally via npm (Node v24.20.0). Project config: `.prettierrc` (`proseWrap: never`, `tabWidth: 2`), `.prettierignore` (skips `Binaries/`, `*.uasset`, `.agents/`).
- **Conversion chain:** `.adoc` → `asciidoctor -b docbook5` → `.xml` → `pandoc -f docbook -t gfm` → `.md` → `prettier --write`. Pandoc's built-in `asciidoc` reader cannot handle Asciidoctor tables or attributes — it must go through docbook5.
- **Post-processing fixes:** glossary `cols="e,2e"` emphasis leaks italic into cell content (strip `*...*` wrapping), `®` from the R key binding becomes `(R)`, anchor IDs `{#...}` stripped, escaped quotes/brackets normalised.
- **All `.adoc` files removed.** Markdown is the canonical format. Architecture doc lives in `Docs/Architecture/` as 13 section `.md` files + combined `architecture.md`.
- **All `.adoc` files removed.** Markdown is the canonical format. Architecture documentation lives in `Docs/Architecture/` as `README.md`, twelve numbered section pages, and `about-arc42.md`.
- All `.md` in the repo passes `prettier --check` (23 files formatted, `.agents/` excluded as vendored content).
## 2026-11-08 — QA branch merged; demo is walkable
@@ -423,7 +423,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-09 — quality priority flipped: realism first, performance last
- Owner decision: realism leads; performance closes the quality list. Applied in `Docs/Architecture/01_introduction_and_goals.md`, `10_quality_requirements.md`, `04_solution_strategy.md`, the combined `architecture.md`, `Docs/release-checks.md` (performance section now last, with the reason), `ROADMAP.md` (a "Quality priority" section) and `AGENTS.md` (the tie-break rule next to the scope-cutting rule).
- Owner decision: realism leads; performance closes the quality list. Applied in `Docs/Architecture/01_introduction_and_goals.md`, `10_quality_requirements.md`, `04_solution_strategy.md`, `Docs/release-checks.md` (performance section now last, with the reason), `ROADMAP.md` (a "Quality priority" section) and `AGENTS.md` (the tie-break rule next to the scope-cutting rule).
- Ordering used: 1 visual realism, 2 geographic fidelity, 3 walkability/usability, 4 build reproducibility/portability, 5 provenance, 6 performance.
- **Both revisions carry the same guard so the order cannot be used as an excuse:** performance is a _floor_, not a preference — 60 FPS at 1440p without frame generation must be met at every release, and if realism cannot fit, the realism scope is cut deliberately and recorded in `CHANGELOG.md`, never the frame rate. Walkability outranks realism in one direction: a block the player falls through is not a demo.
+48
View File
@@ -26,6 +26,7 @@ Every shot is embedded below, in order, so the whole review happens in this file
| 6 | [Third-person body](#6-third-person-body) | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 7 | [Third-person front](#7-third-person-front) | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 8 | [Third-person walk](#8-third-person-walk) | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 9 | [Vehicle](#9-vehicle) | ☐ FAIL 2026-11-09 | ☐ pending |
**Verified** is what `--verify` reports: the frame is not blank, is the right size, and has tonal range. **Approved** is the owner's verdict, recorded here. Each shot also has an **expectation** in [`Tools/visual_targets.json`](../Tools/visual_targets.json) describing the content it must show.
@@ -195,3 +196,50 @@ Resolved and removed from this list on 2026-11-09:
- "The El Capitan Theatre has no marquee or blade sign" — `Tools/ue_stage_theatre_trim.py` stages all six trim actors; verified in `/tmp/gigline/theatre-import.txt`.
- "Materials are not yet created in Blender or fully integrated" — contradicted by the scene conventions above: every surface carries a look-dev material built in `Tools/ue_build_demo_map.py`.
<!-- blender-comparison:begin -->
## Blender vs in-game
Regenerated by `Tools/build_comparison_md.py`; everything between the markers is rewritten, so edit outside them.
- In-game captures: `Screenshots/demo/` (9 files)
- Blender renders: `Screenshots/blender/` (22 files), made by `Tools/blender_screenshots.py`
Left is what the engine draws, right is what Blender has. A gap between them is the import problem, not a taste problem: scale, material colour, missing parts and split systems all show up here before they show up in a review.
### Side by side
| In-game | Blender | Shot |
| --- | --- | --- |
| <img src="demo/review-demo-crossing.png" width="520"> | — | `review-demo-crossing.png` |
| <img src="demo/review-demo-north-buildings.png" width="520"> | — | `review-demo-north-buildings.png` |
| <img src="demo/review-demo-retail-frontage.png" width="520"> | — | `review-demo-retail-frontage.png` |
| <img src="demo/review-demo-spawn-view.png" width="520"> | — | `review-demo-spawn-view.png` |
| <img src="demo/review-demo-street-level.png" width="520"> | — | `review-demo-street-level.png` |
| <img src="demo/review-demo-third-person-front.png" width="520"> | <img src="blender/character-angle-035.png" width="520"> | `review-demo-third-person-front.png` |
| <img src="demo/review-demo-third-person-walk.png" width="520"> | <img src="blender/character-angle-035.png" width="520"> | `review-demo-third-person-walk.png` |
| <img src="demo/review-demo-third-person.png" width="520"> | <img src="blender/character-angle-035.png" width="520"> | `review-demo-third-person.png` |
| <img src="demo/review-demo-vehicle.png" width="520"> | <img src="blender/vehicle-angle-035.png" width="520"> | `review-demo-vehicle.png` |
4 of 9 in-game captures have a Blender counterpart; the rest are street and building shots whose meshes come from the Geo pipeline rather than a .blend.
### Signoff
One row per in-game screen. `—` in Blender means there is nothing to compare against.
| # | In-game screen | Blender counterpart | Verdict | Notes |
| --- | --- | --- | --- | --- |
| 1 | `review-demo-crossing.png` | — | ☐ | |
| 2 | `review-demo-north-buildings.png` | — | ☐ | |
| 3 | `review-demo-retail-frontage.png` | — | ☐ | |
| 4 | `review-demo-spawn-view.png` | — | ☐ | |
| 5 | `review-demo-street-level.png` | — | ☐ | |
| 6 | `review-demo-third-person-front.png` | `character-angle-035.png` | ☐ | |
| 7 | `review-demo-third-person-walk.png` | `character-angle-035.png` | ☐ | |
| 8 | `review-demo-third-person.png` | `character-angle-035.png` | ☐ | |
| 9 | `review-demo-vehicle.png` | `vehicle-angle-035.png` | ☐ | |
Verdicts: ☐ pending · ☑ approved · ☒ rejected. The authoritative per-shot review stays in [README.md](README.md); this page exists to compare Blender against the engine.
<!-- blender-comparison:end -->
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more