Cyberpunk Agent Scaffold

Everything the agent could see on the cyberpunk cases — the agent brief, the build skill it was given, and the metric verifier it was told to run. No reference build code: each agent authors its own build from this knowledge and iterates until both the metric and its own look at the rendered frame pass.

brief/CLAUDE.md

Cyberpunk street build — agent brief (Claude Code harness, model = Muse Spark 1.1)

You drive a running Unreal Engine 5.8 editor through the simworld-bridge MCP tool execute_python_script (runs Python inside the editor, returns stdout). Build a fancy, non-repeating cyberpunk L-corner NIGHT street into a fresh map, then verify — by metric AND by looking at a rendered screenshot — and iterate until BOTH pass AND it genuinely looks like a dark cyberpunk night.

Use the skill (progressive disclosure)

Skill at .opencode/skill/cyber-street-build/. Read SKILL.md first, then open a file under references/ when you reach that phase. Read them with the Read tool.

Hard rules (a metric score is NOT enough — the PICTURE must look right)

  • Write your OWN build code. No ready-made build script; do not look for one or exec one.
  • Drive the editor ONLY via the execute_python_script MCP tool. Small increments (<= ~50 actors/call).
  • Scale ALWAYS 1, ground z0, don't reassign materials to defaults.
  • RESTRAINT: keep dynamic lights UNDER ~100 total. Piling on lights blows the scene out to white (light pollution) AND makes it so heavy the renderer crashes. A few big hero neons beat hundreds.
  • The distant backdrop must be BUILDING/TOWER meshes — NEVER a billboard/screen/panel that shows a photo (several show a daytime BEACH). A beach/sky backdrop = automatic fail.
  • Lock night exposure (unbound PostProcessVolume, auto_exposure min0.05/max0.5) — see the skill.
  • Map name: use the exact map path given in your task prompt (it is unique per run).
  • NEVER trigger a modal dialog (they freeze the editor headlessly). Before new_level, if the map already exists, unreal.EditorAssetLibrary.delete_asset(map) first. Save only with save_current_level() / EditorLoadingAndSavingUtils.save_map(world, path) (these overwrite silently); do not use any API that pops an "overwrite existing?" prompt.

The loop — metric + VISUAL gate (this is the whole point)

  1. Build phases A-F from the skill (small calls), save after each with unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).save_current_level().
  2. Metric verify (via the bridge tool): exec(open(r"E:/UE/cyber_local_run/tools/verify_cyberpunk_scene.py",encoding="utf-8").read())
  3. VISUAL gate — render via the SAME bridge tool (do NOT shell out to a separate renderer): a. Call execute_python_script with: exec(open(r"E:/UE/cyber_local_run/render_vgate.py",encoding="utf-8").read()) (renders your current map at eye level, light settings, to vgate/). It returns immediately; the render is async. b. Then in a Bash tool call: python E:/UE/cyber_local_run/analyze_frame.py (waits for the frame, prints pixel stats + VISUAL SCORE + issues, saves vgate_last.png). This is fast (no rendering). c. Then LOOK yourself: use the Read tool on E:/UE/cyber_local_run/vgate_last.png and judge it as a picture. Is it a believable DARK cyberpunk night street? Over-exposed? Washed to daytime? Is the backdrop a city or a beach? Too many lights? Write down the visual problems you SEE.
  4. Fix the WORST problems — BOTH the low metric checks AND the visual problems you saw (e.g. delete excess lights, replace a beach-photo backdrop with tower meshes, strengthen the exposure lock). Re-verify + re-render + re-look. A high metric with a bad-looking render is a FAIL.
  5. Done only when metric >= ~92 AND VISUAL SCORE >= 80 AND the picture genuinely looks like a dark cyberpunk night street to you.

Finish

Summary: round-by-round metric + visual scores, the visual problems you found and fixed, final map name. Work fully autonomously — do not ask questions.

brief/AGENTS.md

Cyberpunk street build — agent brief

You drive a running Unreal Engine 5.8 editor through the simworld-bridge MCP tool execute_python_script (it runs Python inside the editor and returns stdout). Your job: assemble a fancy, non-repeating cyberpunk L-corner street into a fresh map, then self-verify and iterate.

Use the skill (progressive disclosure — read only what you need)

A skill is available at .opencode/skill/cyber-street-build/. - Start by reading .opencode/skill/cyber-street-build/SKILL.md. - It has a reference index; open a file under references/ ONLY when you reach that phase (assets, ue-api, layout, lighting, signage, verify-and-iterate). Read them with your normal file tools.

Hard rules

  • Write your OWN build code. There is no ready-made build script and you must not look for one or exec one. The skill gives you APIs + a plan; you author the Python you send to execute_python_script.
  • Drive the editor ONLY via the execute_python_script MCP tool. NEVER take a screenshot.
  • Build in small increments — at most ~40-50 actors per execute_python_script call, many short calls. Keep each script focused; do not send one giant script.
  • Scale ALWAYS 1, ground at z≈0, don't reassign materials to defaults. (See the skill's golden rules.)
  • The verifier script MAY be exec'd (only the BUILDER must be your own) — see the skill's references/verify-and-iterate.md.
  • Keep total actors under ~1100.

Definition of done

A saved map /Game/Maps/CyberOC_1 scoring ≥ ~92/100 on the verifier, built in your own incremental code, with a final summary: round-by-round score progression, final scorecard, and what you built/changed. Work fully autonomously — do not ask questions.

skill/SKILL.md

name: cyber-street-build description: > Knowledge for assembling a fancy, non-repeating CYBERPUNK L-corner street inside a running Unreal Engine 5.8 editor (via execute_python_script), from marketplace assets — facades, ground, skyline, night lighting, dense neon signage, holograms, rain/puddles. Use when asked to BUILD or IMPROVE a cyberpunk street/scene. This skill gives you the plan, the key UE-Python APIs, the asset libraries, and the gotchas — you write your OWN build code from it. There is NO ready-made script to run.


Cyber-street build

You are assembling a believable night-time cyberpunk L-corner street: one long leg running along +X, turning up +Y at the corner. Everything is placed at scale 1 (assemble real meshes, never stretch them). You judge your own work with a 0-100 verifier and iterate.

Golden rules (break these and the verifier punishes you)

  1. Scale ALWAYS 1. Never set actor scale != 1 to "make it fit". Pick a mesh that is the right size. (check: no_scaling)
  2. Ground at z ≈ 0. Buildings/props sit on z=0; signs/lights float only where intended.
  3. Never reassign a mesh's material to a default (worldgridmaterial etc.) — keep the asset's own MICs.
  4. Continuous, SOLID facades on both sides of both legs — no see-through gaps, no daylight holes.
  5. Every sign is VOLUME-AWARE: measure its bounds, face it toward the street, offset so it does not clip into the wall. Pair each sign with a neon RectLight. (checks: sign_light_pairing, tilted_signs)
  6. It's NIGHT. Kill the sun (≤0.8), keep skylight dim (≤5), and lock auto-exposure so MRQ can't brighten it to daytime (this is the #1 thing people forget — see references/lighting.md).
  7. Keep total actors under ~1100. If near the cap, prune/vary instead of piling on.

The build plan (phases)

Build in small increments (~40-50 actors per execute_python_script call, many short calls). Order:

  • A — Ground & facades. Lay wet-asphalt road + sidewalks + curbs down both legs, then place a continuous run of building facades along both sides of both legs. Carve the corner so facades don't collide at the L junction. → references/layout.md, references/assets.md
  • B — Distant skyline. Behind the facades, stack a dense backdrop of far towers of varied heights so the view down each street end is full, not empty. → references/layout.md
  • C — Night lighting + weather. Dim moonlight directional, dim skylight, teal exponential fog, warm street lamps down the sidewalks, an exposure-locked PostProcessVolume, rain + puddles + steam. → references/lighting.md
  • D — Dense diverse signage. Many DIFFERENT signs (blade/flush/hanging, varied heights) along both streets, each oriented to the street and paired with a colored neon RectLight. → references/signage.md
  • E — Holograms. A few floating holographic projections above the street (these may float by design).
  • F — Greeble & clutter. Restrained eye-level detail: AC units, pipes, cables, crates, bollards, scaffolds, a little litter (don't overload litter). → references/assets.md

How to work

  1. Read this SKILL.md, then open ONLY the reference files you need for the phase you're on (they're short).
  2. Discover concrete asset paths yourself with the Asset Registry — don't hardcode blindly. → references/assets.md
  3. Build a phase, then run the verifier, read the scorecard, fix the lowest checks, repeat. → references/verify-and-iterate.md
  4. Save after each phase/fix: unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).save_current_level()

Three stages

  1. Build the street (phases A–F above) — verify by metric AND by looking at a render.
  2. Crowd — plan + place your own ZoneGraph pedestrian crowd. → references/crowd.md
  3. Record — a fixed-recipe first-person walk-through video. → references/recording.md

You write ALL the code for every stage from these references; no prebuilt build/crowd/recording script is provided.

Reference index (open on demand)

  • references/ue-api.md — the exact UE-Python calls to spawn meshes/lights/fog/decals/FX + apply materials.
  • references/assets.md — which asset libraries exist and how to enumerate real mesh paths.
  • references/layout.md — the L-corner geometry (legs, carriageway width, sidewalks, corner carve, skyline).
  • references/lighting.md — the night-lighting recipe + the exposure-lock gotcha (critical).
  • references/signage.md — the volume-aware sign-placement method + neon pairing.
  • references/verify-and-iterate.md — every scored check, common failures, and the self-improve loop.
  • references/crowd.md — Stage 2: ZoneGraph lanes (PedD2) + MassCrowd spawner (Density2 + MetaHuman).
  • references/recording.md — Stage 3: the fixed first-person walk MRQ recipe + gotchas.
skill/references/assets.md

Asset libraries + how to find real mesh paths

Two marketplace packs cover everything. Enumerate them yourself — do NOT trust a memorized path; a wrong path makes load_asset return None and the piece silently doesn't spawn.

The two libraries

  • /Game/CyberpunkMegaBundle/5_CP_CityRecife/ — the hero cyberpunk kit.
  • Mesh/ — building facades, storefronts, walls, roofs, props, greeble, signs, litter.
  • Particles/PS_WaterDrip, PS_SteamTube (Cascade emitters).
  • /Game/Neon_Streets/ — neon-street dressing.
  • Meshes/ — modular curbs (Modular/SM_Curb_1_2m), road/sidewalk planes, poles, emissive signs, props.
  • Materials/Instances/ — e.g. MI_Ground_Asphalt (wet road), sign/neon MICs.
  • Materials/Decals/DE_Puddle — puddle decal.
  • FX/NS_Rain, FX/NS_Smoke — Niagara rain & smoke.

Enumerate what actually exists (do this first)

ar = unreal.AssetRegistryHelpers.get_asset_registry()
for a in ar.get_assets_by_path("/Game/CyberpunkMegaBundle/5_CP_CityRecife/Mesh", recursive=True):
    print(a.asset_name, a.get_editor_property("asset_class_path").asset_name)

Filter for StaticMesh. Build yourself category lists by matching the asset NAME, e.g.: - facades/walls: names containing Build, Wall, Set_, Store, Window, Roof, Corner. - signs: Sign, Neon, Billboard, Screen, Led, Arrow, Market, Hotel, Banner, Emissive. - greeble: Extractor, Antenna, Tube, Vent, Pipe, Cable, Crate, Barrel, Pallet, Bollard, Scaffold/Andaime, AC/Conditioner, Duct, Awning, Balcony. - litter (use SPARINGLY): Litter, Trash, Garbage, Papel, Lata, Garrafa, Debris. - holograms: Holo, Hologram, Holograma, Emissor.

CRITICAL: the distant skyline must be BUILDINGS, not a photo panel

The backdrop at the end of the street is the #1 thing that goes wrong. Use building / tower meshes (SM_Building_*, SM_Build*, tower blocks) stacked at scale 1. NEVER use billboard / screen / large flat "panel" meshes that carry a photographic texture for the backdrop — several of those in these packs show a daytime tropical beach / ocean / sky, and if you put one at the end of the street the whole scene reads as a bright beach postcard, not a cyberpunk night. If a mesh's texture is a photo of a landscape, it is NOT a skyline — don't use it as the backdrop.

Restraint beats piling on (avoids light-pollution + render crashes)

More is NOT better. Keep dynamic lights under ~100 total (RectLight+PointLight+SpotLight). A few big hero neons + restrained lamp spacing looks far better — and far darker/moodier — than hundreds of lights, which blow the scene out to white AND make it so heavy the renderer crashes. If a check wants "more lights", add a FEW bright ones, not dozens of dim ones.

Variety matters (anti-repetition check)

Keep a POOL of several different meshes per category and pick varied ones as you step down the street. Placing the same facade mesh 20× in a row triggers repeated_elements / monotone_walls. Rotate through the pool and jitter positions slightly.

skill/references/crowd.md

Stage 2 — pedestrian crowd (MassAI / ZoneGraph)

Add a believable pedestrian crowd walking the finished street. YOU decide the lane layout; the crowd populates at runtime (in PIE / the recording), driven by MassAI over ZoneGraph lanes.

The mechanism (why each piece is needed)

  • ZoneShape actors carry the walkable lanes (a polyline of points).
  • Each ZoneShape's lane profile must be PedD2 = Pedestrian (Bit3) + Density2 (Bit12). That density bit is what makes the MassCrowd Density2 spawn-point generator register these lanes as crowd lanes. Profile ref: unreal.ZoneLaneProfileRef with name="PedD2" and id = GUID A1000000000000000000000000000020. (The profile is pre-committed in Config/DefaultPlugins.ini.)
  • A BP_MassCrowdSpawner (/Game/AI/AgentConfig/BP_MassCrowdSpawner) with:
  • spawn_data_generators = a MassCrowdZoneGraphSpawnPointGenerator_Density2 instance (/Game/AI/AgentConfig/MassCrowdZoneGraphSpawnPointGenerator_Density2) wrapped in a MassSpawnDataGenerator (proportion 1.0). Its density tier must match the lanes' PedD2 or it finds no spawn points.
  • entity_types = a MassSpawnedEntityType with entity_config /Game/AI/AgentConfig/MassCrowdAgentConfig_MetaHuman (proportion 1.0) — this drives the walking peds.
  • count = your target pedestrian count (the generator caps at available lane points).

Key API (write your own placement code from this)

zs = eas.spawn_actor_from_class(unreal.ZoneShape, unreal.Vector(0,0,0))
c  = zs.get_component_by_class(unreal.ZoneShapeComponent)
ref = unreal.ZoneLaneProfileRef(); ref.set_editor_property("name","PedD2"); ref.set_editor_property("id", guid)
c.set_editor_property("lane_profile", ref)
c.set_editor_property("points", [ ZoneShapePoint(position=Vector(x,y,GZ)) , ... ])   # the lane polyline

Guid: g=unreal.Guid(); # parse "A100...0020" — set it on the ref's id. Build a MassSpawnDataGenerator + MassSpawnedEntityType as above and set them on the spawner.

Plan (yours to design)

  • Lanes both on the sidewalks AND inner lanes near the street centre (so peds walk close to a first-person camera at y≈0), with two-way flow. A busy street ≈ 150-200 peds, ~12-16 lanes.
  • Cover the +X main leg (y≈0 corridor, |y|<~520) and the +Y corner leg. Ground the lanes at z ≈ 0 (peds hover / LOD-glitch if lanes float).
  • Save to a new map (e.g. <yourmap>_crowd) so the street stays intact.

Verify (structural — the peds only appear at runtime, i.e. in the Stage-3 recording)

Confirm: number of ZoneShape lanes, exactly one BP_MassCrowdSpawner, the generator is the Density2 one and the lanes are PedD2, and the lanes sit inside the walkable corridor at z≈0. The actual walking crowd is confirmed visually in the Stage-3 recording (which warms up MassCrowd before frame 0).

Prereqs (already set up in this project)

Config/DefaultPlugins.ini has the PedD2 profile; the crowd character BP compiles. If the spawner finds 0 points, the lanes aren't PedD2 or the generator isn't Density2 — fix the mismatch.

skill/references/layout.md

L-corner geometry

A right-angle street. Work in centimetres (UE units).

The two legs

  • Leg A (main): runs along +X from x=0 to x≈8000, centred on y=0.
  • Leg B (corner): runs along +Y from y=0 to y≈8900, centred on x≈8000 — it fills the corner so the camera looking down Leg A sees the turn, not a void.

Cross-section (per leg)

  • Carriageway (road): full width ≈ ±520 from the leg centreline (CARRIAGE_HALF ≈ 520). This is the walkable/road corridor — KEEP IT CLEAR of buildings (a later stage walks a camera down the centre).
  • Sidewalk: a band just outside the carriageway (≈ 520 … 700 from centre). Curbs sit on the edge.
  • Facades: start at the sidewalk's outer edge (≈ 700 from centre, FACADE ≈ 700) and face inward toward the street. Left side faces +perp, right side faces −perp.

Helper idea: a function that converts (distance-along-leg, perpendicular-offset) → world (x,y) depending on whether the leg axis is 'x' or 'y'. Step down the leg in increments equal to each facade's measured width (see references/signage.md for measuring bounds) so facades sit flush, continuous, no gaps.

Facing the street

  • Leg A (axis x): a facade on side −1 (y<0) faces +Y; on side +1 (y>0) faces −Y → yaw 0 or 180.
  • Leg B (axis y): faces ±X → yaw 90 or 270. Signs and lamps follow the same facing logic (face the carriageway).

Carve the corner

Where Leg A's end overlaps Leg B's start, facades from both legs can collide/interpenetrate. After placing both legs, detect actors in the overlap box (use get_actor_bounds) and remove the ones that intrude into the other leg's carriageway, so the junction is a clean open corner (check: no_collisions, l_corner_shape).

Distant skyline (phase B)

BEHIND the facades (perpendicular offset well beyond 700, e.g. 1500-4000 out, and past the leg ends), stack blocks of varied height into "towers" to form a dense far skyline. Bias them toward the ends of both legs and the corner so the view down each street is filled. Assemble at scale 1 from building blocks; vary heights and spacing so it doesn't read as a wall (checks: dark_skyline, no_backdrop).

skill/references/lighting.md

Night lighting recipe (phase C) — and the exposure gotcha

The scene must read as night but NOT be pitch black. The verifier wants the sun essentially off, a dim ambient, dense warm street lamps, and colored neon — plus fog and wet ground.

1. Kill daylight

  • DirectionalLight as a faint moon fill: intensity ≤ 0.8 (0.03–0.6 is typical). Cool color (e.g. 120,140,190). Sun intensity > 1.5 → verifier scores it as daytime (sun_on, hard fail).
  • SkyLight for ambient: intensity ≤ 5 (2.5 or so). lower_hemisphere_is_black = True. SkyLight

    8 washes the scene to daytime (ambient_too_bright).

2. Fog (mood + depth)

unreal.ExponentialHeightFog, then on its component:

fc.set_editor_property("fog_density", 0.025)
fc.set_editor_property("fog_height_falloff", 0.05)
fc.set_editor_property("enable_volumetric_fog", True)
# tint the fog a cool teal via fog_inscattering_color (LinearColor)

No fog → no_fog warning.

3. THE EXPOSURE LOCK (do not skip — #1 forgotten step)

Auto-exposure will otherwise brighten your dark night back up to "daytime" when it's rendered. Lock it with an unbound PostProcessVolume:

pp = eas.spawn_actor_from_class(unreal.PostProcessVolume, unreal.Vector(4000,4000,300))
pp.set_editor_property("unbound", True)          # affects the WHOLE map, not just inside the box
s = pp.get_editor_property("settings")
s.set_editor_property("override_auto_exposure_min_brightness", True); s.set_editor_property("auto_exposure_min_brightness", 0.05)
s.set_editor_property("override_auto_exposure_max_brightness", True); s.set_editor_property("auto_exposure_max_brightness", 0.5)
s.set_editor_property("override_bloom_intensity", True);            s.set_editor_property("bloom_intensity", 1.5)
pp.set_editor_property("settings", s)

If you skip this, the map may score fine but RENDER bright/daytime — a real, easy-to-miss failure. (unbound=False = the lock only applies inside the box and usually misses the camera.)

4. Warm street lamps (density check)

Down each sidewalk, place lamp poles at intervals; give each a warm PointLight whose bulb is offset OUT over the carriageway (the head overhangs the road). Aim for ≥ ~15 dynamic lights per 100 m of street (light_density, too_few_lights). Mirror lamps on both sides (lamps_not_mirrored).

5. Warm/cool balance + a few HERO neons

Mix warm (amber) lamps with cool/saturated (cyan/magenta) neon so both are present (warm_cool_neon). Add a handful of GIANT bright neon RectLights (intensity ≥ ~8) as "hero" light pollution (no_hero_neon, no_light_pollution).

6. Wet ground + rain

Wet-asphalt road material + Niagara NS_Rain + puddle decals + a little steam. Missing → no_rain, wet_ground_rain loses points. Add SphereReflectionCaptures along the street so the wet ground reflects.

skill/references/recording.md

Stage 3 — record a first-person walk-through video

Render an 18s first-person WALK down the finished, crowded street, with head-bob and camera-following rain, as an MRQ frame sequence → assemble to mp4. These parameters are FIXED (so every model's recording is comparable) — you write the code, but don't change the recipe.

Fixed recipe: 1920×1080, 30 fps, 18 s. Eye height z=175, walk +X at 190 u/s from (0,0,175). engine_warm_up_count = 300 (so the MassCrowd populates before frame 0 — essential, or the street is empty). 4 camera-following rain emitters. Motion blur 0.2.

Why each piece

  • MRQ is the only reliable headless render (viewport / PIE HighResShot wedge). Use MoviePipelineQueueSubsystem + MoviePipelinePIEExecutor.
  • First-person POV: give the map a GameMode whose default_pawn_class = None, then place a DefaultPawn (auto_possess_player = PLAYER0) at eye height — it wins possession and IS the camera. Render an empty LevelSequence with NO camera-cut track so MRQ films the possessed pawn's POV.
  • Walk + bob: register a slate post-tick callback (register_slate_post_tick_callback) that, using GameplayStatics.get_time_seconds, moves the pawn to x = SPEED*t, small y/z sin bob, and sets the controller rotation with a gentle sway. Force-possess your FlyViewer each tick (find it via an actor tag; some maps mis-possess a default pawn) and set_view_target_with_blend to it.
  • Camera rain: spawn 4 NiagaraActors with /Game/Neon_Streets/FX/NS_Rain, tag them, and in the tick attach them to the pawn at offsets like (250,0,450),(250,±320,420),(420,0,300).
  • Crowd warmup: the ped crowd only spawns at runtime, so engine_warm_up_count = 300 runs enough ticks that peds are walking before frame 0.

Critical gotchas

  • Unique LevelSequence name per render (e.g. RecSeq_<ms-timestamp>). Re-using a name makes create_asset pop an "Overwrite Existing Object" modal that FREEZES the editor headlessly.
  • The editor's "Use Less CPU when in Background" must be OFF or MRQ never emits frames (already set in this project's config).
  • Output file_name_format = "{frame_number}", PNG, to a folder; write a small done-marker file in the executor-finished delegate so a separate step knows when frames are ready.

Assemble

Once the frames exist, ffmpeg them: ffmpeg -y -framerate 30 -start_number 0 -i "<out>/%04d.png" -c:v libx264 -pix_fmt yuv420p -crf 18 final.mp4 (run this from a Bash tool call; the editor render is async, so wait for the done-marker first).

You author all of the above yourself from this recipe — no prebuilt recording script is provided.

Crowd LOD (avoid pedestrians popping in/out)

MassCrowd culls / swaps pedestrian representation by distance from the camera. With the default MassCrowdVisualizationTrait LOD distances (base_lod_distance = [0,500,1000,5000]) peds beyond 5000 are not rendered and swap representation at 500/1000, so a moving camera makes them pop in/out. For a clean recording, push the LOD tiers out so the whole street stays high-res: on /Game/AI/AgentConfig/MassCrowdAgentConfig_MetaHuman -> config.traits[MassCrowdVisualizationTrait]: lod_params.base_lod_distance = [0, 9000, 14000, 30000], visible_lod_distance = [0, 14000, 30000, 50000], and params.not_visible_update_rate = 1.0. Save the asset once (it's a project-wide, deterministic setting).

skill/references/signage.md

Volume-aware signage (phase D) + neon pairing

Signage is where scenes look cheap if done naively (signs sunk into walls, facing the wrong way, all the same, all at one height). Do it volume-aware.

Measure before you place

A sign mesh has its own size and its own local forward axis. Spawn it once at a scratch location, measure:

a = eas.spawn_actor_from_object(mesh, unreal.Vector(0,0,9000), unreal.Rotator(0,0,0))
origin, extent = a.get_actor_bounds(False)     # extent.x/.y/.z = half-sizes
eas.destroy_actor(a)                            # throw away the probe

Now you know how far it sticks out. Rotate it to face the street (yaw per references/layout.md), then offset it along the wall normal by its own half-depth so the back sits flush on the facade and the face points at the carriageway — never buried in the wall, never floating off it (tilted_signs, floating_signs).

Three kinds — mix them (diversity check)

  • flush: face parallel to the wall, sitting just proud of it.
  • blade: perpendicular to the wall, sticking OUT over the sidewalk toward the street (classic).
  • hanging/overhead: higher up, spanning or projecting over the walk. Vary the HEIGHT band too — some at eye level (~150-350cm), some mid, some high. All signs at one height triggers signs_one_band. Using only 1-2 distinct sign meshes triggers few_sign_meshes / sign_repetition — keep a pool and pick varied ones.

Pair EVERY sign with a neon RectLight

Each sign gets a colored unreal.RectLight placed just in front of it, facing the street, so the sign glows and spills colored light onto the wet ground:

# small helper you write: spawn a RectLight at the sign's face, intensity ~4, radius ~650,
# color matched to the sign (cyan / magenta / amber), cast_shadows=False, label "neon_rect"

Missing pairing → sign_light_pairing loses points; it's also what makes the street feel alive.

Eye-level density

Put enough signage + props in the eye-level band along the corridor so a first-person camera walking the centre always has something interesting close by (eye_level_density, sparse_eye_level). Don't overload litter though (litter_overload).

skill/references/ue-api.md

UE-Python API — the calls you need

All actions run inside the editor via execute_python_script. Subsystems you'll use:

import unreal
eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)      # spawn/destroy/list actors
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)      # save/new level, PIE
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)     # get_editor_world()

Load an asset

unreal.load_asset(path) → returns the asset object (mesh, material, niagara system…) or None if the path is wrong. ALWAYS null-check — a wrong path silently returns None and your spawn does nothing.

Spawn a static mesh (this is 90% of the build)

eas.spawn_actor_from_object(mesh_asset, unreal.Vector(x,y,z), unreal.Rotator(roll,pitch,yaw)) → returns a StaticMeshActor. Do not touch its scale (leave 1,1,1). Purpose: place a building piece / prop. Rotation is Rotator(roll, pitch, yaw) in degrees — yaw turns it around Z (to face the street). Set a label for later bookkeeping: actor.set_actor_label("facade").

Apply a material instance (only if the asset needs it — usually it doesn't)

Get the mesh component, set the material slot:

smc = actor.get_editor_property("static_mesh_component")   # or actor.static_mesh_component
smc.set_material(slot_index, material_instance)

Gotcha: NEVER set a slot to a default/engine material — the verifier flags default_ground / washed meshes.

Spawn a light

eas.spawn_actor_from_class(cls, unreal.Vector(x,y,z), unreal.Rotator(0,pitch,yaw)) where cls is unreal.RectLight (neon panels), unreal.PointLight/unreal.SpotLight (lamps), unreal.DirectionalLight (moon), unreal.SkyLight (ambient). Then get the light component and set properties:

comp = actor.get_component_by_class(unreal.LightComponent)   # or the specific subclass component
comp.set_editor_property("intensity", 4.0)
col = unreal.Color(); col.r, col.g, col.b, col.a = 255, 80, 200, 255
comp.set_editor_property("light_color", col)                 # Color is 0-255 ints, NOT LinearColor here
comp.set_editor_property("attenuation_radius", 650.0)
comp.set_editor_property("cast_shadows", False)              # neon signs: shadows off = cheaper + cleaner

Gotcha: light_color on a light component wants a unreal.Color (0-255). LinearColor is used elsewhere (e.g. DirectionalLightComponent.set_light_color) — if one raises, try the other.

Fog, exposure, reflections — see references/lighting.md (they have specific property names + values).

Measure an actor's real size (for volume-aware placement)

origin, extent = actor.get_actor_bounds(only_colliding_components=False) → both are Vector; the mesh spans origin ± extent. Use this to know a facade's width before you step the next one, and to offset a sign so it doesn't sink into the wall. See references/signage.md.

Particles / rain

Niagara: na = eas.spawn_actor_from_class(unreal.NiagaraActor, unreal.Vector(x,y,z)) then na.get_component_by_class(unreal.NiagaraComponent).set_editor_property("asset", load_asset(NS_RAIN)). Old Cascade systems use unreal.Emitter + component template. Puddles are unreal.DecalActor with a puddle decal material and get_decal().set_editor_property("decal_size", unreal.Vector(...)).

New level / save

les.new_level("/Game/Maps/CyberXX_1")                       # fresh empty map to build into
les.save_current_level()                                    # after every phase / fix
world = ues.get_editor_world()
unreal.EditorLoadingAndSavingUtils.save_map(world, "/Game/Maps/CyberXX_1")   # save-as

List / count actors (stay under the cap)

eas.get_all_level_actors() → list; len(...) for the count. Filter by a.get_class().get_name() or a.get_actor_label().

skill/references/verify-and-iterate.md

Self-verify + iterate

You judge your own scene with a verifier that prints CYBERPUNK SCENE SCORE: XX/100 plus a per-check breakdown and a list of issues. Run it, read the LOWEST checks, fix exactly those, re-run. Loop until ≥ ~92/100 or gains stall.

How to run it

The verifier is a script you may execute (only the BUILDER is off-limits — verifying is fine). It lives inside this project; exec it inside the editor via the bridge tool:

exec(open(r"E:/UE/cyber_local_run/tools/verify_cyberpunk_scene.py", encoding="utf-8").read())

It also writes a JSON with metrics + issues. Read both the score line and the _scoring breakdown.

The scored checks (know what each rewards)

check rewards
night_sun_off DirectionalLight intensity ≤ 0.8 (moon fill ok; > 1.5 = daytime fail)
night_ambient SkyLight intensity ≤ 5 (dim night)
light_density ≥ ~15 dynamic lights per 100 m of street
warm_cool_neon BOTH warm lamps and cool/saturated neon present
corridor_both_sides facades/lights on BOTH sides of the street
sign_light_pairing each sign has a neon light near it
no_scaling no actor scaled != 1
wet_ground_rain wet road material + rain FX + puddles
anti_repetition varied meshes, not the same one repeated in a row
no_collisions props/facades not interpenetrating
lamp_orientation lamps mirrored + facing the street
eye_level_density enough interesting detail at eye level along the corridor
l_corner_shape the plan really reads as an L (two legs + corner)

Common failures → fix

  • sun_on / ambient_too_bright → lower DirectionalLight ≤ 0.8, SkyLight ≤ 5.
  • too_few_lights / light_density low → add warm lamps down both sidewalks.
  • no_hero_neon / no_light_pollution → add a few giant bright neon RectLights (≥ 8).
  • repeated_elements / monotone_walls / sign_repetition → widen your mesh pool, jitter placement.
  • no_collisions low / prop_collisions → offset overlapping props by their measured bounds; carve the corner.
  • floating_signs / tilted_signs → volume-aware placement (references/signage.md).
  • no_backdrop / dark_skyline → add/raise the distant skyline (references/layout.md).
  • no_rain / no_fog / default_ground → add rain FX / fog / the real wet-asphalt material.
  • sparse_eye_level → add restrained eye-level signage/props along the corridor.

Loop discipline

Fix ONLY the lowest checks each round (don't rebuild). save_current_level() after each fix. Stop when ≥ ~92, or after ~6 verifier runs, or when two rounds in a row improve < 1 point.

A note on honesty

The verifier scores geometry/metrics, not a screenshot. The exposure lock (references/lighting.md) can be missing while the score is high — so ALWAYS apply the exposure lock even though no single check fails loudly for it. A high score with a washed-out render is still a fail.

verifier/verify_cyberpunk_scene.py
# Cyberpunk-scene verifier v2 — class/geometry/material based, runs in-editor on CURRENT level.
# Adds: sign DIVERSITY + REPETITION, wet-ground+rain, sign-support (anti-float), building variety,
# litter-restraint. Emits metrics + issues + 0-100 score. Works on any cyberpunk map.
import unreal, json, math, collections, os, statistics
OUT = globals().get("OUT_OVERRIDE") or __import__("os").environ.get("OUT_OVERRIDE") or __import__("os").path.join(__import__("tempfile").gettempdir(), "verify_cyberpunk.json")
eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
actors = eas.get_all_level_actors()

SIGN_KW=("sign","neon","billboard","screen","holo","banner","led","market","hotel","emissive","arrow")
LAMP_KW=("street_light","streetlamp","lamp","light_tube","lampwall","cyberlight")
WALL_KW=("wall","set_0","build","facade","store_front","window","roof","corner","divider")
GROUND_KW=("plane","ground","road","asphalt","sidewalk","floor","pavement","tatil")
LITTER_KW=("litter","trash_bag","garbage","papel","lata","garrafa","copo","panfleto","debris","food_package")
DEFAULT_MATS=("worldgridmaterial","basicshapematerial","defaultmaterial","m_default","defaultdecalmaterial")

def L(v): return [round(v.x,1),round(v.y,1),round(v.z,1)]
def mat0(c):
    try:
        m=c.get_material(0); return m.get_name() if m else None
    except Exception: return None

GREEBLE_KW=("extractor","antenna","tube","energy","vent","pipe","cable","conditioner","crate",
            "barrel","pallet","pot","bush","bike","bucket","chair","scaffold","andaime","vase",
            "hologram","holograma","emissor","post","bollard","wall_detail","wall_boxes","utility",
            "balcony","overhang","awning","duct","trash_container","canvas","powervent","drain")
HOLO_KW=("holograma","hologram","holo","emissor")   # holograms MAY float (by design)
lights=[]; signs=[]; lamps=[]; walls=[]; ground=[]; litter=[]; greeble=[]; holos=[]; scaled_all=[]
fx=collections.Counter(); has=collections.Counter()
for a in actors:
    try:
        cn=a.get_class().get_name(); loc=a.get_actor_location()
        for cc,kd in ((unreal.PointLightComponent,"Point"),(unreal.SpotLightComponent,"Spot"),
                      (unreal.RectLightComponent,"Rect"),(unreal.DirectionalLightComponent,"Directional"),
                      (unreal.SkyLightComponent,"Sky")):
            cs=a.get_components_by_class(cc)
            if cs:
                lc=cs[0]; rec={"kind":kd,"loc":L(loc)}
                try: rec["intensity"]=round(lc.get_editor_property("intensity"),2)
                except Exception: pass
                try:
                    col=lc.get_editor_property("light_color"); rec["color"]=[col.r,col.g,col.b]
                except Exception: pass
                lights.append(rec); break
        if cn=="ExponentialHeightFog": has["fog"]+=1
        if cn in ("SphereReflectionCapture","BoxReflectionCapture"): has["reflcap"]+=1
        if cn=="PostProcessVolume": has["postprocess"]+=1
        if cn in ("NiagaraActor","Emitter"): fx[cn]+=1; has["fx"]+=1
        for c in a.get_components_by_class(unreal.StaticMeshComponent):
            try: sm=c.get_editor_property("static_mesh")
            except Exception: sm=None
            if not sm: continue
            nm=sm.get_name(); low=nm.lower(); m=mat0(c)
            try:
                sc=a.get_actor_scale3d(); scl=[round(sc.x,2),round(sc.y,2),round(sc.z,2)]
            except Exception: scl=[1,1,1]
            try:
                rr=a.get_actor_rotation(); rot=[round(rr.roll,1),round(rr.pitch,1),round(rr.yaw,1)]
            except Exception: rot=[0,0,0]
            try: lab=a.get_actor_label().lower()
            except Exception: lab=""
            rec={"mesh":nm,"mat":m,"loc":L(loc),"scale":scl,"rot":rot,"label":lab}
            scaled_all.append(rec)
            if any(k in low for k in HOLO_KW): holos.append(rec)
            elif any(k in low for k in LITTER_KW): litter.append(rec)
            elif any(k in low for k in LAMP_KW): lamps.append(rec)
            elif any(k in low for k in SIGN_KW): signs.append(rec)
            elif any(k in low for k in WALL_KW): walls.append(rec)
            elif any(k in low for k in GROUND_KW): ground.append(rec)
            elif any(k in low for k in GREEBLE_KW): greeble.append(rec)
            break
    except Exception: pass

def nn_med(pts):
    if len(pts)<2: return None
    ds=[]
    for i,p in enumerate(pts):
        b=min((math.hypot(p[0]-q[0],p[1]-q[1]) for j,q in enumerate(pts) if j!=i),default=1e18); ds.append(b)
    ds.sort(); return ds[len(ds)//2]
def is_warm(c): return c and c[0]>=c[2] and c[0]>150 and (c[0]-c[2])>15
def is_cool(c): return c and c[2]>=c[0] and (c[2]-c[0])>15
def is_sat(c):  return c and (max(c)-min(c))>60

issues=[]; metrics={}; score=0.0; maxs=0.0
def add(sev,kind,detail): issues.append({"severity":sev,"kind":kind,"detail":detail})
def award(got,mx,label):
    global score,maxs; score+=got; maxs+=mx; metrics.setdefault("_scoring",{})[label]=f"{round(got,1)}/{mx}"

# street length from FOREGROUND walls only (exclude the far background skyline so densities aren't diluted)
_wx=[w["loc"][0] for w in walls] or [0]; _wy=[w["loc"][1] for w in walls] or [0]
_cx=statistics.median(_wx); _cy=statistics.median(_wy)
fg=[w for w in walls if math.hypot(w["loc"][0]-_cx,w["loc"][1]-_cy)<7000] or walls
fxs=[w["loc"][0] for w in fg]; fys=[w["loc"][1] for w in fg]
# L/T-aware: sum both axis spans (straight street -> one span dominates)
street_len_m=((max(fxs)-min(fxs))+(max(fys)-min(fys)))/100.0
metrics["street_len_m"]=round(street_len_m,1)
metrics["counts"]={"lights":len(lights),"signs":len(signs),"lamps":len(lamps),"walls":len(walls),
                   "ground":len(ground),"litter":len(litter),"greeble":len(greeble),"holos":len(holos),"fx":has["fx"]}

# 1 NIGHT (8)
dirs=[l for l in lights if l["kind"]=="Directional"]; sun=max((l.get("intensity",0) for l in dirs),default=0)
metrics["sun_intensity"]=sun
award(8 if (not dirs or sun<=0.8) else (5 if sun<=1.5 else 0),8,"night_sun_off")  # allow a moonlight fill (user wants 'not too dark')
if dirs and sun>1.5: add("error","sun_on",f"sun {sun} — daytime")

# 1b NIGHT AMBIENT (5) — bright SkyLight floods walls and reads as daytime even with sun off
skies=[l for l in lights if l["kind"]=="Sky"]; skyint=max((l.get("intensity",0) for l in skies),default=0)
metrics["skylight_intensity"]=skyint
award(5 if skyint<=5 else (2.5 if skyint<=8 else 0),5,"night_ambient")  # allow a VISIBLE night (user: 'not too dark'); only penalize true daytime bleed
if skyint>8: add("error","ambient_too_bright",f"SkyLight {skyint} — washes scene to daytime; drop to <=5")
elif skyint>5: add("warn","ambient_brightish",f"SkyLight {skyint} — risk of daytime bleed; verify by screenshot")

# 2 LIGHT DENSITY (8)
dyn=[l for l in lights if l["kind"] in ("Point","Spot","Rect")]
dens=(len(dyn)/street_len_m*100) if street_len_m else 0; metrics["lights_per_100m"]=round(dens,1)
award(min(1,dens/30.0)*8,8,"light_density")
if dens<15: add("error","too_few_lights",f"{round(dens,1)}/100m")

# 3 WARM/COOL/SAT (8)
warm=[l for l in dyn if is_warm(l.get("color"))]; cool=[l for l in dyn if is_cool(l.get("color"))]; sat=[l for l in dyn if is_sat(l.get("color"))]
metrics["warm/cool/sat"]=[len(warm),len(cool),len(sat)]
g=(4 if warm and cool else 2 if warm or cool else 0)+(4 if len(sat)>=max(3,0.2*len(dyn)) else 2 if sat else 0)
award(g,8,"warm_cool_neon")

# 4 SIGN BAND SPREAD (8)
def band(z):
    z/=100.0; return "ground" if z<1 else "low" if z<3.5 else "mid" if z<8 else "high"
bands=collections.Counter(band(s["loc"][2]) for s in signs); metrics["sign_bands"]=dict(bands)
present=sum(1 for b in ("ground","low","mid","high") if bands.get(b,0)); g=present*1.5+(1 if bands.get("mid") else 0)+(1 if bands.get("high") else 0)
award(min(g,8),8,"sign_band_spread")
if not signs: add("error","no_signs","no signage")
elif present<=1: add("warn","signs_one_band","stratify sign heights")

# 5 LAMP SPACING (6)
pool=[l for l in dyn if l["kind"]=="Point" and is_warm(l.get("color"))]; sp=nn_med([l["loc"] for l in pool])
metrics["street_pool_spacing_cm"]=round(sp) if sp else None
award((6 if sp and 500<=sp<=1500 else 3 if sp and 300<=sp<=2200 else 1 if sp else 2),6,"lamp_spacing")

# 6 CORRIDOR BOTH SIDES (6) — works for X or Y running street
def both_sides(coord_idx, axis_idx):
    cs=[w["loc"][coord_idx] for w in walls]
    if not cs: return 0,0
    mid=statistics.median(cs); lo=sum(1 for c in cs if c<mid-150); hi=sum(1 for c in cs if c>mid+150); return lo,hi
loX,hiX=both_sides(1,0); loY,hiY=both_sides(0,1)
both=max(min(loX,hiX),min(loY,hiY)); metrics["corridor_minside"]=both
award(6 if both>=5 else 3 if both>=2 else 1,6,"corridor_both_sides")
if both<2: add("error","no_corridor","buildings not on both sides")

# 7 SIGN<->RECTLIGHT PAIRING (6)
rects=[l["loc"] for l in dyn if l["kind"]=="Rect"]; paired=sum(1 for s in signs if any(math.hypot(s["loc"][0]-r[0],s["loc"][1]-r[1])<350 and abs(s["loc"][2]-r[2])<250 for r in rects))
ratio=paired/len(signs) if signs else 0; metrics["sign_light_pair_ratio"]=round(ratio,2)
award(ratio*6,6,"sign_light_pairing")

# 8 GROUND PROPS NOT FLOATING (6)
gp=lamps+[x for x in litter]; floaters=[x for x in gp if x["loc"][2]>80]; metrics["floating_ground"]=len(floaters)
award((1-len(floaters)/max(1,len(gp)))*6,6,"no_floaters")

# 9 ATMOSPHERE (6)
g=(3 if has["fog"] else 0)+(2 if has["reflcap"] else 0)+(1 if has["postprocess"] else 0)
award(min(g,6),6,"atmosphere")
if not has["fog"]: add("warn","no_fog","add teal height fog")

# 10 LITTER RESTRAINT (4) — some, not a carpet
lit_per100=(len(litter)/street_len_m*100) if street_len_m else 0; metrics["litter_per_100m"]=round(lit_per100,1)
award((4 if 2<=lit_per100<=40 else 2 if lit_per100<=70 else 0 if lit_per100>70 else 1),4,"litter_restraint")
if lit_per100>70: add("warn","litter_overload",f"{round(lit_per100,1)} litter/100m — too much; thin it out")

# 10b NO MESH SCALING (8) — assemble/tile at scale 1; never brute-force scale meshes
_SCALE_OK_LBL=("high_neon","holo","hologram","skyglow","beam")  # sky holos/neon MAY vary in size (abstract glowing shapes — not assembled geometry)
def scaled(r):
    s=r.get("scale",[1,1,1])
    return any(abs(v-1.0)>0.3 for v in s) or (max(s)/max(0.01,min(s))>1.6)
bad=[r for r in scaled_all if scaled(r) and not any(k in r.get("label","") for k in _SCALE_OK_LBL)]
metrics["scaled_meshes"]=len(bad); metrics["total_meshes"]=len(scaled_all)
frac_ok=1.0-(len(bad)/max(1,len(scaled_all)))
award(8 if frac_ok>=0.98 else 6 if frac_ok>=0.9 else 3 if frac_ok>=0.75 else 0,8,"no_scaling")
if bad: add("warn","scaled_meshes",f"{len(bad)} meshes are non-1 scaled — assemble/tile instead of scaling (e.g. {bad[0]['mesh']} {bad[0]['scale']})")

# 10c DECOR / GREEBLE DENSITY (8) — rich street (greeble + holograms + signs), esp. low level
ndecor=len(greeble)+len(holos)+len(signs)
dec100=(ndecor/street_len_m*100) if street_len_m else 0
metrics["decor_total"]=ndecor; metrics["decor_per_100m"]=round(dec100,1)
award(min(1.0,dec100/70.0)*8,8,"decor_density")
if dec100<30: add("error","too_sparse",f"{round(dec100,1)} decor/100m — street too bare; add AC/pipes/tubes/holograms/signs")
elif dec100<70: add("warn","decor_lowish",f"{round(dec100,1)} decor/100m — add more detail (esp. low level)")

# 10d CITY BACKDROP (6) — distant towers fill the sky (no flat-sky giveaway)
allb=walls+greeble
cx=statistics.median([w["loc"][0] for w in walls]) if walls else 0
cy=statistics.median([w["loc"][1] for w in walls]) if walls else 0
far=[w for w in walls if math.hypot(w["loc"][0]-cx,w["loc"][1]-cy)>3000]
metrics["backdrop_buildings"]=len(far)
award(6 if len(far)>=40 else 4 if len(far)>=15 else 2 if len(far)>=5 else 0,6,"city_backdrop")
if len(far)<15: add("warn","no_backdrop","few distant buildings — add a background skyline so the sky isn't empty")

# 11 WET GROUND + RAIN (8)
gmats=collections.Counter((g["mat"] or "None") for g in ground); metrics["ground_materials"]=dict(gmats.most_common(5))
nondefault=sum(c for m,c in gmats.items() if m and m.lower() not in DEFAULT_MATS and m!="None")
frac_nd=nondefault/max(1,len(ground)); rain=fx.get("NiagaraActor",0)+fx.get("Emitter",0)
metrics["rain_fx_actors"]=rain
g=(5 if frac_nd>=0.8 else 3 if frac_nd>=0.4 else 0)+(3 if rain>=1 else 0)
award(g,8,"wet_ground_rain")
if frac_nd<0.4: add("error","default_ground","ground uses default material — apply MI_Ground_Asphalt (wet)")
if rain<1: add("warn","no_rain","no rain FX — place NS_Rain niagara actors")

# 12 SIGN DIVERSITY + REPETITION (12)
combos=collections.Counter((s["mesh"],s["mat"]) for s in signs)
meshes=collections.Counter(s["mesh"] for s in signs)
n=len(signs); distinct_combos=len(combos); distinct_meshes=len(meshes)
over2=sum(1 for k,c in combos.items() if c>2)
maxrep=max(combos.values()) if combos else 0
div_ratio=distinct_combos/max(1,n)
metrics["sign_diversity"]={"signs":n,"distinct_combos":distinct_combos,"distinct_meshes":distinct_meshes,
                           "combos_over_2":over2,"max_repeat":maxrep,"diversity_ratio":round(div_ratio,2)}
# the actual rule is "<=2 per combo": reward hitting it fully, then mesh + ratio variety.
# scale-aware: dense scenes WILL reuse combos — reward variety RATIO + mesh/combo spread,
# penalize only when a SINGLE combo dominates the scene (not absolute repeat counts).
g  = 6.0 * min(1.0, div_ratio/0.5)                # 6 pts: combo spread (full at ratio>=0.5)
g += min(3.0, distinct_meshes*0.4)                # up to 3 for mesh variety
g += 3.0 * min(1.0, distinct_combos/50.0)         # up to 3 for absolute combo count
if n>0 and maxrep>0.25*n: g -= 4.0*min(1.0,(maxrep-0.25*n)/(0.25*n))  # penalty only if 1 combo >25%
award(max(0,min(12,g)),12,"sign_diversity")
if div_ratio<0.35: add("warn","sign_repetition",f"low sign variety (ratio {div_ratio:.2f}, max_repeat {maxrep}) — vary materials/meshes")
if distinct_meshes<4 and n>=6: add("warn","few_sign_meshes",f"only {distinct_meshes} sign meshes — add variety")

# 13 SIGNS MOUNTED / NOT FLOATING (8) — high signs must sit near a wall/base/frame (support) or roof
SUPPORT_KW=("base","frame","support","plate","tower","pole","bracket","scaffold")
support_xy=[(w["loc"][0],w["loc"][1]) for w in walls]
for s in signs:                       # screen bases / frames also count as supports
    if any(k in s["mesh"].lower() for k in SUPPORT_KW): support_xy.append((s["loc"][0],s["loc"][1]))
def near_wall(s, rad=1500):   # big assembled building chunks: actor center is far from edges
    return any(abs(s["loc"][0]-wx)<rad and abs(s["loc"][1]-wy)<rad for wx,wy in support_xy)
high=[s for s in signs if s["loc"][2]>300]
unsupported=[s for s in high if not near_wall(s)]
metrics["high_signs"]=len(high); metrics["unsupported_high_signs"]=len(unsupported)
award((1-len(unsupported)/max(1,len(high)))*8,8,"signs_supported")
if unsupported: add("error","floating_signs",f"{len(unsupported)} elevated signs float with no wall/support nearby")

# 14 BUILDING VARIETY (6) — wall material variety + height variance (anti-monotony)
wmats=collections.Counter(w["mat"] for w in walls if w["mat"]); zmax=[w["loc"][2] for w in walls]
hvar=statistics.pstdev(zmax) if len(zmax)>3 else 0
metrics["wall_material_variety"]=len(wmats); metrics["facade_z_stdev"]=round(hvar,0)
g=min(3,len(wmats)*0.6)+min(3, hvar/250.0)
award(min(6,g),6,"building_variety")
if len(wmats)<2: add("warn","monotone_walls","walls all one material — vary plaster/brick/tiles")

# 15 HERO NEON + LIGHT POLLUTION (6) — a few GIANT signs + bright glow, not all small
HERO_KW=("bigscreen","wallscreen","barscreen","screenplate","hologramatext","billboard","menuscreen","placa")
heroes=[s for s in signs+holos if any(k in s["mesh"].lower() for k in HERO_KW)]
rect_int=[l.get("intensity",0) for l in dyn if l["kind"]=="Rect"]
bright=[i for i in rect_int if i>=8]
metrics["hero_signs"]=len(heroes); metrics["bright_neon_lights"]=len(bright)
g=(4 if len(heroes)>=3 else 2 if len(heroes)>=1 else 0)+(2 if len(bright)>=2 else 1 if bright else 0)
award(min(6,g),6,"hero_neon")
if len(heroes)<3: add("warn","no_hero_neon","add a few GIANT hero signs (BigScreen/WallScreen/HologramaText) — too uniform/small")
if not bright: add("warn","no_light_pollution","no bright hero RectLights (>=8cd) — boost glow/bloom for light-pollution feel")

# 16 SIGN ORIENTATION (6) — a sign's FACE must be vertical (not lying flat). pitch ~0 (upright mesh)
# OR pitch ~+-90 (a flat-modeled mesh deliberately stood up by attach_sign) are BOTH fine; only a
# random in-between pitch, or any roll, reads as "facing up/down / tilted".
def _bad_pitch(p):
    p=abs(p)%180; return not (p<=20 or p>=160 or 70<=p<=110)   # OK near 0/180 (upright) or ~90 (stood-up flat)
tilted=[s for s in signs if _bad_pitch(s.get("rot",[0,0,0])[1]) or abs(s.get("rot",[0,0,0])[0])>20]
metrics["tilted_signs"]=len(tilted)
award((1-len(tilted)/max(1,len(signs)))*6,6,"sign_orientation")
if tilted: add("error","tilted_signs",f"{len(tilted)} signs at a bad pitch/roll (face not vertical) — pitch must be ~0 or ~+-90 (flat meshes), roll=0 (e.g. {tilted[0]['mesh']} rot={tilted[0]['rot']})")

# 17 SPATIAL ANTI-REPETITION (6) — same mesh must not cluster; identical objects need x/y/z spread
def clusters(items, rad=1300):
    bym=collections.defaultdict(list)
    for it in items: bym[(it["mesh"],it.get("mat"))].append(it["loc"])  # material variation breaks a cluster
    dup=0
    for mp,locs in bym.items():
        for i in range(len(locs)):
            for j in range(i+1,len(locs)):
                a_,b_=locs[i],locs[j]
                if abs(a_[0]-b_[0])<rad and abs(a_[1]-b_[1])<rad and abs(a_[2]-b_[2])<rad:
                    dup+=1; break
    return dup
dup_signs=clusters(signs+greeble); ditems=len(signs)+len(greeble); dup_ratio=dup_signs/max(1,ditems)
metrics["clustered_duplicates"]=dup_signs; metrics["dup_ratio"]=round(dup_ratio,2)
# scale-aware: judge the FRACTION clustered, not an absolute count (dense scenes reuse meshes)
award(6 if dup_ratio<0.25 else 4 if dup_ratio<0.45 else 2 if dup_ratio<0.65 else 0,6,"anti_repetition")
if dup_ratio>=0.45: add("warn","repeated_elements",f"{dup_signs}/{ditems} same mesh+mat clustered <13m (ratio {dup_ratio:.2f}) — vary mesh/material or spread")

# 18 NO COLLISIONS (6) — signs/props overlapping buildings or each other (approx AABB via cached bbox radius)
_bbcache={}
def radius(mp):
    if mp in _bbcache: return _bbcache[mp]
    r=80.0
    try:
        m=unreal.load_asset_by_name(mp) if hasattr(unreal,'load_asset_by_name') else None
    except Exception: m=None
    _bbcache[mp]=r; return r
# lightweight overlap: a sign/greeble center sitting INSIDE another sign/greeble within 90cm in all axes
movable=signs+greeble+holos
coll=0
for i in range(len(movable)):
    for j in range(i+1,len(movable)):
        a_,b_=movable[i]["loc"],movable[j]["loc"]
        if abs(a_[0]-b_[0])<90 and abs(a_[1]-b_[1])<90 and abs(a_[2]-b_[2])<120:
            coll+=1
mov_n=len(movable); coll_ratio=coll/max(1,mov_n)
metrics["overlapping_props"]=coll; metrics["coll_ratio"]=round(coll_ratio,2)
# scale-aware: judge the FRACTION overlapping, not an absolute count
award(6 if coll_ratio<0.05 else 4 if coll_ratio<0.12 else 2 if coll_ratio<0.2 else 0,6,"no_collisions")
if coll_ratio>=0.12: add("warn","prop_collisions",f"{coll}/{mov_n} props overlap (<90cm, ratio {coll_ratio:.2f}) — offset by size")

# 18b DISTANT BUILDING LIGHTING (6) — the far megastructures/bg must be LIT (coloured point lights / search
# beams), not read as black silhouettes. Score the light distribution out on the skyline.
def _pfx(a): return a.get_actor_label().rstrip("0123456789_ ")
far_b = sum(1 for a in actors if _pfx(a) in ("megastructure","bg"))
dist_l = sum(1 for a in actors if _pfx(a) in ("mega_light","beam"))
beams_n = sum(1 for a in actors if _pfx(a)=="beam")
lit_ratio = dist_l/max(1,far_b)
metrics["distant_lighting"]={"far_buildings":far_b,"distant_lights":dist_l,"beams":beams_n,"ratio":round(lit_ratio,2)}
g=(5 if lit_ratio>=0.3 else 3 if lit_ratio>=0.15 else 1 if lit_ratio>=0.05 else 0)+(1 if beams_n>=4 else 0)
award(min(6,g),6,"distant_lighting")
if lit_ratio<0.15: add("warn","dark_skyline",f"far buildings barely lit ({dist_l} distant lights / {far_b} bg+mega) — add coloured point lights + search beams (cyber_kit.light_megastructures)")

# 19 HOLOGRAMS PRESENT IN MID-AIR (6) — the required floating holograms must actually exist AND float
midair_holos=[h for h in holos if 600<=h["loc"][2]<=1600 and "emissor" not in h["mesh"].lower()]
metrics["midair_holograms"]=len(midair_holos)
award(6 if len(midair_holos)>=3 else 3 if len(midair_holos)>=1 else 0,6,"holograms_present")
if len(midair_holos)==0:
    add("error","no_holograms","NO floating holograms in mid-air (z 600-1600). Place 3+ SM_Holograma_*/SM_HologramaText at z 700-1500 OVER the street (not flush on walls) + an SM_EmissorHolografico projector below each. These are REQUIRED and must be clearly visible.")
elif len(midair_holos)<3:
    add("warn","few_holograms",f"only {len(midair_holos)} mid-air holograms — add more clearly-visible floating ones")

# 20 LAMP ORIENTATION (6) — lamps must come in MIRRORED rows facing the street. Works for L-corners
# (the old left/right median split assumed a single straight street and defaulted to 3 on the L).
if len(lamps)>=4:
    def _yaw(l): return round(l.get("rot",[0,0,0])[2])%360
    yset=set(_yaw(l) for l in lamps)
    def _has_opp(y):
        o=(y+180)%360; return any(abs(((yy-o+180)%360)-180)<25 for yy in yset)
    mirrored=sum(1 for l in lamps if _has_opp(_yaw(l)))
    frac=mirrored/len(lamps); metrics["lamp_mirror_frac"]=round(frac,2)
    award(6 if frac>=0.6 else 4 if frac>=0.3 else 2,6,"lamp_orientation")
    if frac<0.6:
        add("warn","lamps_not_mirrored",f"only {round(frac*100)}% of lamps have a mirrored-yaw counterpart — face both rows to the street centre (each row's yaw 180deg apart)")
else:
    award(3,6,"lamp_orientation")

# 21 EYE-LEVEL SIGN DENSITY (6) — the low+mid bands (z 150-800) must be PACKED with neon/billboards
eye=[s for s in signs if 150<=s["loc"][2]<=800]
eye100=(len(eye)/street_len_m*100) if street_len_m else 0
metrics["eye_level_signs_per_100m"]=round(eye100,1)
award(6 if eye100>=18 else 4 if eye100>=10 else 2 if eye100>=4 else 0,6,"eye_level_density")
if eye100<10:
    add("warn","sparse_eye_level",f"only {round(eye100,1)} signs/100m at eye level (z 150-800) — pack the mid & low bands with many more neon + billboards (richness)")

# 22 L-CORNER SHAPE (6) — the street must BEND (two legs), not be a straight tunnel
_lx=[w["loc"][0] for w in fg]; _ly=[w["loc"][1] for w in fg]
xext=(max(_lx)-min(_lx)) if _lx else 0; yext=(max(_ly)-min(_ly)) if _ly else 0
metrics["wall_extent_xy"]=[round(xext),round(yext)]
both_legs=min(xext,yext)   # L-corner -> BOTH long; straight street -> one axis is just the ~street width
award(6 if both_legs>=4000 else 3 if both_legs>=2500 else 0,6,"l_corner_shape")
if both_legs<2500:
    add("error","not_l_shaped",f"walls span {round(xext)}x{round(yext)}cm — this is a STRAIGHT street (the short axis is just the street width). Build a real L-CORNER: a 2nd leg perpendicular to the 1st, both legs long, the bend blocking the far view.")

final=round(score/maxs*100,1) if maxs else 0
report={"map":ues.get_editor_world().get_path_name(),"score":final,"raw":f"{round(score,1)}/{round(maxs,1)}",
        "metrics":metrics,"issues":sorted(issues,key=lambda i:{"error":0,"warn":1,"info":2}.get(i["severity"],3))}
with open(OUT,"w") as f: json.dump(report,f,indent=1,default=str)
print("="*64); print(f"CYBERPUNK SCENE SCORE: {final}/100   ({report['raw']})"); print(f"map: {report['map']}"); print("-"*64)
for k,v in metrics["_scoring"].items(): print(f"  {k:22s} {v}")
print("-"*64)
print("diversity:", json.dumps(metrics.get("sign_diversity",{})))
print("ground_mats:", metrics.get("ground_materials")); print("rain_fx:", metrics.get("rain_fx_actors"), "| litter/100m:", metrics.get("litter_per_100m"), "| unsupported_high_signs:", metrics.get("unsupported_high_signs"))
print("-"*64)
for i in report["issues"][:18]: print(f"  [{i['severity']:5s}] {i['kind']}: {i['detail']}")
print("WROTE",OUT)