Commit graph

787 commits

Author SHA1 Message Date
Erik
d82f07d4e5 feat(R2-Q5): RemoteMotionSink DELETED — funnel dispatches straight into PerformMovement; AP-73 retired
The funnel's retail-ordered dispatches now go directly into the entity's
motion-table stack via Motion/MotionTableDispatchSink (Core):
ApplyMotion → PerformMovement(InterpretedCommand), StopMotion →
PerformMovement(StopInterpretedCommand). No axis collection, no
single-cycle priority pick, no Commit pass, no HasCycle probe, no
Run→Walk→Ready fallback chain — GetObjectSequence 0x00522860 +
is_allowed decide (closes H4, H5-callers, H11-callers, H15, H17-carry).
Run-while-turning now blends for real: the turn is a Branch-4 modifier
combined over the untouched run substate (AP-73 DELETED from the
register).

AnimationSequencer gains the public PerformMovement passthrough (lazy
initialize_state + locomotion velocity synthesis on success — AP-75;
remote body translation via PositionManager.ComputeOffset depends on
CurrentVelocity) and InitializeState(); HasCycle DELETED (the miss
hazard is structurally gone — GetObjectSequence checks the cycle before
any surgery; new conformance test pins sequence+state untouched on a
miss).

GameWindow: sink swap with the TurnApplied/TurnStopped ObservedOmega
callbacks (H17 carried verbatim — register AP-76, retire R6); spawn +
door sites run retail's enter-world order (initialize_state installs
the table default, the wire's initial motion dispatches unguarded — the
L.1c fallback chains deleted); despawn drains the pending queue via
HandleExitWorld (MotionDone success:false per entry, 0x0051bda0).

5 new MotionTableDispatchSink conformance tests (lazy init, Branch-4
turn blend + callback, Case-B stop unwind, Case-A stop re-drive,
miss no-op). Full suite green: 3,462 passed.

Live smoke vs ACE: in-world, NPC emote/Ready UMs dispatching through
the new path, [MOTIONDONE] completions firing across entities — first
live proof of the Q3→Q4→Q5 chain. Zero exceptions.

Registers: AP-73 deleted; AP-76 added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:55:13 +02:00
Erik
3b9d9bb6be feat(R2-Q4): adapter cutover — SetCycle/PlayAction rehosted on PerformMovement; Fix B / fast-path / stop-anim fallback / G17 gate DELETED
AnimationSequencer becomes a thin shim over the verbatim R2 stack:
SetCycle dispatches the style change (Branch 1) then the motion (Branch
2/3/4) through MotionTableManager.PerformMovement; PlayAction is the
same dispatch (actions rebuild via Branch 3; modifiers are Branch 4
physics-only combine_motion — the AP-73 turn-blend mechanism now runs
for real). CurrentStyle/CurrentMotion/CurrentSpeedMod are read-only
mirrors of MotionState (post-adjust_motion, signed mod). Lazy
initialize_state on first drive (retail lazy-create analog) keeps
undriven sequencers do-nothing.

DELETED legacy inventions (net -648 lines): the adapter fast-path
(replaced by Branch-2 fast re-speed: change_cycle_speed +
subtract/combine_motion), Fix B + IsLocomotionCycleLowByte (replaced by
remove_redundant_links on the pending queue — the retail mechanism the
2026-05-03 cdb trace pointed at), the stop-anim low-byte fallback
(replaced by get_link's reversed-key double-hop — retail's actual
backward-walk settle plays the windup link REVERSED), GetLink/BuildNode/
EnqueueMotionData + the G17 HasVelocity/HasOmega gate (add_motion sets
unconditionally), MultiplyCyclicFramerate + the G13 composite,
PlayAction's insert-before-tail machinery, SCFAST/SCFULL diagnostics.
KEPT at the adapter (register rows): the boundary adjust_motion remap +
locomotion velocity/omega synthesis (AP-75, retire R3-W6/R6), K-fix18
skip-link as post-dispatch RemoveAllLinkAnimations (AP-74, retire
R3-W4 when LeaveGround fires it).

GameWindow queue-drain wiring (the §4 G6 seam): each drained AnimDone
hook → manager.AnimationDone(true) (retail AnimDoneHook::Execute
0x00526c20 → Hook_AnimDone 0x0050fda0 chain), UseTime once per tick
(0x00517d57/0x00517d67); IMotionDoneSink bound to an
ACDREAM_DUMP_MOTION recorder (AD-36 — R3 rebinds to
MotionInterpreter.MotionDone).

Conformance: the 11-scenario pre-cutover trace suite (a6235a36) replays
green with six EXPECTED-DIFF annotations (double-hop walk-run routing,
reversed settle links, Branch-4 physics-only modifiers, Branch-1
style-change links, post-adjust mirrors) — everything unannotated is
byte-identical. Legacy suites repaired by a dedicated agent (fixtures
gain the retail-mandatory StyleDefaults + class-bit-tagged ids;
reflection state-poking replaced with real dispatch; zero category-D
regressions — the suspected cursor bug was a fixture class-bit gap),
plus two vacuously-passing tests fixed. Register: stale IA-4 deleted
(R1-P1 ported the negative-factor swap).

Full suite green: 3,458 passed (374+425+713+1946).

Closes H6, H7, H8, H9, H10-adapter, H16-wiring (r2-port-plan.md §3 Q4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:41:27 +02:00
Erik
4bfcd2735e fix(L.2g-S2b): turn/sidestep axes checked BEFORE overlay classification in RemoteMotionSink
The first S2b cut classified turn/sidestep dispatches (Modifier-class
0x65xxxxxx) into the generic overlay router before the axis checks ran —
turns never reached the cycle pick OR the ObservedOmega seed. S2 smoke
symptoms: turn-in-place showed no animation (orientation snapping only)
and running-in-circles had no client-side rotation between UPs.
Log evidence: 30 [TURN_WIRE] events, zero turn [SETCYCLE]s
(launch-s2-smoke.log).

Harness gap noted: the conformance suite stops at the funnel->sink
boundary; extending golden coverage through the sink (SETCYCLE-level
fixtures) is scheduled with the R-phase rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:19:46 +02:00
Erik
67506ce988 feat(L.2g-S2b): wire remote entities onto the CMotionInterp funnel (DEV-1 integration)
OnLiveMotionUpdated's remote branch (368 lines of bulk-copy + cycle
picker + per-axis DoInterpretedMotion + command-list router) collapses
to: build InboundInterpretedState from the wire (retail UnPack defaults;
MoveTo packets feed the PlanMoveToStart seed as the forward command) ->
MotionInterpreter.MoveToInterpretedState(ims, RemoteMotionSink) ->
sink.Commit().

RemoteMotionSink (new, App): receives the funnel's gate-passed
dispatches in retail order; the axis-priority pick (fwd > side > turn),
Run->Walk->Ready missing-cycle fallback, overlay routing, ObservedOmega
seeding, and diag lines are MOVED VERBATIM from the pre-S2 block.
Register row AP-73 documents the single-cycle composition approximation
(retail blends modifiers via re_modify — DEV-9, retires with S3/S6).

Retail-verbatim behavior changes:
- Absent stance now defaults to NonCombat 0x8000003D (retail UnPack
  default, S0-trace-verified) instead of keep-current.
- Remote command lists flow through the funnel's 15-bit action-stamp
  gate — retail's actual mechanism for ACE's re-bundled stale entries;
  the old skip-SubState router workaround is now local-player-only.
- Airborne cycle preservation is the funnel's contact_allows_move gate
  (K-fix17's guard semantics, now from the retail mechanism).
- Stops ride the same path: empty UM -> flat copy -> Ready dispatch ->
  get_state_velocity 0 (DEV-3 core; the 300ms stop-detection fallback
  stays until S6 verifies NPC unification).

Full suite green (3290). Live smoke next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:59:00 +02:00
Erik
a2f8104cbf feat(L.2g-S5): delete player pace-inference layer; close #39 (DEV-2)
S0 wire probe (live capture, retail actor via ACE) refuted the premise
the #39 machinery was built on: walk<->run Shift toggles arrive as
EXPLICIT UMs (0x0005 <-> 0x0007@runRate) because retail's default-
difference packing baselines forward_command against Ready — W-held is
always packed, and ACE re-emits it unconditionally with the holdKey
upgrade (MovementData.cs:104-119). The frequent flags=0 autonomous UMs
are genuine keys-released / heading-only states; retail applies them as
full stops (InterpretedMotionState ctor 0x0051e8d0 defaults Ready +
move_to_interpreted_state 0x005289c0 flat copy).

Retail has NO pace->animation adaptation anywhere in its inbound
pipeline (two decomp dives + ACE cross-check, deviation map DEV-2), and
the refinement layer's 0.2s-grace re-promotion after legitimate Ready
UMs was itself the observed Ready<->Run thrash / rubber-band component.

Deleted: ApplyPlayerLocomotionRefinement, UmGraceSeconds,
PlayerRunPromoteSpeed/PlayerRunDemoteSpeed, RemoteMotion.LastUMTime,
the synth-player refinement call in OnLivePositionUpdated. Player-remote
cycles are now UM-driven only, exactly like retail. NPC PlanFromVelocity
path untouched (S6 unifies).

S0 findings + S1 live validation (0 false UM_STALE drops across 280 UMs)
recorded in docs/research/2026-07-02-inbound-motion-deviation-map.md.

fix #39: closed — root-cause narrative corrected, machinery removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:10:28 +02:00
Erik
cb74e64343 feat(L.2g-S1): retail movement-event staleness gate (DEV-6)
Port retail's three-stamp inbound gate for 0xF74C UpdateMotion:

- MotionSequenceGate (Core/Physics): CPhysicsObj::is_newer (0x00451ad0)
  wraparound u16 compare, verbatim per ACE PhysicsObj.is_newer (the BN
  pseudo-C setcc returns are garbled; ACE + branch structure are the
  oracle). Gates: INSTANCE_TS at dispatch (stale incarnation drops
  before any stamp is touched), MOVEMENT_TS strictly-newer (stamped
  BEFORE the server-control check, per CPhysics::SetObjectMovement
  0x00509690), SERVER_CONTROLLED_MOVE_TS drop-when-stored-newer.
- Seed from CreateObject's PhysicsDesc timestamp block (index 1 =
  ObjectMovement now parsed; ACE WorldObject_Networking.cs:411-420
  order) — without seeding, entities whose movement sequence is past
  0x8000 at spawn would drop every UM against a zero stamp.
  Adopt-on-first / advance-only-after, so the #138 rehydrate replay of
  retained spawns cannot regress live stamps.
- UpdateMotion + EntitySpawn now carry instance/movement/serverControl
  sequences + isAutonomous (was parsed-past; isAutonomous feeds the
  S2 funnel's last_move_was_autonomous). Gate wired at the top of
  OnLiveMotionUpdated before any state mutation; [UM_STALE] diag under
  ACDREAM_DUMP_MOTION / ACDREAM_REMOTE_VEL_DIAG; gate dropped with the
  entity on DeleteObject.

Register: AD-32 added (adopt-newer-incarnation instead of retail's
QueueBlobForObject); TS-26 updated (UM side closed, UP side open).
Deviation map: docs/research/2026-07-02-inbound-motion-deviation-map.md.

19 new gate tests + parser coverage; full suite 3276 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:44:06 +02:00
Erik
d34721fa94 feat(D6.2b): send retail-faithful raw forward_speed=1.0 on the wire
The outbound MoveToState now sends the RAW forward_speed 1.0 (omitted by
default-difference packing) instead of the pre-computed runRate, matching what
retail's client sends.

Settled the open question with a live echo-test: acdream sends forward_speed=1.0,
ACE broadcasts back RunForward @ runRate (not 1.0), and a retail observer saw
+Acdream run at full pace. So ACE RECOMPUTES the broadcast run speed from the
character's run skill and auto-upgrades WalkForward+HoldKey.Run -> RunForward for
observers. The earlier PlayerMovementController comment citing ACE
MovementData.cs ("ACE relays the speed, so the wire must send run_rate") was
wrong; corrected.

Changes:
- PlayerMovementController section 6: forward wire command is WalkForward @ 1.0
  (+ HoldKey.Run when running); LocalAnimationCommand still carries RunForward for
  the local cycle. Backward already 1.0. The MotionStateChanged detection now
  keys the walk<->run toggle off HoldKey + LocalAnimationCommand (forward_speed is
  constant 1.0); the forward_speed comparison is retained but never fires.
- GameWindow: reverted the D6.2b echo-test one-liner; the wire RawMotionState
  takes forward_speed from result (now 1.0).

The wire and the D6.2a local velocity are now both raw-1.0. Threading them onto
one shared RawMotionState (removing section-6's duplicate build) is a
behavior-neutral cleanup follow-up.

Full suite green (3255). Docs: roadmap + pseudocode doc updated with the
echo-test finding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:36:46 +02:00
Erik
78e163a41e feat(L.2b): outbound movement wire parity — RawMotionState default-difference, JumpPack, contact byte
Ports the three retail outbound-movement packers verbatim (decomp-derived
golden bytes, confirmed via the Ghidra bridge, cross-checked vs holtburger):

- D1 — RawMotionState::Pack (0x0051ed10): new AcDream.Core.Physics.RawMotionState
  data type (11 fields + actions, retail defaults) + RawMotionStatePacker that
  sets a flag bit only when the field DIFFERS from its default. MoveToState.Build
  now takes a RawMotionState instead of presence-based nullable params, so the
  over-sent forwardSpeed=1.0 / currentHoldKey=None / default per-axis holdkeys are
  no longer emitted. num_actions packs into bits 11-15 (not "bits 11-31").
- D3 — MoveToStatePack::Pack (0x005168f0) trailing byte =
  (standingLongjump ? 0x02 : 0) | (contact ? 0x01 : 0); explicit contact/
  standingLongjump params (standingLongjump=false honestly until the feature lands).
- D4 — JumpAction rewritten to retail JumpPack::Pack (0x00516d10): extent,
  velocity, full Position, four u16 timestamps, align. Removed the spurious
  objectGuid/spellId u32s; Position is now packed (it was absent). Body 56 bytes.
- Position::Pack (0x005a9640) / Frame::Pack (0x00535130) verified already-correct
  (cellId, origin xyz, quaternion wxyz); locked with a golden test, no change.

GameWindow callers adapted minimally: build the RawMotionState from the existing
MovementResult values (behavior preserved except the intended D1 omissions) and
pass cellId/position/rotation to the Jump send. Pre-existing MotionInterpreter
placeholder struct RawMotionState renamed LegacyRawMotionState (D6/Phase-2 scope,
pure rename) to free the name for the retail-faithful type.

D5 audit: confirmed a real divergence — retail SendMovementEvent (0x006b4680)
stamps only last_sent_position_time after an MTS while SendPositionEvent
(0x006b4770) stamps all three; acdream's NotePositionSent stamps all three on
both paths. Left unchanged (comments added at both call sites), recorded as
register TS-33, deferred to a dedicated cadence-port slice.

Tests: RawMotionStatePackTests / MoveToStateGoldenTests / JumpActionTests /
PositionPackTests + updated MoveToStateTests / AutonomousPositionTests. Full
suite green (Core.Net.Tests 372, full solution 3228 passed / 4 pre-existing skips).

Register: TS-24/TS-25 refreshed (packer now supports actions/style; runtime
emission still deferred), TS-33 added. Roadmap L.2b shipped note added.
Spec: docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md (2-6)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:30:01 +02:00
Erik
ca94b479bf Merge claude/hopeful-maxwell-214a12 — D.2b UI Studio + faithful importer + Character window
UI Studio (preview panels through the production renderer), importer dat-fidelity (Fix A/B/C/4/5:
the importer carries dat font/justification/color; boundary look=importer / state=runtime), and the
Character window Attributes tab (reads as retail). 3062 tests green.
Handoff: docs/research/2026-06-26-mockup-stage-handoff.md.

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
2026-06-26 12:33:51 +02:00
Erik
a0d33956f6 feat(ui): importer Fix C — per-element dat FontDid resolver (studio path); character level uses its retail font
DatWidgetFactory.Create now accepts an optional fontResolve: Func<uint,UiDatFont?>
parameter. When supplied and the element has a non-zero FontDid, the element
receives its own dat font instead of the shared global datFont fallback.
Null = original single-font behavior (the live GameWindow path passes null —
provably unchanged). LayoutImporter.Build/BuildFromInfos/Import all thread
the optional resolver down to the factory.

RenderStack gains a lazy font cache (ConcurrentDictionary, pre-seeded with
VitalsDatFont + LargeDatFont) and a ResolveDatFont(uint) method. StudioWindow
wires stack.ResolveDatFont into LayoutSource so every studio import gets
per-element fonts. GameWindow import calls left passing null (follow-up todo).

CharacterStatController font-hack cleanup (diagnosed via one-shot console dump
then removed):
- Name (0x10000231): dat FontDid = 18px font — remove datFont override (null)
- Heritage/PkStatus: dat FontDid = 14px fonts — remove override
- LevelCaption: dat FontDid = 16px — remove override (same font, no visual change)
- Level (0x1000023B): dat FontDid = 36px (the big retail gold font) — was forced
  to rowDatFont/LargeDatFont (18px); now drops to null so the dat 36px font drives
- TotalXpLabel/TotalXp: dat FontDid = 16px — remove override
- FooterTitle (0x1000024E): dat FontDid = 20px — remove datFont override
- KEEP: synthesized elements (XP meter overlays, 9 attribute rows, tab sprites)
  still use datFont directly since they have no dat origin

All Label/LabelTwoLine/LabelLeft/LabelProvider helpers updated: null = keep
build-time dat font; non-null = controller explicit override (backward-compat).

8 new tests in DatWidgetFactoryFontResolveTests:
- null resolver → DatFont == global datFont
- FontDid=0 → resolver not called
- resolver returns null → fallback to global datFont
- resolver called with element's FontDid
- controller DatFont override wins after build
- LayoutImporter.Build threads fontResolve to factory
- meter element fires resolver for non-zero FontDid
- BuildFromInfos without fontResolve param = original behavior

Build + all 710 App tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:30:13 +02:00
Erik
a236dc33ac fix(studio): Character window — larger row text + tighter attribute rows
- RowHeight reduced 44→30px to pack the 9 rows tighter, matching retail's denser list
- Attribute row name/value text now uses Font 0x40000001 (MaxCharHeight=18px) instead of
  the default 0x40000000 (16px); both fonts are in client_portal.dat (confirmed 2026-06-26)
- RenderStack gains LargeDatFont field; RenderBootstrap.Create loads both fonts
- FixtureProvider passes LargeDatFont as rowDatFont to CharacterStatController.Bind
- CharacterStatController.Bind gains rowDatFont? parameter (falls back to datFont);
  passed to BuildAttributeRows so row UiText elements use the larger font
- Full solution tests green (681/683 App + 1579/1581 Core + 343 Net + 425 UI)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 02:22:00 +02:00
Erik
101a35cc2d fix(studio): Task 3 review — UiRoot.Pick, RenderStack IDisposable, dt cleanup
Code-review follow-ups to the ImGui inspector:
- Add public UiRoot.Pick(x,y) over the private HitTestTopDown (honors
  Z-order + modal exclusivity); StudioWindow uses it instead of a manual
  UiElement.HitTest with subtracted ScreenPosition.
- RenderStack : IDisposable — disposes the GL pieces it owns in one place;
  StudioWindow OnClosing + Dispose both call _stack?.Dispose(), closing the
  error-path leak (only UiHost was disposed on the Dispose-without-OnClosing
  path).
- Drop the stale _dt field; OnRender passes its own dt to Tick + BeginFrame.
- Fix a stale PanelFbo comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:14:47 +02:00
Erik
79ee3ffbe2 feat(studio): RenderBootstrap — shared render stack for the UI previewer
Extracts the subset of GameWindow.OnLoad the UI Studio needs into a
standalone RenderBootstrap.Create factory: bindless detection, shaderDir,
SceneLightingUboBinding, mesh shader, TextureCache, animLoader,
WbMeshAdapter, SequencerFactory, EntitySpawnAdapter,
EntityClassificationCache, WbDrawDispatcher (+ A2C gate from
QualitySettings), UiDatFont load, and UiHost.  No terrain / sky /
physics / streaming — only the pieces listed in the RenderStack record.

GameWindow is untouched; this is additive new code only.

Note: task spec listed VitalsDatFont as AcDream.App.UI.Layout.UiDatFont
but the type lives in AcDream.App.UI — corrected here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:17:43 +02:00
Erik
8fa66c23d5 fix(D.2b): Slice 2 — retail-exact doll pose, camera + heading (visual gate)
The paperdoll doll now matches retail: correct held pose, framing, and
facing. Three decomp-sourced fixes closed the visual gate.

Pose: cdb-confirmed m_didAnimation = 0x030003C0 (gmPaperDollUI), played
once + HELD (set_sequence_animation framerate=0, RedressCreature
0x004a3c22). Dumping the dat showed the 29-frame anim has only two
distinct keyframes — frame 0 (transitional, bent arm) and frames 1..28
(byte-identical: the settled stance, arms down + leg back) — so
ApplyPaperdollPose applies the LAST frame statically (no looping).

Camera: ported verbatim from UIElement_Viewport::SetCamera (decomp
0x004a5a39). position (0.12,-2.4,0.88); direction (0,0,0) => IDENTITY
view frame => look straight down +Y, ZERO yaw; FOV pi/4 (CreatureMode
ctor default 0x004543cf); ambient 0.3. The prior hand-tune aimed the
camera at mid-body, adding a ~2deg yaw that turned the doll's face away
— full-body framing comes from eye-height + FOV, not aiming.

Heading: retail Frame::set_heading(h) (0x00535e40) builds facing
(sin h, cos h); System.Numerics CreateFromAxisAngle(+Z, +h) rotates the
body's default +Y forward to (-sin h, cos h) — the X-lean was MIRRORED
(~22deg), the real cause of the turned-away face. Negate the angle to
land on retail's facing.

Wrap-up: stripped the temporary O/P pose-frame stepper + Slice2
diagnostics; divergence register AP-66 reworded, AP-67 (RTT doll render
vs in-cell CreatureMode::Render) + AP-68 (per-race UpdateForRace
unimpl) added; DollCameraTests pinned to the retail values + a zero-yaw
guard. tools/cdb/paperdoll-pose.cdb = the pose-DID capture script.
Build + full suite green (Core 1579 / Core.Net 343 / App 597 / UI 425).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:16:27 +02:00
Erik
01594b4cfd merge: integrate main (D.2b paperdoll/inventory UI line) into the physics/collision branch
Brings the 134 D.2b UI commits onto the physics/collision development line so
main can fast-forward. Today's #149 (BSP-less static collision) + #150 (open
doors fully passable) + the full collision/streaming/dense-town-FPS arc meet
the paperdoll/inventory work.

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
2026-06-25 12:57:46 +02:00
Erik
2ac7ea776f fix(physics): #149 — collide BSP-less landblock statics via Setup cylsphere
Town props placed as landblock stabs whose ONLY collision is a Setup
CylSphere/Sphere (no physics BSP) registered ZERO collision shapes and were
walk-through -- torches, braziers, lamp-posts, candle-stands, posts.

Root: the ISSUES #83 / A1.6 gate `!_isLandblockStab` skipped Setup
cyl/sphere registration for ALL landblock stabs, on the false assumption
"landblock stabs collide via BSP only (retail CBuildingObj)." That
over-broadened -- it also killed collision for BSP-less stabs.

Retail's CPhysicsObj::FindObjCollisions (@0x0050f050) uses binary dispatch:
the object's physics BSP if it HAS one (HAS_PHYSICS_BSP_PS 0x10000), ELSE its
CSetup CylSpheres/Spheres -- never both. Confirmed via live retail cdb on the
Holtburg torch (Setup 0x020005D8 at world (105.99,17.17)): FindObjCollisions
target num_cylsphere=1, cyl h=2.2 -- a cylsphere, exact-matching the dat
(cylSphere r=0.2 h=2.2); the StabList confirms stab[95]=0x020005D8 there.

Fix: gate the Setup cyl/sphere registration on `entityBsp == 0` instead of
stab-ness. Preserves #83's anti-doubling (stab WITH a BSP -> BSP-only) while
restoring collision for BSP-less stabs. Other landblock entities on this path
(scenery -- tree-trunk cylspheres) are unaffected.

Live-verified: torch + candle/brazier family block now; ~115 cyl/sphere
Setups register across streamed landblocks. Core suite green (1595/0).

The earlier selection-sphere hypothesis was WRONG and is reverted -- the cdb's
r=0.48 sphere was the player/NPC body (every body sphere is ~0.48), not the
torch. The correct cdb method: capture the TARGET at FindObjCollisions (not
`this` in CSphere::intersects_sphere) and confirm by position + Setup-id.

(Issue numbered #149 to stay clear of main's #148; this worktree branched
before main's #145-148 were added.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:21:34 +02:00
Erik
989cc25d80 fix(physics): register BSP-only furniture weenies -- drop premature cyl/sphere/radius gate
RegisterLiveEntityCollision had a premature gate at the top of the method:
  if (!hasCyl && !hasSphere && !hasRadius) return;
This fired BEFORE ShadowShapeBuilder.FromSetup ran. The builder emits a BSP shape
for every Part whose GfxObj has a PhysicsBSP, regardless of CylSpheres/Spheres/Radius.
A furniture weenie with only a physics-BSP mesh (candle holder, candelabra, etc.)
has no CylSpheres, no Spheres, and Radius=0 -- so it was always dropped, making it
fully passable (invisible wall that lets the player walk through it).

Fix: remove the premature gate. The three `bool` locals (hasCyl, hasSphere, hasRadius)
are retained -- `hasRadius` is still used by the Radius fallback lower in the method
for entities with no CylSphere/Sphere/BSP but a non-zero setup.Radius. The correct
final gate at shapes.Count==0 (after builder + Radius fallback) handles all cases:
  - BSP-only entity: builder emits BSP shape -> shapes.Count>0 -> registered.
  - Truly shapeless (no BSP, no cyl, no sphere, no radius): builder empty, no Radius
    fallback fires -> shapes.Count==0 -> return (not registered, passable). Correct.

Retail anchor: CPhysicsObj::FindObjCollisions (acclient_2013_pseudo_c.txt:276917) --
the gate at pc:276917 is on the MOVER's CPartArray, not a target-side shape filter.
CPartArray::FindObjCollisions (pc:286236) iterates ALL parts; each part's
find_obj_collisions tests physics_bsp when present. There is no retail equivalent of
our premature gate that skips BSP-only targets.

Tests (ShadowShapeBuilderShapeSourceTests): two new cases.
  Setup_WithBspPart_NoCylSpheres_EmitsBspShape -- proves the builder emits the shape
    the premature gate was discarding.
  Setup_WithPartButNoBsp_NoCylSpheres_YieldsEmptyShapeList -- regression guard proving
    truly shapeless entities are still not registered (the shapes.Count==0 gate holds).
Full Core suite: 1595 pass / 0 fail / 2 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:04:32 +02:00
Erik
79dee342f2 fix(physics): Slice 1 — delete render-mesh-AABB synthetic collision; DAT-only shape authority
Retail oracle: CPartArray::InitParts@0x00517F40, CGfxObj::Serialize@0x00534970 (physics_bsp
gated on serialized-flags bit-0), CPhysicsPart::find_obj_collisions@0x0050D8D0 (returns OK /
passable when physics_bsp==null). Render-mesh bounds never enter collision.

Changes:
- GameWindow.cs: delete the ~200-line VISUAL mesh-bounds collision block (the
  isPhantomSetup / isPhantomGfxObj locals + the if-block computing worldMin/worldMax
  AABB + the ShadowObjects.Register call that capped and registered the synthetic
  cylinder). Also removes dead counter variables scHaveBounds/scRegistered/scNoBounds/
  scTooThin; trims the ProbeBuildingEnabled summary line accordingly.
- PhysicsDataCache.cs: delete IsPhantomGfxObjSource (the predicate that only existed
  to fence the mesh-AABB synthesis; the "phantom" concept is now the default — no DAT
  shape means no registration, verbatim with retail).
- PhysicsDataCachePhantomSourceTests.cs: deleted (tested the removed method).
- ShadowShapeBuilderShapeSourceTests.cs: new guard test — a Setup with parts but
  hasPhysicsBsp=false and no CylSpheres/Spheres yields an empty shape list, locking
  the DAT-only rule in the builder.
- retail-divergence-register.md: AP-2 row deleted (divergence retired).

Objects with no DAT physics shape (no CylSpheres, no Spheres, no part with a
PhysicsBSP) now register no collision shape and are passable, verbatim with retail.
Objects with real DAT shapes (BSP parts, CylSpheres) are unaffected.

dotnet build green, 22/22 tests passing (ShadowShapeBuilderShapeSourceTests +
CellarUpTrajectoryReplay + CornerFlood + Issue147ArwicBuildings replay harnesses).

Visual gate pending: walk Holtburg + open world; objects that become passable must
match retail (DAT has no physics shape — trees with real CylSpheres still solid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:57:46 +02:00
Erik
9743537e62 fix(physics): #147 — far-town city/perimeter walls had no collision (portal-less buildings skipped)
A town's outer/perimeter walls have NO collision even on a fresh login: the
player walks straight through them while houses block fine. Confirmed NOT a
frame issue — #146's bldOrigin probe showed Arwic buildings are correctly
framed (~12 m from the player, not km off), so the "#145 far-town frame"
premise was wrong here.

Root cause (dat-confirmed, Issue147ArwicBuildingsDumpTests): a perimeter wall
is stored in LandBlockInfo.Buildings as a doorless shell — 16 of Arwic's 30
buildings are PORTAL-LESS, ringing the town at 24 m intervals (x=12/132,
y=12/108). The building-collision cache loop skipped them via
`if (building.Portals.Count == 0) continue;` — a filter meant only for the
transit/entry feature (CellTransit.CheckBuildingTransit) that also dropped the
collision shell. Retail's find_building_collisions (0x006b5300) tests the shell
BSP independent of the portal list, so a doorless wall still collides.

Fix: don't skip portal-less buildings — cache them with an empty portal list
(no transit) but their collision shell (ModelId) intact. User-verified: Arwic
perimeter walls now block (Collided/Slid). Adds the dat-dump fixture test.

Suites green: Core 1569(+2 skip), App 468(+2 skip), UI 425, Net 317.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:24:48 +02:00
Erik
49d743f88a fix(physics): #146 — re-base building collision per landblock apply (lost after portal-in)
Static building/house-wall collision worked on a fresh login to a town but was
lost after logging in elsewhere and PORTALING in: the foot-sphere clipped
straight through walls while server-spawned doors (own collision) still blocked.

Root (capture-confirmed, ACDREAM_PROBE_BUILDING bldOrigin): the building shell
BSP's WorldTransform is computed from the streaming-relative origin
(origin = (lb − _liveCenter)·192) AT CACHE TIME, and CacheBuilding is idempotent
(first-wins per cell). A teleport recenters _liveCenter, but the idempotent guard
never re-bases the cached transform — so the shell sat at a stale world offset
(login Arwic → portal Holtburg: bldOrigin=(-5488,2149), ~5.5 km from the player
at (83,24)), and FindBuildingCollisions never penetrated → result=OK everywhere →
no block. Terrain doesn't suffer this because AddLandblock overwrites its
WorldOffset on every apply.

Fix: clear a landblock's cached buildings at the start of each ApplyLoadedTerrain
(PhysicsDataCache.RemoveBuildingsForLandblock), then let the existing loop
re-populate fresh with the CURRENT origin — the per-apply re-base terrain already
gets, while keeping CacheBuilding's per-cell first-wins within each fresh pass
(retail CSortCell::add_building). Also adds bldOrigin to the [bldg-channel] probe.

Verified on the exact repro (Arwic login → Holtburg portal → walk into wall):
bldOrigin now (107.5,36.0) at the wall (was -5488,2149); channel returns
Collided → Slid (block + wall-slide). Suites green: Core 1568(+2 skip),
App 468(+2 skip), UI 425, Net 317.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:33:06 +02:00
Erik
afd5f2a012 fix(streaming): #138-B avatar vanishes after teleporting out — don't relocate during PortalSpace
After a teleport-OUT the per-frame avatar-sync (GameWindow ~:8018) called
GpuWorldState.RelocateEntity with the player controller's cell — which stays the
FROZEN SOURCE cell until PlaceTeleportArrival materializes the destination. So
mid-transit it dragged the avatar (which the teleport's rescue/re-inject had
correctly placed at the destination center) back into the now-UNLOADED source
landblock's pending bucket, where nothing recovers it (RelocateEntity only scans
_loaded). Net: the avatar vanished after teleporting out and stayed gone.

Fix: skip the per-frame relocate while the player is in PortalSpace. The teleport
machinery (DrainRescued + PlaceTeleportArrival) owns the avatar's landblock during
transit; per-frame relocation resumes at FireLoginComplete (InWorld).

Live-verified with a new env-gated avatar-lifecycle probe (ACDREAM_PROBE_ENT /
EntityVanishProbe; [ent] draw-set transitions + [dyn] cull check): pre-fix the
trace showed `[ent] APPEND lb=0x0007FFFF(source) -> PENDING -> DRAWSET ABSENT`
never recovering; post-fix every teleport goes RESCUE -> ABSENT ->
APPEND(destination) -> PRESENT and stays drawn (4 teleports incl. to Holtburg,
session ended PRESENT).

Suites green: Core 1568(+2 skip), App 468(+2 skip), UI 425, Net 317.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:30:20 +02:00
Erik
a9d06a613a chore: strip throwaway dense-town FPS profiling apparatus (plan Task 5)
The FPS deep-dive landed (dense Arwic 75 -> ~165 fps via the cell-object
batching + cell-particle consolidation, both already committed). Remove the
throwaway diagnostic apparatus now that it has served its purpose:

- delete FrameProfiler.cs (whole-frame TimeElapsed + [PASS-GPU] glFinish +
  [CPU-PHASE]/[GPU-PHASE] timers + the =1/=2 ACDREAM_FPS_PROF modes)
- GameWindow: _fpsProf/_frameProfiler/_msaaSamples fields, the BeginFrame/
  EndFrame/MarkUpdateStart hooks, the terrain glFinish, and the landscape
  sub-phase LsMark instrumentation
- RetailPViewRenderer: the DrawInside per-phase Phase()/MarkGpu markers
- ParticleRenderer / PortalDepthMaskRenderer / EnvCellRenderer: the per-pass
  glFinish brackets
- delete DegradeCoverageProbeTests.cs (the dead distance-degrade probe)

KEPT (the real fixes): RetailPViewRenderer cell-object batching + consolidated
cell-particle pass; EnvCellRenderer.CellHasTransparent. Build + full test suite
green (468 App incl. pview replay tests; 1566 Core; 317 Net; 425 UI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 00:10:00 +02:00
Erik
9f51a4db18 perf(pview): consolidate per-cell cell-particle pass into one union draw
DrawCellObjectLists drew cell particles PER visible cell, and each call
(DrawRetailPViewCellParticles -> ParticleRenderer.Draw) re-walked the ENTIRE
live particle set to filter by owner id — O(cells x particles). Measured via
[CPU-PHASE] at dense Arwic this was the cellobjects sink (~5.4 ms CPU; the
phase's GPU share is 0.01 ms — pure CPU). gpu is 0.5 ms; the dense town is
~96% CPU-bound (the earlier "6.7 ms GPU" was a moving/streaming transient).

Static owners are disjoint per cell (InteriorEntityPartition.ByCell partitions
by ParentCellId), so the UNION of survivors (= _allCellStatics, already
accumulated in loop 1 for the batched entity draw) draws EXACTLY the same
emitters in ONE pass: the callback gates on owner id, the renderer sorts
globally back-to-front, and the per-cell slice was never used for clipping
(scissor gate deleted in T3; DisableClipDistances). Runs after the batched
static draw so emitters still depth-test against same-cell statics. Deletes the
loop-2 re-cull and collapses the per-cell BuildDrawList allocations N -> 1
(also eases the GC spikes).

Adversarial review (3 angles) confirmed emitter-set equivalence, no double-
draw, equal-or-better compositing, and no lost per-cell side effect — and found
the OLD code DOUBLE-DREW additive particles for multi-view-polygon cells (one
DrawCellParticles per slice, same owner set each time; the #121 over-bright
class). Consolidation draws each emitter once, fixing that latent bug.

Pixels identical-or-better; draw-mechanism speed only. Build + full suite green
(pview replay tests incl. HouseExitWalkReplay/TowerAscent/Issue130 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:59:16 +02:00
Erik
290e731ce3 perf(pview): batch per-cell cell-object draws into one cross-cell draw
DrawCellObjectLists called WbDrawDispatcher.Draw once per visible cell (each
orphaning 6 SSBOs + full state setup) — the top CPU-submission sink at dense
Arwic (cellobjects ~3.5ms/frame, measured via [CPU-PHASE]; the frame is
~96% CPU-bound, GPU only 0.5ms). Apply the shipped cells-shell batching
pattern to cell OBJECTS: collapse N per-cell draws into ONE cross-cell draw.

Two-loop structure preserves the statics-before-particles depth order:
  loop 1 — per-cell viewcone cull, accumulate all survivors + the union of
           cell ids;
  one batched DrawEntityBucket for every cell's statics;
  loop 2 — per-cell DrawCellParticles, after the statics own the depth buffer.

Correctness: same survivor set (union visibleCellIds gate is equivalent to the
per-cell {cellId} filter); transparency composites equal-or-better (the
dispatcher sorts opaque front-to-back + transparent back-to-front globally,
WbDrawDispatcher.cs:1469-1470); particles still occlude against same-cell
statics (loop 2 runs after the batched draw); dynamics-last + shells + seals
unchanged. Also drops the per-cell new[]{entry} alloc (~50-100/frame) to one.

Pixels identical — draw-mechanism speed only (render-perf is not faithfulness-
gated per the project steer). Spec: docs/superpowers/specs/2026-06-23-cellobject-
draw-batching-design.md. Build + full test suite green (App pview replay tests
incl. HouseExitWalkReplay/Issue130DoorwayStrip pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:48:20 +02:00
Erik
fe1f81371a chore(diag): FPS_PROF=2 clean split + [CPU-PHASE] DrawInside timers [throwaway]
Decouple the whole-frame TimeElapsed query from the per-pass glFinish so the
CPU-vs-GPU split is honest: ACDREAM_FPS_PROF=2 runs the frame query with NO
per-pass glFinish (PassGpuEnabled stays "1"-gated). Plus [CPU-PHASE] timers
around each DrawInside phase (flood/assemble/prepare/partition/landscape/
portalmask/shells/cellobjects/dynamics) — the CPU analog of [PASS-GPU].

This is what proved the dense town is ~96% CPU-bound (GPU=0.5ms) and that the
cost is per-cell draw submission (cellobjects ~3.5ms), not the portal floods /
punch-seal / clip allocs the static analysis had guessed. Throwaway; strip with
the rest of the FPS apparatus (plan Task 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:41:45 +02:00
Erik
376f4e6190 chore(diag): add particle + punch/seal glFinish timers (ACDREAM_FPS_PROF) [throwaway]
Per-pass GPU attribution for the dense-town deep-dive: ParticleRenderer.Draw and
PortalDepthMaskRenderer.DrawDepthFan report into FrameProfiler [PASS-GPU]. Joins
the cells/terrain timers. glFinish serializes -> inflates absolutes; use for
relative attribution. STRIP with the rest of the apparatus when FPS work lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:21:58 +02:00
Erik
8067d3b04a perf(pview): batch EnvCell look-in shell opaque pass (interior-root parity)
DrawBuildingLookIns had the same per-cell heavy Render pattern. Lift the opaque
shell Render out into one per-building batch (after that building's aperture
punches); keep the per-cell loop for transparent (skip-empty) + per-cell statics
/dynamics/particles. User-verified: no missing walls, look-in interiors correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:10:28 +02:00
Erik
3af7d0048d perf(pview): batch EnvCell shell opaque pass + skip empty transparent
Dense-town FPS root cause: DrawEnvCellShells called the heavy per-frame
EnvCellRenderer.Render once PER cell x opaque+transparent (~94 calls/frame,
24.75ms = 75% of GPU at Arwic). Batch opaque into one Render(Opaque, allCells)
(z-buffer order; per-instance CellId-keyed lighting => safe) + skip the
transparent Render for opaque-only cells, keep far->near for the rest.

Measured (Arwic, same facing, profiler on): cells 94 calls/24.75ms -> 1 call/
0.37ms; frame 34ms/29fps -> ~10ms/100fps p50.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:51:19 +02:00
Erik
f72f7ce1f4 feat(envcell): CellHasTransparent predicate (shell-batching prep)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:50:31 +02:00
Erik
e27923b4b5 chore(diag): dense-town FPS profiling apparatus (ACDREAM_FPS_PROF) [throwaway]
FrameProfiler (frame/update/render/present/gpu split + per-renderer glFinish
attribution) + OnRender/OnUpdate hooks + terrain & EnvCell glFinish timers +
the degrade-coverage probe test. Used to root-cause the dense-town FPS; STRIP
when the fix lands (mirrors 92e95be).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:49:18 +02:00
Erik
fe319bd2aa fix(D.2b): Slice 2 visual gate — frame the whole doll + caption the Slots toggle
Visual gate 1 (user): the doll rendered but only the legs showed (camera
aimed at the model origin = the feet) and the Slots button was invisible.

- DollCamera: aim the look-at at mid-body (~0.95 m) and stand back ~3.7 m
  so the whole ~1.9 m figure fits. (Size is a later retail-comparison
  polish per the user.)
- PaperdollController: the Slots button (0x100005BE) is found + wired
  (diagnostic confirmed armorSlots=9/9, viewport=UiViewport,
  slotsButton=UiButton) but its dat element has no face sprite, so it drew
  nothing. Give it a gold "Slots" caption (UiButton.Label, like chat Send)
  via a new datFont param on Bind. Temporary [Slice2-paperdoll] diagnostic
  logs the button rect + the found widgets (stripped at wrap-up).

Idle animation deferred to a focused follow-up (faithful idle needs a full
AnimatedEntity + Sequencer through the TickAnimations multi-branch path +
re-dress coordination — real integration risk vs the verified static doll).

Build + full App suite green (594).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:58:22 +02:00
Erik
536f1c04cd fix(streaming): drop _datLock from the terrain apply (FPS swing root cause)
ApplyLoadedTerrainLocked makes zero DatCollection calls (all dats pre-read by
the worker into lb.PhysicsDats), and its other mutations are update-thread-only
or ConcurrentDictionary-safe, so the dat lock is unnecessary around it.
Removing it eliminates the measured 24ms-median / 88ms-p95 lockwait stall that
was the 30↔200 FPS swing. The worker still serializes its own dat reads on
_datLock; only the apply stops contending. Build + 1568 Core tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:37:50 +02:00
Erik
81a5605ff4 refactor(apply): read dats from the bundle, not DatCollection (no lock change yet)
ApplyLoadedTerrainLocked's six Get<T> sites now read from lb.PhysicsDats via
TryGetValue (loud-fail on a gather/apply id mismatch). Zero _dats.Get calls
remain in the apply. Behavior identical: same ids -> same cached dat objects
-> same surfaces/BSP/ShadowObjects. Lock removal is the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:36:09 +02:00
Erik
4a99b55a73 feat(streaming): worker pre-reads ApplyLoadedTerrain dats into the bundle
BuildPhysicsDatBundle mirrors the apply's six Get<T> sites (LandBlockInfo,
EnvCell, Environment, building Setup, entity GfxObj, entity Setup) under the
worker's existing _datLock and attaches them to LoadedLandblock. Far tier
gets PhysicsDatBundle.Empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:33:07 +02:00
Erik
8b0365e7a9 feat(D.2b): Slice 2 — wire paperdoll doll RTT pass + re-dress into GameWindow
Drives the doll 3-D pass in a pre-UI hook (after the world passes, before
_uiHost.Draw), gated on inventory-open AND doll-view (the viewport widget's
Visible, set by the Slots toggle). RefreshPaperdollDoll clones the live
player entity (_entitiesByServerGuid[player]) into a DollEntityBuilder doll
with COPIED MeshRefs (frozen pose); re-dress is triggered by a dirty flag
set in OnLiveAppearanceUpdated (0xF625) — the C# analog of RedressCreature.

Correctness fix: the doll is passed in animatedEntityIds so the dispatcher
BYPASSES the Tier-1 classification cache (WbDrawDispatcher.cs:1142). Without
it, a re-dress (a new WorldEntity with the same fixed DollRenderId) would
serve the previous doll's cached batches and the new gear wouldn't appear.

Renderer disposed in the GameWindow teardown. Build + full App suite green
(594). Static doll (no idle animation yet — next slice). Visual gate next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:22:45 +02:00
Erik
3cdecb536b feat(D.2b): Slice 2 — PaperdollViewportRenderer RTT pass (static doll)
The C# analog of CreatureMode::Render: draws one re-dressed player clone
into a private FBO (RGBA8 + depth24-stencil8) with the fixed DollCamera +
one distant light (retail 0.3,1.9,0.65 @ 2.0), sealed in a GLStateScope so
it can't disturb world/UI GL state. frustum:null ⇒ the doll's synthetic
landblock is always visible ⇒ walked from entry.Entities and drawn from its
current MeshRefs (static pose; idle animation is a later slice).

Manages the FBO directly via GL (ManagedGLFramebuffer is unused/bitrotted
WB infra). UiViewport blit flips V (FBO bottom-left origin vs UI top-left)
so the doll isn't upside-down. Not yet wired — GameWindow hook is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:14:20 +02:00
Erik
362d41aacf feat(D.2b): Slice 2 — DollEntityBuilder (player Setup+ObjDesc -> doll WorldEntity)
Mirrors the palette/part-override mapping at GameWindow.cs:3390-3431 in a
testable static helper. Build() accepts plain (SubPaletteId, Offset, Length)
and (PartIndex, GfxObjId) tuples, builds PaletteOverride only when
subPalettes.Count > 0 (same gate as GameWindow), and poses the entity at
origin facing the viewer (191.367905° / +Z). Reserved synthetic guid
0xDA11D011 keeps the doll distinct from the live player and satisfies
EntitySpawnAdapter's ServerGuid != 0 guard. 7 new tests green; suite 594/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:07:02 +02:00
Erik
10cb31223f feat(D.2b): Slice 2 — DollCamera (fixed paperdoll ICamera)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:00:27 +02:00
Erik
92e95bea53 chore: strip world-load deep-dive diagnostic probes
Removes the scenery-frame, building-reach, and stream-resid probes added during the
world-load/FPS deep-dive (all root-caused + fixed). Gated-off diagnostics only; no
behavior change. The fixes they found remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:23:15 +02:00
Erik
9945d46280 fix(streaming): reload terrain on an outdoor teleport (sky-arcs)
Terrain vertices are baked into the GPU relative to the render origin (_liveCenter),
which only moves on a teleport. A FAR jump unloads/reloads everything → fine. But a
NEARBY outdoor jump (e.g. (170,168)->(169,180), 12 landblocks) leaves the old and new
streaming windows overlapping, so StreamingRegion.RecenterTo KEEPS the ~330 overlapping
blocks (correct — they're in the new window) — yet they still hold vertices baked at the
OLD origin. The instant _liveCenter moves they render shifted by the jump distance: a
band of terrain hanging in the sky ("terrain in the sky" arcs). Confirmed by probe: every
stale slot was offset by EXACTLY deltaLB*192 ((-1,12)*192 = (-192,2304)), 330 of them
persisting after the hop.

Fix: StreamingController.ForceReloadWindow() — on an OUTDOOR teleport, SYNCHRONOUSLY drop
every resident landblock (render slot + physics + state) so none survives the frame stale,
then null the region so NormalTick re-bootstraps the whole window fresh at the new origin
(the near ring is priority-applied behind the fade; the rest streams). Called from
OnLivePositionUpdated's outdoor recenter branch; sealed dungeons keep PreCollapseToDungeon.

Verified live: the exact nearby hop that produced 330 stale slots now produces 0 across
the whole session, and the horizon is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:18:27 +02:00
Erik
a4c68520ea fix(D.2b): paperdoll empty slots show a visible frame (not transparent)
User correction at the visual gate: the green "figure" in the paperdoll is
the LIVE 3D character (the doll — can be naked), NOT per-slot silhouettes.
The default view (Slots button OFF) = the doll + non-armor slots; pressing
Slots hides the doll and shows the armor slots. So the doll + the Slots
toggle are Slice 2 (the UiViewport); there are no per-slot silhouette sprites
to chase.

For Slice 1 (no doll yet) the right empty-slot look is simply a VISIBLE FRAME
so every slot position can be seen + used — which fixes the "I see only slots
with equipment, no empty slots" report. The earlier transparent (EmptySprite=0)
came from the stale silhouette assumption. PaperdollController now takes an
emptySlotSprite; GameWindow passes the inventory grid's empty square
(0x06004D20) for a consistent visible frame.

App suite 580 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:02:11 +02:00
Erik
15e320490d fix(world+streaming): trees-in-sky Z, O(1) anim, teleport near-ring + immediate unloads
Three apparatus-confirmed fixes from the world-load/FPS deep-dive (all live-verified).

1. trees-in-sky — scenery ground-Z now samples THIS landblock's OWN heightmap
   (TerrainSurface.SampleZFromHeightmap, lock-step with the physics terrain) instead
   of the global PhysicsEngine.SampleTerrainZ query. At build time the landblock isn't
   registered in physics yet, so that query could only return null OR a STALE
   neighbour's height — the previous location's terrain, still registered after a
   teleport recenter — planting scenery at the old altitude (+250..500m, confirmed via
   the [scenery-z-stale] probe). Own-heightmap is correct in every case; the query is
   removed. (GameWindow.BuildSceneryEntitiesForStreaming)

2. FPS per-hop — TickAnimations recovered each animated entity's server guid via an
   O(N) ReferenceEquals reverse scan over ALL _entitiesByServerGuid (which never
   evicts, so N climbs every teleport — the drops-with-each-hop sink). Replaced with
   ae.Entity.ServerGuid: O(1), exact-equivalent (the dict key IS entity.ServerGuid).
   (GameWindow.TickAnimations)

3. teleport arrival + bulk floating terrain — two streaming fixes:
   - Near-ring eager-apply: a teleport applies the destination's 3x3 surroundings
     (StreamingController.PriorityRadius) and holds the fade until they're resident
     (PhysicsEngine.IsNeighborhoodTerrainResident), so the player arrives in a loaded,
     collidable world instead of one landblock in the void.
   - Immediate unloads: DrainAndApply no longer throttles UNLOADS at the per-frame
     load budget — they're cheap (free GPU buffers, no upload). A teleport produced
     ~600 unloads draining at 4/frame, leaving the previous region resident for
     seconds (floating terrain) and accumulating across rapid hops (951 resident vs a
     625 window). Only GPU-upload LOADS are metered now. Cut out-of-window resident
     650 -> 63 and resident 951 -> 688 (live-verified via [resid-audit]).

Includes gated-off diagnostic probes (ACDREAM_PROBE_SCENERY_FRAME / _BLDG_REACH /
_STREAM_RESID) used to root-cause the above — zero-cost when unset, same pattern as
the committed tp-probe.

The pre-existing teleport-induced "terrain arcs in the sky" (present in the dd2eb8b
baseline too, with NONE of this work) are a SEPARATE bug — investigated next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:40:18 +02:00
Erik
058da60212 feat(D.2b): wire PaperdollController into the inventory frame (Slice 1)
Adds the _paperdollController field and PaperdollController.Bind(...)
call in the inventory-frame block alongside InventoryController, wiring
up the paperdoll equip slots with their icon composer and wield action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:35:51 +02:00
Erik
124a4a2efa feat(D.2b): wire InventoryController drag-drop to the live session
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:55:20 +02:00
Erik
8fbde99441 Revert "fix(world): AP-48 client-side visibility cull (FPS sink across portal-hops)"
This reverts commit e5b2d15b63.
2026-06-22 18:58:15 +02:00
Erik
e5b2d15b63 fix(world): AP-48 client-side visibility cull (FPS sink across portal-hops)
acdream accumulated every CreateObject from every town visited and never pruned by
distance/time (only on server DeleteObject / respawn de-dup), so the entity tables +
the O(N^2) TickAnimations scan grew with each hop and sank FPS (confirmed in Release).

Faithful port of holtburger liveness.rs (ACE_DESTRUCTION_TIMEOUT_SECS=25,
CONSERVATIVE_VISIBILITY_DISTANCE_M=384): a world entity is evicted only after being
>384m AND outside the 3x3 landblock neighborhood for 25s continuous (arm-on-leave /
clear-on-return). Logic in a pure, unit-tested EntityVisibilityCuller; GameWindow
wires a 1Hz tick that snapshots the world entities + player and tears each evicted
guid down through the existing pruner. Player + held/equipped/contained items are
excluded (player by guid; inventory items never carry a world position so they never
enter the culled map). A re-created object starts fresh (deadline cleared on remove).
Skipped during a teleport hold (frozen player position). AD-32 registered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:45:07 +02:00
Erik
ba5f974f56 feat(D.2b): wire InventoryController container-open callbacks to the live session
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:32:56 +02:00
Erik
6e78fcd7f6 fix(teleport): dungeon-exit priority-apply footgun + wall-clock timeout + arrival facing
T6 visual gate found three issues; probe pinned the roots.

- StreamingController.DrainAndApply: the priority hunt + outbox drain were gated
  behind 'if (budget <= 0) return;' AFTER the deferred-buffer drain. During a
  dungeon-exit expand (~600 completions) the buffer is always >= budget, so the
  hunt never ran and the destination never priority-applied (APPLY stalled ~5s;
  dest built +265ms but applied +6s). Restructured: priority hunt runs FIRST and
  unconditionally. Indoor (single-LB collapse) was unaffected, which is why only
  outdoor exits broke.
- Teleport timeout was a 600-FRAME count; the empty-world exit hold renders at
  ~1000fps so it fired in ~0.6s and force-placed into the skybox before the
  expand finished. Now wall-clock (10s) — worldReady wins the race.
- Arrival facing: synced _playerController.Yaw to the server orientation via the
  exact inverse of YawToAcQuaternion (ExtractYawFromQuaternion has a 270deg offset
  vs our yaw). Previously only the render mesh got the rotation → camera/movement
  faced the stale pre-teleport direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:27:55 +02:00
Erik
3ce1fae332 feat(teleport C): TAS-driven fade transit + retire TeleportArrivalController
Wires the dormant TeleportAnimSequencer as the transit driver: on PlayerTeleport
the player holds in PortalSpace behind a full-screen fade (FadeOverlay) until the
destination terrain is resident (TeleportWorldReady, gated on the priority-applied
landblock), then materializes (Place), and after the world fades back in regains
control + acks the server (FireLoginComplete). No movement resolves against the
empty world, so the outbound cell frame can't corrupt. Outdoor changes from
place-immediately back to hold-until-resident (now fast, not a band-aid).

- FadeOverlay: fullscreen NDC black quad, alpha = ShowTunnel ? 1 : FadeAlpha.
- Retires TeleportArrivalController + its 2 tests (TAS subsumes the driver role).
- Divergence register: AD-2 updated to the new mechanism; AD-31 (fade vs swirl).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:03:25 +02:00
Erik
9ac719424c test(teleport): tp-probe AIM/ENQ/BUILD/APPLY/PLACED instrumentation (REMOVABLE)
Acceptance apparatus for the teleport-residency fix. ACDREAM_PROBE_TELEPORT=1
gates 5 log points with cross-thread TickCount64 timestamps + the _datLock
waited/held measurement. Stripped (or promoted) at verification (plan T6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:45:15 +02:00