Retail's Region dat stores SkyObjectReplace.Luminosity / Transparent /
MaxBright as percentages in the 0..100 range. Our shader expects
fractions in 0..1. We were passing raw values (luminosity up to 100)
straight into the sky fragment shader's rgb-multiplier:
rgb = sampled.rgb * uTint.rgb * uLuminosity;
At the "Sunny" DayGroup's noon keyframes (verified via live diag),
Luminosity = 100 → shader multiplied the cloud texture RGB by 100 →
min(rgb, vec3(1.2)) clamped all channels to 1.2 → pure white sky.
Also gave the dawn/dusk purple sky effect on top of the pale texture.
Fix: SkyDescLoader.ConvertTimeOfDay divides Luminosity, Transparent
and MaxBright by 100 when loading each SkyObjectReplace. The Rotate
field stays as degrees (values like 270° are genuine headings, not
percentages).
Transparent was accidentally surviving via a 0..1 clamp downstream,
but we fix it for consistency and so brightness-attenuating values
in the 0..99 range (partial fade during dawn/dusk) work correctly
instead of rounding to full-transparent.
WorldBuilder's SkyboxRenderManager does NOT apply these fields at
all — that's why they never hit this bug. Our port applies them for
per-keyframe day-night fades, so we needed the unit conversion.
Also picked up in this commit (incidental, already running):
- Sky render: per-submesh blend mode from TranslucencyKind.Additive
for sun/moon-style self-bright objects (Additive bit 0x10000).
Luminous flag 0x40 intentionally NOT mapped to additive — that
flag is on the sky dome + cloud sheets and making them additive
produced the previous "fully white" iteration of this bug.
- ToD default seed: DayTicks/16 (Midsong = hour 9 = true noon)
instead of DayTicks*0.5 which landed on Gloaming-and-Half (sunset)
due to DerethDateTime's +7/16 day-fraction offset. Pre-TimeSync
view now correctly starts at noon.
- Lightning flash: brighter white-blue (vec3(1.5,1.5,1.8)) instead
of dim grey; ceiling relaxed during flash so the strobe actually
blows out. Cadence (strike intervals, decay) unchanged.
- Saved docs/research/2026-04-21-sky-deep-audit.md with the
decompile+ACE+ACME+WorldBuilder research done to corner this bug.
Open follow-up (not fixed here): sky clouds are white at noon /
don't get the dusk/night purple tint. Our sky shader is fully unlit
— doesn't apply sun/ambient directional light like the terrain
shader does. AmbientColor in the keyframe data carries the right
tint (purple at midnight, magenta at dusk) but we pass
uTint = Vector4.One instead of the keyframe value. Next commit will
wire directional-sun + ambient into sky.frag so cloud meshes pick
up the time-of-day color.
All 717 tests green. User-confirmed: sky colors are now "much
better" after this change (previously fully white).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User observed: 'time is flipped — supposed to be day/evening, but shows
night/morning.' That's a ~half-day offset.
Root cause in ACE DerethDateTime.cs line 23:
private const double dayZeroTicks = 0; // Morningthaw 1, 10 P.Y. - Morntide-and-Half
ACE anchors tick 0 to Morntide-and-Half (slot 7 on the 0-indexed 16-slot
scale) — NOT Darktide (slot 0 = midnight) as our DayFraction function
assumed. Confirmed by DerethDateTime.cs:145:
private int hour = (int)Hours.Morntide_and_Half;
Fix: shift DayFraction by +7/16 * DayTicks (3333.75) so tick 0 maps to
its real calendar slot. Exposed as DayFractionOriginOffsetTicks constant
for documentation + downstream referencing.
Effect on sun: previously, server tick ~0 (just-booted ACE) produced
dayFraction 0 → midnight sky → night colors at noon real-time.
Now dayFraction 7/16 = 0.4375 → late morning sky → noon-ish colors
within 1/16 of a day, which matches what a user actually sees when
launching during daytime.
Tests updated for the corrected convention:
- DerethDateTime.DayFraction(0) = 7/16 (not 0).
- CurrentHour(0) = MorntideAndHalf (not Darktide).
- IsDaytime(0) = true.
- Midnight (Darktide, slot 0) is 9/16 of a day past tick 0.
- SkyState + WorldTimeDebug tests retargeted to the new frame.
Build green, 711 tests pass.
Ref: references/ACE/Source/ACE.Common/DerethDateTime.cs:23-25 + :145.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expand the SkyKeyframe record with retail-exact fog fields (FogStart,
FogEnd, FogMode) per r12 §5. The existing FogDensity field is retained
for backwards compat with tests that pin it; new shipping code reads
FogStart / FogEnd / FogMode directly.
Add WeatherSystem (WeatherKind + EnvironOverride enum + 10s transition
ease + deterministic per-day-index roll) matching r12 §6.1. Roll weights
are ~60% Clear / 20% Overcast / 12% Rain / 5% Snow / 3% Storm — tuned
against retail observations. Storm mode triggers lightning flashes
every 8–30 s via an exponential-decay (200ms τ) flash level that the
shader consumes as an additive scene bump.
Add SkyDescLoader that parses the Region dat (0x13000000) into
LoadedSkyDesc — DayGroupData with SkyObjectData (visibility window +
arc sweep), per-keyframe SkyObjectReplaceData, and a shader-ready
SkyStateProvider builder. Sun/ambient colors are pre-multiplied by
DirBright/AmbBright so the shader never needs to know about retail's
scalar brightness field.
19 new tests (weather determinism, transition ease, environ override
tint, flash decay, dat-load conversion with fog + pre-mult colors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Client-side deterministic sky + weather + day/night system per R12.
Retail's model is 95% client-side: the server just delivers its
current PortalYearTicks (double, seconds since boot-seed) at login and
in TimeSync packets; the client computes everything else locally from
the constants in r12 §1.2 + ACE DerethDateTime.cs.
Core layer (AcDream.Core/World):
- DerethDateTime: retail-exact calendar (16 hours/day, 30 days/month,
12 months/year, 7620 ticks/day, 2,743,200 ticks/year). HourName enum
covers all 16 named half-hour slots (Darktide → GloamingAndHalf);
MonthName covers the 12 Derethian months (Snowreap → Frostfell).
DayFraction, CurrentHour, IsDaytime, ToCalendar.
- SkyKeyframe + SkyStateProvider: 4-keyframe default day/dawn/noon/dusk
with linear color + angular-wrap heading interpolation + slerp-like
shortest-arc lerp so heading wraps 350° → 10° don't tween backwards
through 180°. Default keyframe colors tuned to retail screenshots
(sunrise warm, noon white, sunset red, midnight deep blue).
- WorldTimeService: owns the live clock. SyncFromServer(ticks) sets
baseline; NowTicks advances by real-time elapsed. Exposes DayFraction,
CurrentSky, CurrentSunDirection, IsDaytime for the render thread.
This is the foundation Phase G.2 (dynamic lighting) consumes: lighting
uniforms are fed from CurrentSky's SunColor / AmbientColor / sun
direction, varying smoothly across the day.
Tests (16 new):
- DerethDateTime: midnight, half-day, wrap, Dawnsong, Midsong,
day/night flag at dawn vs Darktide-Half, year rollover, month
advance.
- SkyState: 4-default keyframes, noon-exact matches frame data,
midpoint lerps between neighbours, wrap across midnight doesn't
produce NaN, sun direction returns unit vector, WorldTimeService
sync + DayFraction at noon.
Build green, 587 tests pass (up from 570).
Ref: r12 §1 (Portal Year math), §2 (sky objects), §4 (color lerp).
Ref: ACE DerethDateTime.cs + NetworkSession TimeSync handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the first on-screen HUD for the dev client plus today's mouse-control
refinements. Also lands yesterday's scenery-alignment changes that were
left uncommitted in the working tree.
Overlay:
- BitmapFont rasterizes a system TTF via StbTrueTypeSharp into a 512x512
R8 atlas at startup (Consolas on Windows, DejaVu/Menlo fallbacks)
- TextRenderer batches 2D quads in screen-space with ortho projection;
one shader + two draw calls (rect then text) for panel backgrounds
under glyphs
- DebugOverlay composes info / stats / compass / help panels on top of
the 3D scene; toggles via F1/F4/F5/F6; transient toasts for key events
- DebugLineRenderer and its shaders (carried over from the scenery work)
are properly committed in this commit
Controls:
- Per-mode mouse sensitivity (Chase 0.15, Fly 1.0, Orbit 1.0); F8/F9 to
adjust the active mode multiplicatively (x1.2)
- Hold RMB to free-orbit the chase camera around the player; release
stays at the new angle (no snap-back)
- Mouse-wheel zooms chase distance between 2m and 40m
- Chase pitch widened to [-0.7, 1.4] so mouse-Y tilts both ways from
the default neutral angle
Scenery alignment (carried from yesterday's session):
- ShadowObjectRegistry AllEntriesForDebug + Scale field
- SceneryGenerator uses ACViewer's OnRoad polygon test + baseLoc +
set_heading rotation
- BSPQuery dispatchers accept localToWorld so normals/offsets transform
correctly per part
- TransitionTypes.CylinderCollision rewritten with wall-slide + push-out
- PhysicsDataCache caches visual-mesh AABB for scenery that lacks
physics Setup bounds
Port ACME's EnvCellManager portal visibility system:
- New CellVisibility class: BFS portal traversal from camera cell,
portal-side clip-plane test, FindCameraCell with grace period
- LoadedCell data populated during streaming (portals, clip planes,
world/inverse transforms, local AABB from CellStruct vertices)
- WorldEntity.ParentCellId tags interior entities for filtering
- InstancedMeshRenderer.Draw accepts optional visibleCellIds set —
interior entities whose parent cell isn't visible are skipped
- Conditional depth clear between terrain and static mesh when
camera is inside a cell (ACME GameScene.cs pattern)
When camera is outdoors, all interiors render (visibleCellIds=null).
When camera enters a building, only BFS-reachable cells render.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE: MarkPersistent(chosen.Id) stored the SERVER GUID (e.g.
0x5000000A) but RemoveLandblock checked entity.Id which is the LOCAL
sequential counter (e.g. 42). Different number spaces → never matched
→ persistent rescue never triggered → player entity lost on unload.
Fix:
- Added WorldEntity.ServerGuid field (0 for dat-hydrated scenery)
- Live entity spawn sets ServerGuid = spawn.Guid
- RemoveLandblock checks entity.ServerGuid against _persistentGuids
- MarkPersistent still stores the server GUID (correct)
This bug has been reported across multiple sessions as "character
disappears when walking far." The neverCullLandblockId fix only
prevented frustum culling but didn't prevent the entity from being
removed from the render list entirely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Performed a side-by-side comparison of every LCG formula in SceneryGenerator.cs
against the decompiled retail acclient.exe (Ghidra output):
Scene-selection hash chunk_00530000.c:1144 — MATCH (0x2a7f2b89·x+0x6c1ac587)·y - 0x421be3bd·x + 0x7f8cda01
Per-object frequency chunk_00530000.c:1168-74 — MATCH accumulator pattern cellMat2*(0x5b67+j)
X displacement chunk_005A0000.c:4858-66 — MATCH offset 0xb2cd=45773
Y displacement chunk_005A0000.c:4871-78 — MATCH offset 0x11c0f=72719
Quadrant rotation chunk_005A0000.c:4880-4902 — MATCH constants 0x6f7bd965/0x421be3bd/-0x17fcedfd
Object rotation hash chunk_005A0000.c:4924-26 — MATCH offset 0xf697=63127
Scale hash ACViewer ObjectDesc.cs — MATCH offset 0x7f51=32593 (chunk not dumped)
Key finding: the decompiled client normalises signed-int LCG values with
"if (val < 0) val += 2^32" before dividing by 2^32. Our unchecked((uint)(...))
is exactly equivalent. ACViewer's reference omits this cast for some formulas
(displacement, rotation) and is subtly wrong for those; our implementation
already had the correct uint cast throughout.
Added inline decompiled-source citations to all five algorithm sites plus
an updated class-level doc comment noting the audit status and implementation note.
No behaviour change — comments only.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four fixes from the ACME StaticObjectManager cross-reference:
1. GfxObjMesh: normalize vertex normals (1d). Dat normals may not be
unit-length; without normalization, lighting is wrong per-vertex.
2. SetupMesh: add third-fallback placement frame (2a). If neither
Resting nor Default exists, use the first available frame from
PlacementFrames. Matches ACME's GetDefaultPlacementFrame.
3. SceneryGenerator: building cell exclusion (4d). Compute which
terrain vertices have buildings (from LandBlockInfo.Objects +
Buildings), skip scenery spawns in those cells. Prevents trees
from spawning inside building footprints.
4. SceneryGenerator: slope filter (4e). Compute terrain normal Z at
each displaced position and check against ObjectDesc.MinSlope /
MaxSlope bounds. Prevents trees from spawning on cliff faces.
Also confirmed 4f (scenery Z=0) is NOT a bug — GameWindow's hydrator
lifts scenery to terrain Z at line 1213. The Z=0 in SceneryGenerator
is a placeholder correctly overridden at render time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Four targeted fixes for user-reported movement/visual bugs:
1. Player entity disappearing: GpuWorldState now supports persistent
entities (MarkPersistent/DrainRescued). The player character survives
landblock unloads and gets re-injected into the streaming window at
the current center landblock.
2. Feet sinking into terrain: +0.15 Z bias in PlayerMovementController
keeps the character model above terrain z-fighting edge cases.
3. Camera after portal teleport: ChaseCamera.Update now called
immediately after teleport snap so the camera recenters on the new
position instead of lingering at the pre-teleport location.
4. Scenery on roads: SceneryGenerator now checks road status at the
final displaced position (not just the origin vertex), catching
objects that drift from non-road vertices onto road cells.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The IsRoadVertex check and helper were dropped by a linter pass after the
previous commit. Re-adding them explicitly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Makes NPCs and other server-spawned entities actually move and
transition animations based on the live server feed. Before this,
Phase 6.6/6.7 only parsed the messages and fired events that nothing
consumed, so NPCs stayed frozen at their CreateObject spawn point
playing one idle cycle forever.
Changes:
- GameWindow now keeps a parallel _entitiesByServerGuid dictionary
built at CreateObject hydration time so motion / position updates
can find the target entity by its server guid.
- WorldEntity.Position and Rotation become get/set (like MeshRefs did
in Phase 6.4) so the position-update handler can reseat an existing
entity in place without reallocating MeshRefs.
- OnLiveMotionUpdated re-resolves the cycle via MotionResolver using
the server's new (stance, forward-command) override and either
swaps the AnimatedEntity's current cycle or removes it from the
animated set if the new pose is static.
- OnLivePositionUpdated translates the new landblock-local position
into acdream world space (same math as CreateObject hydration) and
writes it back onto the entity.
Subscriptions are added alongside the existing EntitySpawned hook so
the three handlers run synchronously on the UDP pump thread, matching
the existing pattern.
194 tests green (98 Core + 96 Core.Net).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 6.1-6.3 resolved the right cycle and rendered its first frame
as a static pose. Phase 6.4 actually walks the cycle over time so
creatures, characters, and props animate their idle motion — the
breathing the user noticed was missing after Phase 6.1.
MotionResolver gains GetIdleCycle() returning IdleCycle(Animation,
LowFrame, HighFrame, Framerate). The existing GetIdleFrame() now
shares a private ResolveIdleCycleInternal helper, so the resolution
algorithm (motion-table override, stance/command priority, fallback)
is identical for both entry points and stays in one place.
WorldEntity.MeshRefs becomes a get/set so the per-frame tick can
swap in fresh per-part transforms without rebuilding the entity.
Static decorations never get touched.
GameWindow keeps a Dictionary<entityId, AnimatedEntity> for entities
whose motion table resolved to a multi-frame, non-zero-framerate
cycle. AnimatedEntity caches a per-part template (gfxObjId +
surfaceOverrides + scale) snapshot taken from the hydration pass so
the tick doesn't redo AnimPartChange/TextureChange resolution every
frame — only the per-part transform matrices are recomputed.
OnRender calls TickAnimations(dt) before Draw. The tick advances each
entity's CurrFrame by dt*Framerate, wraps it inside [LowFrame, HighFrame],
samples the corresponding AnimationFrame, and rebuilds the entity's
MeshRefs by composing scale → quaternion rotate → translate per part
in the same order SetupMesh.Flatten uses, then baking the entity's
ObjScale on top in the same PartTransform * scaleMat order as the
hydration path.
160 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements the other half of ObjDesc: SubPalettes (palette-range
overlays) that repaint palette-indexed textures with per-entity color
schemes. Ported algorithm from ACViewer Render/TextureCache.IndexToColor
after the user pointed out I was prematurely implementing from scratch
instead of checking all the reference repos.
The Nullified Statue of a Drudge sends (setup=0x020007DD with a drudge
GfxObj animPart replacing part 1, plus 2 texChanges targeted at part 1,
plus 1 subpalette id=0x04001351 offset=0 length=0). The TextureChanges
swap fine detail surfaces; the SubPalette with length=0 ("entire palette"
per Chorizite docs) remaps the drudge's flesh-tone palette to stone.
Without this commit, the statue looked like a normal flesh drudge
because palette-indexed textures decoded with the base flesh palette.
Added:
- Core/World/PaletteOverride.cs: per-entity record carrying
BasePaletteId + a list of (SubPaletteId, Offset, Length) range
overlays. Documents the "offset/length are wire-scaled by 8"
convention and the "length=0 means whole palette" sentinel.
- WorldEntity.PaletteOverride nullable field. Per-entity (same across
all parts), in contrast to MeshRef.SurfaceOverrides which is per-part.
- TextureCache.GetOrUploadWithPaletteOverride: new entry point that
composes the effective palette at decode time. Composite cache key
is (surfaceId, origTexOverride, paletteHash) so entities with
equivalent palette setups share the GL texture.
- ComposePalette: ports ACViewer's IndexToColor overlay loop:
for each subpalette sp:
startIdx = sp.Offset * 8 // multiply back from wire
count = sp.Length == 0 ? 2048 : sp.Length * 8 // sentinel
for j in [0, count):
composed[j + startIdx] = subPal.Colors[j + startIdx]
Critical detail: copies from the SAME offset in the sub palette, not
from [0]. Both base and sub are treated as full palettes sharing an
index space.
- StaticMeshRenderer.Draw: three-way switch on (entity.PaletteOverride,
meshRef.SurfaceOverrides) picks the right TextureCache path:
- Both → palette override (it handles origTex override internally)
- Only tex override → GetOrUploadWithOrigTextureOverride
- Neither → plain GetOrUpload
- GameWindow.OnLiveEntitySpawned: builds PaletteOverride from
spawn.BasePaletteId + spawn.SubPalettes when the server sent any.
Reference note: the user asked "but I mean THIS MUST BE IN WORLDBUILDER"
which was the right push. WorldBuilder is actually a dat VIEWER and its
ClothingTableBrowserViewModel is a 10-line stub — it doesn't apply
palette overlays because it doesn't need to. The actual algorithm lives
in ACViewer (a MonoGame character viewer), which I should have checked
earlier. CLAUDE.md updated with a standing rule: always cross-reference
all four of references/ACE, ACViewer, WorldBuilder, Chorizite.ACProtocol,
plus holtburger. A single reference can be misleading; the intersection
is usually the truth.
Tests: 77 core + 83 net = 160, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Finishes the TextureChange half of ObjDesc. Characters' clothing now
renders with correct per-part textures (user-verified "looks good"
after previous "partial coverage" / "wrong clothes"). The Nullified
Statue still looks like a flesh-colored drudge because the statue's
color comes from SubPalettes (palette-indexed texture recoloring),
which is the remaining major Phase 5 piece.
The first attempt at TextureChange application was silently broken by
an ID-type mismatch: the server encodes OldTexture/NewTexture as
SurfaceTexture (0x05XXXXXX) ids, but my sub-meshes are keyed by
Surface (0x08XXXXXX) ids. The override dict was keyed by one type
and looked up by the other, so TryGetValue never hit and no override
actually applied.
Diagnosed via Phase 1 systematic debugging with resolve-level logging:
live: spawn +Acdream texChanges=20
live: texChange part=0 old=0x05000BB0 new=0x0500025D
...
live: resolve part=0 surface=0x08000519 origTex=0x05000BB0 [MATCH]
live: resolve part=0 surface=0x0800051C origTex=0x05000CBE [MATCH]
... 10/10 lines [MATCH]
The [MATCH] lines proved the server's OldTexture IS reachable via a
Surface→OrigTextureId lookup, just needed keying by the right value.
Fix:
- TextureCache.GetOrUploadWithOrigTextureOverride(surfaceId, origTexOverride):
loads the base Surface dat for its color/flags/palette, but
substitutes the override SurfaceTexture id in the decode chain.
Caches under a (surfaceId, origTexOverride) composite key.
- MeshRef.SurfaceOverrides is now Dictionary<uint, uint> keyed by
Surface id, value = replacement OrigTextureId. Null means no
overrides.
- GameWindow.OnLiveEntitySpawned now does TWO passes when texture
changes are present:
1. Group the raw server changes by PartIndex into (oldOrigTex →
newOrigTex) dicts
2. For each affected part's post-animPartChange GfxObj, iterate
its Surfaces list, resolve each Surface → OrigTextureId, and
if that matches a raw change's oldOrigTex, write an entry
Surface id → newOrigTex into the final override map
- StaticMeshRenderer.Draw: when sub-mesh surface id has an override,
call GetOrUploadWithOrigTextureOverride instead of GetOrUpload.
Verified live: +Acdream's clothing renders correctly, NPCs are
"much better" (characters previously naked are now dressed). Statue
has the full mechanical pipeline working (resolve diagnostic shows
2/2 Surfaces [MATCH] for the statue's override dict) but its visible
color comes from the separate SubPalette overlay that isn't wired yet.
Also added a statue-targeted diagnostic block that dumps its full
ObjDesc contents (texChanges + subPalettes + animPartChanges) by
name match, which is how I traced the Nullified Statue of a Drudge's
specific ObjDesc. Lives under `if (isStatue && ...)` so normal logins
aren't spammed.
Cross-referenced against two new references this session:
* references/Chorizite.ACProtocol (cloned from github.com/Chorizite/
Chorizite.ACProtocol.git on user's suggestion) — confirms the
ObjDesc field order and PackedDword-of-known-type convention.
* references/WorldBuilder/... (already in repo) — confirms the
Surface→OrigTexture→SurfaceTexture→RenderSurface chain and the
P8/INDEX16 palette decode path.
Tests: 77 core + 83 net = 160, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds SceneryGenerator.Generate which walks Region.TerrainInfo.TerrainTypes
+ Region.SceneInfo.SceneTypes for each landblock vertex, selects a scene
using the AC client's pseudo-random LCG hash of global cell coordinates,
then rolls each ObjectDesc's frequency, computes a displaced cell-local
position, random scale, and random rotation — the exact algorithm
ACViewer ports from the retail AC client's get_land_scenes().
Phase 2 rendered 239 explicit Stab+Building entities on the 3x3 Holtburg
grid but was missing every procedurally-placed tree, bush, rock, fence,
and small decoration because these are not stored as LandBlockInfo entries.
This adds 419 scenery entities across the same 9 landblocks, bringing the
total to 658.
Integration in GameWindow.OnLoad: after the existing Stab/Building
hydration loop, iterate each landblock's scenery spawns, resolve each
to a GfxObj or Setup via the same mesh pipeline, bake the random scale
into each MeshRef's PartTransform so the static mesh renderer doesn't
need a scale field on WorldEntity, and sample the landblock heightmap
bilinearly for the ground Z (simpler than ACViewer's find_terrain_poly
slope-aware placement).
Deliberate deferrals for first pass:
- No slope-based rejection (obj.MinSlope/MaxSlope). Trees may end up on
cliffs they shouldn't be on.
- No road-overlap rejection. Scenery may spawn in roads.
- No building-overlap rejection. Scenery may clip buildings.
- No WeenieObj handling (those are dynamic spawns, not static scenery).
All three filters will be added in a follow-up phase when we have the
walkable-polygon infrastructure they need.
Build clean, 48 tests still pass, smoke verified: "scenery: spawned 419
entities across 9 landblocks", process runs without exceptions.
Addresses the user visual feedback after Phase 2b: "some extra details
are missing, like a tree and the statue on top of the foundry". The tree
issue is now fixed (419 trees/bushes/rocks/etc placed). The foundry
statue may still be missing if it's a hierarchical Setup part (Phase 2a's
SetupMesh.Flatten intentionally doesn't walk ParentIndex) — that's a
separate fix if smoke verification shows it's still missing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>