The e2e asset check now completes: PASS on all six steps - empty maps 9 s, demo build 10 s, review build 9 s,
captures 80 s, judge 388 s, review file. Four breakages from the folder move had to be fixed first:
- ue_placement and ue_common are siblings of the scripts that import them, but the rewrite put only the tools
root on sys.path, so every Unreal commandlet died with ModuleNotFoundError. Both paths are inserted now.
- render_screenshots.sh resolved the repository root one level up, so commandlet paths became Tools/Tools/...
and the capture step failed in 0 s.
- the driver treated a result file left by an earlier run as success, which is how a ten-second 'demo build'
passed without building. Result files are unlinked before each step and child output streams to a per-step
log file rather than a pipe.
- two editors on one project hang instead of failing, so the driver refuses to start while one is running.
Also corrects ue_recreate_maps.py: LevelEditorSubsystem.new_level() refuses a path whose package is still in
memory and delete_asset() leaves the file on disk, measured create->delete->create returning False. The step
now loads each map, destroys every actor, saves, and reports the count it left (0), which is the property the
builders actually need.
Open question recorded in TODO.md: the rebuilt map holds 409 actors and 613 KB against 600 and 886 KB before,
and the ~190 missing ones look like the facade-kit and theatre-trim placements that run as separate steps.
The e2e check recreates the maps as empty levels by design, and the run was cancelled while it was still
rebuilding DemoMap. The commit that followed swept the empty 159 KB map into git, replacing the populated
886 KB one.
Both maps are restored from f681d83, the last commit that had them built. The recreate step stays in the
toolchain - it is correct - but it must never be committed mid-run: the maps are only valid content after the
builders have saved them again.
One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:
Tools/unreal/ commandlets and the two map builders, with ue_common.py and ue_placement.py as the
shared imports (they need the engine)
Tools/blender/ builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
(they need the Blender Flatpak)
Tools/review/ the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
visual_targets.json
Tools/gen/ image and texture generation: the ComfyUI driver, deterministic texture writers,
reference fetching
Tools/input/ OS-level input injection
root _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/
Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.
51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.
Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
Used the workflow integration from the previous step for real: generated a warm limestone ashlar albedo
with two profiles compared (AlbedoBase 17.04 vs Krea-2 13.90, AlbedoBase won), saved it as
Art/textures/generated/T_FacadeAshlar_AI.png beside its provenance sidecar, and imported it to
/Game/Hollywood/Textures.
ue_hero_facade_import.py now imports that albedo and, for the two stone slots, builds a material with
TextureSampleParameter2D -> Multiply(tint) -> Base Color, tiling the UVs 6x, so the map carries the coursing
and the tint keeps the value inside the reference's range.
Two real bugs fixed on the way:
- the albedo path was resolved against unreal.Paths.project_dir() (Game/GigLine), not the repository root, so
the import silently skipped;
- the authored materials were never saved - only the mesh was - so after a reload the slots pointed at
nothing and the elevation fell back to one default colour.
Honest status: the nodes are verified present and saved (Constant3Vector, TextureSampleParameter2D,
TextureCoordinate, Multiply) and the source mesh has UVs, but the render still shows the flat tint. Recorded
in TODO.md with the next probe: read the compiled Base Color pin instead of the node list.
THIRD_PARTY_NOTICES.md gains the AlbedoBase row (commercial use, credit required).
Re-read /home/zwitschi/StabilityMatrix/Data/README.md - it now documents 141 GB of models and four
verified, core-node image/texture graphs - and wired the ones that matter into the toolchain.
comfyui_generate.py gains a 'texture' subcommand that rebuilds the API graph for each documented workflow,
because the files in Workflows/ are ComfyUI UI format and /prompt cannot accept them:
albedobase AlbedoBase XL v3.1, 30 steps, CFG 6.0, dpmpp_2m/karras, baked VAE
krea2 Krea-2 Turbo NVFP4, 8 steps, CFG 1.0, euler/simple, ConditioningZeroOut, texture-fix VAE
qwen21 Qwen-Image 2.1 int8, 25 steps, CFG 1.0, euler/simple, TextEncodeQwenImage21, texture-fix VAE
Default pair is albedobase + krea2 - both commercially usable - generated from the same prompt and seed,
scored by high-frequency detail, and compared before the winner takes --out. Smoke-tested at 512 px: both
rendered, albedobase won 18.91 against 17.51, and the resulting limestone albedo is a plausible material
sheet.
Tools/README.md documents the profiles with their settings, a licence row per profile (Qwen-Image 2.1 is
research-only and must not ship), and the two limits the workflows carry: they produce albedo only, and
ComfyUI 0.44 has no seamless sampler, so tiling has to be checked by offsetting the image 50%. Five tests
cover the graph shapes; the suite is at 108.
Tools/README.md now covers the generative tools next to each other:
- comfyui_generate.py drives the LOCAL ComfyUI at 127.0.0.1:8188, which this repository does not start. A new
subsection says so and says what to do: start Stability Matrix, launch ComfyUI, re-run. It points at
/home/zwitschi/StabilityMatrix/Data/README.md for the package version, the launch arguments and the shared
80 GB model library, and notes that THIRD_PARTY_NOTICES.md must carry the model and licence before a
generated texture ships.
- generate_facade_textures.py and generate_character_textures.py are listed as the deterministic procedural
path: no model, no network, byte-identical on every run.
- The tool's own docstring carries the same Stability Matrix instruction.
The default second checkpoint was sd_xl_base_1.0.safetensors, which is NOT installed; it is now
epicrealismXL_pureFix.safetensors, verified against the running server. The pair was smoke-tested end to end
at 512 px: both checkpoints rendered, both scored, juggernautXL won at 20.37 against 12.93, and the winner
landed on --out with the loser kept beside it.
A single vision model has been wrong in both directions on this project - it missed real defects and
disputed frames that were correct - so agreement between different model families is worth more than one
confident opinion.
judge_screenshot.py now runs two models by default (openai/gpt-4o-mini + qwen/qwen3.7-flash, both served
on OpenRouter; claude-3.5-haiku is gone, which the first attempt discovered with a 404). combine()
reconciles them: agreement carries the verdict with the mean confidence, disagreement reports 'uncertain'
and keeps both reasons rather than letting the more confident model win. --model still forces a single
judgement, and every line prints which model said what.
comfyui_generate.py generates the same prompt and seed with two checkpoints, scores both by
high-frequency detail (mean absolute Laplacian, which catches blurry or flat results), keeps the loser as
<out>__<model>.png with its sidecar, and moves the winner onto --out. --alt-model restores the single
run.
Tests cover the reconciliation - agreement, disagreement, unknown verdicts, and no verdicts at all - and the
suite is at 103.
Iterated the loop five times against Art/reference/hero-masonic/. What took the iterations was the material,
not the geometry:
- M_FacadeStone in /Game/Hollywood/Site is the KIT stone; the hero does not use it, so warming it changed
nothing.
- The hero's materials come from the FBX and live at /Game/Hollywood/ElCapitan/M_Facade_*. Unreal keeps an
existing material asset when an FBX referencing it is re-imported, so a Blender colour change never
arrived.
- Deleting those materials before the import broke the mesh's slots: one colour, no banners, no windows.
The working step is tint_imported_materials(): import normally, then author one plain Constant3Vector
material per facade slot with the colour sampled from the reference, and assign it to the matching slot. The
elevation is now warm sandstone with ashlar coursing, three red banners, two window bands and three recessed
portals with steps.
Also: the portal's solid stone reveal z-fought with the door leaf and read as stripes, so the recess is
carried by jambs, lintel and shadow; banners are 1.35 x 6.2 m hung from 7.2 m instead of 1.55 x 5.4.
Loop iteration 5 verdict is still 'no': it wants the carved inscription, medallion relief, a crimson banner
tone and visible window grilles - all recorded as the next iteration. Rows 4-6 are pending review with the
new captures and two comparison plates.
The 94 symlinks in ~/.agents/skills and the full second copy in
~/.pi/agent/skills/blender-skills were the same packs under two roots, so Pi
reported a collision for every one of them on every start and silently dropped
the loser.
- The 93 upstream packs, the shared references/ notes and the LICENSE now live
once, flat and one level down, in the common agent folder ~/.pi/agent/skills/.
- The repository keeps only the project-specific pack:
.agents/skills/blender-skills/blender-director/. Its ../references/ links now
point at ~/.pi/agent/skills/references/ because it no longer sits beside them.
Its SKILL.md declared metadata.role twice (specialist, orchestrator), which is
fatal YAML - the pack had never actually loaded.
- AGENTS.md and Docs/toolchain.md describe the split and warn against copying the
common packs back into the repository.
Verified by walking all three discovery roots and parsing every frontmatter:
97 skills loaded, 0 malformed, 0 collisions.
Tools/hero_iterate.sh runs the whole chain - chmod the Blender files, build, import, render, judge, plate -
and fails loudly at each step with its log under /tmp/gigline. Tools/hero_compare.py builds the side-by-side
plate (reference | rendered, one height, labelled); two are committed for review.
This pass: the stone is warmer in Blender (0.66/0.60/0.48 against the near-white blockout) and the banners are
1.35 x 6.2 m hung from 7.2 m, matching the reference proportion instead of the 1.55 x 5.4 that sat too low and
too fat.
Iteration 1 judge verdict, confidence 0.9: lacks warm sandstone, lacks fluted column detail, banners wrong,
no doorways, no inscription or medallions. Checked against the plate, two of those are wrong (120 flute
strips exist and cast shadows; three 1.2 m deep doorways with steps exist and read black) and three are real.
The split is recorded in TODO.md with the next action for each - the important one being that the stone colour
must change in the UE material (ue_hero_facade_import.py), not in the Blender material, which the render does
not use.
Every .blend under Art/ was read-only from the checkout, and a Blender save then fails with 'Cannot save
blend file ... is not writable'. In Tools/hero_facade_build.py the save runs BEFORE the FBX export, so the
old FBX was silently reused and a build could look successful while changing nothing. All 20 .blend and
.blend1 files are now writable and 'find Art -name *.blend ! -perm -u+w' returns nothing.
Re-rendered the full capture set afterwards. The pixels are unchanged, which is the expected result and worth
writing down: render_screenshots.sh renders the IMPORTED Unreal assets, so a Blender change needs
build -> import -> render. Chmod alone cannot change an image.
Also commits the asset state today's imports and the collision-hull clearing produced (67 site meshes, 8
textures, 6 theatre meshes, the demo map), after verifying the walkable demo is unaffected: the player still
stands on Sidewalk_North_07 at 158 cm with feet at 161.
Art/reference/hero-masonic/ (15 images, git-ignored) replaces the single Wikimedia photo as the source of
truth. Two front elevations drive this pass: 02-masonictemple01.jpg, a clean street-level frontal, and
06-el-capitan-theater-frontal-fachade.jpg; 05-hollywoodmasonictemple-1922.jpg records the original masonry.
Changes:
- the blank Hero_Stylobate_LintelBand across the portals is removed - the reference has open doorways with
steps, and they now read as three dark openings with banners above them;
- rustication(): five ashlar courses from the pavement to the portal heads, 4 cm joints proud of the wall;
- balustrade(): bottom rail, top rail and 38 balusters along the roof edge, where the reference shows a
cresting and railing.
Still missing against the same references, and recorded: warm sandstone instead of flat pale stone, lattice
grilles in the window bays, the carved inscription, and medallions that do not read at distance.
The blend is read-only after a checkout - chmod u+w first or the save fails silently and the FBX is never
written, which happened once here.
Rows 4-6 re-rendered and pending review.
The three hero captures showed a plain block with six columns because every applied detail sat BEHIND the
wall's front face, inside the solid mass:
Hero_Banner_1 y -1.08..-1.02 (55 cm inside)
Hero_Portal_1 y -2.35..-1.15 (2 m inside)
Hero_Window_1_1 y -0.60..-0.52 (2 cm inside)
The colonnade, cornice and dentils happened to protrude, which is exactly what the shots showed.
The depth is now stated once, in hero_facade_build.py: FRONT_Y is the wall's front face and
seat_on_front() moves every applied part until its street-facing face sits at FRONT_Y + offset - 8 cm for
banners and jambs, 5 cm for entablature and cornice, 3 cm for window panes, 4 cm for mullions, 10 cm for
portal signs. It reports what it moved, 99 parts.
After re-export, re-import and re-render the front elevation now shows the three red banners, the two
window bands, the dentil course and the portals. What is still missing against the reference: rusticated
banding on the stone, lettering on the motto band, grilles in the windows, a balustrade on the parapet, and
the blank lintel band across the portals that the reference does not have.
Rows 4, 5 and 6 are back to pending for review because the images changed.
Measured the Y positions in the source and the imported mesh:
blend scene Y -12.50 .. +0.13
Hero_Mass y = -BODY_D/2 - 0.5, so it spans -12.50 .. -0.50
Hero_Banner_1 -1.08 .. -1.02, i.e. 55 cm BEHIND the wall's front face
imported bounds y -13 .. +1250 cm
So the banners - and by the same construction the window bays and portal surrounds - sit inside the mass and
never render. The columns, cornice and dentils protrude, which is exactly what the captures show: a plain
block with six columns.
The camera check is answered as well: the three hero cameras are aimed at (-13336, 1307) where the hero actor
is, and the frontage sits at the origin, so the framing was correct all along.
Next step is mechanical, not artistic: put a FRONT_Y constant in Tools/hero_facade_build.py, place every
elevation detail relative to it, re-export and re-render. The model does not need remodelling before its
detail can be seen.
Started the hero facade task the way it has to start - by comparing the reference photograph with the
current render rather than with memory of it.
Reference: el-capitan-facade-day-wikimedia-2009-10-27.jpg, north elevation, daylight.
The model (SM_HeroFacade, 4 materials) is much further along than the review captures suggest: six fluted
columns with capitals and abaci, architrave, entablature, cornice with 52 dentils, parapet, motto band with
surround and two medallions, three banners, five window pairs, and three portals with jambs, lintels,
mullions and five steps each.
What it genuinely lacks: surface. Rusticated banding, carved lettering on the motto band, window grilles,
and a balustrade with finials on the parapet.
And one thing to check before any modelling: the three hero captures show a plain block with six columns and
nothing else, while the mesh carries banners, windows and portals and is staged at (-13336, 1307, 0). The
likely explanation is that the cameras frame Frontage_SM_ElCapitanTheatre at the origin, or the wrong side
of the hero - in which case the rejection is describing a camera error. One render aimed at Hero_Facade from
Y -4000 decides it, and three new tasks are recorded with that first.
A shared heading string made one edit land in two sections, so the hero-facade task carried a copy of the
vehicle's bisect evidence. The section is back to its own two steps.
Also re-ran the gates on the tree as it stands after today's ground and placement work:
Tools/ue_audit_scene.py 593 mesh actors, every mesh has a material, building X ranges as expected,
no duplicate actor labels, 0 failures
prettier --check all Markdown clean
unittest 99 tests pass
Third fix tried and failed: the terrain staged in the probe map with collision disabled entirely. The pawn
still rises (Z 315 -> 421), exactly as with collision enabled.
With that, the terrain's convex hull, its collision channel and its collision itself are all eliminated, and
the failure survives in a map whose only difference is the presence of a 279 m static mesh.
The player is unaffected throughout - re-checked after every attempt, still standing on Sidewalk_North_07
at 157 cm - so nothing in the walkable demo regressed.
Deferred rather than guessed, per AGENTS.md: the task is marked deferred with the three failed fixes, the
probe workflow and switches a fresh session inherits, and the next question framed precisely - why a map
containing a collisionless 279 m static mesh changes a physics body's behaviour, which points at streaming,
memory layout or the broadphase rather than at collision authoring.
Generated probe levels deleted; the tools stay.
Built the map-content bisect the task asked for: Tools/ue_vehicle_probe_map.py duplicates ReviewMap,
optionally adds one demo mesh and the pawn, and saves it as VehicleProbe.
control (no extra mesh) Z 300 -> 297 rests on the review floor
+ Terrain_DEM Z 300 -> 498 thrown upward
So Terrain_DEM alone reproduces the fault in a map that works. Two fixes were tried:
- Tools/ue_clear_simple_collision.py cleared the auto-generated convex hulls from all 76 site/street
meshes - the terrain had ONE convex element over 279 m - and set them to complex-as-simple. Still rises.
- The terrain component set to QueryOnly, so traces hit it and physics ignores it. Still rises.
Neither changed the behaviour, so the ejection is neither the convex hull nor the collision channel. The
next probe is the complex triangles themselves: give Terrain_DEM no collision at all in the probe map and
see whether the pawn falls. If it falls, the triangle winding is pushing bodies out of the ground mesh -
the same inverted-winding family that made the sidewalk unstandable earlier.
Both tools have README rows; the pawn is parked again.
Two more probes, both negative:
--novehmover movement component tick disabled from the constructor -> the pawn still rises
overlaps GetOverlappingComponents at spawn -> 0 components
So it is not the Chaos movement component, and the body is not inside anything, at any height. With the
velocity clamped to zero, the mover disabled, zero overlaps and a correct dynamic body, the transform is
still written every frame - in DemoMap only.
That is the limit of targeted probing. The remaining path is systematic rather than clever: rebuild DemoMap
in a fresh empty level and add one group of staged content at a time, running the pawn profile after each,
until it starts rising. Five or six runs name the actor or mesh; guessing at engine internals has not, in
ten probes.
The probes stay in the tree as switches: -probes (all diagnostics), -novehmover, -profile, plus the velocity
clamp and overlap list. They cost nothing when unused.
The decisive clamp: zero linear and angular velocity every frame for two seconds.
t=0.25 Z=3220 vZ=0 | t=0.75 Z=3683 vZ=0 | t=1.25 Z=4197 vZ=0
It rises at ~1200 cm/s with velocity pinned to zero, so this is not a force, an impulse, or a bad initial
state - all of those the clamp would have removed. Something calls a transform setter on the vehicle every
frame.
Our own source contains exactly one direct transform write outside the character (the shot actor's camera),
so the writer is engine-side: the Chaos vehicle component driving UpdatedComponent from a vehicle state that
was never created, or a skeletal-mesh transform update.
Also diffed the two maps, since the pawn works in ReviewMap and not in DemoMap: kill_z, gravity, lights and
partitioning are identical; DemoMap adds 593 static mesh actors, a height fog and a post-process volume,
ReviewMap adds a shot actor and the character. Nothing in either list can write a transform, so the map
difference is not in its actors or settings.
Ran the bisect the task asked for. The same pawn, unchanged, in two maps:
ReviewMap, flat floor Z 200 -> 188 -> 174 -> 157 -> 87 vZ -208 -> -663 falls correctly
DemoMap, on the street Z 486 -> 920 -> 1126 -> 1385 vZ ~0 teleports up
DemoMap, 60 m away Z 291 -> 467 -> 920 -> 1343 vZ +1595 launches
DemoMap, 25 m up Z 2496 -> 2664 -> 3068 -> 3434 vZ +1509 launches at once
So the pawn, its physics asset, its wheels, its mass and its movement component all work: on a plain floor
it falls and settles. Everywhere in DemoMap it is thrown upward, at any height, including 25 m in the air,
which rules out contact and points at something the demo map stages and the review map does not.
That inverts the previous nine hypotheses - they were all about the vehicle - and puts the next probe in the
map: diff the staged actors and world settings between the two builders.
Pawn parked; the bisect numbers are in the task.
The dynamics probe rules out the last structural explanation:
simulate=1 instance=yes profile=PhysicsActor
A normal dynamic actor - and it still travels ~700 cm/s upward with vZ oscillating around zero. So the
teleport is applied to a genuinely dynamic body, which is why not one of the force-shaped settings
(gravity, mass, penetration, spawn height) changed anything.
Recorded with it: the bisect plan for the next pass, because guessing at Chaos internals has stopped being
the cheap option. Spawn the pawn in ReviewMap's empty floor to separate asset from world, and spawn a stock
AWheeledVehiclePawn beside it to separate our setup from the engine's.
Pawn parked; captures unchanged.
Instrumented the movement component so the write is caught at its source rather than inferred from the
actor: a UGigLineVehicleMovement subclass is substituted for the stock component through
AWheeledVehiclePawn::VehicleMovementComponentName, and it logs CreateVehicle(), the first UpdateState and
the component tick.
mover tick: Z 661 -> 1143 -> 1443 -> 1718 ..., output null, wheels 0
physics OFF at 3 s: Z 2247, and it does not move for a whole second afterwards
profile: t=0.25 Z=515 vZ=-8 | t=0.75 Z=920 vZ=-22 | t=1.25 Z=1287 vZ=173
Two conclusions:
1. The mesh's physics state IS what moves the actor - switching physics off freezes it at once.
2. It moves WITHOUT VELOCITY: position rises ~700 cm/s while vZ oscillates around zero. That is a
kinematic teleport, not an impulse and not a force - which is exactly why gravity, mass and
penetration all measured clean. The vehicle-state output is null throughout, so the teleport comes
from the Chaos layer driving the body kinematically while the vehicle itself was never created.
Pawn parked, captures regenerated so the demo stays clean.
Sampled the actor transform immediately before and after Super::Tick on every frame: it never changes
there. The 'inside Super::Tick' line never fires, yet the actor climbs 400-1400 cm/s.
With the earlier results that leaves exactly one consistent candidate: the Chaos movement component's own
tick or its async physics step, outside APawn::Tick. That also explains why SetComponentTickEnabled(false)
did not stop the climb and why the body's reported velocity is ~0 while the actor moves - the movement
component is writing a transform it took from a vehicle state that was never created.
Next step is a subclass of UChaosWheeledVehicleMovementComponent that logs UpdateState /
OnUpdateTransform together with the vehicle-state pointer, so the write is caught at its source rather
than inferred from the actor.
Three more measured facts:
bones VehicleRig root Wheel_FL Wheel_FR Wheel_RL Wheel_RR
setups Wheel_FL Wheel_FR Wheel_RL Wheel_RR -> exact match, mesh suspect ruled out
mover updated=VehicleMesh ticking=1
stop tick disabled at 2.5 s with the actor at Z 2001; at 3.0 s it is at Z 2258
terrain CTF_USE_COMPLEX_AS_SIMPLE with 0 box elements -> no invisible lid to eject from
So the wheel bones are right, there is no proxy collision to fight, and disabling the mover's tick did not
stop the climb. That moves the suspect list to the pawn's own Tick/root: something writes the actor
transform every frame regardless of the physics body, and the 1 cm bounds are its fingerprint.
Pawn parked again; the diagnostic stays.
The previous edit to this section never applied - a substring that no longer existed - so the task
still described only the first launch observation. It now records everything measured this session:
box 452x205x40 centre 0,0,-18 | bounds extent 1,1,1
velocity -24,-182,-28 cm/s | mass 1500 | sim=1 | setups=4 wheels=0
The conclusion is that the body is nearly still while the actor is nine metres up, so this is a
transform problem rather than a force one, and the suspects are ordered: the component hierarchy, the
Chaos movement component's transform updates, and then the skeletal mesh's bone layout - which would
explain CreateVehicle() bailing before it ever builds wheels.
Ruled out with a run each: spawn penetration at four heights, panel collision, chassis box dimensions,
world gravity, and mass.
The motion probe splits the problem in two:
velocity -24, -182, -28 cm/s (a body that cannot climb 9 m in 1.5 s)
mass 1500 kg (the override DID apply)
box 452 x 205 x 40 (correct)
bounds extent 1,1,1 (still degenerate)
actor Z 1393, spawned at 458
The physics body is nearly still while the actor is nine metres above its spawn. So this is not a
force problem at all - it is a transform problem: something writes the actor's transform that is
neither the body's velocity nor our code, which is also what the degenerate bounds are telling us from
the other side.
Next: find who writes that transform. Suspects are the Chaos movement component's UpdateState /
OnUpdateTransform and the component hierarchy - a pawn whose root is the mesh while the physics state
belongs elsewhere behaves exactly like this.
Pawn parked again so the demo stays clean.
The last open row (DemoMap vehicle) is approved, so every capture in the table has a verdict: 20
approved and 3 hero-facade rows excluded from this pass and tracked as their own task.
The status header now says so, so a reader does not have to count rows to learn where the review
stands.
Owner approved the last three mannequin rows, so every ReviewMap capture is now approved except the
three hero-facade shots, which live in their own task.
Two housekeeping changes in the same table:
- approved rows drop the standing 'vision judge still reports floating' note; the disagreement is
recorded in MEMORY.md, and the table is for approval state, not for a second copy of it.
- row 22 goes back to pending re-review rather than staying rejected: the image changed after the
rejection (the vehicle cameras were re-aimed), so it needs a fresh verdict, not the old one.
Owner review: rows 1, 2 and 3 show the mannequin about 2 cm too high; rows 8 and 9 show nothing wrong.
The staging height moves from 8 cm to 6 cm above the floor plane (floor_z on the mannequin's
sit_on_floor call, which now logs +6.0), and rows 8 and 9 are approved. Rows 1, 2 and 3 go back to
pending with the change noted in the table, since the image is what changed.
Captures regenerated.
Four more things measured instead of guessed:
chassis box 452 x 205 x 40, centre 0,0,-18 -> correct for the car
world gravity global_gravity_z = 0.0, and the character falls normally
spawn height 5 m up: the pawn still climbs, so penetration is not the cause
mass override SetMassOverrideInKg at 0.5 s never logged
The one number that does not fit: the pawn's mesh component reports bounds extent 1,1,1 while
simulating and carrying a 452 cm collision box, and the actor drifts 4.8 m sideways while climbing
with no input. A body that simulates with correct collision but degenerate bounds is not the body the
movement component is driving, so the next question is which component owns it and whether
CreateVehicle() runs at all.
The pawn is parked again; the diagnostic logging stays (it is the tool that produced this), and the
evidence is in the 'Vehicle physics' task.
Three hypotheses tested and ruled out, each with its own run:
spawn level with the road -> actor climbs to Z 1993
spawn 1.2 m above the road -> climbs to 2131
visual panels' collision removed -> climbs to 2066
So neither the spawn penetration nor the panels' overlap is pushing the pawn up; the force is inside
the vehicle. With zero wheels there is no suspension to blame either, which leaves the chassis body
itself - its box size against the mesh bounds, or the skeletal mesh's own collision fighting the
physics asset body.
The pawn is parked again so the demo carries no 18 m flying car, and the ruled-out list is recorded in
the 'Vehicle physics' task next to the two remaining suspects.
The pawn was found again after a cleanup removed its staging, and the diagnostic answers the
zero-wheel question with a bigger one: with the pawn present the actor is launched to Z 1993 cm - 18 m
above the ground it stands at 150 - and still reports 0 wheels.
GigLine vehicle diag: mesh=1 simulating=1 physasset=yes setups=4 wheels=0
GigLine vehicle: settled at Z 1993 cm, simulating=1
Wheels are only created inside CreateVehicle() when the physics state produced a vehicle, so the
chassis body is the suspect rather than the wheel setup: PA_Vehicle_Sedan has one box on the root bone,
and a body that starts intersecting geometry is the usual reason a vehicle pawn launches itself.
Parked rather than shipped: the pawn is not staged, the visible car stays as panels, and the physics is
'Vehicle physics - Chaos rig' in TODO.md with the evidence and three concrete steps. The demo no longer
carries an 18 m flying car in its captures.
Owner verdicts: rows 11 (shadow compare) and 12 (vehicle staging) approved. The three hero-facade
rows are no longer reviewed inside this pass - they are asset work, not vehicle work, so they become
their own TODO section ('Hero facade rework') with two steps: rebuild the facade against the reference
photography, then re-render the three staging shots and put them back in the table.
The table rows point at that task instead of repeating 'rework needed'.
Row 3 stays pending: it is the same mannequin raise as 1, 2, 8 and 9 and needs the same re-review.
Owner review of the last set:
1, 2, 8, 9 rejected - 'mannequin still too low'
14 approved, but 'floating, only maybe 2 cm too high'
The mannequin correction had been a no-op: actor bounds on a freshly assigned skeletal mesh read as
zero, so sit_on_floor returned +2 cm while the soles sat level with the floor plane. It is now raised
8 cm, which puts them clearly above it, and the four rows are back to pending for re-review with that
noted.
The player start drops 2 cm (258 -> 256) for row 14.
Also repaired the four table rows: a cell-index mistake in the previous edit had emptied their item
links.
The review notes named the same class of problem in six rows: things sitting a little too low or too
high in the floor. They were all hand-typed staging heights, and every one was wrong by a different
amount - the mannequin's root is not at its soles, the resting cube's origin is not at its base, the
vehicle's wheels are 2 cm below its panel origin.
sit_on_floor() measures the actor's bounds and shifts it so the mesh's lowest point lands 2 cm above
the floor plane, and reports the correction it applied:
MPFB character +2.0 cm
ScaleTest_A -48.0 cm (it was floating)
vehicle wheels +2.0 cm
The DemoMap vehicle shot was rejected as 'no vehicle in the scene': both vehicle cameras were still
aimed at the car's old position, so re-aiming them at Y -1450 brings it back into frame.
Status table notes record each fix; the hero-facade rework and the shadow-compare expectation are
left for the owner's next review.
The road was never mis-modelled: a 279 m strip imports with ONE auto-generated convex hull, and a
convex hull's top is the strip's maximum height. A downward trace therefore landed at 366 cm - a flat
lid about 2 m above the visible road, which sits at roughly 152 cm there. That single fact explains
every failed sidewalk fix, the 'road above its own sidewalk' reading, and why the player always ended
up on Road_Hollywood.
Slicing the road the way the sidewalks already were fixes it, and the cross-section is now continuous:
Terrain_DEM 154
Sidewalk_North_07 158
Road_Hollywood_07 158 (was 366)
Two changes had to land together: the road exported in slices, and the map stopped staging the single
Road_Hollywood asset - which still sat on top of the slices and won every trace until removed.
All 23 captures regenerated; the player and the car both stand on a continuous ground plane.
Found the mechanism behind the 2 m disagreement. street_mesh.py builds the road across -10.5..+10.5 m
and the north sidewalk +10.5..+15.8 m, so along the straight the two bands meet exactly at the gutter.
The strips are generated along a curved centreline, and the fan-out at the bend pushes the road's outer
edge to -13.8 m while the sidewalk occupies -17.3..-11.8 m - a 2 m overlap where the road surface is
366 cm and the sidewalk 158 cm.
That is the surface a downward trace at the player's Y kept finding, and the reason the scene read as
the road hovering above the pavement.
The fix is a clamp in strip() so a row's outer offset cannot pass the nominal gutter, or trimming the
road's ends - one function, then re-export, re-import and the 14 s probe confirms it. Recorded in
MEMORY.md and TODO.md. The demo reads correctly meanwhile because both objects stand on the sidewalk.
Three changes close the float:
- The sun went from pitch -74 to -82, so shadows fall under the objects instead of a metre away.
That is what made grounded objects read as hovering, and the judge reacted to it in every run.
- The car is parked on the same sidewalk surface the player stands on (158 cm at this X) rather
than on the carriageway, which the shipped site geometry places ~2 m ABOVE its own pavement - a
data defect still open, but no longer visible in the review shots.
- The player already stands on the sidewalk since Y was moved to -1450.
The wheel and feet captures show the contact: feet and tyres on the pavement with their shadows at
the contact point. judge_screenshot still returns 'floating' for all three, which is advisory only -
AGENTS.md is explicit that it never replaces the human verdict - so it is recorded in the status
table as a disagreement rather than iterated against any further.
All 23 captures are regenerated and awaiting approval.
The player start was at Y -1150, and the north sidewalk slice at that X spans Y -1734..-1182. He was
standing 32 cm south of it, on the roadway - which is why every trace named Road_Hollywood and every
sidewalk fix changed nothing. Moved to Y -1450 and the capsule now rests on the sidewalk:
top surface under player: Z=158 mesh=Sidewalk_North_07 (actor Z 248)
body: mesh bottom 161, capsule bottom 161
The cameras that frame the pawn moved with him, and the car now sits on the measured carriageway at
366 rather than the 342 it had.
The captures still read as floating in the vision judge's opinion, and the table says why honestly:
the placements measure correct, but at this sun angle the shadows fall well away from the objects,
which is what the judge is reacting to.
All 23 captures are regenerated and await a fresh approval.
Regenerated the street blend from OSM and the DEM (the code was already correct: 279.6 m centreline,
saved clean) and sampled both surfaces at the player's X in metres:
Road_Hollywood y -9.0..-9.1, z 0.87..0.94
Sidewalk_North y +11.9..+12.0, z 1.51..1.59
The road is the south band at about 0.9 m and the north sidewalk is about 1.55 m - exactly a kerb
above it, which is what the generator is supposed to produce. The source is correct.
In the engine, at the player's Y (the north sidewalk band) the trace reports Road_Hollywood at 366 cm:
the road is neither in that Y band nor at that height in the source. So the mismatch is introduced
between Blender and Unreal - the FBX Y mirror documented in export_site_fbx.py, or the importer's
transform - not by the geometry or the placement code.
No screenshot signoff is requested: nothing visual has changed, and approving these captures would
mean approving that defect.
Added a -profile probe: one downward trace every 20 m along the street at the player's Y, naming
the surface each time. It settles the question the bounding boxes could not:
x=-14336..-2336 z=366 Road_Hollywood (flat)
x=-336 z=302 Road_Hollywood
x=1664 z=5 Terrain_DEM
The road is FLAT at 366 cm across the demo block, while the sidewalk slices follow the DEM and
descend 380 -> 104 cm. So the ~2 m gap is not an import artefact and not a collision problem: the
road itself does not follow the elevation the sidewalks follow.
That also explains why every sidewalk fix failed - the surface the player stands on is this flat
road, and the sloped sidewalk is metres below it.
Next: how rows is built in Art/blender/street/street_mesh.py. The road and the sidewalk read the same
dem_z(x, y), so a flat road means the sampler returns the same value for every row.
Checked the obvious escape route and closed it: there are no duplicate Demo_Road_Hollywood actors after
several import rounds - one actor, identity transform, mesh Z -44..366. So the height the trace reports
is the road mesh's own local height at that XY, not a stale actor or a far-side polygon.
In the source blend the road and sidewalk sit within a kerb height of each other at the same street
position, so the ~2 m gap appears after the mesh leaves Blender. That points at the import path - unit
handling or the Y mirror documented in export_site_fbx.py - rather than the strip generation, and it is
the next thing to test.
Records the numbers rather than another attempt: at the player's X the sidewalk slice tops at 169 cm
and Road_Hollywood at 366 cm, in the same map, so the player lands on the road.
The code in Art/blender/street/street_mesh.py builds both surfaces from one sampler - road as
dem_z + 0.02, sidewalk as dem_z + kerb * t - so a 2 m gap cannot come from the assembly logic. The
next place to look is the DEM lookup or the mesh's coordinate frame, not the strip maths.
Also records what has now been ruled out on the collision side: slicing, UCX hulls,
complex-as-simple and winding orientation.
Two findings, one a bug and one the real cause.
Bug: the far cut of each slice used clear_outer=True, which kept the wrong half, so every 'slice'
still spanned to the end of the street and inherited the same far-away bbox maximum - which is why
slicing appeared to change nothing. Both cuts now keep the middle, and the slices are disjoint:
their tops read 380, 340, 309, 284, 254, 231, 205, 169, 138, 104 cm along the street.
Real cause: at the player's X (-133 m) the sidewalk tops out at 169 cm while Road_Hollywood is at
366 cm. The road is about 2 m ABOVE its sidewalk, so the player lands on the road and no collision
setting on the sidewalk can change that - the sidewalk is not where the street is. The two surfaces
were generated from different elevation passes and no longer meet at the kerb.
That is site-pipeline data, not placement. The next step is to regenerate road and sidewalks from
one elevation pass so they meet at the kerb, which is one export, one import and one 14 s probe now
that the loop is fast.
The ground measurement was the slowest part of every iteration: the game ran until the shell's
timeout because nothing told it to stop. -probes now logs the top surface, the capsule and the
body's lowest point three seconds after spawn and quits on the next tick. A run went from minutes
to 13.7 s, which is the difference between one experiment per turn and one per five minutes.
Also applied set_complex_collision_as_simple to sidewalks and kerbs, as the terrain already had:
it did not make them block, so that flag is not their problem either.
And a correction that matters: the road is 366 and the sidewalk 380 in the shipped map. The 158 cm
I measured earlier came from the site blend's own Z datum, which sits about 2.2 m below the map's
because the terrain was added after that blend was written. So the float is about 14 cm - the
character stands on the road because the sidewalk does not collide - not the metre it looks like
against the kerb.
The site exporter now splits sidewalks and kerbs into ~20 m pieces
(SEGMENTED / SEGMENT_M in Art/blender/site/export_site_fbx.py), 56 slices in all, so each has a
bounding box that means something where it stands instead of reporting the highest point on a
279 m slope. They import and stage (63 site meshes in the demo map).
The hypothesis behind it is dead, and that is the useful part: with the invisible sidewalk
stopgap retired, the player now falls to Road_Hollywood at 366 - the slices do not block either.
So the 279 m aspect ratio was never the cause; a 20 m slice of the same mesh fails identically.
The sidewalk geometry itself is what does not collide, and the next place to look is its shape -
an open surface with no volume, inverted winding or zero-thickness triangles - not the import
flags and not the size.
The stopgap retirement stands: the player walks on the road, which is real geometry that does
collide, rather than on an invisible ledge.
The probe now logs the horizontal distance from the player to the impact point. It is 0 cm, which
rules out the last remaining alternative explanation - that the trace was landing on a distant
section of the 279 m street mesh - and confirms the character stands on what is directly beneath
it.
With that closed, the three measurements are consistent: ground at Z 380 (the collision surface),
capsule bottom 382, body's lowest vertex 382. The engine's geometry is right. What remains is the
visible sidewalk mesh, whose top at that XY is not reachable from Python and whose bounding box
reports the highest point on a 279 m sloping slab rather than the local one.
Ran the 158 cm attempt again, with a staging log this time, and it fails the same way: with the
slab at a 158 cm top the player falls to Z -4412 and never hits anything, while the identical
actor collides at 380. Setting the start above the slab put the player on Road_Hollywood at 366 -
so the game's ground here really is road 366 / sidewalk bbox 380, and the Blender-measured 1.58 m
does not describe the shipped map; the blend predates the terrain. The 158 hypothesis was wrong and
is retracted in TODO.md.
Geometry is now measured end to end:
top surface under player: Z=380 mesh=Cube (actor Z 470)
body: mesh bottom 382, capsule bottom 382, body above capsule 0 cm
The body's lowest vertex and the capsule bottom agree within 0 cm, sitting 2 cm above the collision
surface. If the captures still read as floating, the cause is the visible sidewalk mesh sitting
lower than its bounding box at that XY - and that value is not reachable from Python, so it needs
a measurement in the mesh itself.