WorldPicker.Pick previously had no occlusion test — any entity along
the click ray within maxDistance was a candidate, including ones
behind walls. Adds the CellBspRayOccluder static helper that
Möller-Trumbore-tests the click ray against every polygon in every
currently-cached EnvCell BSP, returning the nearest wall-hit `t`.
Both Pick overloads gate candidate selection by that wall-t (legacy
ray-sphere via world-space `t`, screen-rect via camera-space clip.W
depth — matching ScreenProjection.TryProjectSphereToScreenRect's
convention).
PhysicsDataCache exposes a new CellStructIds snapshot accessor so the
caller can iterate without needing the private cache dictionary.
CellPhysics.BSP/PhysicsPolygons/Vertices relaxed from required to
nullable so test fixtures can construct a CellPhysics from Resolved
alone without a real DAT BSP object. GameWindow snapshots the loaded
cell physics on each Pick call and passes the occluder callback.
Closes#86.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the [indoor-bsp] probe + ProbeIndoorBspEnabled toggle for the
Indoor walking Phase 1 BSP-cluster investigation. Mirrors the existing
[resolve] / [cell-transit] / [indoor-*] pattern: one log line per
BSPQuery.FindCollisions call from FindEnvCollisions' cell branch,
capturing cell id, sphere local-pos, result TransitionState, and the
hit poly's normal + side-type via the LastBspHitPoly side-channel
(already wired for ProbeBuildingEnabled, now also fires for the indoor
flag).
Toggle via ACDREAM_PROBE_INDOOR_BSP=1 env var or DebugPanel checkbox.
Zero-cost when off.
Predecessor for the three fix commits that will close ISSUES.md
#84/#85/#86 after the capture session.
Spec: docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After visual verification 2026-05-18 (turn lag, coast-and-settle,
slope-tilt, jump tracking with contact-plane projection all working),
make the retail chase camera the default. Legacy ChaseCamera stays
available via the DebugPanel toggle (ACDREAM_RETAIL_CHASE=0 or the
checkbox) pending a follow-up deletion commit.
Env var polarity now matches AlignToSlope: default-on if unset, off
only when explicitly "0".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related AnimationSequencer fixes for visible animation glitches at
motion-cycle boundaries.
1. Link-tail blend hold (closes#61). BuildBlendedFrame was wrapping
nextIdx unconditionally to rangeLo at the high-frame boundary —
correct for looping cyclic nodes (idle/run/walk loops), wrong for
one-shot links and action overlays. During the ~30 ms fractional
tail before the sequencer transitions to the next queue node, the
blend mixed frame[end] with frame[0], producing a one-frame flash
through the anim's starting pose. Symptoms: door swing-open flap
(frame 0 = closed pose) and player run-stop twitch (frame 0 =
mid-stride). Fix: gate the wrap on curr.IsLooping; non-looping
nodes hold the boundary frame until AdvanceToNextAnimation fires.
2. Stop-anim direction fallback. Stopping from WalkBackward /
SideStepLeft / TurnLeft hit a null linkData from GetLink (the dat
authors a single forward/right stop link and reuses it for both
directions). SetCycle then enqueued only the Ready cycle, snapping
straight to idle with no leg-settle blend. Fix: when the primary
GetLink lookup is null, retry with the substate's low byte remapped
to its forward/right peer (0x06→0x05, 0x10→0x0F, 0x0E→0x0D).
Both fixes are pinned by new regression tests in
AnimationSequencerTests that fail against the prior code (Y=5.02 for
the link tail wrap → frame 0 blend; Y=0 for the backward stop snapping
to Ready cycle).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First application of CLAUDE.md's new Code Structure Rules §5
("Runtime probes belong in diagnostic owner classes"). Migrates the
four ACDREAM_DUMP_STEEP_ROOF call-site env reads into a single
PhysicsDiagnostics.DumpSteepRoofEnabled property initialized from the
env var at type init, with a runtime setter that follows the existing
ProbeResolveEnabled / ProbeCellEnabled / ProbeBuildingEnabled pattern.
Sites migrated:
- AcDream.Core/Physics/PhysicsEngine.cs:637 (KILL-VELOCITY-APPLIED log)
- AcDream.Core/Physics/TransitionTypes.cs:718 (PHASE3-RESET log)
- AcDream.App/Input/PlayerMovementController.cs:1117 (FRAME log)
- AcDream.App/Input/PlayerMovementController.cs:1199 (per-frame bounce log)
Behavior-preservation only. ACDREAM_DUMP_STEEP_ROOF=1 still produces
identical [steep-roof] log output. The class-comment in
PhysicsDiagnostics already anticipated this migration
("Future slices may fold the older ACDREAM_DUMP_* env vars into this
class for unified runtime toggling").
Not yet wired to a DebugVM checkbox — runtime toggling is available
via the property setter for future debugging sessions, but exposing
it on the panel is a 30-second future cut, not in scope here.
Build: green.
Tests: same pass/fail profile as before this commit (8 pre-existing
Core failures unrelated to physics-diagnostics; App.Tests / Core.Net.Tests
/ UI.Abstractions.Tests all green).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retires divergences flagged in the 2026-05-16 faithfulness audit:
1. AP cadence. Replaces the 1 Hz idle / 10 Hz active flat heartbeat
with a diff-driven model gated on `Contact && OnWalkable`
(acclient_2013_pseudo_c.txt:700327 SendPositionEvent). Sends on
position or cell change while grounded on walkable, plus a 1 sec
heartbeat; suppressed entirely airborne. PlayerMovementController
exposes `NotePositionSent(pos, cellId, now)` which GameWindow stamps
after each AutonomousPosition / MoveToState send — mirrors retail's
shared `last_sent_position_time` between SendPositionEvent
(0x006b4770) and SendMovementEvent (0x006b4680). Known divergence
from retail: ours is per-frame-while-moving, retail's effective rate
is ~1 Hz during smooth motion (cell/plane checks). Filed as #74,
blocked by #63 — when #63 lands we revert to retail's narrower gate.
2. Workaround retirement. Removes TinyMargin (0.05 m inside arrival)
and the AP-flush before re-send (`SendAutonomousPositionNow`). The
diff-driven cadence makes both obsolete. Close-range turn-first
deferred Use is kept (it IS retail — ACE Player_Move.cs:66-87
mirrors retail's CreateMoveToChain pre-callback rotation), renamed
`OnAutoWalkArrivedSendDeferredAction` to clarify it's a FIRST send.
`isRetryAfterArrival` parameter dropped.
3. Far-range Use/PickUp retry. Restored — was load-bearing, not the
"redundant cleanup" the Group 2 audit thought. Issue #63 means ACE
drops the first Use as too-far without re-polling on subsequent APs;
the arrival re-send is what makes far-range Use complete. Logs
include `(queued for arrival re-send pending #63)` to make this
explicit. Removes when #63 closes.
4. Screen-rect picker. New `AcDream.Core.Selection.ScreenProjection`
helper shared by `WorldPicker` and `TargetIndicatorPanel`. The
`Setup.SelectionSphere` projects to a screen-space square (retail
anchor `SmartBox::GetObjectBoundingBox` 0x00452e20); picker
hit-tests the mouse pixel against the same rect the indicator draws,
inflated by 8 px (`TriangleSize`). Guarantees what-you-see is
what-you-click — including rect corners that were dead zones under
the old ray-sphere picker. Per-type radius (1.0/1.6/2.0 m) and
vertical-offset (0.2/0.9/1.0/1.5 m) heuristic lambdas retired;
`IsTallSceneryGuid` deleted; `EntityHeightFor` trimmed to 1.5 m × scale
defensive default. No defensive sphere synth — entities without a
baked `SelectionSphere` are skipped, matching retail's
`GfxObjUnderSelectionRay` (0x0054c740).
5. Rotation rate run multiplier (Commit A precursor). `TurnRateFor(running)`
helper applies retail's `run_turn_factor = 1.5f` (PDB-named
0x007c8914) under HoldKey.Run, matching `apply_run_to_command` at
0x00527be0 (line 305098). Effective: walking ≈ 90°/s, running ≈ 135°/s.
Keyboard A/D + ApplyAutoWalkOverlay both use it.
6. Useability gate (Commit A precursor). `IsUseableTarget` corrected to
`useability != 0` per `ItemUses::IsUseable` at 256455 — ANY non-zero
passes (USEABLE_NO=1, USEABLE_CONTAINED=8, etc.), not just the
USEABLE_REMOTE bit. Cross-checked against 4 call sites in retail
(ItemHolder::UseObject 0x00588a80, DetermineUseResult 0x402697,
UsingItem 0x367638, disable-button-state 0x198826). Added
`ProbeUseabilityFallbackEnabled` diagnostic
(`ACDREAM_PROBE_USEABILITY_FALLBACK=1`) to measure how often the
creature/BF_DOOR fallback fires for ACE-seed-DB entities with
null useability.
CLAUDE.md updated with the graceful-shutdown rule for relaunch:
Stop-Process bypasses the logout packet, leaving ACE's session marked
logged-in for ~3+ min. CloseMainWindow() sends WM_CLOSE so the
shutdown hook runs and the logout packet reaches ACE.
Tests: +3 ScreenProjectionTests + 6 WorldPickerRectOverloadTests = +9.
Core.Net 294/294 pass; Core 1073/1081 (8 pre-existing Physics failures
unchanged). Visual-verified 2026-05-16: rotation rate, useability,
screen-rect click area, double-click + R-key + F-key Use/PickUp at
short and long range — dialogue/door/pickup fire on arrival.
Filed follow-ups #70 (triangle apex/size DAT sprite), #71 (picker
Stage B polygon refine), #72 (cdb omega.z probe), #73 (retail-message
sweep pattern), #74 (per-frame AP chattier than retail — blocked by
#63). Old ray-sphere `WorldPicker.Pick(origin, direction, ...)`
overload kept for back-compat; no callers in acdream proper.
Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two retail divergences fixed from the 2026-05-16 faithfulness audit
(Commit A of the plan at docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md).
1. Rotation rate ignored HoldKey.Run. Retail's CMotionInterp::
apply_run_to_command (decomp 0x00527be0 line 305098) multiplies
turn_speed by run_turn_factor (1.5, PDB-named symbol at 0x007c8914)
when input is TurnRight/TurnLeft under HoldKey.Run. Effective
running rotation is 50% faster (~135°/s vs walking ~90°/s).
Our keyboard A/D and ApplyAutoWalkOverlay used a fixed walking
rate.
New: RemoteMoveToDriver.TurnRateFor(running) helper. Keyboard
path passes input.Run; auto-walk overlay passes
_autoWalkInitiallyRunning. The walking-rate base
(BaseTurnRateRadPerSec = π/2) is unchanged; TurnRateRadPerSec
constant is preserved as the walking-rate alias for callers
that don't have run/walk state (NPC remotes).
2. IsUseableTarget gated on `useability & USEABLE_REMOTE (0x20)`,
which was stricter than retail. Per ItemUses::IsUseable
(acclient_2013_pseudo_c.txt:256455) cross-referenced with 4
call sites, retail's IsUseable() semantic is `_useability != 0`.
But visually retail's USEABLE_NO (1) entities don't approach
either, because ACE never broadcasts MovementType=6 for them.
Our client installs a speculative auto-walk BEFORE the server
responds, so we'd visibly approach + face signs before the
wire packet was rejected.
Pragmatic fix: block USEABLE_UNDEF (0) AND USEABLE_NO (1) in
IsUseableTarget — slightly stricter than retail's
IsUseable but matches retail's user-visible behaviour
("R on sign does nothing"). Documented in the doc-comment so
a future implementer knows the gap.
3. New IsPickupableTarget gate for F-key path — requires
USEABLE_REMOTE (0x20) bit. Null-useability fallback for
BF_CORPSE + small-item ItemTypes (preserves M1 ground-item
pickup flow when ACE seed DB doesn't publish useability).
4. R-key (UseCurrentSelection) upfront gate now ALWAYS uses
IsUseableTarget. R is conceptually "use" with smart-routing
to pickup as a downstream optimization. F-key (SendPickUp)
uses IsPickupableTarget directly.
5. Retail toast strings on block, centralised in new
src/AcDream.Core/Ui/RetailMessages.cs:
- "The X cannot be used" (data 0x007e2a70, sprintf 0x00588ea4)
fires on UseCurrentSelection / SendUse gate block.
- "The X can't be picked up!" (sprintf 0x00587353) fires on
SendPickUp non-pickupable block.
- "You cannot pick up creatures!" (data 0x007e22b4) fires on
SendPickUp creature block (was previously silent).
- Plus 4 inactive retail strings ready for future call sites:
CannotBeUsedWith (two-target Use), CannotBePickedUp (formal
pickup variant), CannotBeUsedWhileOnHook_HooksOff +
CannotBeUsedWhileOnHook_NotOwner (housing). All cite their
retail data addresses + runtime sprintf addresses.
6. ProbeUseabilityFallbackEnabled diagnostic (env var
ACDREAM_PROBE_USEABILITY_FALLBACK=1) logs every time the
null-useability fallback fires. Settles whether the
fallback for creature + BF_DOOR/LIFESTONE/PORTAL/CORPSE
entries in ACE's seed DB without useability is hot code
or theoretical defense.
Test coverage:
- +3 RemoteMoveToDriverTests cover TurnRateFor walking/running/back-compat.
- +7 RetailMessagesTests cover each retail string with retail anchor.
- +1 CreateObjectTests TryParse_WeenieFlagsUsable_ReadsUseableNoValue
pins parser correctness for USEABLE_NO=1.
- 294/294 Core.Net pass; 24/24 new+touched Core tests pass.
- Pre-existing baseline of 8 Physics test failures unchanged
(BSPStepUp + MotionInterpreter regression noise from prior
sessions; out of scope here).
Deferred to a separate session per user direction:
- Click area = indicator-rect retail fidelity. Retail's picker
uses per-part CGfxObj.drawing_sphere + polygon refine
(0x0054c740); ours uses single Setup.SelectionSphere ray-
intersect. The rect corners are dead zones today. Three fix
options analyzed: screen-space rectangle hit-test, sqrt(2)
sphere inflation, polygon refine Stage B.
Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported intermittent selection — 'sometimes can be selected,
sometimes not'. Cause: WorldEntity.Position is at FEET level (Z=ground
for standing humanoids), so a 0.7m sphere centred there only covered
the lower legs. Clicks on chest (Z≈1.2m) or head (Z≈1.7m) missed
because the closest-approach distance from the cursor ray to the
feet-centered sphere exceeded the radius.
Fix:
- Sphere centre now defaults to position.Z + 0.9 m (humanoid
mid-body). New optional verticalOffsetForGuid callback overrides
per entity.
- Default radius bumped 0.7 → 1.0 m to match the new sphere
placement (1.0 m at 0.9 m height covers a 1.8 m humanoid from
shin to top-of-head).
GameWindow.PickAndStoreSelection wires the callback:
- Creatures (ItemType.Creature flag): vz = 0.9 m (humanoid centre)
- Large flat objects (BF_DOOR | BF_LIFESTONE | BF_PORTAL |
BF_CORPSE): vz = 1.0 m + radius 2.0 m (mid-door/lifestone)
- Everything else (ground items): vz = 0.2 m (just above feet)
Existing 9 WorldPicker tests still pass — their head-on ray geometry
doesn't depend on the vertical offset.
Visual test surfaced three follow-ups:
1. Square box, not 1:2 rectangle.
WidthHeightRatio: 0.5 → 1.0. Retail's Vivid Target Indicator draws
a square; the earlier humanoid-aspect ratio looked wrong for
non-humanoids and didn't match retail screenshots.
2. Large flat objects (doors / lifestones / portals / corpses)
weren't selectable with the new tight 0.7 m pick sphere.
WorldPicker.Pick now takes an optional radiusForGuid callback so
the host can per-entity decide a larger radius. GameWindow's pick
site supplies a lambda that bumps to 2.0 m for any entity with
BF_DOOR (0x1000), BF_LIFESTONE (0x4000), BF_PORTAL (0x40000), or
BF_CORPSE (0x2000) set in ObjectDescriptionFlags. Default stays
at 0.7 m for humanoids and items.
3. New [B.7] pick-info diagnostic on each successful pick:
[B.7] pick-info guid=0x... itemType=0x... pwd=0x... color=(r,g,b)
Lets us verify e.g. whether a 'green NPC' really is server-side
flagged as Vendor (BF_VENDOR=0x200, retail-defined green) vs a
bug in our colour lookup. The pwd bit table is acclient.h:6431-
6463 — same flags retail's gmRadarUI::GetBlipColor branches on.
Note: textured retail-sprite corner triangles remain a B.7 follow-up
deferred per the spec. MVP uses procedural fills.
User-observed bug: 'I selected a retail player once, now I cant select
anything else.' Cause: the 5 m fixed pick sphere covered most of the
visible area around an entity, so once the cursor was anywhere near an
NPC or player, every subsequent click resolved to that same NPC/player
instead of the actual cursor target.
0.7 m roughly matches the actual hitbox radius of humanoid bodies and
most pickable items. Clicking on the entity's silhouette still hits;
clicking next to it or through it to a closer target now correctly
picks the closer target.
Existing 9 WorldPicker unit tests all pass — they tested geometric
behaviour at picked test radii, not the literal 5 m constant.
Follow-ups (deferred to a future picker phase):
- Per-itemType radius (tighter for tapers, looser for chests).
- Priority sorting at equal hit-distance (items beat NPCs).
- Ray-vs-actual-mesh test instead of bounding sphere.
Together with B.7's target indicator (corner triangles, c7e5f9f /
4bc95ec) this gives the user both 'I can hit what I'm aiming at'
AND 'I can see what I just hit' — fixes the over-pick at the source
plus surfaces it visually when it does still happen.
Static helper resolving a target indicator / radar blip colour from
ItemType + the raw PublicWeenieDesc._bitfield acdream already parses
onto EntitySpawn. Dispatch order matches retail decomp at 0x004d76f0:
Portal (BF_PORTAL = 0x40000) → cyan
Vendor (BF_VENDOR = 0x200) → green
Creature && !IsPlayer → yellow
Player + IsPK (BF_PLAYER_KILLER = 0x20) → red
Player + IsPKLite (= 0x2000000) → pink
Player (other) → white (Default)
Otherwise (item / object) → light grey
RGBA values are hand-tuned to visually match retail screenshots; the
real RGBAColor_Radar* constants live in retail static data and can be
swapped in later without breaking call sites.
8 unit tests cover the full type/flag matrix (item, NPC, friendly
player, PK, PKLite, vendor, portal-priority-over-flags).
Next: TargetIndicatorPanel (App, ImGui draw) that uses this lookup.
Per the B.6 design spec (now retail-grounded on Option A), slice 1 is
pure-additive logging so the next session has a clean trace of what
ACE actually sends to the local player during a server-initiated
auto-walk.
New PhysicsDiagnostics.ProbeAutoWalkEnabled static flag, env-var-
initialized from ACDREAM_PROBE_AUTOWALK=1. Probe sites:
[autowalk-out] on SendUse + SendPickUp — the packets that trigger
ACE's CreateMoveToChain when the target is out of WithinUseRadius.
[autowalk-mt] on OnLiveMotionUpdated for _playerServerGuid only —
captures MovementType + MoveToPath origin/min-dist/obj-dist +
moveTowards + speed/runRate. Lets us see exactly the wire data
retail's PerformMovement case 6 (0x00524440) was acting on.
[autowalk-up] on OnLivePositionUpdated for _playerServerGuid only —
cadence + payload of ACE's position broadcasts during auto-walk.
No behavior change. All flags off by default; opt in with the env var
during a focused reproduction. Designed to be mirrored into DebugVM
checkbox state later (parallel to ProbeResolve / ProbeCell / ProbeBuilding)
but not wired yet — env-var-only for the first trace session.
B.4b visual test confirmed the L.2g slice 1 handoff's open question:
ACE's Door.Open() broadcasts state=0x0001000C (HasPhysicsBSP |
Ethereal | ReportCollisions), NOT the state=0x14+ that retail servers
send (Ethereal | IgnoreCollisions). The L.2g pipeline correctly
mutates ShadowObjectRegistry with the new state, but
CollisionExemption.ShouldSkip required both bits and the door stayed
solid.
Retail (acclient_2013_pseudo_c.txt:276782) wraps FindObjCollisions in
`if NOT (state & ETHEREAL && state & IGNORE_COLLISIONS)`. ETHEREAL
alone takes a different retail path at line 276795 that sets
sphere_path.obstruction_ethereal = 1 and lets downstream movement
allow passage despite the contact. We haven't ported that downstream
path yet.
Pragmatic shortcut: widen the early-out to ETHEREAL alone so doors
become passable when ACE flips the bit. Retail-server broadcasts
still hit the same branch correctly (both bits set implies ETHEREAL).
Compatible with both server styles.
Renames test EtherealOnly_NotSkipped -> EtherealOnly_Skipped and
flips its assertion. 13 CollisionExemption tests pass; full suite
1046 pass / 8 pre-existing baseline fail (unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code review flagged two latent correctness bugs in Pick:
1. The single t = -b - sqrt(d) intersection skipped entities whose
5m bounding sphere contained the ray origin. Realistic at
point-blank range — if the player stands within ~5m of a door,
the near-plane sits inside the door's bounding sphere and the
door becomes unpickable. Standard fix: when t_near < 0 fall
through to t_far = -b + sqrt(d) (the sphere exit point).
2. The discriminant formula assumes |direction| = 1. BuildRay
currently normalizes so the assumption holds at the wire, but
the contract wasn't documented. Added an explicit
<param name="direction"> note.
New test Pick_RayOriginInsideEntitySphere_StillReturnsServerGuid
covers the inside-sphere case. Suite: 9/9 WorldPicker tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Pick(origin, direction, candidates, skipServerGuid, maxDistance)
to AcDream.Core.Selection.WorldPicker. Iterates candidates, skips
entities with ServerGuid==0 (atlas/dat-hydrated statics — no server
identity) and the caller's skipServerGuid (the player self).
Geometric ray-sphere intersection at 5m radius (matches
WorldEntity.DefaultAabbRadius). Returns the nearest hit's ServerGuid
within maxDistance (50m default), or null on miss.
6 xUnit tests added: hit, miss, two-in-line-returns-closer, skip-guid,
skip-zero-server-guid, beyond-max-distance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New AcDream.Core.Selection.WorldPicker static helper. BuildRay
unprojects pixel (mouseX, mouseY) through a view+projection matrix
pair into a world-space (origin, direction) ray. Used by
GameWindow.OnInputAction to drive entity picking on click.
Pure math, no state, no DI. Composes view*projection (System.Numerics
row-vector convention, matching the rest of acdream's camera path —
see GameWindow.cs:6445 FrustumPlanes.FromViewProjection). 2 xUnit
tests cover center-of-viewport (forward ray) and right-of-center
(positive-X deflection).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New mutator that overwrites cached PhysicsState bits on every shadow copy
of the named entity. The existing CollisionExemption.ShouldSkip(...) check
(acclient_2013_pseudo_c.txt:276782) reads the same cached field, so a
post-spawn ETHEREAL flip is now honored on the next resolver tick without
any resolver-path change.
Retail anchor: CPhysicsObj::set_state at acclient_2013_pseudo_c.txt:283044.
Slice 1 scopes to the bare state-write — retail's cosmetic side-effect
handlers (0x800 lighting, 0x20 nodraw, 0x4000 hidden) don't fire for the
ETHEREAL bit and stay deferred.
Three TDD tests cover: ETHEREAL flip from 0->0x4; unregistered-entity
no-op; entity spanning multiple cells gets all copies updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First Holtburg-doorway capture showed all 191 [resolve-bldg] entries
labeled "n/a (cylinder)" — including hits attributed to the building
0xA9B47900 which [entity-source] confirmed was registered as type=BSP.
The label was a probe bug, not a real cylinder route.
Root cause: BSPQuery's grounded-path (Path 5) returns early via
`StepSphereUp(transition, worldNormal, engine)` when no step is already
in progress. The slice-1 side-channel write at line 1546 came AFTER
that early return, so it never fired for the dominant grounded-player
case. Compounding: StepSphereUp recurses into ResolveWithTransition →
FindObjCollisions, whose per-entity `LastBspHitPoly = null` clear
wiped any earlier write before the outer attribution emitter read it.
Fix:
1. BSPQuery Path 5: move LastBspHitPoly write to the top of
`if (hit0 || hitPoly0 != null)` blocks (both foot- and head-sphere),
BEFORE the StepSphereUp early return. Recursion-safe — the inner
resolve's BSP writes will overwrite with the inner entity's poly,
but for the dominant case (same wall hit on both outer and inner)
that's still the correct attribution.
2. TransitionTypes.FindObjCollisions: drop the per-entity clear of
LastBspHitPoly. With BSPQuery now writing at hit-detection time
instead of response-computation time, the side-channel value is
reliable without per-iteration zeroing.
3. TransitionTypes [resolve-bldg] emission: key the "n/a (cylinder)"
label on `obj.CollisionType` directly, not on LastBspHitPoly being
null. A BSP entity with a null poly now logs "n/a (BSP path —
side-channel not written, missing BSPQuery wire site)" so any
future BSPQuery path that's missing the wire is visible in the
trace rather than being silently mis-labeled.
Verified: build green, the 2 slice-1 tests still pass, 8 pre-existing
failures unchanged.
Spec: docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md
First capture (showing the label bug): launch-l2d-slice1.log lines
12086-12120 (representative [resolve-bldg] entries for obj=0xA9B47900).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds ACDREAM_PROBE_BUILDING — a read-only per-shadow-entry probe that
captures full BSP collision evidence whenever TransitionTypes.FindObjCollisions
attributes a hit (via the existing L.2a slice 3 chain). One multi-line
[resolve-bldg] entry per attributed hit: partIdx, hasPhys, bspR vs
vAabbR, world-space entOrigin_lb, and the actual hit polygon's vertices
in both object-local and world space.
Paired with a one-time [entity-source] line at every ShadowObjects.Register
call site in GameWindow so entityId from a probe line is greppable to its
WorldEntity source within a single log file.
Plumbing: BSPQuery writes the resolved hit polygon to a new
PhysicsDiagnostics.LastBspHitPoly side-channel at the 5 SetCollisionNormal
sites in Paths 5/6 + CollideWithPt. TransitionTypes clears that field
before each shadow-entry dispatch and reads it back at the L.2a slice 3
attribution site to emit the probe line.
Spec component 4 originally described an out ResolvedPolygon? parameter
on BSPQuery.FindCollisions; the static side-channel achieves the same
observable behavior without plumbing through BSPQuery's recursive private
methods. Deviation noted in PhysicsDiagnostics.LastBspHitPoly's XML doc.
Reframes the plan-of-record's L.2d sub-direction paragraph: the 2026-05-12
handoff proposed porting CBuildingObj + per-cell walkability, but ACE
BuildingObj.cs:39-52 + named-retail acclient_2013_pseudo_c.txt:701260
show find_building_collisions is one BSP test on Parts[0]. Per-cell
walkability belongs to L.2e, not L.2d. L.2d slice 1 is the diagnostic;
slice 2 is the actual fix scoped from slice 1's evidence (one of three
hypotheses: wrong BSP loaded / over-registered parts / BSPQuery flaw).
Tests: 2 synthetic unit tests in PhysicsDiagnosticsTests.cs pin the
static API contract that the BSPQuery → side-channel → TransitionTypes
emission chain depends on. The multi-line line format itself is verified
by acceptance criterion 2 (live Holtburg-doorway capture) — covering it
here would require a heavy PhysicsEngine + Transition fixture for a
diagnostic-only emission.
Verified: dotnet build green; the 2 new tests pass; the 8 pre-existing
test failures listed in the L.2a handoff (MotionInterpreter GetMaxSpeed_*,
PositionManager.ComputeOffset_BothActive_Combined,
PlayerMovementController.Update_ForwardInput_*, Dispatcher.W_held_*,
BSPStepUpTests.{D4,C3}) remain failing — none introduced by this slice.
Spec: docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md
Conformance anchors:
- acclient_2013_pseudo_c.txt:701260 (CBuildingObj::find_building_collisions)
- acclient_2013_pseudo_c.txt:323725 (BSPTREE::find_collisions)
- ACE references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs:39-52
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CollisionInfo.CollideObjectGuids list + LastCollidedObjectGuid
fields existed but were never written anywhere in the codebase — slice
2's [resolve] probe found this when 85 hit=yes lines came back with
no obj= attribution.
This commit fills the gap at the only place we have the attribution
data: the per-object iteration in Transition.FindObjCollisions, where
obj.EntityId is in scope right after each per-object BSPQuery /
CylinderCollision call. Two cases trigger an Add():
- result != TransitionState.OK (object hard-blocked transition)
- normal flipped invalid→valid during the call (BSPQuery captured
a slide normal without halting — covers wall-slide cases).
Beyond the diagnostic, this also fixes a quiet structural gap — any
future physics behavior that wants "who did I just collide with"
(PvP exemption sanity check, NPC bump rules, etc.) was previously
flying blind on stub fields. Now the data flows.
Build green. Will re-test the doorway with the same trace to get
the wall's entity id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the existing [resolve] probe line to surface
ci.LastCollidedObjectGuid (hit object) + ci.CollidedWithEnvironment
(terrain hit flag) + ci.CollideObjectGuids.Count (when >1) so the
operator can tell WHICH entity the wall is, not just the wall normal.
Tonight's L.2a slice 1 trace caught a clean wall-slide at the
Holtburg-area doorway (n=(0,1,0), 122 hit=yes lines), but had no way
to attribute the hit to a specific entity — the L.2d sub-direction
call (door collision shape vs building wall mesh) needs the entity id
to pick the right fix. This extension provides it on the next run.
Format change for [resolve] hit field:
Before: hit=yes n=(0.00,1.00,0.00)
After: hit=yes n=(0.00,1.00,0.00) obj=0xCC0CXXXX
hit=yes n=(0.00,1.00,0.00) env
hit=yes n=(0.00,1.00,0.00) obj=0xCC0CXXXX env nObj=3
Pure additive within the existing PhysicsDiagnostics.ProbeResolveEnabled
gate. No new env var, no new file. Build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New static `AcDream.Core.Physics.PhysicsDiagnostics` holds two
runtime-toggleable flags initialized from env vars:
- ACDREAM_PROBE_RESOLVE=1 — emit one [resolve] line per
PhysicsEngine.ResolveWithTransition call: input/target/output
position+cell, ok-vs-partial, grounded-in, contact-plane status,
wall normal if hit, walkable-polygon valid, moving entity id.
- ACDREAM_PROBE_CELL=1 — emit one [cell-transit] line per
PlayerMovementController.CellId change: old → new cell, current
world position, reason tag (resolver / teleport).
Both also exposed as runtime-toggleable checkboxes in the DebugPanel
"Diagnostics" section. Unlike the existing four Dump-* checkboxes
(which only mirror sticky-at-startup env vars), the two new ones
forward directly to PhysicsDiagnostics — toggling on/off takes
effect on the next physics resolve, no relaunch.
Why now: L.2's plan-of-record (docs/plans/2026-04-29-movement-collision-
conformance.md) explicitly says "Land L.2a diagnostics first. Do not
make another physics change blind." This slice closes the most-load-
bearing gap in L.2a — a general-purpose probe on the resolver outcome
and a cell-transit log — so that later L.2b/c/d/e physics changes can
be evidence-driven instead of guessed. Foundation for the indoor /
dungeon walking trajectory (G.3 unblock).
Pure additive: when both flags are off (default), the probes collapse
to a single static-bool read per resolve, zero log cost. PlayerMovement
Controller's two CellId-mutation sites are now routed through a
private UpdateCellId(reason) helper for diag chokepoint.
Build green, 1032/1040 unit tests pass. The 8 failing tests are
pre-existing on the branch base (verified by stash-and-rerun);
none touch resolver or cell-transit code; all fail identically with
this slice stashed. Investigation deferred to a follow-up.
Refs: docs/plans/2026-04-29-movement-collision-conformance.md (L.2a
shipped-slice note added in same commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds per-entity part-transform side-table mirroring _rotationByEntity.
SpawnFromHook now transforms the hook offset through partTransforms[partIndex]
before rotating to world space. Backwards-compatible: entities without
registered part transforms fall through to identity (pre-C.1.5b behavior),
so the existing C.1.5a rotation-seed test stays green.
Adds SetEntityPartTransforms public method. Cleared on StopAllForEntity
alongside the rotation entry.
2 new xUnit tests:
- SpawnFromHook_AppliesPartTransform_WhenRegistered — part 1 lifted +Z=1,
hook offset (1,0,0), PartIndex=1 → world (1,0,1).
- SpawnFromHook_FallsBackToIdentity_WhenPartIndexOutOfBounds — PartIndex=99
on a 2-part array → offset applied without crash, pre-C.1.5b behavior.
Closes the renderer side of #56. EntityScriptActivator wiring (Task 3)
lands next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Computes Matrix4x4 per Setup part by walking PlacementFrames[Resting] →
[Default] → first-available, matching SetupMesh.Flatten's priority.
Foundation for #56 fix: ParticleHookSink will use these to apply each
CreateParticleHook's PartIndex-relative offset to the right mesh part.
4 xUnit tests cover Resting-over-Default preference, Default fallback,
empty-PlacementFrames returns empty, DefaultScale application.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LandblockLoader.BuildEntitiesFromInfo restarted nextId at 1 per landblock,
producing colliding entity.Id values across landblocks. EntityClassificationCache
keys by entity.Id alone, so cross-LB collisions caused cache pollution:
multiple stabs sharing id=1 -> cache entry for id=1 ended up with the
CONCATENATION of multiple entities' batches -> buildings rendered up in the
air with wrong textures (visual gate observation 2026-05-10).
Audit at docs/research/2026-05-10-tier1-mutation-audit.md did not verify
entity.Id uniqueness - that was an unchecked assumption. Cache design
trusted entity.Id was globally unique; for stabs it wasn't.
Fix: optional landblockId parameter on BuildEntitiesFromInfo. When non-zero,
stab Ids are namespaced as 0xC0XXYY00 + nextId, matching the scenery
(0x80XXYY00) and interior (0x40XXYY00) namespacing already in GameWindow.cs.
The 0xC0 top byte distinguishes stabs from those. Existing tests pass
landblockId=0 and keep their legacy starting-from-1 behavior.
Known latent: if any one landblock has >256 stabs, nextId overflows the
low byte. Same pattern + same limitation as scenery/interior. Out of scope
for the immediate Tier 1 cache bug; not affecting current Holtburg play.
Adds 2 regression tests pinning the namespacing + the legacy fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Phase A.5 spec §4.6 Change #2: WalkEntities's per-entity AABB
frustum cull was recomputing Position±5 per frame per entity. With
~10.7K entities (N1=4) at 240 FPS that is ~2.5M wasted Vector3
ops/sec.
Read the AABB from the WorldEntity cache (T8 schema) instead.
RefreshAabb runs lazily on AabbDirty=true. Populate at register time:
- LandblockLoader.BuildEntitiesFromInfo: RefreshAabb after each new
WorldEntity construction (stabs + buildings). Refactored from
inline object-initializer to named variable to enable the call.
- EntitySpawnAdapter.OnCreate: RefreshAabb after entity state init
(position/rotation already set via the WorldEntity passed in).
Dynamic entities (NPCs, players) move every frame via direct
Position writes in GameWindow.cs. Migrated all three per-frame
write sites to SetPosition() (T8 mutator) so AabbDirty propagates:
- line 5942: player entity render position update
- line 6951: remote animated entity interpolated path
- line 7279: remote animated entity landing/movement path
The lazy RefreshAabb in WalkEntities catches up on the next frame
after any SetPosition call — render thread only, no races.
Build green, 986 passed / 8 pre-existing failures unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Widens LandblockMesh.Build's surfaceCache parameter from Dictionary to
IDictionary so any IDictionary implementation compiles at call sites.
Switches GameWindow._surfaceCache from Dictionary to ConcurrentDictionary
so T11's streaming worker can call Build off the render thread without
a lock.
The TryGetValue+assign lookup inside Build is not atomic, but BuildSurface
is deterministic (same palCode -> same SurfaceInfo), making last-write-wins
under concurrent access benign. Comment added at the pattern site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds AabbMin/AabbMax (per-entity world-space bounding box) and AabbDirty
flag to WorldEntity. RefreshAabb() recomputes the box from Position ±5 m
(DefaultAabbRadius). SetPosition() writes Position and marks the cache
dirty so the dispatcher calls RefreshAabb on first read rather than
carrying stale bounds.
AabbDirty defaults to true on construction — freshly-built entities have
zero AabbMin/AabbMax until RefreshAabb is called. Two new conformance tests
verify the ±5 m geometry and the dirty/clean state machine.
Per Phase A.5 spec §4.6 Change #2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves Adjustment 4 (Option A): WorldEntity now carries the server-
sent AnimPartChange data as PartOverrides and a HiddenPartsMask bitmask.
EntitySpawnAdapter.OnCreate populates AnimatedEntityState from these
fields at spawn time. GameWindow's CreateObject handler converts the
network-layer AnimPartChange records into lightweight PartOverride
structs.
This unblocks Task 22: the WbDrawDispatcher can now resolve per-part
GfxObj overrides and hidden-part suppression from entity state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Phase N.1 step 8 (final code cleanup): now that ACDREAM_USE_WB_SCENERY
has been default-on (commit b84ecbd), remove the legacy in-line
algorithms so we don't accumulate dead-code drift.
Deleted:
- SceneryGenerator.UseWbScenery (feature flag)
- SceneryGenerator.IsOnRoad / DisplaceObject / RoadHalfWidth (legacy
ports — Generate used to call them)
- The legacy in-line implementation in Generate()
- SceneryGeneratorTests.DisplaceObject_* (test the deleted method)
- SceneryWbConformanceTests.cs entirely (purpose served — proved
equivalence pre-migration; would compare WB to WB after delete)
Renamed:
- GenerateViaWb -> GenerateInternal (it's the only path now)
Kept:
- Public IsRoadVertex predicate (small surface, useful)
- WbSceneryAdapter (consumed by GenerateInternal)
- All WbSceneryAdapterTests (still cover the adapter)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase N.1 step 7: flips ACDREAM_USE_WB_SCENERY to default-on after
visual verification at Holtburg confirmed Issue #49's previously
missing edge-vertex trees are still visible and rotation is correct.
A known cosmetic difference (the road-edge tree at landblock 0xA9B1)
remains. ACME WorldBuilder applies an additional per-vertex road
check that suppresses it; we tried adding it (commit e279c46) but
it over-suppressed in other landblocks (reverted in 677a726). Filed
as a follow-up issue in ISSUES.md (added in Task 8).
ACDREAM_USE_WB_SCENERY=0 still reverts to the legacy path. Task 8
will delete the legacy path entirely once a session passes without
visual regressions on default-on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase N.1 hotfix: scenery near a road still rendered in acdream
even with WB-backed generation. Investigation (worktree session
2026-05-08) showed ACME WorldBuilder skips the entire vertex when
its road bit is set, before any per-object spawn rolls. ACME line:
references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074
if (entry.Road != 0) continue;
This check was previously REMOVED in commit 833d167 with a comment
claiming retail doesn't have it. The comment was wrong: ACME mirrors
retail and keeps the check, and the upstream Chorizite/WorldBuilder
we forked omits it (which is why our newly-WB-backed Generate path
still produced the bad tree). Adding back to both Generate (legacy)
and GenerateViaWb (WB-backed) for parity.
This does NOT regress #49: the 9x9 loop expansion that recovered
missing edge-vertex scenery is unchanged. Only vertices whose own
road bit is set are now skipped -- same as ACME.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase N.1 step 5: when the flag is set, Generate() delegates to
GenerateViaWb. Default off; flag flips to default-on in step 7
after visual verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase N.1 step 4: parallel implementation of Generate() that calls
WB's SceneryHelpers (Displace/CheckSlope/RotateObj/ObjAlign/ScaleObj)
and TerrainUtils (OnRoad/GetNormal) instead of the inline ports.
Not yet wired in — Generate() still runs the legacy path. Step 5
adds the dispatch.
Per-helper conformance tests in step 3 prove the Displace/OnRoad/
GetNormalZ/ScaleObj substitutions are behavior-equivalent. Rotation
is intentionally NOT conformance-tested because our existing port
diverges from retail by ~180°; WB's RotateObj fixes that bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase N.1 step 3: prove our inline algorithms match WorldBuilder's
helpers for representative inputs including the 0xA9B1 edge-vertex case.
Four conformance tests pass: Displace, OnRoad, GetNormalZ, ScaleObj.
Our hand-ported algorithms match WB's helpers exactly for these.
Rotation is intentionally NOT conformance-tested. Investigation against
retail's Frame::set_heading (named-retail 0x00535e40) and
Frame::set_vector_heading (0x00535db0) showed our acdream port uses a
shortcut formula `yawDeg = -(450-degrees)%360` that diverges from
retail's atan2 round-trip by ~180°. WorldBuilder's SetHeading ports
the round-trip faithfully and matches retail. Our existing port is
wrong — undetectable visually because per-tree rotation noise masks
the offset. The migration to WB.SceneryHelpers.RotateObj fixes this
bug; adding a conformance test would lock in the wrong behavior.
Bumps IsOnRoad to internal so the OnRoad conformance test can call it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase N.1 step 2: read the env var into a static bool. No behavior
change yet — the flag is consumed in step 5 when GenerateViaWb is
wired in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses code-review feedback on commit 26cf2b8. The dropped
ArgumentException length guards were correct to drop because
DatReaderWriter.LandBlock self-initializes Terrain[] and Height[]
to length 81 in its constructor — but that invariant was not
documented anywhere visible to future readers. Adds an XML doc
<remarks> block explaining the guarantee so callers constructing
synthetic LandBlocks know what to expect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase N.1 step 1: WbSceneryAdapter.BuildTerrainEntries converts our
LandBlock dat type into the TerrainEntry[81] shape WorldBuilder's
TerrainUtils / SceneryRenderManager consume.
Field mapping (TerrainInfo → TerrainEntry):
TerrainInfo.Road (bits 0-1) → TerrainEntry.Road
TerrainInfo.Type (bits 2-6) → TerrainEntry.Type
TerrainInfo.Scenery (bits 11-15) → TerrainEntry.Scenery
LandBlock.Height[i] → TerrainEntry.Height
The spec listed the texture property as 'Texture' but TerrainEntry's
actual property is named 'Type' (confirmed from source). The spec also
described LandBlock.Terrain as ushort[81] but it is TerrainInfo[81] —
DatReaderWriter already decodes the bit fields so the adapter uses
TerrainInfo's named properties rather than raw bit-shift expressions.
Spec: docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase N.0 setup for the WorldBuilder migration. Replaces the local
read-only clone of Chorizite/WorldBuilder at references/WorldBuilder/
with a git submodule pointing at our fork
(github.com/eriknihlen/WorldBuilder.git, branch acdream).
Changes:
- .gitignore: exempt references/WorldBuilder from the references/
ignore rule so the submodule can be tracked.
- .gitmodules (new): submodule entry tracking acdream branch on fork.
- src/AcDream.Core/AcDream.Core.csproj: add ProjectReference to
WorldBuilder.Shared and Chorizite.OpenGLSDLBackend so we can call
TerrainUtils, SceneryHelpers, etc. from our Core code.
Build green, all 93 scenery/terrain tests pass. The 8 pre-existing
DispatcherToMovement test failures are unrelated and exist on main.
Notes for users picking up this branch on main:
- After merge, the existing local clone at references/WorldBuilder
may need to be removed before `git submodule update --init` will
populate the submodule.
- Working on the fork happens via `cd references/WorldBuilder && git
checkout acdream && <changes> && git push`. To pull upstream
Chorizite/WorldBuilder fixes: `git remote add upstream
https://github.com/Chorizite/WorldBuilder.git && git fetch upstream
&& git merge upstream/master`.
Next: Phase N.1 — replace SceneryGenerator algorithm calls with
WB's SceneryHelpers + TerrainUtils.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The obj_within_block check using Setup.SortingSphere.Radius rejects
far too many spawns. Sorting spheres for trees are 5-10m, creating
a wide exclusion zone around every landblock edge. WorldBuilder
produces correct scenery with just bounds+road+building+slope checks
and no bounding sphere check. Revert to match WorldBuilder's approach.
The single extra tree near the road at vtx=(4,8) in 0xA9B1 remains
as a known minor discrepancy from retail — root cause TBD.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Retail's CLandBlock::get_land_scenes creates a PhysicsObj for each
scenery spawn, then calls CPhysicsObj::obj_within_block (0x00461c30)
which verifies the model's sorting sphere stays within [r, 192-r] on
both axes. Edge-vertex spawns displaced close to the boundary (e.g.,
a tree at Y=190.97 from vertex y=8) get rejected because their sorting
sphere extends past the landblock edge.
We were missing this check, which caused a tree near a road at
~(85, 191) in landblock 0xA9B1 to appear in acdream but not retail.
The tree legitimately passed all other filters (road, building, slope)
but its Setup sorting sphere radius (~2-5m) meant it overflowed the
boundary.
Fix: look up each unique Setup's SortingSphere.Radius from the dat
(cached per objectId) and apply the within-block bounds check after
the slope filter, matching retail's order. GfxObj objects (0x01) use
radius 0 (permissive) since they're typically small single-mesh items.
Also: remove temporary ACDREAM_SCENERY_DIAG logging, fix duplicate
xmldoc on IsOnRoad, update road check reference to named-retail PDB
symbol (CLandBlock::on_road at 0x0052FFF0).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three fixes to match retail CLandBlock::get_land_scenes (0x00530460):
1. Loop bound: iterate 9×9 vertices (side_vertex_count=9), not 8×8
cells. Edge vertices (x=8 or y=8) produce valid spawns when the
per-object displacement shifts the position back into [0, 192).
Confirmed by named retail decomp do-while condition, WorldBuilder
vertLength=9, ACViewer Terrain.Count=81, AC2D wTopo[9][9].
2. Building suppression: check at the DISPLACED position's cell
(CSortCell::has_building per spawn), not at the loop vertex index.
Matches WorldBuilder buildingsGrid[gx2, gy2] pattern.
3. Slope filter: replace finite-difference gradient approximation
with triangle-aware normal sampling via new static method
TerrainSurface.SampleNormalZFromHeightmap. Picks the correct
triangle via IsSplitSWtoNE, matching retail find_terrain_poly →
polygon->plane.N.z and WorldBuilder's GetNormal().
Tests: 5 new tests for SampleNormalZFromHeightmap (flat=1.0, sloped<1,
cross-validates with SampleSurface instance method) and DisplaceObject
edge-vertex validity.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Closes#48. Trees on sloped cells visibly hovered above the visible
terrain because GameWindow.SampleTerrainZ (the bilinear fallback used
during scenery hydration before physics registers a landblock) had
its diagonal arms swapped — used the SEtoNW triangle test on SWtoNE
cells and vice versa. The ACDREAM_DUMP_SCENERY_Z=1 diagnostic showed
every scenery line ran through the bilinear path (streaming race),
so on hilly terrain scenery was placed at a Z up to ~1.5 m off from
the visible mesh.
Latent since ff325ab (2026-04-17 "feat(ui): debug overlay + refined
input controls" carrying along the upgrade). That commit reached for
WorldBuilder TerrainUtils.GetHeight as the secondary oracle and
re-derived the triangle-pair tests; the named-retail / ACE algorithm
in TerrainSurface.SampleZ (used by the physics path / player Z) was
always correct, so player feet stayed flush — the two paths just
disagreed and only scenery noticed.
Fix:
- TerrainSurface.InterpolateZInTriangle (private static) — single
source of truth for the triangle pick + barycentric Z, sourced
from FUN_00532a50 / ACE LandblockStruct.ConstructPolygons.
- TerrainSurface.SampleZFromHeightmap (public static) — heightmap-
byte-array variant for the scenery hydration fallback. Both this
and TerrainSurface.SampleZ (instance) now delegate to the same
InterpolateZInTriangle.
- GameWindow.SampleTerrainZ — thin wrapper over the new static.
- TerrainSurfaceTests.SampleZFromHeightmap_AgreesWithInstance_AcrossWholeLandblock
asserts both sampler paths agree at 1500 sample points across both
diagonals, so future drift gets caught.
The ACDREAM_DUMP_SCENERY_Z=1 diagnostic in BuildSceneryEntitiesForStreaming
is kept committed (env-var gated, zero cost when off) — useful for
the related #49 scenery (X, Y) placement investigation filed in the
same commit.
Visual verified at Holtburg landblock 0xA9B30001 2026-05-06: the
formerly floating 32 m pines (setups 0x020002D3 / 0x020002D9) now
sit flush on the visible terrain mesh.
Test baseline: dotnet test reports the same 8 pre-existing motion /
BSP step-up failures as the handoff doc warned about — no new
failures introduced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Humanoid bodies (Setup 0x02000001 + heritage variants) rendered visibly
flat / bulky vs retail because we drew the base GfxObj id from Setup /
AnimPartChange directly. Retail's CPhysicsPart::LoadGfxObjArray
(0x0050DCF0) treats that base id as the entry point to a DIDDegrade
table; close/player rendering uses Degrades[0].Id, which is the
higher-detail mesh that carries bicep / deltoid / shoulder geometry.
ACViewer also has this bug — it was the key signal it isn't acdream-
specific. Both clients drew the LOD-3 base mesh (e.g. 14 verts / 17
polys for Aluvian Male upper arm 0x01000055), missing the close-
detail variant (0x01001795: 32 verts / 60 polys).
Adds GfxObjDegradeResolver that walks the table with safe fallbacks
at every step. Wired in GameWindow after AnimPartChange application
and before texture-change resolution so texture overrides match the
resolved mesh's surfaces. Gated by ACDREAM_RETAIL_CLOSE_DEGRADES=1
and scoped to humanoid setups (34 parts with >=8 null-sentinel
attachment slots) while the fix bakes — the change is harmless on
non-humanoid setups (resolver falls back to base when no degrade
table) but we hold the broader sweep until LOD distance plumbing
lands.
User confirmed visually 2026-05-06: bicep, deltoid, and back-muscle
definition match retail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visual-verify of fix#2 (commit 863d96b) showed [SCFULL] correctly reports
currNodeIsCyclic=True after each direct Walk↔Run SetCycle (the link is
removed and _currNode is set to _firstCyclic). User report still:
- Animation continues running visually after Shift toggle to Walk
- Body slows ("speed decreases"), causing rubber-banding
- Adding a turn motion in that state makes the cycle finally transition
to walking
So either:
- _currNode is reset to a stale node BETWEEN SetCycle and Advance
- _currNode is correctly on the new cycle but its AnimRef is wrong
(e.g., the same Animation as the previous cycle, dat-side issue)
- BuildBlendedFrame reads from somewhere other than _currNode
Adds CurrentNodeDiag + FirstCyclicAnimRefHash properties on
AnimationSequencer that expose the active node's Animation
identity-hash, IsLooping, Framerate, frame range, and FramePosition.
TickAnimations logs them on every SEQSTATE tick (1 Hz throttle, gated
on ACDREAM_REMOTE_VEL_DIAG=1).
The [CURRNODE] line with animRef vs firstCyclicAnimRef proves whether
_currNode is actually on the new cycle's anim or has drifted to
something else. Compared across SetCycle SCFULL log lines + the
following CURRNODE ticks, we can see the exact moment the cycle
diverges from what SetCycle set.
No code-behavior changes. Pure read-only instrumentation. Per
Phase 4.5 of systematic-debugging — STOP attempting fixes; gather
evidence first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>