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>
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
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>
CPhysicsObj::FindObjCollisions at pc:276961-276989 has a two-layer mechanism for
ethereal doors. Layer 1 (BSPQuery Path-1 sphere_intersects_solid, pc:323742) already
ported in Task 3 handles the open-gap case. Layer 2 (pc:276963-276977) is a
force-reset that catches the residual case: when the player's sphere CENTER crosses
a thin ethereal slab, sphere_intersects_solid can still return Collided via
HitsSphere (polygon contact on the slab face). Without Layer 2, the opened door
remains an invisible wall until the player's center passes ~0.48m beyond the slab.
Port: in FindObjCollisionsInCell, immediately after the shape dispatch (BSP / Sphere /
Cylinder branches), insert the Layer-2 gate:
if (result != OK && sp.ObstructionEthereal && !sp.StepDown && (obj.State & 0x1u) == 0)
{ result = OK; ci.CollisionNormalValid = false; }
The STATIC_PS (0x1) guard (pc:276969) ensures static-ethereal env geometry still
blocks. Cylinder/Sphere shapes already return OK from their own ObstructionEthereal
early-outs so Layer 2 is effectively BSP-only in practice, but is written
unconditionally matching retail.
Tests (ObstructionEtherealTests): three new BSP Layer-2 cases using a synthetic
wall polygon in local-space coordinates. (A) ethereal non-static: passable. (B)
ethereal+static: still blocks. (C) non-ethereal: still blocks. All 11 tests pass;
DoorCollision/CellarUp/CornerFlood/HouseExitWalk regression gate: 25/25 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Part A (D4): CObjCell::check_entry_restrictions (pc:309576) gate is omitted from
FindEnvCollisions. Investigation confirmed the gate requires restriction_obj (per-cell
access-lock entity id) + weenie-object-table dispatch (CanMoveInto / CanBypassMoveRestrictions)
— neither exist in acdream. CellPhysics has no restriction_obj field; DatReaderWriter
models no per-cell access locks. Gap is inert in all dev content (ACE starter area has
no access-locked env cells). Documented as AP-50 in the retail divergence register.
Part B (W1): Replace the hardcoded-false smell in BspOnlyDispatch with named internal
helpers PvpExempt() and MissileIgnore() that return false with retail oracle citations
(pc:276808-276841 and pc:274385 respectively). BspOnlyDispatch now folds all three
terms in retail's exact predicate structure. Behavior is byte-identical in M1.5 scope
(both stubs false ⇒ reduces to HAS_PHYSICS_BSP_PS check alone, same as before).
A6.P7 door dispatch unchanged.
Tests: 3 new guard tests in A6P7DispatchRulesTests — W1_PvpExempt_ReturnsFalseInM15Scope,
W1_MissileIgnore_ReturnsFalseInM15Scope, W1_BspOnlyDispatch_DoorStateStillDispatchesBspOnly.
Suite: 1590 pass / 0 fail / 2 skip (was 1587 + 3 = 1590).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors RemoveBuildingsForLandblock (#146) for indoor CellPhysics entries.
_cellStruct is first-wins (CacheCellStruct's ContainsKey guard); without
eviction a dungeon's BSP WorldTransform is permanently locked to the
_liveCenter value at first streaming — a teleport recenter leaves cells at a
stale offset (~source↔dest distance), and foot-sphere collision queries miss
the geometry that visually renders correctly.
PhysicsDataCache.RemoveCellsForLandblock iterates _cellStruct.Keys and
TryRemove-s every entry whose high-word matches the evicted landblock prefix.
PhysicsEngine.RemoveLandblock now calls it alongside ShadowObjects.RemoveLandblock
so cell BSPs rebase on the next CacheCellStruct pass, same as buildings.
No divergence register row needed: this closes a gap introduced when the #146
building-eviction pattern was created without the symmetric cell eviction.
Tests: RemoveCellsForLandblockTests (3 cases): evicts-matching-prefix,
empty-cache no-throw, no-matching-cells leaves-others. Core suite: 1587 pass / 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retail oracle greps confirmed:
- CSphere::intersects_sphere @ 0x00537ae4 (pc:321692): the ethereal branch
is `void __thiscall` — all paths return void (no COLLIDED). The function
performs a proximity check only; no blocking result is produced.
- CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573): same void-return
pattern — ethereal branch calls collides_with_sphere (check only, no slide),
all returns are void = passable.
Change: added `if (sp.ObstructionEthereal) return TransitionState.OK` at the
top of SphereCollision and CylinderCollision in TransitionTypes.cs, mirroring
the void-return semantics of both retail functions. The existing per-object
clear at pc:276989 (line 2837) still fires after the early OK return.
Before this fix: an ethereal-alone NPC/ghost with a Cylinder or Sphere shadow
shape would BLOCK the player (regression introduced when Task 3 made ETHEREAL-
alone fall through ShouldSkip instead of instant-skipping). After: all three
shape types — BSP (via BSPQuery Path 1), Sphere, and Cylinder — correctly pass
through when obstruction_ethereal is set.
Tests: added 4 tests to ObstructionEtherealTests.cs verifying:
- Ethereal Cylinder → passable (sweep passes through, no CollisionNormalValid)
- Ethereal Sphere → passable (same)
- Non-ethereal Cylinder → still blocks (regression guard)
- Non-ethereal Sphere → still blocks (regression guard)
Full Core suite: 1584 pass, 0 fail, 2 skip (pre-existing dat skips).
Pseudocode doc updated with confirmed cyl/sphere ethereal contracts and the
complete set/clear/consume flow summary.
Retail refs:
- CSphere::intersects_sphere @ 0x00537ae4 / acclient_2013_pseudo_c.txt:321692
- CCylSphere::intersects_sphere @ 0x0053b4a0 / acclient_2013_pseudo_c.txt:324573
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retail CPhysicsObj::FindObjCollisions (0x0050f050) only instant-skips when
BOTH ETHEREAL_PS (0x4) AND IGNORE_COLLISIONS_PS (0x10) are set (pc:276782).
ETHEREAL-alone sets sphere_path.obstruction_ethereal=1 (pc:276806) and
continues to the shape dispatch. BSPTREE::find_collisions (0x0053a496) routes
Path 1 (sphere_intersects_solid) when the flag is set (pc:323742): the open
door has no solid leaf at the doorway, so the test returns OK → player passes
through. CEnvCell::find_env_collisions (0x0052c144) clears the flag first so
ENV walls are never weakened (pc:309580, "D5 clear").
Changes:
- CollisionExemption.ShouldSkip: require BOTH bits for Gate-1 early-out
(previously ETHEREAL alone returned true — the AD-7 shim). Divergence
register row AD-7 deleted.
- SpherePath: add ObstructionEthereal field (mirrors retail
SPHEREPATH.obstruction_ethereal).
- FindObjCollisionsInternal loop: set sp.ObstructionEthereal=(target&0x4)!=0
before shape dispatch; clear it after (per-object clear pc:276989).
Also clear at the null-BSP continue site to keep flag clean.
- FindEnvCollisions: clear sp.ObstructionEthereal=false at top (D5 clear
pc:309580) — ENV cell walls are always solid.
- BSPQuery.FindCollisions Path 1: change `obj.Ethereal` (ObjectInfo.Ethereal,
always false — dead code) to `path.ObstructionEthereal`. Gate now correctly
mirrors retail pc:323742: PLACEMENT_INSERT || obstruction_ethereal.
Consume site change (BSPQuery.cs before/after):
BEFORE: if (path.InsertType == InsertType.Placement || obj.Ethereal)
AFTER: if (path.InsertType == InsertType.Placement || path.ObstructionEthereal)
Mirrors retail pc:323742 exactly. obj.Ethereal was dead code (ObjectInfo.Ethereal
is never set true anywhere); the correct flag is SpherePath.ObstructionEthereal.
Tests: 1580 pass / 0 fail / 2 skip (was 1576/0/2 + 4 new in ObstructionEtherealTests.
CellarUp, CornerFlood, DoorCollision, HouseExitWalk all green — no wall regressions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Setup.Spheres were previously coerced to short cylinders (CylHeight=2*r),
which is geometrically wrong: a cylinder has flat caps; a sphere does not.
This ported CSphere::intersects_sphere (0x00537A80) so sphere-typed shadow
entries are tested as spheres — 3-D distance, no height clamping.
Changes:
- ShadowObjectRegistry.cs: added ShadowCollisionType.Sphere (enum value 2).
The BuildFloodSpheres anyCyl dedup at :232 is unaffected: only Cylinder
sets anyCyl=true; Sphere shapes fall through to the BSP-fallback path
(anyCyl=false → included), which is correct.
- ShadowShapeBuilder.cs: FromSetup now emits ShadowCollisionType.Sphere
(CylHeight=0) for Setup.Spheres instead of a short Cylinder.
- CollisionPrimitives.cs: added SweptSphereHitsSphere — quadratic swept
solve ported from ACE Sphere.cs::FindTimeOfCollision, which is a C# port
of retail's CSphere::intersects_sphere @ 0x00537A80. Sign convention
confirmed against the decomp: retail negates the root to produce a
forward t ∈ (0,1].
- TransitionTypes.cs: added Sphere narrow-phase branch between BSP and
Cylinder in FindObjCollisionsInCell; uses 3-D distance for overlap
(not XY-only). Added SphereCollision() method implementing the 3-D
wall-slide response. Updated diagnostic logging at :2734 to cover Sphere.
- Updated ShadowShapeBuilderTests for new Sphere type assertion.
- New SphereIntersectsSphereConformanceTests: 9 geometrically-anchored
cases (head-on, tangent, perpendicular-miss, lateral-near-miss,
sweep-away, beyond-step, degenerate-zero-sweep, already-overlapping,
vertical-sweep).
Retail oracle: CSphere::intersects_sphere @ 0x00537A80 (named-retail);
ACE Sphere.cs::FindTimeOfCollision (C# port, cross-confirmed).
Build: 0 errors, 10 warnings (pre-existing).
Tests: 1576 pass / 0 fail / 2 skip (1578 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
Visual gate 2 (user): the "Slots" toggle caption was visible but unclickable.
Root cause (UiRoot.OnMouseDown/OnMouseUp): a left-press on a non-drag-source
widget inside a whole-window-Draggable frame (the inventory window's IA-12
drag) set _windowDragTarget; OnMouseUp then early-returned before emitting the
Click. So the paperdoll Slots button (the first plain button inside the
draggable inventory frame) never received its click. Chat/toolbar buttons
escape this — their frames aren't whole-window-draggable.
Fix (toolkit, root cause not band-aid): add UiElement.HandlesClick (a virtual
opt-out parallel to IsDragSource); UiButton overrides it true; OnMouseDown
routes a HandlesClick press to the widget (like CapturesPointerDrag) instead of
the window-drag, so OnMouseUp emits the Click. 2 regression tests lock it
(HandlesClick widget in a Draggable frame emits Click; a plain one doesn't).
Build + full App suite green (596, +2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Carries the parsed dat objects ApplyLoadedTerrainLocked needs so the worker
can pre-read them and the apply can run lock-free. Optional field (default
null) keeps existing LoadedLandblock construction back-compatible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Adds ArmorSlotElementIds (the exact 9 ids gmPaperDollUI::ListenToElementMessage
flips per decomp 175674-175706) and PaperdollViewState (SlotView/DollVisible/
ArmorSlotsVisible/Toggle) as a nested public class on PaperdollController.
Wires ApplyView() into the constructor so the Slots button (0x100005BE) drives
armor-slot Visible and the doll viewport Visible on every click. Initial state
is doll-view (armor slots hidden). The doll viewport element (0x100001D5) is
bound as UiElement? so this slice stays independent of Slice 3's IUiViewportRenderer
seam; _armorSlots collects whichever of the 9 ids were found in the live layout.
Three new unit tests verify the id set, the default state, and the round-trip
toggle against the public PaperdollViewState surface only.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
PickupEvent (0xF74A) and DeleteObject (0xF747) are semantically distinct:
- 0xF747 = weenie DESTROYED → evict from ClientObjectTable (weenie_object_table)
- 0xF74A = object LEFT THE 3D WORLD VIEW (moved into a container) → remove
the 3D WorldEntity, but the weenie persists in ClientObjectTable
Before this fix, both paths fired EntityDeleted identically, causing
ObjectTableWiring to evict the weenie from ClientObjectTable. The follow-up
InventoryPutObjInContainer (0x0022) then tried MoveItem on an unknown guid
and no-op'd, so the unwielded item simply vanished.
Fix: add `bool FromPickup` (default false) to DeleteObject.Parsed. WorldSession
sets it true on the PickupEvent path and false on the DeleteObject path.
ObjectTableWiring.Wire's EntityDeleted handler skips table.Remove when
FromPickup is true, preserving the weenie for the container-move echo.
GameWindow.OnLiveEntityDeleted (3D entity removal) is untouched — it fires
for both pickups and destroys, as intended.
Divergence register: AP-65 added (data ghosts for other-player pickups until
teleport/relog clear; harmless — no UI queries ContainerId 0).
Tests: +5 (DeleteObject FromPickup parser regression; wiring retain/evict
semantics; Parsed default/explicit FromPickup). 343/343 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code-quality review on Task 5:
- I1: Concerns was unscoped (CurrentlyEquippedLocation != None) → an NPC's
wielded item (which also carries that wire field) triggered spurious full
repaints. Narrowed to (WielderId==p || ContainerId==p), matching
InventoryController; OnObjectMoved's from/to-player backstop still catches
unwield-into-a-side-bag. Populate's own scope already prevented wrong data;
this kills the wasted repaints.
- I2: replaced the dual-`index++` (assign-vs-skip) with a for-i loop;
SlotIndex = SlotMap position (= the drag payload's SourceSlot on unwield).
- M1/M2: comment that the cell's SpriteResolve + the discrete-slot accept/
reject ring (0x060011F9/F8, not the grid insert-arrow) are factory-provided.
- Added two behavioral tests: a live player wield repaints the slot
(ObjectMoved → Concerns → Populate); an NPC's wielded item never appears on
the doll (player-scoping).
- Synced the stale spec §4b/§6c/§8 to the Task-3 Option-1 reality (the
optimistic wield is ContainerId-based and does NOT write WielderId).
App suite 580 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the InventoryPutObjInContainer 0x0022 handler which already does
MoveItem + ConfirmMove. Without this, WieldItemOptimistic's pending snapshot
would linger until the session ended (or incorrectly roll back on 0x00A0).
ConfirmMove is a no-op when nothing is pending, so safe for server-initiated
login wields.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code-quality review (needs-changes) on Task 3: WieldItemOptimistic wrote
item.WielderId directly, but RollbackMove (via MoveItem) never cleared it
→ a wield-rollback left a pack item with a stale WielderId=player.
Root-cause fix (vs the reviewer's snapshot-WielderId suggestion): acdream's
existing WieldObject 0x0023 confirm models a wielded item as ContainerId=
wielder + equip=mask and does NOT touch WielderId. So the optimistic path
must match — drop the WielderId write entirely. Optimistic state now equals
the confirmed state, rollback fully restores through MoveItem alone, and the
stale-state class is structurally eliminated. The paperdoll's
(WielderId==p || ContainerId==p) filter still matches optimistic wields via
ContainerId (login-equipped items match via WielderId from their CreateObject).
Also: + the wield+move outstanding-count combo test (spec §8), MoveItem doc
note that it doesn't manage WielderId, and the test pins WielderId==0 post-
optimistic-wield to document the model. Core 74/74 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add WieldItemOptimistic (instant optimistic wield — sets ContainerId=WielderId=player,
CurrentlyEquippedLocation=equipMask, snapshots pre-wield position) and extend the
_pendingMoves tuple to carry the pre-move EquipMask so RollbackMove restores EQUIPPED
state faithfully on server rejection. Shared RecordPending helper replaces the inline
snapshot block in MoveItemOptimistic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old enum invented two phantom bits (HandArmor=0x2000, FootArmor=0x10000) and
used non-retail names (Necklace, LeftBracelet, RightBracelet, LeftRing, RightRing,
AetheriaRed/Yellow/Blue), shifting every slot above 0x1000 out of alignment with
retail INVENTORY_LOC (acclient.h:3193) and ACE EquipMask.
Replace the enum body with verbatim retail values. Add EquipMaskTests to
numeric-pin every member so future renumbering breaks at compile/test time.
Existing consumers (ClientObjectTable, InventoryController, GameEventWiringTests,
ClientObjectTableTests) only reference EquipMask.MeleeWeapon and EquipMask.None --
both present at their correct retail values -- so no call sites needed updating.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
De-risk gate for Sub-phase C: verifies that the gmPaperDollUI subtree
imported under the inventory frame (0x21000023) materialises a
representative set of 6 equip-slot element ids as UiItemList widgets,
confirming the controller-binding plan can proceed without an importer
pre-fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Visual gate: moving an item within a bag reshuffled every item ("the order is
not set"). Root cause: insert-before set ONLY the dragged item's ContainerSlot
to N, colliding with the item already at N; the sort-by-slot tie then reordered
the whole grid on every repaint. Fix: MoveItemOptimistic now does a proper
index-based INSERT (shift the others, renumber 0..N-1 gapless) like retail's
ItemList_InsertItem, and HandleDropRelease uses the target's GRID INDEX
(SlotIndex) as the placement rather than its raw ContainerSlot. Regression test
pins the shifted, gapless order. Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
I2 (real bug): Clear() (teleport/logoff) and Remove() (item destroyed) now drop
the item's _pendingMoves entry — a stale snapshot on a recycled guid could
otherwise mis-rollback a different future item.
I1 (race): track an OUTSTANDING count per item so an early ConfirmMove (0x0022)
for the first of several in-flight moves of the SAME item can't clear the
snapshot while a later move is unconfirmed — a later reject can still roll back
to the original instead of stranding the item at the rejected position.
Both with regression tests; full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the 3 tests the spec-compliance review flagged: 0x0022 ConfirmMove clears
the pending move (no rollback after a confirm); OnDragOver advisory-accepts a
closed bag (cap>0, contents not indexed = AP-61); App-level drop -> RollbackMove
reverts the optimistic move to the original container+slot. Production code was
already correct; these exercise the spec's test plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Faithful port of retail UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0):
each container cell (side bags + main pack) shows a vertical UIElement_Meter
(element 0x10000347, back 0x06004D22 / fill 0x06004D23) filled to
GetNumContainedItems / ItemsCapacity, clamped [0,1]; hidden for non-containers
(CapacityFill=-1). Drawn procedurally on UiItemSlot like the triangle/square
overlays (back full + front clipped bottom-up). Right-anchored flush to the cell
edge (visual gate: the dat X=26 sat ~5px off the right edge). Visually confirmed
2026-06-22. Divergence AP-59; polish deferred to ISSUES #146 (exact rect/anchor,
fill direction vs m_eDirection 0x6f, closed-bag lazy-load).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
After a teleport ACE floods a town's CreateObjects; WorldSession.Tick drained the
ENTIRE inbound queue every frame, each spawn hydrating mesh+textures under _datLock
on the render thread — monopolizing the update loop for ~a minute and starving the
streaming apply, so only the destination landblock loaded and neighbors trickled in
(visible at high render-FPS because update/render are separate Silk.NET callbacks).
Bound the per-frame drain to a ~4ms wall-clock budget once InWorld (handshake uses
the blocking PumpOnce path, never Tick). A time budget self-adapts to the highly
variable per-datagram cost; the tail stays queued (unbounded channel, FIFO) and
drains over the next few frames. Acks are queued per packet BEFORE the heavy handler
(WorldSession.cs:680), so deferring the tail only delays the tail's acks a few frames
— within ACE's tolerance (holtburger defers acks on a flush cadence; verified in
references/holtburger session/send.rs). Budget decision extracted as a pure testable
static (4 unit tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior commit pinned 0x060011F4 from a research dat-dump of
GetDIDByEnum(0x10000004,7) — it rendered as a GREEN TILE (green slot, no
pack) at the visual gate. Dat-exported the candidates (AcDream.Cli
dump-sprite-sheet / export-ui-sprite): 0x0600127E is the 32x32 brown
backpack (user-hinted, PNG-confirmed). Swap the pinned literal; the
test + AP-51 register row updated to the visually-verified id. Container
type-underlay (green) + backpack base still composited via _iconIds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The m_topContainer cell (0x100001C9) rendered blank (tex=0). Retail's
IconData::RenderIcons (0x0058d1ee) has an IsThePlayer() branch that draws a
CONSTANT backpack — m_idIcon = GetDIDByEnum(0x10000004, 7) = 0x060011F4,
m_itemType = TYPE_CONTAINER — NOT the player's body icon (the original AP-51
"equipped-pack weenie icon" premise was wrong). Compose that base over the
Container type-underlay via the existing _iconIds delegate. Verified vs decomp
(407546-407549) + IconComposer.GetIcon (base=arg2, type drives underlay) + a
live dat dump (map 0x25000008 index 7 = 0x060011F4). Test locks type+literal.
AP-51 reworded to the residual hardcoded-vs-runtime-resolve nuance (cf. AP-55).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
BuildCellSetAndPickContaining discarded the bool from TryGetTerrainOrigin — when
the current landblock's terrain hadn't been applied yet (priority-apply in flight
after a teleport or dungeon exit), blockOrigin was silently set to (0,0,0). The
AdjustToOutside/GetOutsideLcoord math treated world-frame sphere coordinates as
block-local and marched the cell one landblock per tick in the direction of movement
until lbX or lbY underflowed to 0x00. ACE rejected every subsequent move as a
failed transition.
Fix: honor the bool return. When terrain is unregistered for an OUTDOOR seed
(low < 0x0100), return currentCellId verbatim — "no block-local frame →
preserve". This mirrors the NO-LANDBLOCK verbatim contract in PhysicsEngine.Resolve
and is correct: the cell stays last-known-correct until terrain registers.
Indoor seeds are explicitly excluded (blockOrigin is never consumed by the indoor
pick path; outdoorPickAllowed=false for indoor seeds).
Reproduce + verify via CellMarchLandblockPreservationTests (two new FAILING-before
tests: WestEdge and SouthEdge with empty cache, no anchor → lbX/lbY preserved).
TeleportFarTownRunawayTests updated: no-anchor path now also preserves (pre-fix it
marched south to 0x59; post-fix returns currentCell unchanged).
CellTransitFindCellSetTests, Issue112MembershipTests, PhysicsEngineTests: added
RegisterTerrain for the streaming-center block (in production it is always resident
before outdoor resolves run; tests that used blockOrigin=(0,0,0) as an implicit
fallback now register the block explicitly). All 1567 tests pass.
Divergence AD-30 added to retail-divergence-register.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a high-16-bit-prefix landblock residency check used as the teleport
worldReady gate — true once the destination landblock's terrain+cells
have been registered via AddLandblock, regardless of whether the caller
passes a canonical (0xFFFF), cell-resolved, or bare landblock id.
Two TDD tests confirm: false before registration, true after, and
that a cell-resolved id on the same landblock returns true.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace RemoveAt(0)-in-a-loop drain idiom with RemoveRange(0, i) in both
Step-1 deferred drain and the post-found deferred drain inside DrainAndApply,
making each an O(N) single shift instead of O(N²) on the render-thread hot path.
Add PriorityNeverArrives_noThrow_noLoss_noDoubleApply test: sets a priority id
that never appears in the outbox, ticks several times, asserts no throw, no
loss of the non-priority completions, and no double-apply (applied count ==
completions enqueued). Comment above the priority-hunt explains the failure
mode: completions relocate to _deferredApply while the hunt is active and drain
at per-frame budget until the caller clears PriorityLandblockId.
Restyle test 2 to use named constructor arguments and break the compressed
lambda onto readable lines (matches test 1 style).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds PriorityLandblockId (uint, default 0) + _deferredApply buffer.
DrainAndApply now: (1) applies up to budget from the deferred buffer,
(2) when PriorityLandblockId != 0, hunts the worker outbox in chunks
applying the priority LB immediately on match and buffering any
non-priority items drained past it for later frames,
(3) falls back to normal drain when no priority is set or not found.
Extracts ApplyResult(result) + ResultLandblockId(result) helpers so
both the priority and normal paths share identical side-effects.
No existing behaviour changes on the non-priority path.
21 streaming tests pass (19 existing + 2 new priority-apply tests).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>