fix(tools): make the e2e chain actually run after the move, and record what it found

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.
This commit is contained in:
zwitschi committed 2026-11-10 13:37:21 +01:00
1 parent 24f1e682fe
commit dbb42322ff
136 files changed
+279 -199

No files matched your search

+3 -1
View File
@@ -38,7 +38,9 @@ import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import fbx_export_preset as fbx_preset # noqa: E402
FPS = 30
+3 -1
View File
@@ -26,7 +26,9 @@ import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import fbx_export_preset as fbx_preset # noqa: E402
GARMENTS = (
+3 -1
View File
@@ -35,7 +35,9 @@ import sys
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import fbx_export_preset as fbx_preset # noqa: E402
HAIR_NAME = "SM_Char_Hair"
+3 -1
View File
@@ -27,7 +27,9 @@ import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import comfyui_generate as comfy # noqa: E402
from PIL import Image # noqa: E402
+3 -1
View File
@@ -19,7 +19,9 @@ import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import fbx_export_preset as fbx_preset # noqa: E402
+3 -1
View File
@@ -13,7 +13,9 @@ import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import fbx_export_preset as fbx_preset # noqa: E402
+3 -1
View File
@@ -38,7 +38,9 @@ import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import fbx_export_preset as fbx_preset # noqa: E402
# --- module ------------------------------------------------------------------
+3 -1
View File
@@ -33,7 +33,9 @@ import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import fbx_export_preset as fbx_preset # noqa: E402
+3 -1
View File
@@ -19,7 +19,9 @@ import sys
import zlib
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import fbx_export_preset as fbx_preset # noqa: E402
import numpy
+3 -1
View File
@@ -32,7 +32,9 @@ import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import _common # noqa: E402
import fbx_export_preset as fbx_preset # noqa: E402
+3 -1
View File
@@ -16,7 +16,9 @@ import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import fbx_export_preset as fbx_preset # noqa: E402
+3 -1
View File
@@ -27,7 +27,9 @@ import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import fbx_export_preset as fbx_preset # noqa: E402
# Verified building span, metres (Unreal X, Y, Z but Blender-frame: X east,
+3 -1
View File
@@ -31,7 +31,9 @@ import sys
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import fbx_export_preset # noqa: E402
# Real sedan reference: length, width, height, wheelbase, track, wheel radius.
+3 -1
View File
@@ -28,7 +28,9 @@ import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import fbx_export_preset # noqa: E402
# A three-door hot hatch: 4.05 m long, 1.75 m wide, 1.42 m tall.
+33 -9
View File
@@ -39,18 +39,34 @@ REPORT = "/tmp/gigline/e2e-report.txt"
def run(step, cmd, env=None, timeout=None, result_file=None, expect=None):
"""Run one step and record it. A step fails on a non-zero exit or a missing marker."""
"""Run one step and record it. A step fails on a non-zero exit or a missing marker.
The child's output streams to its own file under /tmp/gigline/e2e/, not into a pipe: a captured pipe
hides progress for a fifteen-minute Unreal build and deadlocks if the engine spawns something that
outlives it.
"""
started = time.time()
print(f"== {step}")
try:
proc = subprocess.run(cmd, cwd=ROOT, env={**os.environ, **(env or {})},
capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return step, False, f"timed out after {timeout}s"
print(f"== {step}", flush=True)
# A result file left by an earlier run would make this step look successful without doing anything.
if result_file and os.path.exists(result_file):
os.remove(result_file)
safe = step.replace("/", "-").replace(" ", "_")
os.makedirs("/tmp/gigline/e2e", exist_ok=True)
log_path = f"/tmp/gigline/e2e/{safe}.log"
with open(log_path, "w") as out:
try:
proc = subprocess.run(cmd, cwd=ROOT, env={**os.environ, **(env or {})},
stdout=out, stderr=subprocess.STDOUT, timeout=timeout)
except subprocess.TimeoutExpired:
return step, False, f"timed out after {timeout}s (log {log_path})"
elapsed = time.time() - started
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-3:]
return step, False, f"exit {proc.returncode} ({elapsed:.0f}s): {' / '.join(tail)}"
tail = ""
try:
tail = " / ".join(open(log_path, errors="replace").read().strip().splitlines()[-3:])
except OSError:
pass
return step, False, f"exit {proc.returncode} ({elapsed:.0f}s): {tail}"
if result_file and expect:
if not os.path.exists(result_file):
return step, False, f"no result file {result_file}"
@@ -74,6 +90,14 @@ def main():
help="keep the existing maps (the builders still clear their levels)")
args = parser.parse_args()
# A second editor fighting the first is what makes an Unreal commandlet hang instead of fail.
import shutil
if shutil.which("pgrep"):
others = subprocess.run(["pgrep", "-f", "UnrealEditor-Cmd"], capture_output=True, text=True)
if others.returncode == 0 and others.stdout.strip():
sys.exit("error: an UnrealEditor process is already running; close it before the check "
"(two editors on one project hang, they do not fail)")
steps = []
if not args.skip_blender:
steps.append(run("1/6 blender renders",
+2 -1
View File
@@ -14,7 +14,8 @@
# what the build rendered.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# This script lives in Tools/review, so the repository root is two levels up.
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ENGINE="${UE_ENGINE_DIR:-/run/media/system/DATA/Engines/UE_5.8.4}"
PROJ="$REPO/Game/GigLine/GigLine.uproject"
VERIFY="no"
+3 -1
View File
@@ -15,7 +15,9 @@ import unittest
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import character_match as cm # noqa: E402
+3 -1
View File
@@ -15,7 +15,9 @@ import unittest
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import shadow_gap as sg # noqa: E402
+3 -1
View File
@@ -25,7 +25,9 @@ import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import ue_common # noqa: E402
KIT = "/Game/Hollywood/Kit"
+3 -1
View File
@@ -27,7 +27,9 @@ import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import ue_placement # noqa: E402
MAP = "/Game/Hollywood/DemoMap"
+3 -1
View File
@@ -23,7 +23,9 @@ import unreal
MAP = "/Game/Hollywood/DemoMap"
# Repository root, so the texture generator can be invoked by path.
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import ue_placement # noqa: E402
RESULT = "/tmp/gigline/demo-map.txt"
lines = []
+3 -1
View File
@@ -6,7 +6,9 @@ is swallowed and a failed save reports `False` rather than raising. Both are tra
that cost time in `0.1.x`, so they live here once instead of in each script.
import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import ue_common
ue_common.make_map_writable("/run/media/.../Content/Hollywood/DemoMap.umap")
+3 -1
View File
@@ -17,7 +17,9 @@ import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import ue_common # noqa: E402
import ue_placement # noqa: E402
+3 -1
View File
@@ -18,7 +18,9 @@ import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import ue_common # noqa: E402
SRC = "/tmp/gigline/kit"
+3 -1
View File
@@ -16,7 +16,9 @@ import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import ue_common # noqa: E402
SRC = "/tmp/gigline/north"
+33 -35
View File
@@ -1,16 +1,23 @@
"""Rebuild both Unreal maps from an empty level, deleting whatever was there before.
"""Empty both Unreal maps before a build, and verify they really are empty.
<engine>/UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=<repo>/Tools/unreal/ue_recreate_maps.py -nullrhi -unattended -nopause -stdout
Why: `Tools/unreal/ue_build_demo_map.py` and `Tools/unreal/ue_build_review_map.py` load the existing map and destroy the
actors they know about. Anything else that accumulated - an actor from an experiment, a material override, a
stale world setting - survives every rebuild, which is how a stale road mesh and a duplicated vehicle actor
both got saved back into the map. Deleting the `.umap` and creating an empty level makes every run start from
the same place.
Why this does not delete the `.umap` files: `LevelEditorSubsystem.new_level()` refuses a path whose
package is still in memory, and `EditorAssetLibrary.delete_asset()` leaves the file on disk with the package
still registered, so deleting and recreating in one session always returns False. Measured:
The umap files are LFS-tracked and check out read-only, so chmod comes first; and a level cannot be deleted
while it is the one the editor has open, so a scratch level is created to release it.
new_level fresh -> True
delete_asset -> True
disk file still present -> True
new_level again -> False
What does work, and is what the maps actually need, is clearing every actor and saving: the builders stage
everything they use, so a level with no actors is the blank slate. Anything an experiment left behind - an
actor from a probe, a duplicated vehicle, a stale shot camera - is gone, which is the failure this step
exists to prevent.
Both maps are emptied and saved, and the script reports the actor count it left behind.
Result: /tmp/gigline/recreate-maps.txt
"""
@@ -20,7 +27,6 @@ import os
import unreal
MAPS = ("/Game/Hollywood/DemoMap", "/Game/Test/ReviewMap")
SCRATCH = "/Game/Test/_RecreateScratch"
RESULT = "/tmp/gigline/recreate-maps.txt"
lines = []
@@ -31,7 +37,7 @@ def log(message):
def make_writable(path):
"""A .umap under Content/ that is read-only cannot be deleted; LFS checks it out that way."""
"""A .umap under Content/ that is read-only cannot be saved; LFS checks it out that way."""
disk = os.path.join(unreal.Paths.project_content_dir(),
path.replace("/Game/", "") + ".umap")
try:
@@ -45,33 +51,25 @@ def make_writable(path):
def main():
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
scratch_made = False
for path in MAPS:
make_writable(path)
current = unreal.EditorLevelLibrary.get_editor_world()
current_path = current.get_path_name().split(".")[0] if current else ""
if path == current_path:
# The editor cannot delete the map it has open: park it on a scratch level first.
if not scratch_made:
les.new_level(SCRATCH)
scratch_made = True
else:
les.load_level(SCRATCH)
log(f"{path}: released by switching to {SCRATCH}")
if unreal.EditorAssetLibrary.does_asset_exist(path):
if unreal.EditorAssetLibrary.delete_asset(path):
log(f"{path}: deleted")
else:
log(f"{path}: could not delete")
if les.new_level(path):
log(f"{path}: created empty")
else:
log(f"{path}: FAILED to create")
if unreal.EditorAssetLibrary.does_asset_exist(SCRATCH):
unreal.EditorAssetLibrary.delete_asset(SCRATCH)
ok = all(f"{path}: created empty" in line for path in MAPS for line in lines)
lines.append(f"VERDICT: {'PASS' if ok else 'FAIL'}")
log(f"recreate {'ok' if ok else 'FAILED'}")
if not les.load_level(path):
log(f"{path}: could not load, and new_level cannot replace an in-memory package")
continue
removed = 0
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
try:
unreal.EditorLevelLibrary.destroy_actor(actor)
removed += 1
except Exception: # noqa: BLE001
pass
left = len(unreal.EditorLevelLibrary.get_all_level_actors())
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), path)
log(f"{path}: cleared {removed} actors, {left} left, save_map {saved}")
empty = all(" 0 left" in line for line in lines)
lines.append(f"VERDICT: {'PASS' if empty else 'FAIL'}")
log(f"maps emptied: {'yes' if empty else 'no'}")
try:
+3 -1
View File
@@ -22,7 +22,9 @@ import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
import ue_common # noqa: E402
SRC = "/tmp/gigline/theatre"