Commit graph

415 commits

Author SHA1 Message Date
Erik
4f7e8ec30a feat(core/slice-1): TeleportAnimSequencer — define enums, record, event types
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:55:40 +02:00
Erik
7605439efa test(physics #145): continuous-tracking test — refutes the review staleness claim
Adversarial review flagged a possible CellPosition staleness on indoor->outdoor
transition. Verified against source: false positive (SnapToCell isn't called on
building entry; the body is world-space so the delta is frame-invariant; the anchor
disengages indoors). Added a test proving CellPosition tracks the outdoor cell under
the world position across a multi-cell, cross-landblock walk and stays canonical.
Core cell-sync 6/6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:38:47 +02:00
Erik
6349ba49aa fix(physics #145): Slice 3 — carried-anchor membership; closes the far-town cascade
The outdoor membership pick derived the landblock origin from the terrain
registry, which returns (0,0) for an UNSTREAMED neighbour — so a fresh far-town
teleport at a landblock edge marched the cell id one block per physics tick
(the cascade; the 17410 ACE rejects is its wire artifact).

Fix: thread the CARRIED cell-relative frame anchor (body.Position -
body.CellPosition.Frame.Origin) into the pick via SpherePath.CarriedBlockOrigin.
That anchor IS the true landblock world origin, correct even for an unstreamed
neighbour, so the pick re-derives the SAME (consistent) cell and never marches.
- CellTransit.FindCellSet/BuildCellSetAndPickContaining: Vector3? carriedBlockOrigin
  (null default = legacy TryGetTerrainOrigin → every existing caller/test untouched).
- PhysicsEngine.ResolveWithTransition: set the anchor from a SEEDED OUTDOOR body
  whose carried landblock matches the resolve cell (else null → legacy).
- PlayerMovementController.SetPosition: 3-arg overload seeds CellPosition from the
  wire's (cell, local) via SnapToCell; 2-arg delegates with cellLocal=pos (anchor
  (0,0,0) == legacy → zero test churn).
- GameWindow.CellLocalForSeed: the placement seam (_liveCenter used ONCE here to
  derive the cell-local; physics carries it forward without _liveCenter).
Regression: TeleportFarTownRunawayTests (south + east edge, unstreamed neighbour).
Core 1529 / App 480, zero regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:51:04 +02:00
Erik
7928b445ab fix(physics #145): Slice 2a — canonicalize outdoor seed + re-derive cell index every tick
The wire local is LANDBLOCK-relative [0,192); the cell low word = floor(local/24),
so a consistent (cell,local) pair must keep them in lockstep. Two retail-faithful
corrections vs the first pass:
 - SnapToCell canonicalizes the OUTDOOR seed via AdjustToOutside (retail
   SetPositionInternal/adjust_to_outside @0x00504A40 — the #107 'never trust a
   server (cell,pos) pair' protection). Indoor seeds stay verbatim (BSP-validated).
 - SyncCellPositionDelta calls AdjustToOutside on EVERY delta, not just on 192 m
   crossings, so intra-landblock 24 m cell-index changes track (needed by Slice 3
   membership). Idempotent within a cell.
Tests rewritten to verify both (the earlier test paired an inconsistent cell+local).
Core 1527 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:55:27 +02:00
Erik
afe495b9e6 feat(physics #145): Slice 2a — PhysicsBody carries CellPosition; setter delta-syncs into the cell frame
Add CellPosition (retail Position type) alongside the world Vector3 Position.
The Position setter mirrors each world delta into the cell-local origin and
calls AdjustToOutside only when the local coord crosses a landblock boundary
([0,192) on X or Y), so the within-block cell id is preserved from the wire
seed. SnapToCell seeds both positions from the wire's (cell, local) pair
verbatim — no streaming center involved. Unseeded bodies (ObjCellId==0) and
indoor cells are no-ops in the delta path. UpdatePhysicsInternal's existing
`Position +=` desugars through the new setter automatically; no call sites
changed. 4 new unit tests; full Core suite 1526 passed / 0 failed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:49:39 +02:00
Erik
c980763322 refactor(physics #145): Slice 1 — rename Frame->CellFrame to avoid DatReaderWriter.Types.Frame collision
The new value type collided with DatReaderWriter.Types.Frame (used in
physics-adjacent code like ShadowShapeBuilder), which the structural fix
(per-file using-aliases across 6 files) would have re-incurred in every
later physics slice. Renamed the TYPE to CellFrame; the Position.Frame
MEMBER keeps retail's name. Restored the 5 alias-only files to their
pre-Slice-1 state; synced spec + plan. Core 1522 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:34:36 +02:00
Erik
438bb681a5 feat(physics #145): Slice 1 — Position/Frame types + LandDefs.GetBlockOffset (0x0043e630)
Introduces the two value types (Frame, Position) that represent retail's
cell-relative position pair (acclient.h:30647/30658). Types are unused
by consumers yet — zero behavior change. Also ports LandDefs::get_block_offset
(pc:69189, @0x0043e630): world-meter offset between two named landblock ids,
the ONLY cross-cell translation primitive in retail physics. Conformance tests:
same-landblock→Zero, south-neighbour→(0,-192,0) (the exact #145 cascade cell),
east-neighbour→(+192,0,0), diagonal→(+192,+192,0). 4/4 pass; full Core suite
1522 passed / 0 failed. DatFrame alias added to 4 files that had using
DatReaderWriter.Types + using AcDream.Core.Physics in scope simultaneously.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:30:20 +02:00
Erik
0a5f91b6fe fix(streaming): #138 — rescue persistent entities from the pending bucket on unload
GpuWorldState.RemoveLandblock rescued persistent entities (the player)
only from the _loaded list, silently dropping one sitting in the
_pendingByLandblock bucket. The player is re-injected via AppendLiveEntity
every frame; right after a teleport its destination landblock has not
streamed in yet, so the player lands in the pending bucket — and if that
landblock is then unloaded during the streaming churn, the persistent
entry was dropped, violating the "persistent therefore survives unload"
contract. Leading candidate for the #138 "own avatar vanishes after a
couple round-trips" symptom (cumulative; needs user visual confirm).

Fix: scan the pending bucket for persistent guids and rescue them too,
so DrainRescued re-parks them at the next valid landblock. Provable
correctness fix with a deterministic test (rescue-from-pending plus a
negative for non-persistent). Core tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:07:28 +02:00
Erik
bf66fb4123 fix(streaming): #138 — re-hydrate server objects from the retained spawn table on reload
Doors/NPCs/portals vanished after a portal OUT of the 0x0007 dungeon back
to Holtburg. Root cause confirmed via ACE + holtburger cross-reference:
the dungeon collapse drops a landblock's render entities for FPS, and ACE
will NOT re-broadcast objects whose guid is still in its per-player
KnownObjects set (never cleared on a normal teleport — ACE relies on the
client retaining its object table and culling stale objects itself). So
nothing restored them on the way back.

Retail-faithful fix: a real client keeps its weenie_object_table and
re-renders the world from it (holtburger keeps the table across a
teleport; only suspends physics bodies). acdream's _lastSpawnByGuid (the
parsed CreateObject records — position + Setup + appearance) IS that
table and survives the collapse (the collapse path never calls
RemoveLiveEntityByServerGuid, the only thing that prunes it). On landblock
(re)load, replay OnLiveEntitySpawnedLocked for retained spawns whose
render entity is absent — independent of any ACE re-send.

- LandblockEntityRehydrator: pure selection (landblock match; skip
  already-present, the player, and mesh-less spawns), unit-tested (7).
- StreamingController: onLandblockLoaded callback after AddLandblock
  (Loaded = dungeon-exit expand) and AddEntitiesToExistingLandblock
  (Promoted = Far->Near).
- GameWindow.RehydrateServerEntitiesForLandblock: present-gate keys on
  GpuWorldState (NOT _entitiesByServerGuid, which holds collapse
  orphans), replay under _datLock; the replay's own
  RemoveLiveEntityByServerGuid de-dup scrubs the orphan state.

Corrects the handoff: ClientObjectTable is inventory-only (no world
position/Setup) and cannot rebuild a render entity; _lastSpawnByGuid is
the world-object table. Register row AP-48 (no retail 25s visibility
cull). dotnet build + 1518 Core tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:06:41 +02:00
Erik
a15bd3b56d fix(streaming): #145 — teleport re-use via server-authoritative placement
Portals only worked once per session: teleporting OUT of a dungeon
mis-rooted the player into the SOURCE dungeon's coordinate frame, so every
move was sent dungeon-framed and ACE rejected it ("failed transition") —
the player couldn't move, never reached a portal, and the world wouldn't
re-render (only skybox).

Root cause: acdream's streaming-relative frame recenters on teleport, but
resident physics landblocks keep their load-time world-offset. After
recentering onto the outdoor destination, the collapsed source dungeon
(offset 0,0 as the prior center) and the destination (offset 0,0 as the
new center) overlap, and the Z-agnostic outdoor cell-snap returns the
dungeon for both the arrival placement and every per-frame resolve.

Fix (server-authoritative teleport placement):
- Drop the stale source center landblock from physics at the teleport
  recenter (GameWindow.OnLivePositionUpdated) so the resolve falls through
  to the server position (Resolve NO-LANDBLOCK verbatim) until the
  destination streams in.
- Place outdoor teleports immediately (TeleportArrivalRules) — holding is
  futile because streaming does not progress during a PortalSpace hold.
- Clear a dangling CellGraph.CurrCell when its landblock is removed
  (PhysicsEngine.RemoveLandblock) — otherwise the dungeon-streaming gate
  keeps streaming collapsed onto the gone dungeon (only skybox renders).

Keeps DungeonStreamingGate (gate suppression during the hold). Indoor
(dungeon-entry) placement is unchanged (cell-keyed, IsSpawnCellReady).

User-verified: in->out->re-enter works repeatedly, no ACE errors, world
renders. Remaining facets (server objects + own avatar not rendering after
a teleport-out) are entity render/lifecycle — split to #138.

Registers AP-36 + AD-2 updated. New: DungeonStreamingGate (+4 tests),
TeleportArrivalRules (+4 tests). Build + 2727 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:38:00 +02:00
Erik
31d7ffd253 merge: bring main (A7 lighting Fix A–D + UN-7 + #140 Fix D) into the D.5 branch
Integrates main's 19 commits (A7 outdoor/indoor torch lighting Fix A/B/C/D,
GlobalLightPacker, shader updates, UN-7) under the D.5 toolbar/item-model stack
(D.5.1/D.5.2/D.5.4/D.5.3a). Auto-merged cleanly except docs/ISSUES.md.

Conflict resolved: both lineages used #140 for different issues. Kept main's
#140 = "A7 Fix D" (resolved); renumbered the toolbar/selected-object issue to
#141 (note added; this branch's commits/spec still reference #140 — immutable).
The register auto-merged (AP-46 cites file:line, not #140; UN-7 keeps #140=Fix D).

Build + full suite green on the merged tree (2,713 passed / 4 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:01:20 +02:00
Erik
c83fd02642 merge: bring main (UN-7, #140 filing, D.2b UI rows) into A7 Fix D round-2 branch
Resolves the divergence-register conflict: kept the accurate per-VERTEX AP-35
(Fix A shipped per-vertex; main's row was the stale pre-Fix-A per-pixel text),
kept main's UI rows AP-37..AP-42, and renumbered this branch's torch-gate row
AP-37 -> AP-43 (AP-37 was taken by main's LayoutDesc row). AP count 41 -> 42.
Retargeted the AP-37 references in WbDrawDispatcher + the CHECKPOINT to AP-43.
Marked ISSUES #140 RESOLVED (b7d655b) with the corrected root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:29:53 +02:00
Erik
b7d655bce7 fix(lighting): A7 Fix D round 2 — outdoor objects get NO torches (retail useSunlight gate) (#140)
The Holtburg meeting-hall facade washed out warm/bright vs retail. The round-1
checkpoint blamed torch REACH (acdream Falloff 6×1.3=7.8m vs a supposed retail
Falloff 4). That theory is WRONG, and this commit fixes the real cause.

Empirical (HoltburgTorchFalloffProbeTests, headless dat dump via the production
LightInfoLoader): the orange entrance torch (setup 0x020005D8) is raw dat
Falloff 6 and acdream reads it FAITHFULLY — there is no Falloff-4 torch anywhere
in Holtburg. Both clients read the same dat float, so reach was never inflated.

Decomp (read verbatim + corroborated by an independent adversarial workflow):
retail's per-object torch binder minimize_object_lighting (0x0054d480) is gated
in RenderDeviceD3D::DrawMeshInternal (0x0059f398) by `if (Render::useSunlight == 0)`.
The outdoor landscape stage runs useSunlightSet(1) (PView::DrawCells 0x005a485a,
before LScape::draw), so the building EXTERIOR shell — drawn via
DrawBlock→DrawSortCell→DrawBuilding→CPhysicsPart::Draw→DrawMeshInternal — is lit
by SUN + ambient ONLY; torches are SKIPPED. The static bake
(SetStaticLightingVertexColors 0x0059cfe0) is EnvCell-only. So retail NEVER
torch-lights outdoor objects. This exactly explains the isolation test (object
point lights OFF → building matches retail).

Fix: WbDrawDispatcher.ComputeEntityLightSet gates per-object torch selection on
the object being INDOOR (ParentCellId is an EnvCell, (id&0xFFFF)>=0x0100) via the
pure predicate IndoorObjectReceivesTorches. Outdoor objects (building shells with
null ParentCellId, outdoor scenery, outdoor creatures) keep the all-(-1) light
set ⇒ sun + ambient only = retail. The indoor "no sun" half is already handled by
the global sun-kill when the player is inside a cell (UpdateSunFromSky). No
dungeon regression: EnvCell statics get ParentCellId set (keep torches).

Divergence register: AP-37 (residual: acdream keys sun/torch on the object's own
cell + a per-frame player-inside sun-kill, vs retail's per-draw-stage useSunlight;
only matters for through-doorway look-ins). The round-1 CHECKPOINT got a RESOLVED
banner correcting the reach theory.

Tests: WbDrawDispatcherTorchGateTests (7), HoltburgTorchFalloffProbeTests (dat
dump). App 280/1skip, Core 1486/2skip green. Held at the visual gate — not merged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:56:49 +02:00
Erik
39c70f00aa test(lighting): lock the bake contract on golden torches (A7 Fix D oracle)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:26:52 +02:00
Erik
180b4af2a9 refactor(lighting): extract GlobalLightPacker (shared binding=4 layout) — A7 Fix D prep
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:25:11 +02:00
Erik
6eb0fbde46 test(D.5.4): lock creature Name/Type resolution via ClientObjectTable.Get (spec §8)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:08:19 +02:00
Erik
50cee50df1 refactor(D.5.4): delete EnrichItem (superseded by Ingest merge-upsert)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:42:58 +02:00
Erik
2e3f209707 feat(D.5.4): live container membership index (object_inventory_table)
Reindex on Ingest/MoveItem/Remove; GetContents(containerId) ordered by slot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:19:09 +02:00
Erik
d9c427cd6c feat(D.5.4): ClientObjectTable.Ingest merge-upsert + RecordMembership
Field-level merge (retail SetWeenieDesc): create-if-absent else patch present
fields, preserve PropertyBundle. Effects unconditional (D.5.2 contract).
RecordMembership = PD manifest. Locks the Coldeve no-prior-stub fix + out-of-order.
Renames _items→_objects throughout; Reindex stub wired (Task 6 fills it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:08:57 +02:00
Erik
b83f17a927 feat(D.5.4): add item fields to ClientObject + WeenieData ingest DTO
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:57:12 +02:00
Erik
b506f53633 refactor(D.5.4): rename ItemRepository->ClientObjectTable, ItemInstance->ClientObject
Broaden naming to the data side of every server object (retail weenie_object_table
shape). Pure rename; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:36:16 +02:00
Erik
4795a6c7f3 merge: A7 lighting Fix C (sun-vector brightness) + handoff into main
Brings Fix C (57c1135, sun-vector magnitude / ~32% over-bright) + the A7 lighting
handoff doc onto main. Auto-merged clean against the D.2b line. Merged tree builds
green; 18/18 sky tests pass. Fix A/B already on main (37911ed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:35:25 +02:00
Erik
57c11358b6 fix(sky): A7 — correct sun-vector magnitude (ambient + sun were ~32% too bright)
Outdoor lighting was ~32% too bright (washed-out, weak shading). Live cdb on
retail (SmartBox::SetWorldAmbientLight + SkyDesc::GetLighting + LScape::sunlight,
binary matches refs/acclient.pdb) pinned it: at the SAME game time + DayGroup,
acdream's ambient COLOR matched retail exactly (the purple is correct, authored
per-time-of-day in the sky dat) but the LEVEL was 0.607 vs retail's 0.459.

level = AmbBright + 0.2·|sunVec|, both AmbBright=0.40, so acdream's |sunVec|≈1.06
vs retail's ≈0.30. Retail's LScape::sunlight read live = (0.2238, ~0, 0.00352),
magnitude 0.224 = DirBright, y≈0.

RetailSunVector had `y = cos(P)` (≈1) — the raw PRE-transform value SkyDesc::
GetLighting writes to arg5 (0x00500ac9), before LScape::set_sky_position's
world transform. acdream ported the un-transformed vector, so the y=cos(P)≈1
term inflated |sunVec| to ~1.06. That magnitude feeds BOTH the ambient boost
(SkyKeyframe.AmbientColor) AND the sun colour (SkyKeyframe.SunColor =
DirColor×|sunVec|), over-brightening the whole scene (terrain, objects, sky)
~30% and also pointing the sun the wrong way.

Fix: RetailSunVector = DirBright × (cos(P)·sin(H), cos(P)·cos(H), sin(P)) — the
world-space spherical form LScape::sunlight actually holds; |sunVec| == DirBright
for all H/P. After: acdream ambient (0.353,0.176,0.449) vs retail (0.360,0.180,
0.459) — within ~2%, user-confirmed "better outside". Sun direction also corrected
(was pointing ~North from the bad y term).

Tests updated to the cdb-verified values (the prior tests pinned the inflated
magnitude). 18/18 sky tests green. reference-retail-ambient-values memory updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:08:52 +02:00
Erik
702d6e1e90 test(D.5.2): lock effects-clears-to-zero contract (final-review polish)
The 'item with mana vs out of mana' core promise: a draining item whose
UiEffects clears to 0 returns to its base icon. Guards EnrichItem +
UpdateIntProperty unconditional-assign against a future != 0 regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:02:03 +02:00
Erik
5a2af61508 refactor(D.5.2): hoist UiEffectsPropertyId to fields + use it in tests (review polish)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:26:28 +02:00
Erik
77f64d7925 feat(D.5.2): ItemInstance.Effects + ItemRepository.UpdateIntProperty
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:23:20 +02:00
Erik
f8da98b67f feat(D.5.1): ItemRepository.EnrichItem (icon/name/type from CreateObject)
Adds EnrichItem(objectId, iconId, name, type) — enriches an existing
stub created from PlayerDescription with the fuller data carried by its
CreateObject message. Returns false when the item isn't tracked yet
(phase 1: enrich-existing only). Raises ItemPropertiesUpdated on success
so bound widgets (the toolbar) re-render.

Two xUnit tests: enrich-existing updates IconId/Name/raises event (true),
unknown-id returns false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:48:44 +02:00
Erik
37911ed510 merge: A7 lighting (Fix A point-light shape + Fix B per-object selection) into main
Brings the worktree branch claude/thirsty-goldberg-51bb9b into main:
- aa94ced  per-vertex Gouraud + faithful calc_point_light (wrap + norm)
- 4345e77  per-OBJECT point-light selection (minimize_object_lighting)

Auto-merged cleanly against the D.2b retail-UI line (only GameWindow.cs
overlapped, resolved by git). Merged tree builds green; 35/35 Core lighting
tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:50:22 +02:00
Erik
4345e77d62 fix(render): A7 Fix B — per-OBJECT point-light selection (minimize_object_lighting)
Outdoor objects brightened as the camera approached: lighting selected the
nearest 8 lights to the VIEWER and fed that one global set to everything
(LightManager.Tick), so a building's wall torches only lit it once the camera
got close enough for them to win the global top-8. Probe confirmed the scale of
the problem: a single Holtburg view registers 129 point lights — the global cap
of 8 was hopeless.

Retail selects up to 8 lights PER OBJECT by the object's own position
(minimize_object_lighting 0x0054d480), so a torch always lights the wall it
sits on, camera-independent. Ported faithfully:

- LightManager.SelectForObject (pure, TDD, 8 new tests): candidacy
  (light.pos − center)² < (Range + radius)², nearest-8 among those. Plus
  BuildPointLightSnapshot for the per-frame stable-indexed light list.
- mesh_modern.vert: two SSBOs — binding=4 GLOBAL point-light array (the
  snapshot), binding=5 per-instance light SET (8 int indices into it, -1 =
  unused), parallel to the binding=0 instance buffer (mirrors the U.3 clip-slot
  mechanism). accumulateLights keeps ambient + sun from the SceneLighting UBO
  (cleared as faithful by the lighting audit) and loops THIS instance's point
  lights. pointContribution factored out (same calc_point_light wrap+norm shape).
- WbDrawDispatcher: per-entity light set computed ONCE at the isNewEntity site
  (constant across the entity's parts), by the entity's AABB sphere; threaded
  into grp.LightSets parallel to grp.Matrices; global + per-instance buffers
  uploaded in Phase 5. Camera-independent ⇒ stable for static buildings.
- GameWindow: BuildPointLightSnapshot + dispatcher.SetSceneLights each frame.

Tests: 17/17 LightManager + 36/36 dispatcher clip-slot/clip-frame green
(parallel-array lockstep preserved). Visually gated: the meeting hall now holds
steady as the camera approaches (was the popping symptom).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:47:40 +02:00
Erik
5ac9d8c19c merge: bring main into claude/hopeful-maxwell-214a12 (LayoutDesc importer branch)
main was 65 commits ahead of this branch's fork point. Only conflict was the
divergence register: both sides appended an 'AP-32' row. Resolved by keeping
main's AP-32..AP-36 (cell-shell lift, look-in cells, alpha deferral, dungeon
streaming, point lights) and renumbering the importer's row to AP-37; AP header
count -> 37. GameWindow.cs auto-merged cleanly. Verified: AcDream.App builds
0/0; AcDream.App.Tests 354 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:19:15 +02:00
Erik
6f81e2c91d fix(render): hide editor-only placement markers in dungeons — port retail's degrade-to-nothing (#136)
The "red cone" (+ green floor petals) in the 0x0007 Town Network dungeon is a dat
EnvCell static object (Setup 0x02000C39 / GfxObj 0x010028CA) using pure red/green
MARKER textures (0x08000109 / 0x0800010A). It is an EDITOR-ONLY placement marker:
its DIDDegrade table 0x11000118 is {slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX},
i.e. visible ONLY at distance 0 (the WorldBuilder editor origin) and degraded to
GfxObj id 0 (nothing) at any real distance. retail's distance-based degrade
(CPhysicsPart::UpdateViewerDistance 0x0050E030 -> Draw 0x0050D7A0) therefore never
draws it in the live client.

acdream's render pipeline is extracted from WorldBuilder, which (being an editor)
renders every cell static's base mesh directly and has NO degrade handling at all
(zero DIDDegrade references in references/WorldBuilder) — so acdream inherited the
"show the marker" behavior and drew it forever. It only became visible now because
the #135 login-into-dungeon fix drops the player at the exact saved spawn next to it.

Fix: GfxObjDegradeResolver.IsRuntimeHiddenMarker() detects the editor-marker pattern
(HasDIDDegrade + Degrades[0].MaxDist==0 + a degrade entry with Id==0). The EnvCell
static-object hydration (GameWindow ~5793) skips such GfxObjs — whole-stab for bare
GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via
meshRefs.Count==0). This is the faithful equivalent of retail's runtime degrade for
static geometry (always viewed at distance > 0); real LOD objects (slot0.MaxDist>0)
and degrade-to-real-mesh objects are untouched.

Diagnosis was extensive (geometry-not-VFX via particle-off; texture-not-lighting via
flat-ambient frame dumps; per-surface runtime decode pinned the red/green marker
surfaces; a draw-time probe pinned the dat-static entity id; a dat dump of the Setup +
degrade table confirmed the editor-marker pattern). Verified live via a frame dump:
the red cone + green petals are gone, all real dungeon decorations still render.
4 new GfxObjDegradeResolver unit tests cover the marker / normal-LOD / no-table /
degrades-to-real-mesh cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:03:08 +02:00
Erik
019350fa31 feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost
Adds the plugin-facing UI registration surface (Task 9, final D.2b task).
Plugins call host.Ui.AddMarkupPanel(path, binding) from Enable(); calls are
buffered in BufferedUiRegistry before the GL window opens, then drained into
UiHost.Root in GameWindow.OnLoad inside the RetailUi block after the first-
party vitals panel. Faulty plugin markup is isolated (try/catch per panel,
logged + skipped). IPluginHost.Ui added; AppPluginHost wired; StubHost in
Core.Tests updated; BufferedUiRegistryTests confirms drain-once semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:46:37 +02:00
Erik
712f17f0f2 fix(G.3): pre-collapse dungeon streaming at login/teleport — kill the login FPS ramp (#135)
On login (or teleport) into a dungeon, FPS started ~10 and climbed over ~30 s.
Root cause: the dungeon "collapse" (which shrinks the 25x25 streaming window to
the player's single dungeon landblock — AC dungeons have no neighbours) only
fires once the per-frame `insideDungeon` gate reads true, and that gate keys on
the physics CurrCell, which isn't set until the player is PLACED, which waits for
the dungeon landblock to hydrate. So during the whole hydration window NormalTick
bootstraps the full window — ~24 unrelated ocean-grid neighbour dungeons + their
~19k entities each — and the collapse only mops them up afterward. That mop-up is
the ramp.

Fix: trigger the SAME collapse early, the instant we recenter the streaming center
onto a sealed dungeon cell, before the first NormalTick.

- StreamingController.PreCollapseToDungeon(cx,cy): fires EnterDungeonCollapse
  early (idempotent). The expensive neighbour window is never enqueued.
- GameWindow.IsSealedDungeonCell(cellId): reads the EnvCell dat SeenOutside flag
  (CurrCell is null pre-placement) — the same flag ObjCell.SeenOutside and the
  per-frame gate use, so the early decision matches the eventual one. Distinguishes
  a real dungeon from a cottage/inn interior (SeenOutside → keeps its outdoor
  surround). Excludes the 0xFFFE/0xFFFF structural shell ids so an outdoor spawn id
  can't type-confuse a LandBlock record as an EnvCell.
- Hooks: OnLiveEntitySpawnedLocked (login) + OnLivePositionUpdated (teleport).
- Observer robustness: during a teleport PortalSpace hold the streaming observer
  follows the recentered destination, not the frozen pre-teleport position (which
  could drift >=2 landblocks off and trip ExitDungeonExpand). And
  _lastLivePlayerLandblockId is now filtered to the player guid (resolves the
  Phase A.1 TODO) so a stray NPC UpdatePosition can't drift the login-hold observer
  off the dungeon.

Faithful EARLY trigger of the existing AP-36 collapse mechanism, not a new
workaround — AP-36 amended in the same commit. Adversarially reviewed across
timing / threading / faithfulness lenses; 5 new tests including the real runtime
ordering (Tick bootstraps, then PreCollapse cancels). Core suite green (1463).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:46:56 +02:00
Erik
66888d2c8e fix(textures): DecodeSolidColor null-safe against null ColorValue
A Base1Solid (or OrigTextureId==0) Surface can carry a null ColorValue;
DecodeSolidColor dereferenced it (color.Alpha) and threw NullReferenceException.
It is called directly from TextureCache.DecodeFromDats, OUTSIDE
DecodeRenderSurface's try/catch, so the NRE crashed the whole client. Surfaced
by the D.2b chrome prove-out feeding UI surface ids. Guard null -> Magenta
(the decoder's existing "undecodable" sentinel). Test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:36:07 +02:00
Erik
3b93f91ebe feat(A7): LightBake Core — verified per-vertex static-light burn-in (foundation, not wired)
The faithful fix for the spotty dungeon/house/outdoor lighting is retail's per-vertex
static-light bake (D3DPolyRender::SetStaticLightingVertexColors 0x0059cfe0), NOT a
per-pixel ramp. This lands the GL-free Core: LightBake.PointContribution /
ComputeVertexColor port calc_point_light (0x0059c8b0) VERBATIM — verified against a
clean Ghidra decompile (the BN pseudo-C is x87-mangled): half-Lambert wrap with
LIGHT_POINT_RANGE=0.75 (0x007e5430), the distsq>1 norm branch, the per-channel
min-to-color clamp, and the final [0,1] clamp. static_light_factor=1.3 (0x00820e24)
is already folded into LightSource.Range by LightInfoLoader.

7 conformance tests (hand-derived golden values) green. NOT wired yet — the
integration (a per-vertex colour attribute on the cell mesh + the bake driver keyed
on envCellId + the shader consumption) is the remaining A7 work; see ISSUES.md A7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:27:45 +02:00
Erik
2561918a70 fix(G.3): pin dungeon collapse to the cell's landblock, not the position-derived one (#133)
"The dungeon is broken" — the collapse was unloading the REAL dungeon. A dungeon's
EnvCells sit at arbitrary "ocean" world coords with negative cell-local Y (snap
showed pos=(58.9,-69.6) in cell 0x00070133), so the observer landblock
_liveCenterY + floor(pp.Y/192) = 7 + floor(-69.6/192) = 7 + (-1) = 6 lands one row
off. The collapse pinned to 0x0006 and unloaded 0x0007 — the real dungeon — which
nulled CurrCell (the cell no longer existed) and left the player floating in
outdoor-lit empty space (lb 1/1 @ ~1585 fps, but the wrong landblock). This is the
Bug-A negative-local-coordinate class.

Fix: when inside a dungeon, pin the collapse to the cell's OWN landblock
(CurrCell.Id >> 16), never the position-derived observer landblock — the cell id is
the authoritative landblock for ocean-placed dungeon geometry.

Also hardened the hysteresis so a transient CurrCell flicker can't thrash:
- Re-collapse when insideDungeon at a DIFFERENT landblock (multi-landblock dungeon).
- Expand only on a DISTANT move (Chebyshev > 1) — a real exit teleports far from the
  ocean-grid block; the off-by-one flicker is always an ADJACENT (±1) landblock, so
  it now HOLDS the collapse instead of expanding.
- SweepCollapsed always preserves _collapsedCenter (the true dungeon landblock),
  never the per-frame observer landblock.

Build green; 59 streaming tests green (flicker regression test updated to the
realistic adjacent off-by-one).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:51:50 +02:00
Erik
d9e7dd65e9 fix(G.3): hysteresis on the dungeon streaming gate — stop collapse↔expand thrash (#133)
The first cut of the dungeon gate keyed expand on the per-frame insideDungeon
signal (CurrCell is a sealed EnvCell). Live, CurrCell momentarily resolves to
null mid-frame while the player stays put in the dungeon landblock, so the gate
flipped collapse→expand→collapse every few frames. Each expand re-streamed the
full 25×25 window; the unloads couldn't keep up (MaxCompletionsPerFrame=4), so
registered lights leaked to 212k and FPS spiked to single digits between the
~199 fps collapsed frames.

Fix: once collapsed, key the gate on the STABLE observer landblock, not CurrCell.
Stay collapsed while the player remains in the dungeon landblock (_collapsedCenter);
expand only when the observer actually moves to a different landblock (portal/
teleport out). CurrCell flicker no longer thrashes.

Regression test added (Collapsed_CurrCellFlickersToNull_SameLandblock_DoesNotExpand).
Build green; 60 streaming tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:43:18 +02:00
Erik
56860501b6 fix(G.3): collapse streaming to the single dungeon landblock indoors (#133 FPS)
Dungeon FPS sat at ~30 (frame ~33ms) because the 25x25 streaming window around
the dungeon landblock pulled in ~129 NEIGHBORING landblocks + their thousands of
torch/particle emitters, all drawn though never visible. In AC all dungeons are
packed adjacent in the unused "ocean" map grid, so those neighbors are unrelated
dungeons. The FPS timeline proved it: 247 fps at login (lb 0/0, ~10K entities) →
17 → 30 as landblocks streamed in (lb 0→129) — the cost tracked LANDBLOCK count,
not entities.

Retail-faithful: ACE LandblockManager.GetAdjacentIDs returns ZERO adjacents for a
dungeon (`if (landblock.IsDungeon) return adjacents;`, Landblock.cs:577-582) —
every dungeon is a self-contained landblock you never see out of.

Fix: when the player stands in a sealed indoor cell (CurrCell.IsEnv &&
!SeenOutside — the same predicate that kills the sun/sky), collapse streaming to
just the player's dungeon landblock and unload the neighbors. Building interiors
(cottage/inn) have SeenOutside cells, so they are NOT gated and keep their
surrounding terrain (the frozen building/cellar demo is unaffected). Unloading the
neighbors also tears down their lights (removeTerrain → UnregisterOwner), shrinking
LightManager._all from ~2227 toward retail's ≤40 — which directly helps the A7
lighting bake landing next.

Mechanics (StreamingController):
- Edge IN: ClearPendingLoads() cancels the in-flight 25x25 window (new streamer
  ClearLoads control job — worker drops queued Loads, keeps Unloads), unload every
  resident neighbor, pin a radius-0 StreamingRegion, (re)load the dungeon block if
  needed.
- Stay collapsed: sweep any straggler that finished loading after the edge (a Load
  the worker had already dequeued before ClearLoads).
- Edge OUT (portal/teleport to outdoors): rebuild the full two-tier window at the
  new center, unload anything stale.

AP-36 added to the divergence register (the gate uses the cheap SeenOutside cell
predicate as an approximation of ACE's full landblock IsDungeon classification).
GameWindow also carries a TEMP ACDREAM_LOG_FPS=1 headless FPS line (strip after
the A7 FPS+lighting verification).

Build green; 58 streaming tests green (6 new dungeon-gate tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:32:56 +02:00
Erik
007e287309 fix(A7): port retail calc_point_light (1-dist/falloff) ramp — kill the "spotlight" hard edge (#133)
The dungeon/house/outdoor lights read as hard-edged blown discs ("spotlights")
because our point/spot shader used `atten = 1.0` flat inside a hard `d < range`
cutoff. The mesh.frag comment claimed this was retail-faithful ("no attenuation
inside Range... the bubble-of-light look relies on crisp boundaries", citing
r13 10.2) — that was a misread and the literal cause of the symptom.

Verified against the decomp (not guessed): calc_point_light (0x0059c8b0, the
PER-VERTEX point-light path that lights static walls) scales each light's
contribution by (1 - dist/falloff_eff) — a LINEAR ramp that fades to exactly 0
at the edge, eliminating the hard disc. falloff_eff = Falloff * static_light_factor,
and static_light_factor = 1.3 (0x00820e24), NOT the 1.5 config_hardware_light
rangeAdjust (that 1.5 is the D3D-dynamic path for moving objects, a different
path). The Ghidra port (acclient.c:808639) is more garbled — BN pseudo-C is the
oracle here; the exact normalization factor + a half-Lambert wrap (0.5*dist+N*L)
are x87-obscured (same artifact class as GetPowerBarLevel) and left unported.

Changes:
- mesh_modern.frag + mesh.frag: replace flat atten with clamp(1 - d/range, 0, 1);
  Range now carries falloff_eff so the ramp fades to 0 at the cutoff. Fix the
  false "no attenuation / crisp bubble" comment in mesh.frag.
- LightInfoLoader: Range = Falloff * 1.3 (static_light_factor), was * 1.5.
- LightManager: correct the stale class doc comment (Tick is now nearest-8
  allocation-free partial-select with NO viewer-range slack filter).
- divergence register: AP-16 updated (slack filter removed), AP-35 added
  (per-pixel vs per-vertex Gouraud; dropped half-Lambert wrap + normalization).
- test: LightingHookSinkTests Range 8*1.3 = 10.4.

Build + 20 lighting tests green. Visual gate pending (game-wide lighting change:
dungeon torches, house candles, outdoor braziers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:48:46 +02:00
Erik
1e70a5a484 fix(G.3 A7): torch range = Falloff x 1.5 (retail rangeAdjust) — wider pools (#133)
Retail PrimD3DRender::config_hardware_light (0x0059ad30) sets the hardware light
Range = Falloff * rangeAdjust (1.5, global 0x00820cc4). We used Range = Falloff, so
torches reached only 2/3 of retail -> tight 'candle/spotlight' bubbles in dungeons.
Match retail's reach. Ambient 0.20 confirmed retail-faithful (the 0.30 was CreatureMode,
not world cells). Lighting suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:58:03 +02:00
Erik
a80061b0c2 fix(G.3 A7): dungeon lighting — select 8 NEAREST lights, not viewer-in-range (#133)
The active-light selection dropped any point light whose range didn't reach the
VIEWER (DistSq > Range^2*slack -> skip). Retail's D3D-style fixed pipeline picks
the 8 NEAREST lights and applies the hard range cutoff PER SURFACE in the shader
(mesh_modern.frag: if (d < range)). The viewer-range candidacy filter suppressed
a torch whenever the player stood outside its range, so a dungeon room with 2227
registered torches lit only the ~1 the player was standing in (activeLights ~= 1,
rest of the room at flat 0.2 ambient = the "lighting off" report). Drop the filter;
take the nearest 8 regardless of viewer range. Removed the now-unused RangeSlack
const; updated the two tests that codified the old filter. Core lighting suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:35:01 +02:00
Erik
2ce5e5c862 fix(G.3a): validated-claim placement keeps the claim's landblock prefix (#133)
The #111 validated-claim branch returned lbPrefix | (cellId & 0xFFFF), where
lbPrefix is found by searching resident landblocks for one containing the
candidate position. A dungeon EnvCell's local Y can be negative, so the dungeon
landblock fails the [0,192) bounds test and the loop matches a neighbouring
(e.g. Holtburg) resident block -> the validated claim 0x00070143 got re-stamped
0xA9B30143, making the client mis-resolve the player to the wrong landblock and
spam ACE with rejected moves. The validated claim's full id is authoritative;
return it directly. Byte-identical for the login case (position in the claim's
own landblock); fixes the far-teleport dungeon case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:27:45 +02:00
Erik
90786c19e2 handoff: M1.5 dungeon support (G.3) grounded — design research + the terrain-less-premise refutation
Adds the 2026-06-13 dungeon-G.3 handoff doc + a dat-probe test that
RESOLVED the pivotal design ambiguity. A research agent assumed dungeon
landblocks are terrain-less (LandblockLoader.Load returns null ->
"rewrite the pipeline for terrain-less landblocks", 13 seams). The dat
probe refutes it: dungeon landblock 0x0125 has a flat (all-zero-height)
LandBlock record PLUS 71 EnvCells and no buildings/objects -> it streams
fine via the existing pipeline as a flat-terrain landblock.

The real blocker (#133) is narrow: the teleport-arrival handler
(GameWindow.cs:4928) snaps the player via physics.Resolve BEFORE the
dungeon landblock streams in -> Resolve falls back to the resident
Holtburg landblocks -> places the player at A9B3 ocean. Fix shape:
hold-until-hydration (reuse the #107 IsSpawnCellReady gate for the
teleport-arrival path) + place into the EnvCell + the retail
TeleportAnimState portal-space FSM for the full-G.3 loading screen.
ACE confirms dungeons are single-landblock, so "multi-landblock LOD"
is moot.

The handoff captures: this session's closes (#108-residual/#127/#125
gated, #116 partial), the M1.5 re-open decision, the corrected root
cause, the 5-way reference grounding (holtburger/ACE/retail decomp +
the dat probe), the design direction, and the open brainstorm questions.

Next session: resume the brainstorm at "propose approaches" -> spec ->
writing-plans -> implement.

Suites green: App 264+1skip / Core 1445+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 14:53:27 +02:00
Erik
bf18a54369 fix #116 (partial, Ghidra-confirmed): slide_sphere degenerate guard uses F_EPSILON, not EpsilonSq
The user brought up Ghidra; its decompiler (patchmem.gpr, full PDB)
resolved the Binary-Ninja `test ah,5` x87 branch-sign ambiguity that
blocked the desk read. CSphere::slide_sphere (0x00537440) decompiles
cleanly to:

  fVar3 = |cross(collisionNormal, contactPlane.N)|²;
  if (::F_EPSILON <= fVar3) {                       // crease exists
      ... offset = cross * dot(cross,gDelta)/fVar3;
      if (|offset|² < ::F_EPSILON) return COLLIDED_TS;   // degenerate guard
      ... add_offset_to_check_pos -> SLID_TS
  }

Retail compares the SQUARED magnitudes against F_EPSILON
(0.000199999995 ~= 0.0002 = PhysicsGlobals.EPSILON). Our port compared
against EpsilonSq (0.0002^2 = 4e-8) - a ~5000x too-tight threshold (the
BN pseudo-C rendered the comparison as `test ah,5` after an x87 FCMP,
which is sign-ambiguous; agent reads disagreed). Fixed both comparisons
at TransitionTypes.cs:3098,3105 to EPSILON.

Effect: crease-exists now needs >=0.81 deg between the wall and contact
normals (was 0.011 deg - which routed near-parallel pairs through the
numerically unstable projection); the degenerate guard now hard-stops
slides under ~1.41 cm like retail (was 0.2 mm). Branch POLARITY was
already correct - no change there.

No regression: full physics suite (612) + full Core (1443) green. Not a
register deviation (no row existed; this is an undocumented porting
error corrected to match retail).

This does NOT close #116 - it fixes a tangential constant, not either
reported shape. Ghidra also settled the two shapes' diagnosis (recorded
in ISSUES.md #116 + physics digest):
- Shape-1: our cn=UnitZ default IS retail-faithful (validate_transition
  0x0050aa70 has the identical `if (collision_normal_valid==0)
  set_collision_normal(UnitZ)`). The real divergence is upstream -
  tick-22760 our collision_normal_valid was false where retail's was
  true (it recorded the door-face normal). Needs the instrumented
  tick-22760 replay.
- Shape-2 (D4 stays skipped, note sharpened): slide_sphere slides
  in-frame (SLID_TS) so Z=1.92 is faithful and the D4 Z=2.0 hard-stop
  pin is the suspect half; the threshold fix didn't move D4 (real slide,
  not degenerate). Needs a cdb trace of an airborne wall hit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 10:21:51 +02:00
Erik
96a425a9a5 fix #108-residual (root cause): terrain drew DOUBLE-SIDED - port retail landPolysDraw eye-side gate as terrain backface cull
The cellar-ascent grass window was the UNDERSIDE of the z~94 grade
sheet. Retail terrain is single-sided: ACRender::landPolysDraw
(0x006b7040) draws each land triangle ONLY when the camera is on the
POSITIVE (upper) side of its plane (Plane::which_side2 vs
Render::FrameCurrent, zFightTerrainAdjust bias) - a below-grade eye
gets NO terrain, so retail shows sky through the cellar door.

We inherited WB's frame-global cull DISABLE (WB GameScene.cs:841 - an
editor camera goes underground by design) and TerrainModernRenderer.Draw
set no cull state of its own -> terrain rasterized both sides. From a
below-grade eye every aperture sight-ray RISES, so the only 'terrain'
it can see is the grade sheet's underside - which painted the exit-door
aperture (the landscape slice's 2D NDC clip planes (nx,ny,0,dw) have no
depth axis and cannot exclude between-eye-and-portal geometry) and slid
off the door exactly as the eye crossed grade. Membership/viewer was
exonerated by the harness in the previous commit.

Fix: TerrainModernRenderer.Draw owns its cull state (the 7th
self-contained-GL-state instance): Enable(CullFace) + CullFace(Back) +
FrontFace(Ccw), set -> draw -> restore the frame-global CW + cull-off
baseline. GL backface culling evaluates retail's per-triangle eye-side
predicate at rasterization; no shader change.

Pins:
- LandblockMeshTests.Build_AllTriangles_WindCounterClockwiseInWorldXY:
  every emitted triangle CCW in world XY across both FSplitNESW split
  directions - the winding invariant culling depends on.
- TerrainCullOrientationTests: under the production camera convention
  (LookAt up=+Z, Numerics perspective) an up-facing triangle winds CCW
  in window space from above (kept) and CW from below (culled) - guards
  FrontFace inversion, which would blank terrain from above.

Oracle note: retail's through-portal clip has NO portal-face near plane
(PView::GetClip / Render::set_view install edge planes only); nearer-
than-portal exclusion comes from the eye-side cull + cell-level
admission. No register row: this PORTS the retail mechanism, retiring
an undocumented WB-heritage deviation.

Gate pending: cellar climb (grass window gone) + outdoor sanity glance
(terrain intact from above).

Suites: App 263+1skip / Core 1443+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:05:31 +02:00
Erik
007af1391c #108-residual apparatus: vertical cellar-ascent viewer harness - membership/viewer layer EXONERATED
The handoff's 'eye-below-grade membership demote' diagnosis is REFUTED.
The harness drives the production stack headlessly per step of the
A9B4 corner-building cellar ascent (0x0174 -> 0x0175 -> 0x0171, path
fitted from the cellar-up live captures): FindCellList on the
foot-sphere center for the player pick + the PhysicsCameraCollisionProbe
SweepEye chain mirrored verbatim (AdjustPosition at pivot ->
ResolveWithTransition IsViewer|PathClipped|FreeRotate|PerfectClip ->
both fallbacks) with per-step branch attribution.

Result: 0 outdoor/null viewer resolutions while the eye is below grade,
0 sweep failures, 0 fallback branches, across boom distance {2.61, 5}
x damping lag {0, 0.3 m}. The viewer enters the main-floor room at eye
z 94.01 - exactly as the head pops above grade (the stairwell portal
sits AT grade), matching the user's report wording. The root is
INTERIOR for the whole grass window; #108-residual is render-side
(fix in the next commit). Tests stay as the healthy-layer
characterization pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:05:06 +02:00
Erik
0cb97aa594 UN-2 RESOLVED: GetMaxSpeed x4 is byte-verified retail; doc-comment was the misread
The register's UN-2 row recorded a contradiction: the GetMaxSpeed XML doc
claimed the bare run rate was retail-correct (~5.9 m/s catch-up, calling
the xRunAnimSpeed multiply a misread), while the implementation multiplied
by RunAnimSpeed citing ACE. Settled against the binary, not the pseudo-C:

- BN pseudo-C (acclient_2013_pseudo_c.txt:305127) renders get_max_speed as
  void with a bare `this->my_run_rate;` because it DROPS x87 instructions.
- Disassembling the PDB-matched v11.4186 binary at VA 0x00527cb0: all THREE
  return paths end `fld <rate>; fmul dword ptr [0x007C8918]; ret`, and the
  .rdata dword at 0x007C8918 is 4.0f. Sibling get_adjusted_max_speed
  (0x00527d00) carries the same trailing fmul. Verifier committed at
  tools/verify_un2_fmul.py (PE parse + byte decode, rerunnable).
- Retail paths: weenie null -> 1.0 x4; InqRunRate ok -> queried x4;
  InqRunRate failed -> my_run_rate x4. ACE MotionInterp.cs:665-676 matches.

Changes:
- Doc-comment rewritten: the implementation is retail-correct; the catch-up
  speed 2 x get_max_speed ~= 23.5 m/s at run 200 IS retail. The 1-Hz
  remote-blip symptom the old comment attributed to this multiply is
  therefore UNEXPLAINED by it (if it recurs: #41 family, not this).
- Weenie-null path aligned to retail's LITERAL 1.0 default (was MyRunRate).
- Tests re-pinned to the three retail paths (the old NoWeenie test pinned
  the non-retail fallback).
- Register: UN-2 row deleted per the retire rule (6 -> 5 UN rows);
  shortlist renumbered.

This is the 2nd confirmed instance of the BN x87-dropout artifact class
(memory: feedback_bn_decomp_field_names) deciding a register row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:17:50 +02:00
Erik
be03146e30 #112 ROOT CAUSE: outdoor-seed pick lacked retail's growing-array walk - threshold tick-skip became absorbing
The instrumented capture (cottage-112-capture1.log) + dat replay pinned
the transparent-cottage mechanism end to end:

1. The A9B3 cottage's entry cell 0x104 is a 0.22 m-wide THRESHOLD band
   (x 184.68->184.46 at y~82). A running player (~13-16 cm/tick at
   30 Hz) can cross it BETWEEN two physics ticks - the tick where the
   centre is inside 0x104 never happens.
2. Our outdoor-seed branch ran CheckBuildingTransit over a landcell
   snapshot and STOPPED - building-admitted entry cells were never
   expanded. The tick after the skip (centre in 0x100, a deep room not
   building-portal-adjacent) found no containing candidate -> the pick
   kept the outdoor landcell FOREVER (absorbing): the user walked the
   whole interior classified outdoor (render faithfully drew an outdoor
   frame = transparent walls), promoting only on touching
   portal-adjacent 0x102's own volume minutes later (captured:
   0xA9B3003C -> 0xA9B30102 with no transitions in between).
3. Retail cannot strand: CObjCell::find_cell_list (0x0052b4e0) runs ONE
   growing-array walk for EVERY seed (0052b576-0052b5ab,
   cells[i]->find_transit_cells vtable dispatch over the GROWING array)
   - the landcell's building bridge admits 0x104 (the foot sphere still
   overlaps the band one tick after the skip) and the walk expands
   0x104's portals to 0x100 where containment wins. Recovery fires one
   tick after any skip.

Fix: BuildCellSetAndPickContaining now runs retail's single growing
walk for both seeds with per-cell-type dispatch (landcells ->
CLandCell::find_transit_cells 0x00533800 -> CSortCell 0x00534060 ->
check_building_transit 0x0052c5d0; envcells -> FindTransitCellsSphere
with the straddle gate + once-per-walk outside add). The old indoor
branch behavior is preserved (seed at index 0, hysteresis, straddle-
gated outdoor pick); the outdoor branch gains the expansion + the
indoor branch gains the retail landcell bridge dispatch for
straddle-admitted landcells.

Pins (dat-backed, Issue112MembershipTests): tick-skip recovery one tick
past the threshold (RED pre-fix); run-speed entry replay across tick
phases never strands outdoor; threshold-gap outdoor-seed keeps outdoor
(over-fix guard); entry-walk replay diagnostic prints the full
promotion chain (0x3C -> 0x104 -> 0x100 -> 0x103 -> 0x100 -> 0x102).

Suites: App 246+1skip / Core 1438+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:35:52 +02:00
Erik
6a9b529113 #119: entity bounds from dat vertex data - works for every case, not just multi-part
The 1ca412d part-offset expansion fixed the staircase but still rested
on the 5 m promise one level down: a SINGLE part whose mesh extends
more than 5 m from its own origin (offset 0 -> box +-5 m) keeps the
gaze-dependent vanish. Per the user's mandate ("it must work for every
case"), the bound now derives from the dat VERTEX data - the same
vertices that get drawn - so no synthetic containment promise remains.

Oracle context (read this session): retail has NO whole-entity
visibility volume - CPhysicsPart::Draw (0x0050d7a0) viewcone-checks
each part's dat-authored CGfxObj.drawing_sphere at the part's own
world position (RenderDeviceD3D::DrawMesh 0x005a0860). Retail's bound
IS data; ours was a promise. Our per-ENTITY granularity stays (a
deliberate batching-era choice, WB-owned per the inventory) but the
volume is now data-derived and conservative: visually identical by
construction, never culls what retail would draw.

- GfxObjBounds: per-GfxObj vertex AABB, cached by id (parts repeat
  heavily); LocalBoundsAccumulator: union of part-transformed AABB
  corners (conservative-correct under any affine transform).
- WorldEntity.SetLocalBounds + RefreshAabb preferred path: rotate the
  root-local bounds' 8 corners into world axes + DefaultAabbRadius
  margin (absorbs animated-pose drift vs the rest-pose bounds; keeps
  small objects at their historical box size). Offset heuristic stays
  as the fallback for boundless fixtures.
- All four hydration sites wired (outdoor stabs, scenery incl. baked
  scale, interior cell statics, server live spawns).

Tests: tall-single-part coverage (the case 1ca412d could not see),
rotation-following, accumulator union. Suites: App 246+1skip / Core
1434+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:39:05 +02:00
Erik
1ca412d07b #119: entity bounds must cover the parts - the gaze-dependent staircase vanish
User re-gate after 2163308/987313a: run-from-town stairs FIXED, barrel
GONE - but the stairs still vanish by VIEWING ANGLE (visible climbing
down, gone climbing up; same at the tower top). The gate3 probe data
exonerates everything downstream: the entity always draws with correct
batches when it reaches the dispatcher (cache hit:119, restZ correct,
zero WALK-REJECTs, never clip-culled) - so the vanish lives in the one
gaze-dependent gate the probe cannot see: the bounds-based cullers.

WorldEntity.RefreshAabb was a fixed +-5 m box around the entity ANCHOR.
The staircase's 43 parts spiral 15 m ABOVE the anchor, and BOTH
visibility gates derive from the box: the dispatcher's per-entity
frustum cull AND RetailPViewRenderer.EntitySphere (the viewcone sphere
= this box's bounding sphere). Looking up the spiral put the anchor's
neighborhood out of view -> the whole entity culled while 15 m of it
stood in front of the camera; looking down kept the anchor in view ->
visible. Exactly the reported asymmetry.

Fix: expand the box by the largest MeshRef part-translation magnitude
(rotation-invariant, so entity.Rotation needs no handling; identity-
part entities get offset 0 - behavior unchanged; scenery scale is
already baked into the part transforms).

Suites: App 246+1skip / Core 1431+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:58:17 +02:00