Commit graph

1480 commits

Author SHA1 Message Date
Erik
233b469b01 fix #176/#181 (A7 fix #2): stationary server-weenie fixtures take retail's STATIC light curve - isDynamic decided by motion, not origin
The former #143 flag forced every server-object light onto the D3D dynamic path (1/d attenuation, range x1.5); retail's stationary fixtures use the static calc_point_light curve (0x0059c8b0, Ghidra-verified: f=(1-d/range)*intensity*wrap/d^3 beyond 1m, range=falloff*1.3, per-channel colour clamp) - the dynamic path burned every Facility Hub lamp ~10x hotter than retail at 3m, producing the saturated magenta wash that zebra-striped normal wall geometry into the #176 'stripes/triangles' pattern (VSync-on test + clean captures characterized it as STATIC over-bright content, not flicker/tearing/camera). The shader's static branch already implements the verified curve faithfully (mesh_modern.vert pointContribution - its wrap constants pin LIGHT_POINT_RANGE=0.75), so the whole fix is the registration decision. Site-A lights are all stationary today (AP-44); genuinely moving lights re-earn isDynamic when they exist. Suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:45:50 +02:00
Erik
3f34bca06f port #181: retail viewer step subdivision (calc_num_steps 0x0050a0b0) - radius-anchored steps + remainder final step + viewer-exempt small-offset abort
The user's retail axiom (camera rock steady pressed into walls) vs our measured wall-press wander (~0.5mm/frame limit cycle, headless pin Issue181WallPressEquilibriumTests) sent us back to the decomp. Ghidra (clean, vs the BN x87 mush): retail VIEWERS subdivide the sweep into EXACTLY radius-length steps anchored at the start (offsetPerStep = offset*r/len, numSteps = floor(len/r)+1) with the final step recomputed mid-loop as the exact remainder (find_transitional_position 0x0050bdf0), and the negligible-offset abort is NON-viewer-only. Ours used ceil equal-slices for everything and aborted viewers too. Ported faithfully (pseudocode docs/research/2026-07-06-viewer-step-subdivision-pseudocode.md); non-viewer stepping already matched (TRANSITIONAL_PERCENT_OF_RADIUS=1.0).

Measurement: the wall-press limit cycle is UNCHANGED by the port (537.8um avg; a bit-exact 12-frame cycle: ~130um/frame inward creep x11 then a 2.6mm snap). With adjust_to_plane + adjust_sphere_to_poly now also Ghidra-verified faithful, the residual mm cycle is likely retail-class plateau physics - invisible at retail's 60fps vsync, tear-interleaved into visible stripes at our ~1500fps unsynced. The decisive user test: VSync ON (Settings/F11). Fallback discriminator: cdb-trace retail's viewer at a wall press. Suites green (Core 2600 / App 733 / UI 425 / Net 385).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:30:37 +02:00
Erik
87cddce24a fix #176 site-A: idempotent static light registration on landblock re-apply - the stacking wash leak
ApplyLoadedTerrainLocked registered every static Setup light per apply, and RegisterOwnedLight appends unconditionally. A landblock can RE-apply without an unload in between (#168 ForceReloadWindow, Far->Near promotion) and static entity ids are deterministic, so each re-apply stacked another copy of the same lights under the same owner - the [seam-ent] x2->x4 growth, invisible to the identity-keyed [seam-blk] probe (stacked copies share one owner identity while intensity multiplies). The wash intensifies over the session and the stacked exact-tie copies destabilize the 128-cap pool selection sort. Fix: UnregisterOwner(entity.Id) before the per-entity register loop - re-apply idempotent, first apply a no-op. The live-spawn path keeps its existing guid-dedup teardown (GameWindow:3134 -> :4679). Suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:47:33 +02:00
Erik
f10fe4e96c fix #180 (root): retail adjust_to_plane - PathClipped stops now resolve to the contact point, not a step boundary
The autonomous visual loop on the stateful-sought camera exposed the true
root of the #176 stripes: a ~19Hz SAWTOOTH. The sought re-extends ~3mm/frame
and the sweep silently passes while the 0.3m viewer sphere presses up to
~0.25m past the wall plane, then clips a whole transition step (~0.27m) back.
Headless replay against the real Facility Hub corridor BSP (0x8A020164, the
captured ray) reproduced it exactly: pre-fix, embedded targets passed
unclipped and the first detection stopped at the PREVIOUS STEP BOUNDARY,
tracking the target (eyeBack = s - 0.27).

Root cause: BSPQuery.AdjustToPlane - copied from ACE's BSPTree.cs port -
was structurally inverted and ALWAYS returned false (the touchTime==1 branch
re-placed the sphere at the unchanged check position; touchTime<1 iterated
doing nothing; the <0.02 convergence exit returned false). With the
PerfectClip exact-contact machinery dead, CollideWithPt always fell to the
bare Collided path and the transition reverted the colliding step whole.
ACE never noticed: PERFECT_CLIP (0x40) is a client camera flag the server
never exercises (feedback_bn_decomp_field_names class 3 - the retail binary
outranks ACE in branches ACE never runs). The pre-stateful camera flipped
1-step vs 2-step backoffs on mm drift - the measured pulledIn 0.27 <-> 0.53
of the original #176 strobe was step quantization all along.

Rewritten per the retail binary (pseudocode doc
docs/research/2026-07-06-adjust-to-plane-pseudocode.md):

- BSPTREE::adjust_to_plane (0x00539bf0): clearTime/hitTime bounds (0.0/1.0),
  Phase 1 walks plane-touch times re-testing the whole tree (the tree test
  feeds a DIFFERENT blocking poly back into the next iteration), Phase 2
  binary-searches with the SHARED iteration counter, window < 0.02 =
  CONVERGED, final commit = last known-clear time. Only failure = Phase-1
  exhaustion.
- CPolygon::adjust_sphere_to_poly (0x00538170): early-out = plane-band test
  at the START position (was: precise-poly test at the check position);
  touch side = sign(dpPos)*radius (was: hard-coded -radius; ACE misdecoded
  it as movement.LengthSquared() <= r^2); result unclamped per retail.

Replay pin Issue180CorridorSweepHysteresisReplayTests: short-of-touch
targets pass, past-touch targets always clip, and the clipped stop is the
CONSTANT surface-contact point (eyeBack 1.609 across the band; spread
< 0.03m) instead of tracking the target.

Suites green (Core 2600+2skip / App 729+2skip / UI 425 / Net 385).
Pending: visual-loop re-verify + the user gate (#180 + #176 re-gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:20:46 +02:00
Erik
48aaab811c fix #180: port retail's stateful camera sought-position - the sweep target converges onto the wall contact
The camera-collision sweep strobed the eye 0.27m every ~5-10 frames while
the compressed chase boom moved along corridor walls (pulledIn 0.27<->0.53
on ~1.4mm input drift): RetailChaseCamera re-demanded the FULL-length ideal
boom from scratch each frame, so the pivot->eye ray re-rolled the same
knife-edge r+-eps graze on the double-faced slabs every frame, and its two
first-contact solutions tear-interleaved at ~1700fps into the #176
"stripes/triangles".

Retail never re-rolls that ray. CameraManager::UpdateCamera (0x00456660)
interpolates FROM THE CURRENT SWEPT VIEWER toward the desired pose
(interpolate_origin/rotation, stiffness 0.45 x dt x 10, clamped) and the
result becomes viewer_sought_position (SmartBox::PlayerPhysicsUpdatedCallback
0x00452d60); update_viewer (0x00453ce0) sweeps pivot->SOUGHT. Pressed against
a wall the sweep ray extends one interpolation step past the contact
(sub-mm at high fps), so a bistable graze can move the eye by at most that
step - the strobe is structurally impossible. A 0.4mm/2e-4 dead-band parks
the sought exactly on the viewer when converged (0x00456fcd-0x00457035).

- RetailChaseCamera: _dampedEye -> _soughtEye + _publishedEye (retail's two
  Positions); lerp base = the published (swept) viewer; sweep targets the
  sought; total-fallback (ViewerCellId==0) resets the sought like
  set_viewer(player_pos, 1). The old "collision must NOT feed back into the
  damped state" comment had the coupling backwards - what stays clean is the
  transient desired pose, not the sought.
- SweepEye untouched (faithful update_viewer port, exonerated by the #180
  investigation).
- Tests: the old pin asserting instant full re-extension after a clamp
  (the divergence itself) replaced with four retail pins: gradual
  re-extension, sweep-target-converges-onto-contact, total-fallback
  re-extends from the player, wall-press glide stability.
- Pseudocode doc: docs/research/2026-07-06-camera-sought-position-pseudocode.md
  (UpdateCamera tail incl. the sought derivation + set_viewer reset semantics
  + Frame interpolate/close_rotation).
- Register: AD-37 (forward-vector nlerp vs quaternion slerp), AD-38
  (init-at-full-extension vs retail re-extend-from-player) - both
  pre-existing, identified during the decomp reading.

Suites green (Core 2599+2skip / App 729+2skip / UI 425 / Net 385).
Pending: autonomous visual verify + user gate (#180 + the #176 re-gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:49:01 +02:00
Erik
6aabe0b5ac diag #176/#180: isolation toggles pin the residual stripes on the camera sweep, not the render
The #176 gate-2 failure ("stripes/triangles flickering when the camera
is pushed into walls; nothing when zoomed out") is NOT a render defect.
Isolation apparatus added this commit:

- ACDREAM_LIGHT_DEBUG shader modes (mesh_modern.vert/frag + uLightDebug
  upload in EnvCellRenderer/WbDrawDispatcher): 1 = ambient-only,
  2 = dynamics killed, 3 = raw vLit field (texture ignored). The
  pattern SURVIVES mode 3 -> not texture; lives above the light data.
- ACDREAM_CLIP_DEBUG=1 (RenderingDiagnostics.ClipDebugNoShellTrim +
  the EnvCellRenderer slot-fill gate): shell pass draws cells WHOLE
  (retail's shape). Pattern survives -> the per-cell clip trim is
  exonerated.

With every render suspect dead, an autonomous visual loop (synthetic
back-into-wall input + GDI window captures + the [flap-sweep] probe)
pinned the mechanism numerically: at a compressed moving boom the
camera-collision sweep is BISTABLE - consecutive sweeps with ~1.4 mm
input drift flip the first-contact solution 0.27 m along the boom
(pulledIn 0.27<->0.53, every ~5-10 frames, all 368k sweeps ok=True),
and at ~1700 fps unsynced every monitor refresh tear-interleaves the
two views = the stripe/hatch patterns. Filed as #180 with the retail
anchor: viewer_sought_position is STATEFUL (SmartBox 0x00452d75 feeds
the CURRENT swept viewer into CameraManager::UpdateCamera 0x00456660
and assigns the return to the sought, 0x00452d84) - the target
converges to the collided position instead of re-rolling the full
knife-edge ray per frame like our RetailChaseCamera does. SweepEye
itself ports update_viewer 0x00453ce0 faithfully and is exonerated.

Also recorded in #176: the site-A weenie light-registration leak (a
portal's I100 light stacked x2->x4 over one session as re-CreateObject
re-registered it under fresh entity ids).

The #176 lighting fix (d8984e87) remains live-verified; #176 re-gates
after #180 lands. ISSUES: #180 filed, #176 updated. Suites: Core
2599+2skip; toggles inert by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:19:38 +02:00
Erik
d8984e877f fix #176: light pool tracked the camera via flood scoping - collect residents, anchor at player
The seam-floor purple flicker was NOT a draw z-fight. The in-engine
[seam-*] probe (ACDREAM_PROBE_SEAMDRAW - built because RenderDoc cannot
capture this pipeline: it hides GL_ARB_bindless_texture and the
mandatory-modern startup gate throws; AMD GPU rules out Nsight) killed
every double-draw suspect: ONE shell instance per seam cell at the
lifted z, no floor-coincident entity (portal entities sit at z=-12.05),
zero portal depth fans in the sealed Hub. What it caught instead: the
corridor floor's applied light set flipping wholesale with the flood.

Root cause: c500912b scoped BuildPointLightSnapshot by the per-frame
portal flood, on the research doc's gloss of CEnvCell::visible_cell_table
as "the portal-flood visible set". The named decomp refutes the gloss:
add_visible_cell (0x0052de40) DBObj-LOADS absent cells and inserts them;
a cell activation adds itself + its whole dat visible-cell list
(0x0052e228/0x0052e24a); entries leave only via the flush machinery.
It is the RESIDENT-cell registry - gaze can never remove a cell.
add_dynamic_lights (0x0052d410) walks the WHOLE table per frame
(caller 0x00452d30), and insert_light (0x0054d1b0) caps the pool by
distance to Render::player_pos (0x0054d1dd). Retail's pool is a function
of player position only. Ours followed the camera: turning changed the
flood (probe: 8..41 cells across one turn), the six intensity-100
under-room portal purples entered/left the pool, and the wedge blinked.

Fix: BuildPointLightSnapshot(playerWorldPos) collects ALL registered
(=resident) lit lights; over cap keeps dynamics FIRST (retail's separate
7-slot dynamic pool never competes with statics) then nearest-the-player;
the RebuildScopedLights callback is deleted. Live-verified with the probe:
full-circle turn, flood churning 8..41, the floor set held the same 8
identities on every post-spawn frame. The purple wedge SHAPE stays - it
is cdb-proven retail-faithful.

Residual deviation (AP-85 rewritten): single 128 pool vs retail's
7-dynamic/40-static degrade-scaled dual pools - the Hub now shows
7 purples + viewer where retail's cdb showed 4 + viewer + fixture slots;
if the gate reads the wedge as too purple, the A7 dual-pool cap is the
faithful trim.

Pins: PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant
(rewritten to the corrected model),
PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics,
PointSnapshot_OverCap_KeepsNearestThePlayer,
PointSnapshot_ResidentCollection_CellTagDoesNotFilter.
Suites: Core 2599+2skip / App 726+2skip / UI 425 / Net 385.

The [seam-*] probes stay until the visual gate passes, then strip.
Correction banner added to 2026-07-06-a7-per-cell-lighting-pseudocode.md;
outcome banner on the z-fight handoff; ISSUES #176 updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:12:31 +02:00
Erik
8cb3176daa feat(lighting): SelectForCell (all dynamic lights per EnvCell) + #176 handoff — residual is a runtime z-fight
cdb trace of LIVE retail (tools/cdb/issue176-floor-light.cdb, binary<->PDB MATCH) PROVED retail
applies ALL its dynamic lights — 4 intensity-100 magenta portal lights (d3dIdx 3-6, falloff 6) +
the viewer fill (d3dIdx 1, 2.25) — as D3D hardware lights to EVERY Facility Hub cell, every frame,
stable. So the faceted purple wedges on the floor are retail-FAITHFUL. acdream did a per-cell
SelectForObject sphere-overlap 8-cap for cells, so the portal set could differ/flip per cell.

- LightManager.SelectForCell (retail minimize_envcell_lighting 0x0054c170): ALL dynamic lights
  applied unconditionally (shader range cutoff zeroes non-reaching = D3D hardware range), then
  nearest static torches fill remaining slots. Wired into EnvCellRenderer.GetCellLightSet.
  Objects keep SelectForObject (minimize_object_lighting). Pins:
  SelectForCell_AppliesAllDynamicLights_EvenOutOfReach + _SameDynamicSet_ForCellsFarApart_NoFlap.
- Apparatus: [light-detail] gains owner/cell/dyn (pinned the culprit = 2 portal weenies
  0x000F4247/48 in 0x8A020118/19, intensity=100 magenta); CellVertexNormals_SmoothOrFaceted_Dump
  (corridor floor uses SMOOTH per-vertex dat normals, not flat); tools/cdb/issue176-floor-light.cdb.

#176 RESIDUAL is NOT this fix. It's a RUNTIME draw z-fight in the seam floor. Eliminated (evidence):
NOT lighting (per-light cap + this both no-change), NOT membership (render cell 0x8A020164 stable
100% of 188k frames / 526 angles, res=None), NOT dat geometry (coplanar sweep empty at z=-6 floor
incl. cell 0164). NEXT = RenderDoc pixel-history. Full handoff + DO-NOT-RETRY:
docs/research/2026-07-06-176-seam-floor-zfight-handoff.md. Suites green: Core 2599 + 2 skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:55:16 +02:00
Erik
093cdb6d57 Merge branch 'main' into claude/eloquent-hugle-42119e
# Conflicts:
#	.gitignore
2026-07-06 00:47:09 +02:00
Erik
c500912bf8 feat(lighting): A7 visible-cell light scoping + [indoor-light] probe (NOT the #176/#177 fix)
Port retail's per-frame light collection: the point-light pool is built from ONLY the
currently-visible cells' lights, matching CObjCell::add_*_to_global_lights
(0x0052b350/0x0052b390) walked over CEnvCell::visible_cell_table (0x0052d410) — not a
flat world-space set capped at 128-nearest-camera.

- LightSource.CellId (retail insert_light arg6 -> RenderLight +0x6c); tagged at both
  registration sites from entity.ParentCellId (live weenie fixtures + dat EnvCell statics).
- LightManager.BuildPointLightSnapshot(camPos, visibleCells): a light joins the pool iff
  CellId==0 (viewer/global) or its cell is in the flood. 128 cap kept as a now-non-biting
  backstop (retail's is 40 static + 7 dynamic, 0x0081ec94/8).
- Threaded via RetailPViewDrawContext.RebuildScopedLights, invoked in DrawInside after the
  flood resolves prepareCells and before the draws (renderers select from the same
  in-place-rebuilt PointSnapshot; EnvCellRenderer clears its per-cell cache each pass).
- [indoor-light] probe (ACDREAM_PROBE_INDOOR_LIGHT=1) dumps the scoped-pool SET COMPOSITION.
  Un-skips LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant.

CORRECTION: the handoff called the camera-cap the "confirmed" #176/#177 mechanism. The probe
PROVES scoping works (291 Hub fixtures -> pool of 1-9, ~285 through-floor lights dropped/frame,
CellIds match the flood), but the user's VISUAL GATE showed BOTH symptoms unchanged. So pool
composition is NOT the cause. #176 real cause = an over-bright purple point light
(intensity=100, color 0.784,0,0.784 -- from [light-detail]); #177 = a portal-visibility miss
(stairs not drawn looking back). Both stay OPEN. This change is retail-faithful and retires the
camera-eviction latent bug; kept as such, not as the symptom fix. Register AP-85 corrected;
ISSUES #176/#177 re-diagnosed; render digest banner updated.

Decomp: insert_light 0x0054d1b0, minimize_object_lighting 0x0054d480, calc_point_light
0x0059c8b0; pseudocode docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md.
Suites green: Core 2595 + 2 skip, App 719 + 2 skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 00:35:01 +02:00
Erik
91afea24b4 perf(pipeline): MP-Alloc Task 4 - reuse per-frame animatedIds + drawableCells sets
Completes the safe-batch (implementer finished this but died before
committing; coordinator independently verified bit-identity + safety
post-interruption). Two per-frame HashSet<uint> allocations replaced by
reused cleared-in-place fields:
- GameWindow._animatedIdsScratch: WalkEntitiesInto treats null and empty
  identically (line 682 `is null || Count == 0`), so an always-non-null
  empty set == the old null-when-empty local. Verified.
- RetailPViewRenderer._drawableCellsScratch: flows into
  RetailPViewFrameResult.DrawableCells (live ref) but every consumer reads
  it same-frame; sigDrawableCells is a per-frame OnRender local that
  EmitRenderSignatureIfChanged only FORMATS to a string (no reference
  retention, no _lastSig set field). Built frame N -> read frame N ->
  cleared frame N+1. No cross-frame corruption.

Build green, full suite 4116 passed / 4 skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 00:09:03 +02:00
Erik
76b8b4e9fc perf(pipeline): MP-Alloc Task 3 - pool InteriorEntityPartition.Result
InteriorEntityPartition.Partition allocated a fresh Result (a Dictionary
plus 2 Lists) and a new List<WorldEntity> per newly-seen visible cell,
every frame — the sole call site is RetailPViewRenderer.DrawInside,
once per frame, single-threaded.

Fix: add a Partition(Result, visibleCells, landblockEntries) overload
that clears an existing Result in place (Result.ClearForReuse) and
refills it, reusing each cell's List<WorldEntity> across frames when
the cell key survives (the visible-cell set is normally stable frame to
frame). RetailPViewRenderer now owns one _partitionResult instance
(matching its existing _shellBatch/_buildingGroups/_lookInPrepareScratch
reuse pattern) instead of allocating one per DrawInside call. The
original allocating Partition(visibleCells, landblockEntries) overload
is kept unchanged for tests and one-shot callers (it now delegates to
the reuse overload against a fresh Result).

Bit-identical output required pruning: without removing cell buckets
that end a frame with zero entries, a cell that leaves visibility would
leave a stale empty List sitting in ByCell, changing ByCell.Count and
.Keys enumeration versus the old always-fresh-Dictionary behavior (two
GameWindow diagnostics - sigSceneParticles and FormatPartitionCounts -
read those directly, not just via TryGetValue). Result.PruneEmptyCellBuckets
removes any zero-count bucket after each Partition call, keeping
ByCell's key set exactly what a fresh dictionary would have held.

Verified safe: the sole production consumer (RetailPViewRenderer.
DrawInside) and every downstream reader (WbDrawDispatcher walks,
GameWindow diagnostics) consume partition.ByCell/OutdoorStatic/Dynamics
synchronously within the same frame that built them - no cached
reference survives into the next frame's Partition() call (the one
GameWindow field that stores the reference, _interiorPartition, is
write-only, never read).

dotnet build clean, dotnet test 4120 passed / 4 skipped (unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 23:47:23 +02:00
Erik
a4b42a7417 perf(pipeline): MP-Alloc Task 2 - reuse particle draw-list + iterator
ParticleRenderer.Draw is called up to ~11 times per frame (sky pre/post,
scene, per-visible-cell, dynamics, unattached passes). Each call
allocated a fresh List<ParticleDraw> (BuildDrawList) and a fresh
List<ParticleInstance> (the per-batch `run` list), and ParticleSystem.
EnumerateLive was a `yield return` iterator block - a heap-allocated
state machine allocated fresh on every Draw call regardless of particle
count.

Fix: reuse _drawListScratch/_runScratch fields (Clear() + refill) on
ParticleRenderer. Replace EnumerateLive's iterator with a struct
enumerable/enumerator pair (ParticleSystem.LiveParticleEnumerable): the
foreach fast path uses the struct enumerator directly (zero allocation),
while LINQ/test callers (.ToList(), .Single(), etc.) still work via the
explicit IEnumerable<T> interface implementation, which falls back to a
boxed iterator only when that surface is used - those call sites are
test-only, not the per-frame render path this task targets.

Verified safe: all 11 Draw() call sites in GameWindow.cs are plain
synchronous invocations from the single-threaded OnRender chain (no
Task.Run/Parallel dispatch), and each call fully drains its lists
before returning - no call ever overlaps another's use of the reused
buffers. Per-cell filtering semantics unchanged (same predicate, same
traversal order).

dotnet build clean, dotnet test 4120 passed / 4 skipped (unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 23:43:14 +02:00
Erik
b8c05e2bbe perf(pipeline): MP-Alloc Task 1 - reuse animation pose buffers per entity
AnimationSequencer.Advance() allocated a fresh PartTransform[partCount]
every call (BuildBlendedFrame/BuildIdentityFrame), and GameWindow.
TickAnimations reassigned entity.MeshRefs to a fresh List<MeshRef>(partCount)
every tick for every animated entity - including idle NPCs on a breathe
cycle. Both fire every frame in steady state and both become garbage
immediately after being consumed.

Fix: cache a PartTransform[] sized once in the AnimationSequencer
constructor (_setup is readonly and never reassigned, so partCount is
fixed for the sequencer's lifetime) and overwrite it in place each
Advance() call. Cache a List<MeshRef> on the long-lived AnimatedEntity
record (one per animated entity, held in GameWindow._animatedEntities)
and Clear()+refill it each tick instead of allocating a new list.

Verified safe: TickAnimations runs single-threaded to completion before
any draw-side consumer reads MeshRefs (WbDrawDispatcher re-reads
entity.MeshRefs fresh every frame, keyed by entity.Id - it never caches
the list reference across frames). The only place that copies MeshRefs
across an animation boundary (RefreshPaperdollDoll) takes an explicit
`new List<MeshRef>(pe.MeshRefs)` value copy; MeshRef is a readonly
record struct so that copy is unaffected by later in-place mutation of
the source list. No test holds two Advance() results simultaneously.

dotnet build clean, dotnet test 4120 passed / 4 skipped (unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 23:40:57 +02:00
Erik
d591e3bbe5 revert #176/#177 cap raise: the uncapped light pool exposes unported per-cell reach semantics — defer to A7
The MaxGlobalLights 128->1024 fix (4d25e04d) was live-tested and made
the eviction pops stop — but with the full 366-fixture pool active,
three unported retail lighting semantics dominate the Facility Hub:

(a) lights reach THROUGH solid floors/walls: retail registers lights
    per-CELL (insert_light 0x0054d1b0) so the under-room portals'
    purple light never touches the corridor above; our flat
    sphere-overlap selection has no reach/occlusion notion — rooms
    washed magenta (user screenshot).
(b) stationary weenie fixtures ride the DYNAMIC 1/d falloff (~9x
    retail's static 1/d3 bake curve at 3m) — the #143 isDynamic
    assignment is wrong for ACE-served world fixtures.
(c) an unexplained striped z-fight-like artifact on lit floor regions
    (user screenshot; no coincident dat geometry — the coplanar-pair
    sweep came back empty; not a striped texture — all corridor
    surfaces are plain Base1Image stone).

Reverted to 128. The cap is now documented as a LOAD-BEARING STOPGAP:
it accidentally approximates per-cell reach by keeping the pool local
to the camera. The #176/#177 root cause (cap eviction popping per-cell
light sets) stays CONFIRMED and fully documented; the real fix is the
A7 dungeon-lighting arc: per-cell light registration + the static
fixture curve + the stripe hunt, THEN uncap. The desired-end-state pin
is kept as Skip with the full pointer. Register row AP-85 rewritten to
match reality; ISSUES #176/#177 back to OPEN with the complete
mechanism story.

Suites: Core 2591+3skip / App 719 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:11:18 +02:00
Erik
859cf5ec02 refactor(pipeline): MP1b review - unify DatCollectionAdapter + TOC/log test gaps
Adversarially-verified review findings 7 and 8:

(7) The DatCollection->IDatReaderWriter adapter existed as THREE
near-identical copies (App-internal original, Bake's copy, Content.
Tests' copy) — a structure where adapter drift is exactly what the
live-vs-pak equivalence suite cannot detect (both sides would only
drift together if they shared one implementation). Now ONE public
AcDream.Content.DatCollectionAdapter next to IDatReaderWriter (GL-free
home established in MP1a), carrying App's FULL behavior including the
[dat-miss] TryGet tripwire log (which now also covers the bake tool
and the equivalence suite) and the caching/locking. All three copies
deleted; WbMeshAdapter (App), BakeRunner (Bake), and
PakEquivalenceTests (Content.Tests) resolve the shared class.
Iteration properties return the REAL dat iterations — the App copy's
hardcoded 0 was a stub nothing read; the unification intentionally
keeps truth (noted in the doc comment). Verified post-move: no
Silk.NET anywhere in Content / Bake / Content.Tests / Bake.Tests
resolved dependency graphs.

(8) Two test gaps closed in PakRoundTripTests: (a) direct on-disk TOC
sortedness — blobs added in DESCENDING key order, then the raw file
bytes parsed (not through the reader) and every TOC entry asserted
strictly ascending; (b) corrupt-blob logging — five repeated reads
through both public paths (TryReadObjectMeshData + ContainsKey) with
stderr captured, asserting exactly ONE [pak-corrupt] line for the
victim key.

Full suite: 4120 tests, 0 failures (Content.Tests 56, Bake.Tests 1,
plus the pre-existing 4 skips).
2026-07-05 22:18:30 +02:00
Erik
86e0dc4655 fix(pipeline): MP1b review - bake CLI determinism + bounded memory
Adversarially-verified review findings 1 and 10:

(1) The bake pipeline no longer accumulates every decoded ObjectMeshData
in one ConcurrentBag before writing (multi-GB OOM risk on the full
bake), and no longer writes blobs in thread-completion order (which
violated the plan's "bakes must be byte-reproducible run-to-run").
New shape in BakeRunner: build the FULL id list, sort by PakKey, chunk
into 512-id batches; Parallel.ForEach WITHIN each batch; sort each
batch's results by key and AddBlob sequentially; release the batch.
Batches are contiguous key ranges, so the blob region lands in global
key order regardless of thread scheduling, and peak memory is one
batch's output. Side-staged particle-preload meshes drain per batch
into a key-deduped map (first instance wins — per-id extraction output
is deterministic, so instance choice cannot affect bytes) and are
written after all batches, sorted by key, skipping keys already
written.

Program.cs is now a thin arg-parsing shell over the public BakeRunner
so the new dat-gated byte-reproducibility test can drive the REAL
pipeline: tests/AcDream.Bake.Tests (new project, rule 6; registered in
slnx; no Silk.NET in its resolved dependency graph — verified) bakes
the same 9-id mixed fixture twice with DIFFERENT thread counts (8 vs
3 — thread scheduling was the nondeterminism source) and asserts the
two pak files are byte-identical. Ran for real against the dats on
this machine: green.

(10) The isSetup argument for EnvCell extraction now matches at both
call sites (BakeRunner and PakEquivalenceTests both pass false) and is
documented at each: the runtime's own request sites
(WbMeshAdapter.IncrementRefCount / EnsureLoaded) pass isSetup: false
for every MeshRef id including cell-geometry ids. The parameter is
currently dead in MeshExtractor.PrepareMeshData (dispatch is on the
resolved dat type), but two disagreeing call sites were a latent trap.

Header FormatVersion is no longer set by the bake (PakWriter stamps it
per review finding 2, previous commit).
2026-07-05 22:14:31 +02:00
Erik
84d1956d84 fix(pipeline): MP1b review - pak format/reader hardening
Adversarially-verified review findings 2,3,4,5,6,9:

(2) PakFormat.CurrentFormatVersion=1 constant; PakWriter stamps it
unconditionally (caller header templates can no longer produce a
version-0 pak — the default-0 footgun); PakReader refuses any other
version with a message naming found/expected. Tests: versions 0 and 2
both rejected; writer stamps even when the template omits the field.

(3) Reader robustness, all under the documented corrupt-=-missing
CONTRACT (external-file input — surface loudly once, then behave as
absent; never garbage, never throw from a lookup): (a) per-TOC-entry
bounds validation at open (offset/length outside [header, toc) or
past EOF -> logged once, entry missing, siblings unaffected); (b)
TryReadObjectMeshData catches deserialization failures (malformed
structure behind a matching CRC) -> logged once per entry, false; (c)
structurally unopenable files throw at OPEN with a clear message:
unfinalized header (tocOffset < header size — the placeholder-header
crash signature) and TOC-past-EOF truncation. Tests: corrupt TOC
entry, truncated pak, half-written pak, malformed-blob-behind-valid-
CRC (tamper + CRC recompute) — each verifying sibling blobs still read.

(4) Single-pass read: TryReadObjectMeshData now does ONE ReadArray out
of the map; CRC and deserialization run over the same buffer (was: a
separate VerifyCrc traversal + a second ReadArray + a ToArray copy
inside Serializer.Read). New Serializer.Read(byte[]) overload avoids
the defensive copy. Verdict set stays a ConcurrentDictionary (MP1c
calls this from 4 decode workers). True span-over-mmap zero-copy is
deferred to MP1c profiling per the class doc comment.

(5) PakWriter.Dispose restored to try/finally: Finish() does real I/O
and can throw (disk full) — the stream must ALWAYS close so no file
handle leaks mid-unwind. (Reverts the a5926ebc simplification, which
was wrong about this.) Test: dispose after an AddBlob exception leaves
the file deletable.

(6) CRC-32 known-answer vectors: "123456789" -> 0xCBF43926, empty ->
0x00000000, single zero byte -> 0xD202EF8D. The suite was previously
blind to a self-consistent-but-wrong CRC.

(9) Equality comparator floats switched to bit-equality
(SingleToInt32Bits/DoubleToInt64Bits, incl. Vector2/3 + Matrix4x4
components): for byte-identity round-trip purposes `==` was both too
strict (NaN==NaN false — a surviving NaN payload would wrongly FAIL)
and too lax (-0.0==+0.0 true — a sign-bit flip would wrongly PASS).

Content.Tests: 54/54 green (was 43).
2026-07-05 22:08:59 +02:00
Erik
4d25e04d83 fix #176 #177: the camera-capped light snapshot evicted visible cells' torches — per-cell lighting popped at seams
The probe launch discriminated it: the user reproduced the purple floor
flash while [light] (ambient branch) and [pv-input] (portal flood) read
provably healthy — eliminating the last CPU-side theories and exposing
the one channel the probes could not see: per-cell 8-light set
composition.

BuildPointLightSnapshot kept the MaxGlobalLights=128 point lights
nearest THE CAMERA; the Facility Hub registers 366 fixtures, so 238
were evicted per frame by camera distance. SelectForObject (faithfully
camera-independent, and unit-pinned as such) could only choose from the
surviving 128 — an in-range torch of a visible cell that ranked past
the cap dropped out of that cell's 8-set, so per-cell Gouraud lighting
flipped as the chase boom swung the camera:

- #176: the flipping unit is a CELL -> discontinuity lines at exactly
  cell-seam granularity; a torch-losing floor drops to dim blue-grey
  stone at 0.2 ambient (the perceived purple), camera-angle dependent.
- #177: a stair room whose torches all ranked past the cap rendered at
  bare 0.2 ambient (near-black = 'not visible'); approach re-admitted
  them ('pops into existence'); the sweeping boundary dropped the
  ramp's lights mid-descent ('disappears on the last step'). The
  geometry never vanished - its lights did.

Retail's minimize_object_lighting (0x0054d480) has NO global
camera-nearest pool cap (lights register per cell, insert_light
0x0054d1b0). Fix: MaxGlobalLights 128 -> 1024, a non-biting safety
valve (GlobalLightPacker grows to fit; 64 B/light). Register row AP-85.

TDD pin: PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant
(RED at 128 with a Hub-scale 401-light layout, GREEN at 1024). The
pre-existing camera-independence pin covered the SELECTOR but not the
SNAPSHOT it selects from - the pop re-entered one stage upstream.

Suites: Core 2588 / App 719 / UI 425 / Net 385 green. Pending user gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:55:43 +02:00
Erik
a5926ebcfc fix(pipeline): MP1b - remove dead try/catch in PakWriter.Dispose
Self-review finding: Dispose()'s best-effort Finish() call was wrapped
in a try/catch for InvalidOperationException that could never fire —
the surrounding `if (!_finished)` guard already excludes the only
condition under which Finish() throws that exception. Dead defensive
code that LOOKS like it might be swallowing a real failure is exactly
the kind of thing the no-workarounds policy asks to avoid; removed with
a comment explaining why no catch is needed. Behavior unchanged; 43/43
Content.Tests still green.
2026-07-05 21:35:07 +02:00
Erik
50f7dd06cf feat(pipeline): MP1b - acdream-bake CLI
New offline console tool driving AcDream.Content.MeshExtractor (the SAME
extraction code the live client runs, per MP1a) to produce a versioned
pak file. Arguments: --dat-dir (required), --out (default acdream.pak
next to the dats), --ids/--landblocks (dev filters), --threads (default
Environment.ProcessorCount).

Enumeration mirrors existing idioms rather than inventing new ones:
GfxObj/Setup ids via DatCollection.GetAllIdsOfType<T>() (src/AcDream.Cli/
Program.cs); EnvCell ids by walking dats.Cell.Tree for LandBlockInfo
entries (low 16 bits == 0xFFFE) and then the firstCellId+NumCells range
per landblock (the same idiom as GameWindow.BuildPhysicsDatBundle /
BuildInteriorEntitiesForStreaming). GetAllIdsOfType<T>() does not cover
cell-dat range-based types, hence the manual walk.

BakeDatCollectionAdapter is a from-scratch copy of AcDream.App.Rendering.
Wb.DatCollectionAdapter (which is `internal` to AcDream.App and, more
importantly, referencing AcDream.App would drag Silk.NET/GL into the
bake tool — a hard violation of "no Silk.NET anywhere"). It's plain dat-
access glue, not an AC-specific algorithm, so a small duplicate is the
right call over inventing a shared-but-App-rooted package.

Particle-preload GfxObjs MeshExtractor side-stages mid-extraction
(sideStagedSink, thread-safe ConcurrentQueue per MP1a's documented
contract) are deduped against the primary GfxObj set and against each
other before being written as their own GfxObjMesh entries.

ConsoleErrorLogger is a minimal hand-rolled ILogger (stderr, Warning+)
rather than NullLogger — MeshExtractor's LogError/LogWarning calls on
malformed dat entries must stay visible per the project's "logger
injection for silent catches" lesson.

Progress line every 5s (baked/total, failures, elapsed, ETA); a
malformed dat entry is caught per-id and counted as a failure, never
fatal — matches the runtime's own per-id try/catch behavior. Failures
report is printed at the end, capped at 200 lines.

Smoke-tested against the real dats on this machine: --ids
0x01000001,0x02000001 baked 1 GfxObj (40 vertices) + 1 Setup (34 parts)
in 1.4s with 0 failures; a follow-up run adding EnvCell 0xA9B40100
(Holtburg's first interior cell) baked all three asset types
successfully. PakReader opened both scratch paks and read back
deep-correct ObjectMeshData; scratch files deleted after verification.

*.pak added to .gitignore in this commit per the plan.
2026-07-05 21:30:25 +02:00
Erik
981025d58b feat(pipeline): MP1b - PakWriter + mmap PakReader
TDD: PakRoundTripTests written first, confirmed a compile failure
against the not-yet-existing PakWriter/PakReader types.

PakWriter streams [header placeholder][64-byte-aligned blobs][TOC sorted
by key], then seeks back and finalizes the header once TocOffset/TocCount
are known (plan: "TOC last" so the writer doesn't need blob count
up front). PakReader mmaps the whole file once, loads the TOC into a
sorted array for O(log n) binary-search lookup, and verifies each blob's
CRC-32 LAZILY on first access (cached per-index so a corrupt blob logs
exactly once and is thereafter always treated as missing rather than
handing back garbage bytes). No locks anywhere — the mapped memory is
immutable for the reader's lifetime by construction.

CRC-32 implemented directly (standard IEEE 802.3 table-driven variant)
rather than adding a System.IO.Hashing package reference, keeping
AcDream.Content's dependency surface minimal per its existing
"no GL binaries" intent.

9 tests green: header round-trip, every-key-found, blob deep-equality
via the Task 3 comparator, missing-key returns false, corrupted-blob
(single flipped byte) is detected and treated as missing while leaving
sibling blobs unaffected, 64-byte blob alignment, and TOC binary search
cross-checked against a linear scan for both present and absent keys.
2026-07-05 21:21:58 +02:00
Erik
a5ba435839 feat(pipeline): MP1b - ObjectMeshData binary serializer (deterministic round-trip)
TDD: ObjectMeshDataSerializerTests + the shared ObjectMeshDataEquality
field-by-field comparator (reused by Task 6's equivalence suite) written
first, confirmed a compile failure against the not-yet-existing
ObjectMeshDataSerializer type.

Serializes EVERY field of the ObjectMeshData family per the plan's
normative layout: primitives raw LE, arrays as count:i32+payload,
blittable arrays (VertexPositionNormalTexture[], ushort[], byte[]) via
MemoryMarshal.AsBytes bulk copy, TextureBatches written sorted by the
(Width, Height, Format) key tuple for run-to-run determinism regardless
of dictionary insertion order, nullable fields as present:byte+value.
EnvCellGeometry nests recursively (MeshExtractor can populate one level
today; the serializer supports arbitrary depth rather than assuming it).

Namespace-trap finding: StagedEmitter.Emitter resolves to
DatReaderWriter.DBObjs.ParticleEmitter (the dat DBObj, verified via
reflection against the pinned Chorizite.DatReaderWriter 2.1.7 package
and confirmed live by MeshExtractor's `emitter.HwGfxObjId.DataId` call
site compiling), NOT AcDream.Core.Vfx.ParticleEmitter (the runtime
particle-simulation type with a live Particle[] pool that would NOT be
serializable asset data). All ~31 of its fields are written explicitly
rather than delegating to its own Pack/Unpack, which require a live
DatBinWriter/DatBinReader bound to a DatDatabase — coupling our pak's
determinism to a third-party wire-format helper we don't control the
versioning of.

33 tests green: 9 round-trip fixtures (empty/vertices+indices/multi
texture-batch-groups/setup-parts/emitters/nullable-present/nullable-
absent/edge-lines/nested-EnvCellGeometry), same-instance-twice byte-
identity, and dictionary-insertion-order-independence (two orders ->
identical bytes) plus a key-sort-order assertion on the raw bytes.
2026-07-05 21:19:02 +02:00
Erik
8248abe9d4 feat(pipeline): MP1b - pak key + header/TOC primitives
TDD: PakKeyTests + PakFormatTests written first (confirmed a compile
failure against the not-yet-existing AcDream.Content.Pak namespace),
then PakKey (64-bit type:u8|fileId:u32|reserved:u24 compose/decompose)
and PakFormat (64-byte PakHeader, 24-byte PakTocEntry) implemented to
the normative layout in the MP1b plan. 21 tests green, including a key-
ordering test proving ascending numeric key order equals ascending
(type, fileId) tuple order (the TOC binary-search precondition) and an
explicit byte-offset test for both structs.
2026-07-05 21:14:10 +02:00
Erik
b0758d772b refactor(pipeline): MP1a cleanup - narrow public surface, csproj parity, move residue
Coordinator-directed final cleanup before the user gate; none behavioral:

1. MeshExtractor public surface narrowed to the cross-assembly entry
   points App actually calls (PrepareMeshData, PrepareCellStructMeshData,
   CollectParts, ComputeBounds); PrepareSetupMeshData,
   CollectEmittersFromScript, PrepareGfxObjMeshData,
   PrepareEnvCellMeshData, PrepareCellStructEdgeLineData back to private
   (internal dispatch, only reached via PrepareMeshData).
2. sideStagedSink constructor parameter is now REQUIRED (no default;
   type stays nullable for a conscious null): a bake tool that forgot
   the sink would silently lose particle-preload meshes.
3. AcDream.Content.csproj gains TreatWarningsAsErrors + LangVersion
   latest (parity with AcDream.Core.csproj). Surfaced zero warnings.
4. Dead usings removed from ObjectMeshManager.cs (BCnEncoder.*,
   SixLabors.*) — the inline decode moved out in Task 4.
5. Doc fixes: ObjectMeshData.cs cross-assembly <see cref> ->
   plain text (Content can't resolve App types); IDatReaderWriter.cs
   stale Phase O-T7 'both in this namespace' sentence rewritten.
6. Stale test doc comments updated to MeshExtractor.PrepareGfxObjMeshData
   (StipplingSurfaceEquivalenceTests, Issue119UpNullGfxObjDumpTests) —
   comments only, no code/assertion changes.

dotnet build green (0 warnings in Content under warnings-as-errors);
full test suite 4059 passed / 0 failed / 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:52:01 +02:00
Erik
932f904e00 fix(pipeline): MP1a - sink delegate restores immediate side-stage enqueue (exception-path faithfulness)
Coordinator-directed follow-up. The buffer-and-drain seam diverged from
the original on the exception path: pre-MP1a, CollectEmittersFromScript
enqueued particle-preload meshes DIRECTLY into _stagedMeshData
mid-Prepare, so preloads staged before a later throw in the same
Prepare* call (reachable via PrepareEnvCellMeshData side-staging during
its StaticObjects loop, then PrepareCellStructMeshData throwing on a
malformed-dat texture decode) were already safely enqueued. The drain
version only flushed after a successful return — on throw, entries
stranded on the shared extractor until an unrelated successful call
flushed them, and were silently dropped on dispose.

Fix: MeshExtractor takes an Action<ObjectMeshData>? sideStagedSink
constructor parameter; the two CollectEmittersFromScript sites become
_sideStagedSink?.Invoke(meshData) — the original code shape (immediate
hand-off) at those exact lines. ObjectMeshManager wires the sink to
_stagedMeshData.Enqueue, restoring the original immediate-enqueue
semantics including on mid-Prepare throw. _sideStaged buffer,
DrainSideStaged(), and the ProcessQueueAsync drain loop are deleted.
The MP1b bake tool passes its own collector.

Inventory doc updated: MP1a note now records the sink seam and the
Content-owned upload enums, so its no-behavior-change claim is accurate.

dotnet build green; full test suite 4059 passed / 0 failed / 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:39:42 +02:00
Erik
1477cda60a refactor(pipeline): MP1a - Content-owned upload enums; drop Silk.NET from Content
Coordinator-directed follow-up: AcDream.Content must stay Silk.NET-free
(the MP1b bake tool must not ship GL binaries). The Silk.NET.OpenGL
PackageReference added for the PixelFormat?/PixelType? upload hints is
replaced by Content-owned UploadPixelFormat/UploadPixelType enums
(UploadFormats.cs) whose underlying values are the GL ABI constants
(Rgba = 0x1908, UnsignedByte = 0x1401), verified numerically identical
to the Silk.NET.OpenGL members against 2.23.0. This is the one
sanctioned edit to the verbatim-moved Prepare* bodies: enum literal for
enum literal, numeric value identical, behavior unchanged. App casts at
the single upload boundary (AddTexture call in UploadGfxObjMeshData)
via lifted nullable enum conversion — value- and null-preserving.

Also hardens the MP1a _sideStaged hand-off seam: List -> ConcurrentQueue.
One MeshExtractor is shared by up to MaxParallelLoads (4) decode workers;
the original code enqueued to the thread-safe _stagedMeshData directly,
so the hand-off buffer must be thread-safe too. Drain ordering verified:
side-staged entries enqueue BEFORE the top-level result, preserving the
original mid-Prepare FIFO order.

Verified: grep -i silk on the csproj -> no matches; deps.json has zero
Silk entries; dotnet build 0 errors; full test suite green (4059 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:27:38 +02:00
Erik
30cc1e282e refactor(pipeline): MP1a - MeshExtractor extracted to AcDream.Content (verbatim move) 2026-07-05 20:19:52 +02:00
Erik
e3376d4734 refactor(pipeline): MP1a - ObjectMeshData family moved to AcDream.Content 2026-07-05 20:07:31 +02:00
Erik
d778930853 refactor(pipeline): MP1a - lift TextureKey out of the GL atlas class 2026-07-05 20:01:56 +02:00
Erik
651d041e5a feat(pipeline): MP1a - AcDream.Content assembly scaffold 2026-07-05 20:00:33 +02:00
Erik
aa96d7ad77 fix #137 (window climb): the player's collision capsule topped out at 1.2m — head sphere 0.63m too low
The 'run into the last corridor window and pop up through its roof'
report: the live callers passed sphereHeight: 1.2f into
SpherePath.InitPath, whose head-sphere formula (height - radius) put the
head sphere center at 0.72 - the capsule top at 1.2m. The top 0.63m of a
1.83m character had NO collision, so at the corridor-end window alcove
(0x8A020179 -> 0x8A02017E: 0.70m sill face, 1.3m opening, sloped funnel
behind) the step-up's placement never saw the head overlapping the
lintel solids and let the player climb in head-through-roof.

Dat truth (HumanSetup_CollisionSpheres_DatTruth): Setup 0x02000001
spheres = (0,0,0.475) r=0.48 and (0,0,1.350) r=0.48 - capsule top 1.83 =
Setup.Height 1.835. Retail collides with that sphere list verbatim
(CPhysicsObj::transition 0x00512dc0 -> init_sphere(GetNumSphere,
GetSphere, m_scale)).

Fix: PlayerMovementController + the GameWindow remote resolve now pass
sphereHeight: 1.835f (capsule top; head center 1.355 vs dat 1.350).
InitPath unchanged - captured-input replay fixtures (recorded 1.2
inputs) stay byte-identical. Register TS-46: the (radius, capsule-top)
scalar approximation of the Setup sphere list (5mm foot/head offsets;
remotes use human dims) with the retire path (plumb the sphere list).

Pins: WindowOpening_HeadCannotFit_EntryBlocked (22-frame walked approach
wall-slides at the sill, never enters 0x8A02017E) +
WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides (Path-1
placement rejects the raised head in the lintel solids) + the
WindowShaft_FullPolyDump / HumanSetup dat inspections.

Suites: Core 2562 / App 713 / UI 425 / Net 385, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:57:11 +02:00
Erik
4b44a15286 fix(pipeline): MP0 - profiler toggle-path hygiene + Max() seed (review follow-up)
Three review finds, all in the runtime-toggle path or edge math (the
steady-state path was confirmed correct):

1. GPU stale-slot across toggle-off/on: the disabled branch now
   Disposes the GpuFrameTimer (and nulls it) instead of Stop()-ing and
   keeping it — a kept instance would poll slots left pending from
   BEFORE the pause on re-enable and report temporally stale GPU
   samples. The re-enable branch's existing null guard rebuilds the
   ring fresh. Dispose is safe there: the branch runs at the top of
   OnRender with the GL context current.

2. Stale stage accumulation across mid-stage toggle-off: a StageScope
   disposed after the flag flips still calls EndStage, leaving a
   partial delta in _stageAccumTicks. The re-baseline branch now
   Array.Clear()s it alongside the GC re-baselining.

3. FrameStatsBuffer.Max() seeded with 0, clamping all-negative windows
   (the alloc channel can go negative if the boundary ever crosses
   threads) and disagreeing with Percentile(). Now seeds from the
   first live sample after the empty check (slots [0.._count) are
   always the live window regardless of ring wraparound); empty still
   returns 0 to match Percentile. TDD: Max_AllNegative_ReturnsTrueMax
   failed (returned 0) before the fix, passes after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 19:26:28 +02:00
Erik
d4869154d2 fix #137 (seam shake): CheckOtherCells queried remaining cells at a stale pre-climb center
The P2 cellar-lip lesson one loop deeper. CheckOtherCells takes footCenter
by value and used it for every cell in the loop — but a mid-loop query can
MOVE the sphere: at the Facility Hub cell boundaries the neighbor's ramp
floor full-hits, step_sphere_up climbs the foot +0.6mm and returns OK, and
the loop continued querying the REMAINING cells (including the under-room,
portal-ring-3) at the pre-climb height — 0.4mm inside the double-faced
floor slab, grazing its underside (the under-room's ceiling) within the
near-miss window. That dispatched a neg-poly step-up with a DOWNWARD
normal, whose failure funneled into slide_sphere's opposing branch ->
synthetic reversed-movement collision -> Collided -> revert, every frame:
the seam shake (and, pre-mechanism-2-fix, the original absorbing wedge's
entry).

Retail check_other_cells reads the LIVE sphere_path.global_sphere for
every cell (each cell's find_collisions receives the transition itself,
pc:272717+). Fix: re-read footCenter = sp.GlobalSphere[0].Origin at the
top of each loop iteration.

All three Issue137CorridorSeamReplayTests repros un-skipped: the
snapshot-exact west-boundary crossing (capture tick 4101), the east
deep-straddle, and the clean-run lifecycle all GREEN. Full suites: Core
2556 / App 713 / UI 425 / Net 385, 0 failures.

Visual gate pending: corridor run + the purple seam flashing re-check
(expected to be the render exposing the same per-frame oscillation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:15:05 +02:00
Erik
edbb1ffebd feat(pipeline): MP0 - wire FrameProfiler into GameWindow + DebugPanel toggle
GameWindow gets one field (_frameProfiler), one FrameBoundary() call at
the top of OnRender, three stage scopes (Update in OnUpdate, Upload
around _wbMeshAdapter?.Tick(), ImGui around _imguiBootstrap.Render()),
and one Dispose() call in teardown before _dats?.Dispose() releases the
GpuFrameTimer query ring. No new feature bodies land in GameWindow.cs —
all profiler logic lives in AcDream.App.Diagnostics.

DebugVM.FrameProf mirrors RenderingDiagnostics.FrameProfEnabled so the
DebugPanel checkbox ("Frame profiler ([frame-prof])") toggles the 5s
report live. Note: the checkbox lives in
AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs (IPanelRenderer,
backend-agnostic) alongside the other Diagnostics-section checkboxes —
not in AcDream.UI.ImGui, which holds no panel-drawing code per Code
Structure Rule 3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 19:13:15 +02:00
Erik
73bb8777a9 feat(pipeline): MP0 - FrameProfiler facade (CPU/GPU/alloc/stages, 5s report)
Permanent frame profiler: FrameBoundary() at the top of OnRender measures
CPU frame time (swap-to-swap delta), brackets the frame in a GpuFrameTimer
TimeElapsed query (self-disabled under ACDREAM_WB_DIAG=1), and samples
per-frame allocated bytes + GC collection deltas. BeginStage(FrameStage)
scopes attribute CPU time to Update/Upload/ImGui. Emits one [frame-prof]
report line every ~5s while RenderingDiagnostics.FrameProfEnabled is on;
zero-cost stage scopes when off. Report formatting is a pure static
method, unit-tested for invariant-culture formatting and the gpu=off
fallback text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 19:11:25 +02:00
Erik
0a5f7d4924 feat(pipeline): MP0 - FrameProfEnabled flag + GPU TimeElapsed ring
RenderingDiagnostics.FrameProfEnabled is the master toggle for the
upcoming permanent frame profiler (ACDREAM_FRAME_PROF=1, runtime
mirror via DebugVM.FrameProf). GpuFrameTimer wraps a depth-4 ring of
TimeElapsed queries for whole-frame GPU time, mirroring
WbDrawDispatcher's query idiom (including the #125 never-begun-slot
guard). Caller must not run this while ACDREAM_WB_DIAG=1 owns
TimeElapsed queries — GL forbids nested TimeElapsed queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 19:10:28 +02:00
Erik
7d74c68c60 feat(pipeline): MP0 - FrameStatsBuffer ring/percentile core
Fixed-capacity ring buffer of long samples with nearest-rank percentile
and max over the current window. Pure, allocation-free after
construction. Foundation for the MP0 frame profiler
(docs/superpowers/specs/2026-07-05-modern-pipeline-design.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 19:09:56 +02:00
Erik
e8651b3819 fix #137 (corridor phantom resolved): slide_sphere opposing branch returns Collided; the 'wall' was synthetic
The mechanism-1 theory (PortalSide portal polys solid in our physics set)
is REFUTED for the corridor repro, and the remaining half of the phantom
is fixed — no cdb session needed:

- The live hit normal (-1.00,0.03,-0.03) matches NO dat polygon: a
  world-space sweep of both seam cells + every portal-adjacent neighbor
  (CorridorSeam_FindPolygonMatchingLiveHit) returns zero candidates. The
  normal is the negated movement direction — the SYNTHETIC value
  slide_sphere's opposing-normals branch records (reversed = -gDelta).
- Cell 0x8A02016E has IDENTITY rotation (the prior session's 'rotation
  maps the portal planes into the -X wall' was a misattribution). The
  PortalSide polys to 0x011E are +-Y planes 1.4 m beside the player's
  track, perpendicular to the +X run — pos_hits_sphere's directional
  cull rejects them for that movement. They ARE referenced by the dat's
  physics-BSP leaves (CorridorCell_PhysicsBspLeafMembership), so retail
  tests them too when approached into their plane; the dat's
  keep-PortalSide / strip-ExactMatch asymmetry reads as intentional
  (solid window/grate-class portals). No portal-poly filter — exactly
  the blanket-skip the pickup warned against.
- Port fix: CSphere::slide_sphere's opposing-normals branch
  (0x005375d7-0x0053762c) records the reversed displacement and returns
  COLLIDED_TS; our port returned OK ('retail returns OK here' was a
  decomp misread), letting the step complete as-is with the synthetic
  collision normal that validate's epilogue then persisted as the
  sliding normal the wedge absorbed on. TransitionTypes opposing branch
  now returns Collided; pinned by
  SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal
  (RED->GREEN).
- Dat-backed replay (Issue137CorridorSeamReplayTests) reproduces the
  live hit frame verbatim (same in/out to the millimeter, same 016E->017A
  transit, same +8mm settle) and runs the corridor CLEAN: hit=no, no
  sliding normal persisted, six further forward frames advance freely.
- Inspection tests extended: physics-BSP leaf membership walk +
  hit-normal candidate sweep + downward-poly sweep (all report-style,
  dat-gated). Pickup prompt banner'd SUPERSEDED; ISSUES #137 updated
  (door half stays open); audit doc extended with the resolution.

Suites: Core 2551 / App 713 / UI 425 / Net 385, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:27:40 +02:00
Erik
a11df5b8d3 fix #137 (mechanism 2): BSP full-hit stubs leaked sliding normals — the corridor absorbing wedge
The Facility Hub corridor dead-stop's second half: after one seam hit,
every forward resolve returned ok=False hit=no with zero advance. The
body-persisted SlidingNormal (-1,0,0) projected the exactly-anti-parallel
corridor push to zero in AdjustOffset and the step loop aborted at step 0
before any collision test could refresh the state.

Audit (docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md):
retail's only in-transition sliding-normal writer is validate_transition
(0x0050ac21); the whole sphere/BSP layer writes NONE (grep-verified), and
the body persistence (SetPositionInternal 0x005154c2, SLIDING_TS bit sync
0x005154e1) runs only on transition success. Our BSPQuery Contact-branch
full-hit responses were stubs (SetCollisionNormal + SetSlidingNormal +
return Slid) where retail dispatches the real slide_sphere — so the seam
hit (a SUCCESSFUL full-advance resolve per the live log) persisted the
phantom wall's normal, which retail's lifecycle structurally cannot do.

- BSPQuery Contact foot full-hit fallback + head full-hit now route
  through Transition.SlideSphereInternal (CSphere::slide_sphere
  0x00537440 — in-frame slide, no sliding-normal write; ACE
  BSPTree.cs:202,310-316). The dead stub is rewritten as the faithful
  BSPTREE::slide_sphere wrapper.
- PhysicsEngine sliding writeback gated on ok (retail success-only
  placement; behaviorally latent, removes the failed-frame leak class).
- Register: TS-4 amended (Path-6 steep-tangent sites still write the
  normal — now documented), TS-45 added (SphereCollision's write — same
  leak class, left for a follow-up out of #171's blast radius).
- Pins: Issue137SlidingNormalLifecycleTests — both site pins RED->GREEN,
  plus the retail persist/absorb/clear wall lifecycle (validate-write
  persistence, faithful absorbed anti-parallel frame, oblique escape
  clears the bit). BSPQueryTests full-hit pin updated to the real slide.

Mechanism 1 (PortalSide portal polys solid in the physics set) stays
OPEN - #137 not closed; the corridor re-test rides that session.

Suites: Core 2545 / App 713 / UI 425 / Net 385, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:08:29 +02:00
Erik
bb18614a89 fix #175 (take 2): the cycle lookup used the bare style key — silent no-op
The shipped derivation looked up mt.Cycles[DefaultStyle]; the dat
Cycles dictionary is keyed by the COMBINED (style << 16) | substate
word (CMotionTable.cs:683), so the lookup always missed and the pose
override silently fell back to placement frames — user re-test: "175
is not fixed". The pins covered the override plumbing but not the
derivation, the one part with no offline fixture.

Extract the derivation to Core as MotionTablePose.DefaultStatePartFrames
using the retail SetDefaultState chain (StyleDefaults[DefaultStyle] ->
combined-key LookupCycle, same wrap arithmetic as CMotionTable.cs:683)
and pin it against the REAL dat (human MT 0x09000001 resolves a
34-part pose — this test fails on the old key math). Short poses now
apply PER PART (ShadowShapeBuilder already falls back per index) so a
door anim posing only the panels still overrides them while the
BSP-less header keeps its placement frame. [shape-pose] diagnostic
(ACDREAM_DUMP_MOTION) prints mt id + resolved part0 pose per BSP
registration so live launches show the actual outcome.

Suites: Core 2540 / App 713 / UI 425 / Net 385 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:10:19 +02:00
Erik
355e389d4c fix #175: door BSP collision poses at the motion-table closed pose
The Facility Hub double door (Setup 0x02000C9D) embeds the player into
the visual panel from one side and blocks with a phantom slab on the
other: its Setup PLACEMENT frames pose the two panels AJAR (yaw
-150/-30 deg, 0.44 m behind the doorway plane — dat-confirmed by the
Issue175 inspection) while the rendered door poses them CLOSED from
the wire-supplied motion table via the sequencer. ShadowShapeBuilder
read placement frames, so the 1.66x0.29x2.95 m physics slabs
registered at the ajar pose. Retail tests each part's LIVE
CPhysicsPart pose — for an idle door, the motion table's default
(closed) state.

Fix: ShadowShapeBuilder.FromSetup gains partPoseOverride (BSP part
shapes only); RegisterServerEntityCollision derives it from the spawn
MotionTableId via GameWindow.MotionTableDefaultPose (default style ->
first cycle -> LowFrame part frames). Null/short poses fall back
per-part to placement frames — table-less entities and landblock
statics unchanged. One-shot snapshot vs retail's per-frame live pose
is register row AP-84 (equivalent for the door lifecycle: closed ==
default pose; open == ETHEREAL bypasses collision, #150).

Pins: FromSetup_PartPoseOverride_ReplacesPlacementFrames /
_NoOverride_KeepsPlacementFrames / _ShortOverride_FallsBackPerPart.
Suites: Core 2539 / App 713 / UI 425 / Net 385 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:00:06 +02:00
Erik
b54555da62 fix #174: RemoveLinkAnimations seam is HandleEnterWorld (strip + drain)
Retail CPhysicsObj::RemoveLinkAnimations (0x0050fe20) is a tailcall to
CPartArray::HandleEnterWorld (0x00517d70) -> MotionTableManager::
HandleEnterWorld (0x0051bdd0): remove_all_link_animations PLUS a full
pending_animations drain (while (head) AnimationDone(0)), each pop
relaying MotionDone so CMotionInterp pops its pending_motions node in
lockstep. acdream bound the seam to the bare sequence strip, so every
jump's LeaveGround removed the animations that queued manager nodes
were counting down on — orphaning them (NumAnims>0, anims gone) and
permanently damming BOTH queues. MotionsPending() then never drained
(probe round: last player pending=False at the first MovementJump
press; old jump motions still completing at rest minutes later) and
BeginTurnToHeading/BeginMoveForward's verbatim motions_pending gates
starved every armed moveto: ACE's mt-6 walk-to-door armed but the body
never walked (wire-proven, seqs 98-101); the close-range use turn
never completed so the deferred action was silently eaten. Doors only
worked on a fresh session (shallow queue).

Rebind both production sites (remote EnsureRemoteMotionBindings +
the player's EnterPlayerModeNow block) to Manager.HandleEnterWorld();
the sequencer wrapper was a pure passthrough so the manager call is a
strict superset. All six interp seam sites (LeaveGround, HitGround,
Dead, and the detached-object guards) are the same retail chain.
Harness mirrors updated; pins: Issue174LinkStripDrainTests (the seam
drains both queues; fresh motions queue and complete after). Suites:
Core 2535 / App 713 / UI 425 / Net 385 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:33:41 +02:00
Erik
012d5c7bf7 fix #173: remote jump arcs get the retail collision-velocity response
An observed character jumping into a dungeon ceiling hovered at the
roof until its ballistic arc decayed, landing visibly late (user
report, 0x0007). The remote DR tick sweeps collision (position pinned
at the ceiling — no clip-through) but retail's post-transition velocity
response, CPhysicsObj::handle_all_collisions (pc:282699-282715:
v -= (1+elasticity)*dot(v,n)*n), was only ported for the LOCAL player
(L.3a). The remote body kept its +Z launch velocity and re-integrated
it into the roof every tick — the position was clamped but the
timeline was pure ballistics.

Retail runs handle_all_collisions after every SetPositionInternal for
every physics object, remotes included. Mirror the local reflection
block in the remote sweep's post-resolve path: same formula, same
AD-25 airborne-before-AND-after suppression (corridor slides and
landings don't reflect; the landing snap's Velocity.Z <= 0 gate stays
intact), same Inelastic zero-out for future missiles. AD-25 register
row extended to cover both sites.

Suites green: Core 2533 / App 713 / UI 425 / Net 385.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:34:25 +02:00
Erik
6ab269894a fix #172: port the retail CCylSphere collision family (platform step-up)
The Holtburg town-network portal platform (stab 0xC0A9B465, Setup
0x020019E3, CylSphere r=2.597m h=0.256m) blocked the player with an
endless rim slide instead of retail's step-up-onto-top — gating the
whole #137 dungeon repro. Surfaced when #149 started registering
BSP-less stab CylSpheres: the collision SHAPE became right while the
RESPONSE was still the hand-rolled AP-6 approximation (step-up gate +
radial wall-slide only).

Root cause: no cylinder-TOP support anywhere. DoStepUp's internal
step-down probe needs retail's step_sphere_down (0x0053a9b0) to land on
the flat top — a cylinder has no polygons for the walkable search — so
every step-up onto a wide cylinder failed into StepUpSlide and the
player orbited the rim (probe-confirmed: [cyl-test] result=Slid with
horizontal rim normals, launch-137-repro.log).

Port the full family verbatim: dispatcher intersects_sphere 0x0053b440
(placement/ethereal detection, step-down cap landing, walkable probe,
grounded step_sphere_up 0x0053b310, PathClipped collide_with_point
0x0053acb0, airborne land_on_cylinder 0x0053b3d0, Collide-flag
exact-TOI cap rest) + collides_with_sphere 0x0053a880 +
normal_of_collision 0x0053ab50 + slide_sphere 0x0053b2a0. Pseudocode +
settled BN x87 ambiguities (via ACE cross-ref) + two ACE bugs found and
NOT copied (head-slide foot-disp; see doc §8):
docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md

Ethereal cylinders now flow through retail's Layer-2 override
(pc:276961) instead of the early-OK consume — same net #150 behavior,
plus retail placement-blocked-by-cylinder semantics. SlideSphere gains
a sphereNum param (retail slides the head sphere by its own
displacement, 0x0053b843).

Register: AP-6 retired; AP-83 added (PerfectClip TOI tail decoded per
ACE, dead code until missiles). Tests: CylSphereFamilyTests (grounded
step-up onto the exact platform shape, tall-cylinder block, airborne
top landing, ethereal guard); the #42 self-shadow control assertion
updated to the retail observable (denied movement — the old ~1m radial
self-push was the approximation's artifact, not retail). Suites: Core
2533 / App 713 / UI 425 / Net 385 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:52:28 +02:00
Erik
dccd700991 feat(physics): R5-V5 — MovementManager facade owns each entity's interp+moveto pair
Structural capstone of the R5 movement-manager arc; zero behavior change.

Retail MovementManager (acclient.h /* 3463 */, 16 bytes / four pointers)
gives every CPhysicsObj ONE owner for its motion_interpreter +
moveto_manager. acdream carried them as loose per-entity objects wired by
hand at three sites. This slice:

- New src/AcDream.Core/Physics/Motion/MovementManager.cs — owns
  MotionInterpreter + lazy MoveToManager (MakeMoveToManager 0x00524000 via
  a MoveToFactory closure, the acdream stand-in for the physics_obj/
  weenie_obj backpointers) + the relays with retail call shapes:
  PerformMovement 0x005240d0 (types 1-5 -> minterp, 6-9 ->
  MakeMoveToManager + moveto, (type-1)>8 -> 0x47), UseTime 0x005242f0
  (moveto only), HitGround 0x00524300 (minterp FIRST then moveto),
  HandleExitWorld 0x00524350 (minterp only), CancelMoveTo 0x005241b0,
  HandleUpdateTarget 0x00524790, IsMovingTo 0x00524260.
- RemoteMotion.Movement + PlayerMovementController.Movement hold the ONE
  facade; Motion/MoveTo become child views so the comment-dense call sites
  read unchanged. The three wiring sites (EnsureRemoteMotionBindings,
  EnterPlayerModeNow, the chase harness — same commit per the mirror rule)
  construct through MoveToFactory + MakeMoveToManager(), preserving the
  pre-facade eager timing (side-effect-free ctor = unobservable either way).
- Relay call sites repointed: both remote landing HitGround pairs + the
  player landing pair, despawn HandleExitWorld, TickRemoteMoveTo + the
  player Update UseTime, RouteServerMoveTo (takes the facade; routes via
  the retail PerformMovement dispatch), InstallSpeculativeTurnToTarget,
  host HandleUpdateTarget/InterruptCurrentMovement closures (retail
  CPhysicsObj::HandleUpdateTarget @0x00512bf0 fan head + the TS-36
  interrupt chain, now the literal facade relays).
- NOT absorbed per the slice spec: unpack_movement stays App
  (RouteServerMoveTo + UM heads; Core.Net types stay out of Core.Physics);
  TS-42 per-tick order untouched (R6); #170/#171 gate-passed machinery
  untouched. PerformMovement's set_active(1) head not re-asserted (spawn
  asserts Active; status quo — no new register row).
- Register: TS-41/TS-42 source wording freshened to the facade shape;
  AD-36 retire note corrected (facade half closed; residue = entities
  that never get a RemoteMotion). No new rows.
- Conformance: 15 new MovementManagerTests pin the dispatch table, lazy
  create, relay targets/order, null tolerance. Suite 4052 green; the
  183-case/funnel/moveto/chase/sticky suites UNMODIFIED (harness
  construction mirrors production, test bodies untouched).

Decomp: docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:12:19 +02:00
Erik
f423884bd1 feat(physics): R5-V4 behavioral slice — head stance dispatch (all mt) + #164 autonomy bit + mt-0 wire flags
Three unpack_movement parity items (facade deferred per the handoff's
own optional clause — see r5-wiring-handoff §V4 status):

1. HEAD style-on-change (0x00524440 @00524502-0052452c): both GameWindow
   routing heads (remote + player) now dispatch DoMotion(style, ctor
   defaults) when the UM's stance differs from the interp's current
   style, BEFORE the movement-type routing — for EVERY type. Previously
   style applied only through the mt-0 funnel copy, so a chase/turn UM
   (mt 6-9) carrying a stance change started the move in the OLD stance
   (a monster charged in NonCombat posture until the next mt-0). The
   RetailObserverTraceConformanceTests exclusion note updated — the
   trace filter stays (head calls can't appear in a
   MoveToInterpretedState replay) but the production gap it pointed at
   is closed.

2. #164 (closes): the action-replay loop threads each action's autonomy
   into the dispatch params (Autonomous = the 0x1000 splice, raw
   305982) — replayed actions enter the interpreted actions list with
   their real autonomy instead of ctor-default false.

3. mt-0 wire flags consumed (UpdateMotion parsed them since R4-V3):
   0x1 StickToObject → CPhysicsObj::stick_to_object port (0x005127e0:
   resolve target, PartArray radii — 0 when shapeless, guid-as-is for
   acdream's flat entity table — → PositionManager.StickTo; unresolvable
   target → no stick), at BOTH case-0 tails in retail order
   (@00524583-0052458e: funnel apply → stick → longjump flag);
   0x2 StandingLongJump → Motion.StandingLongJump, UNCONDITIONAL write
   (absent flag clears — retail @0052458e). ServerMotionState gains the
   StandingLongJump field.

Conformance: ChaseArm_WithStanceChange_AppliesStanceBeforeTheChase
(harness mirrors the routing-head dispatch) +
Actions_ReplayCarriesAutonomyIntoTheInterpretedList. Suite 4041 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:18:26 +02:00
Erik
699669502c fix(#171): sticky deep-overlap back-off sign pin — the runaway-to-center (gate-2 residual)
Gate 2: pack behavior good except "sometimes the monster is in the
character / too close vs retail". The ACDREAM_PROBE_STICKY capture
nailed it frame-by-frame: 1661 deep-overlap AdjustOffset ticks, ALL
steering inward (dist -0.65 -> -0.78 -> ... -> -1.9 at +0.13/tick),
monsters converging to centerDist~0 — while the suppressed-snap lines
show ACE's authoritative positions stayed properly OUTSIDE (drift up to
7.7 m). The radii were correct (tgtR=0.68, ownR=0.59-0.98).

Root cause: ACE's literal decode of StickyManager::adjust_offset
(`if (delta >= |dist|) delta = dist;`) leaves delta POSITIVE when the
overlap exceeds one tick's step — steering TOWARD the target center, a
runaway whose equilibrium is centers-coincident. ACE servers virtually
never reach that branch (quantum >=1/30 -> threshold ~1 m); at
render-rate quanta the threshold is ~0.13 m and pack jostle trips it
constantly. The BN mush (0x00555554-0x00555597) is unreadable on
exactly this compare; the retail oracle (side-by-side on the same ACE:
monsters separate) refutes the ACE-literal reading.

Pin: sign-correct clamp — `else if (dist < 0) delta = -delta` (back off
rate-limited). Identical to ACE-literal in every shallow/outside case.
Register row AP-82 (same commit) with the cdb verification note.
Conformance: StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_
RateLimited. Full suite 4039 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:51:24 +02:00
Erik
7a82317614 fix(#171): gate NPC UP hard-snaps while stuck — the sticky/snap tug-of-war (gate-1 residuals)
Gate 1 (2026-07-04): "better in general" + three residuals — monsters
pushed INTO the player, attacks with stale facing, position
"flashing/flapping instead of gliding". All three are ONE mechanism:
the legacy NPC UpdatePosition handler hard-snaps position, orientation,
and velocity/cycle UNCONDITIONALLY, fighting the armed sticky every UP
(ACE's authoritative rest pose sits ~0.6 m out and its server-side
facing lags the strafing target; the client stick holds 0.3 m + live
facing — oscillation at UP cadence).

Retail is immune by architecture, not by tuning: UP corrections flow
through the InterpolationManager into the SAME per-tick
PositionManager::adjust_offset chain where StickyManager::adjust_offset
OVERWRITES them while armed (0x00555190 chain order; 0x00555430 assigns
m_fOrigin). A server correction can never fight an armed stick
frame-by-frame. The remote-player branch already has exactly that
(queue -> combiner -> sticky overwrite); the legacy NPC path snaps
outside the chain.

Fix: suppress the NPC UP position/orientation/velocity-adoption snaps
while the entity is stuck (PositionManager.GetStickyObjectId() != 0) —
the retail chain semantics translated to the snap architecture.
LastServerPos/Time + cell bookkeeping still record; server truth
reasserts on the first UP after unstick, bounded by the 1 s sticky
lease. Register row TS-44 (same commit); retires with the S6/R6
interp-queue unification of the NPC path.

Apparatus: ACDREAM_PROBE_STICKY=1 — per-guid [sticky] lifecycle lines
(STICK / UNSTICK / LEASE-EXPIRE / TARGET-status teardown), per-armed-
tick steer lines (signed gap dist, applied delta, heading delta, live
resolve), and [sticky-snap-skip] at the suppressed-snap site.
PhysicsDiagnostics.ProbeStickyEnabled owns the flag (rule #5).

Full suite 4038 green. Awaiting gate 2 (pack melee vs retail).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:34:25 +02:00
Erik
5bd2b8bc8b fix(#171): R5-V3 — bind sticky melee (StickyManager live) + real arrival radii
Group-melee interpenetration + facing drift: the R5-V1-ported
StickyManager/PositionManager were Core-only — the StickTo/Unstick seams
were unbound and every arrival radius was 0, so ACE's Sticky|UseSpheres
melee chases closed ~one body-radius too deep and froze at stale arrival
poses until the next wire re-arm. Retires TS-39.

Wiring (anchors re-verified against the named decomp this session):
- EntityPhysicsHost owns a PositionManager; HandleUpdateTarget fans
  MoveToManager then PositionManager (CPhysicsObj::HandleUpdateTarget
  0x00512bc0 order).
- Seams bound remote + player: MoveToManager.StickTo (BeginNextNode
  sticky arrival @0x00529d3a), Unstick (PerformMovement head), and
  MotionInterpreter.UnstickFromObject (UM funnel head, 0x0050eaea).
- AdjustOffset at the retail UpdatePositionInternal slot (@0x00512d0e):
  NPC branch composes pre-sweep (steer is swept by ResolveWithTransition);
  remote-player branch chains the combiner offset through the shared
  delta frame (the interp stage) so sticky OVERWRITES when armed
  (0x00555430 assigns m_fOrigin, not accumulates); player inside the
  30 Hz physics quantum before UpdatePhysicsInternal.
- UseTime (the 1 s lease watchdog) at the UpdateObjectInternal tail
  (@0x005159b3): unconditional per remote; player gated on the physics
  tick (retail's MinQuantum gate skips UseTime too).
- Real setup cylsphere radii (CPartArray::GetRadius/GetHeight
  0x005180a0/0x005180b0 = setup radius/height x ObjScale from the spawn
  record): own via EnsureRemoteMotionBindings + player wiring; target via
  RouteServerMoveTo AND the speculative use-walk install (retail resolves
  the target PartArray at EVERY MoveToObject site — ACE PhysicsObj.cs:951).
- Teardown parity: exit_world (0x00514e60) UnStick + ClearTarget before
  the ExitWorld notify; player teleport fires teleport_hook's tail
  (UnStick in SetPosition + EntityPhysicsHost.NotifyTeleported =
  ClearTarget + NotifyVoyeurOfEvent(Teleported) @0x00514f1b) so mobs
  stuck to the player drop their sticks on a recall.
- SERVERVEL arbitration also yields to a stuck entity (same starvation
  class as the #170 fix — sticky owns the between-snap translation).
- StickyManager.UseTime aligned to retail's strict > deadline
  (0x00555626; ACE >): two V1 tests had pinned the >= edge — corrected.

Register: TS-39 deleted; TS-41 narrowed (stickyArmed gate); TS-43 added
(remote teleport_hook gap — self-corrects within the 1 s lease); AP-23
narrowed (real radii at the speculative site; only the use-radius
buckets remain invented).

Conformance: 2 new full-stack sticky scenarios in
RemoteChaseEndToEndHarnessTests (arrive -> stick -> strafing-target
gap+facing track -> lease expiry; unstick-on-rearm -> re-stick).
Full suite 4038 green.

Pre-commit adversarial diff review (3 lenses + per-finding refuters)
confirmed and fixed 4 findings: ObjScale-dead radius read, player
UseTime order inversion, missing teleport voyeur notify, speculative-
site radius asymmetry.

Awaiting the user visual gate: pack melee side-by-side vs retail
(attackers reshuffle + keep facing; some overlap is ACE-server-side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:46:17 +02:00