merge: placement and collision stabilization checkpoint

This commit is contained in:
Erik 2026-08-03 12:54:55 +02:00
commit c7d5fc14b9
242 changed files with 81829 additions and 2433 deletions

View file

@ -24,6 +24,92 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## C3c placement cutover — 2026-08-02
- **#276 — OPEN — SpawnPlacementSettler discards the settle's resolved
cell.** `SpawnPlacementSettler.TrySettle`
(src/AcDream.Core/Physics/SpawnPlacementSettler.cs:61) commits
`settle.Position` but never reads `settle.CellId`: a compressed
first-gravity-frame settle whose few-cm sweep crosses a cell boundary
(outdoor/EnvCell seam, stacked EnvCells) leaves the body's cell at the
placement cell until the next resolve corrects it. Inherited #270
semantics — shared by the remote spawn seed and the C3c local
first-entry settle (register row AD-61). Fix shape: commit the
settle's resolved cell through the same body/cell channel the per-tick
resolve writeback uses; needs a conformance test placing a body above
a floor whose containing cell differs from the wire cell. Found by C3c
review round 1 (retail minor M2).
- **#277 — OPEN — route-1 far-Create relies on a practical radius bound,
not an invariant.** A graphical-host wire Create for a landblock the
streaming window never reaches keeps its residence + one drive-pending
entry for the session (re-Advanced per frame). Bounded today because
the collision-publication window (5×5, two-tier N₁=4) is strictly
larger than ACE's Create-broadcast group, so the far set is empty; if
C4 changes either radius, route 1 needs the F7-style
service-window/celless conversion
(`HeadlessSessionWorldProjection.cs:557-570` is the template). Wake
and despawn-reap paths are verified correct (C3c adversarial delta
review). Related narrowing: a remote whose landblock leaves the
headless service window between ProjectSpawn and placement commit
still parks (route 8, rarer than the pre-F7 leak).
- **#278 — NARROWED 2026-08-03 — post-C3c user-session triage bundle.**
Resolved and user-verified: (a) the persistent login purple haze no longer
races raw PlayerCreate receipt (`175ad6b0`); (c) `/ls` works; (d) remote
monsters no longer pop in behind the player or place/attack in a different
coordinate frame (`670f307c`); (e) the retirement-receipt replay loop that
stalled streaming and portal convergence is gone (`01f4791e`); and (f)
materialization/effect presentation is bound after canonical placement
(`f24532ad`, `175ad6b0`). The remaining item is (b): explicitly compare
lateral glide against impassable slopes with open #269 before closing this
bundle. Far terrain that can visibly continue building after portal reveal
is tracked separately as #280.
- **#279 — DONE (2026-08-03, user-verified) — one-shot spell/effect
scripts arriving during the suppressed-until-receipt window were lost.**
`EntityEffectController` now retains the mixed F754/F755 FIFO behind an
exact-incarnation initial-presentation barrier and replays it only after
canonical placement has bound the mesh, pose owner, and particle visibility
resources. Live rebuckets also keep the effect cell synchronized with the
entity cell. Spell buffs, recalls, arrows, and combat spell projectiles were
verified in the connected client; focused effect, projectile, and
cell-transition tests cover the race. Landed at `f24532ad`.
- **#280 — OPEN — portal reveal can expose an incompletely streamed distant
landscape.** User-observed 2026-08-03: after some recalls, the nearby
destination is playable but terrain near the far end of the view continues
visibly building after portal space exits. The current outdoor reveal gate
is explicitly only `WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius =
1` (a 3x3 landblock neighborhood), while the normal configured view extends
substantially farther; this permits the world viewport to open before its
visible static destination is complete.
**Retail oracle:** `CellManager::PreFetchCells @ 0x00455820` sets
`blocking_for_cells` until `LScape::PreFetchCells @ 0x00505660` has walked
the configured `mid_radius` square and each required
`CLandBlock::PreFetchCells` / `CLandBlockInfo::PreFetchCells` building and
connected EnvCell dependency is available. While blocked,
`SmartBox::UseTime @ 0x00455410` checks prefetch status but does not advance
ordinary object maintenance, physics, landscape, game time, or ambient
audio; the portal viewport and UI remain live and may show retail's centered
"In Portal Space - Please Wait..." notice. Once the destination is ready,
retail resumes it behind the portal viewport during `TAS_TUNNEL_CONTINUE`
before the later tunnel-to-world reveal.
**Fix shape:** replace the hard-coded radius-one reveal requirement with a
retail-derived, quality-configured destination prefetch window and keep one
generation-scoped reservation across terrain, statics/buildings, EnvCells,
render publication, composite textures, and collision until that complete
visible window is ready. Preserve bounded asynchronous preparation and the
existing wait cue; never reveal early merely to meet a timeout. Do not wait
for an unknowable "all dynamic server objects delivered" condition—ACE has
no such terminal marker and some object delivery follows LoginComplete.
**Acceptance:** at every quality/view-distance setting, repeated login,
`/ls`, spell recall, and portal routes reveal no constructing terrain,
buildings, statics, interiors, missing composite textures, or nearby
collision; slow destinations remain in the authored portal presentation
with responsive UI until ready, then receive the existing hidden settling
interval before the world viewport appears. Dynamic monsters/items may
continue to arrive authoritatively after reveal.
## Current queue — 2026-07-27 ## Current queue — 2026-07-27
- **Structural handoff:** all eight `GameWindow` decomposition slices and the - **Structural handoff:** all eight `GameWindow` decomposition slices and the
@ -35,11 +121,12 @@ What does NOT go here:
[`docs/architecture/code-structure.md`](architecture/code-structure.md). [`docs/architecture/code-structure.md`](architecture/code-structure.md).
- **Active M4 prelude:** resume - **Active M4 prelude:** resume
[`plans/2026-07-23-world-interaction-completion.md`](plans/2026-07-23-world-interaction-completion.md). [`plans/2026-07-23-world-interaction-completion.md`](plans/2026-07-23-world-interaction-completion.md).
Slices 13, including the complete assessment surface, are user-accepted. Slices 14, including equipped-child picking, are user-accepted. Vendor
Equipped-child picking and vendor browse/transactions remain Slices 46. browsing and authoritative transactions remain Slices 56.
- **Separate rendering gate:** `#225`, lifestone/particle alpha ordering. Its - **Separate rendering gate:** `#225`, lifestone/particle alpha ordering. Its
connected performance, lifetime, and unattended portal routes pass. connected performance, lifetime, and unattended portal routes pass.
- **Carried behavior debt:** `#116` slide response (Campaign P P2 scope; - **Carried behavior debt:** `#116` slide response, `#273` tight-gap
collision clearance, and deferred restricted-house gate `#274`;
`#153` closed 2026-07-30 on the AD-30 hold + arrival StopCompletely + `#153` closed 2026-07-30 on the AD-30 hold + arrival StopCompletely +
canonical outbound + reveal-barrier evidence chain). TS-50/TS-51/TS-53 are canonical outbound + reveal-barrier evidence chain). TS-50/TS-51/TS-53 are
tracked in the divergence register. tracked in the divergence register.
@ -98,6 +185,143 @@ Copy this block when adding a new issue:
--- ---
## #275 — Unify the legacy Position wire path onto the executor's route classifier
**Status:** OPEN — post-cutover (physics campaign, filed 2026-08-02)
**Severity:** LOW (internal refactor debt; not a retail divergence)
**Component:** Runtime / inbound Position
**Description:** the legacy `InboundPhysicsStateController.TryApplyPosition`
(today's only production Position wire caller) has no route-classification or
contact concept; the continuation executor's `ApplyPositionAction` runs
`RuntimeAuthoritativePositionRouteClassifier` with the wire's own `IsGrounded`
bit and threads the classified `installPlacementFrame`/`clearParent` flags
(register rows AP-131 documents the legacy caller's unconditional flags, AD-60
the cell-semantics difference). When the production cutover wires the executor
in, unify the legacy caller onto the same classifier (or delete it with the
route) and retire AP-131/AD-60's legacy halves. See
`InboundPhysicsStateController.TryApplyPosition` remarks.
---
## #274 — Restricted/barred-house entry needs a connected retail comparison
**Status:** OPEN — explicitly deferred by the user on 2026-07-31
**Severity:** LOW (validation debt; no confirmed failure)
**Filed:** 2026-07-31
**Component:** physics / EnvCell entry restrictions
**Description:** Campaign P ported retail
`CObjCell::check_entry_restrictions` and retired AP-71, but the final
connected barred-house scenario was not run. The user requested that this
gate be deferred and retained as an issue rather than block current work.
**Acceptance:** at a known restricted house, use equivalent characters in
retail and acdream. Both must reject an unauthorized character at the same
threshold, while an owner/guest enters normally. Record the house/cell,
character access state, and result before closing.
---
## #273 — ACDream can squeeze through tight world gaps that block retail
**Status:** OPEN — live mismatch confirmed 2026-07-31; exact location/capture
still required
**Severity:** MEDIUM (world traversal differs from retail)
**Filed:** 2026-07-31
**Component:** physics / player collision shape and cell collision
**Symptom:** in some tight world-geometry passages, acdream can pass through
a gap that blocks the retail client. The Campaign P wall/corner slide,
crowd, remote movement, door, portal, and general collision checks otherwise
passed.
**Scope:** this is not folded into #116, which tracks a specific
near-perpendicular slide-response/fixture family. The new symptom is
under-blocking or clearance divergence and may involve the active player
sphere list, scale/pose, candidate-cell collision set, or a missing
world-collision primitive.
**Next evidence:** record one exact reproducible location, heading, movement
input, character scale/equipment, and cell ID in both clients. Capture the
acdream transition path and active Setup-derived sphere list before changing
collision math.
**Acceptance:** the captured tight gap blocks or permits traversal at the same
clearance as retail without regressing normal doorways, stairs, wall grazing,
or crowd movement.
---
## #272 — Strength enchantments do not invalidate burden
**Status:** DONE — 2026-07-31 (implementation, automated gates, and user live
buff/death gate)
**Severity:** HIGH (movement state and retained HUD disagree with retail)
**Filed:** 2026-07-31
**Component:** player qualities / enchantments / burden
**Symptom:** while overburdened, casting a Strength spell did not reduce the
burden state until the base Strength attribute changed. Dying purged the
Strength spell but did not restore the overburdened state.
**Root cause:** `LiveSessionEventRouter.RecomputeBurden` read raw
`AttributeValue.Current` and subscribed only to base Strength/object-table
changes. The indicator bar and inventory meter had the same raw-Strength
composition, and the inventory meter did not observe enchantment changes.
Retail `CACQualities::InqLoad @ 0x0058F130` calls
`CACQualities::InqAttribute @ 0x00591A00`, which applies
`CACQualities::EnchantAttribute @ 0x00594570`; every load query therefore uses
effective Strength.
**Fix:** all three consumers now read
`LocalPlayerState.GetEffectiveAttribute(Strength)`. The Runtime burden owner,
indicator bar, and inventory meter subscribe to the canonical
`Spellbook.EnchantmentsChanged` edge, covering add, remove, expiration,
dispel, and death purge through one path. Regression tests pin both
buff-to-unburdened and purge-to-overburdened transitions without any base
attribute update.
**Acceptance:** overload a character, cast a Strength spell, and observe the
burden icon/meter plus movement update immediately. Die while the spell is
active and observe the spell purge restore the overburdened icon/meter and
movement immediately. **Passed live 2026-07-31:** the user confirmed the
burden state now updates correctly.
---
## #271 — Stair-side collision reverses uphill movement and rapidly slides the player down
**Status:** DONE — 2026-07-31 (implementation + user live gate)
**Severity:** MEDIUM (movement feel and navigation)
**Component:** physics / `edge_slide` / `precipice_slide`
**Symptom:** while running diagonally up an outdoor staircase and pressing
against its side, the character could suddenly move backward and slide rapidly
to the bottom.
**Root cause:** a 677-quantum live trace caught the first bad frame. A valid
X side-wall collision turned a requested `+0.88377` uphill Y displacement into
`-0.34059`, followed three frames later by a 1.52 m snap to terrain.
`EdgeSlideAfterStepDownFailed` promoted ACDream's separately retained
`LastWalkable` polygon into the current `SPHEREPATH::walkable` slot. At the
stair side this could be the preceding tread, so `PrecipiceSlide` projected
against stale geometry and reversed the tangent.
Named-retail `CTransition::edge_slide @ 0x0050B3D0` never substitutes an older
polygon: when current `walkable` is null it back-probes at the current sphere
center, restores the failed candidate, and only then invokes
`SPHEREPATH::precipice_slide @ 0x0050CC80`. Both stale-history substitutions
are removed. The exact captured frame is pinned against the installed stair
fixture: pre-fix output moved downhill to Y `75.346481`, while the retail-flow
result advances uphill to Y `76.078186` and Z `60.016247`. Core Release passes
4,108 tests / 2 skips; the complete Release solution passes 10,062 tests /
5 skips. The user then repeatedly climbed the same stairs while pressing into
their sides and confirmed the rapid downhill slide was gone. Evidence:
`docs/research/2026-07-31-271-stair-side-slide-capture.md`.
---
## #270 — Stuck spell animations + intermittently missing monster attack animations ## #270 — Stuck spell animations + intermittently missing monster attack animations
**Status:** CLOSED 2026-07-31 — both symptoms user-verified fixed (stuck casts: exhaustion-edge gate `a46c8e65`; missing monster attack animations: spawn settle placement `21b3a3f3` + lost-cell retry `807fdb5f`). Final settle-session log: 14/15 spawn settles grounded; Falling-refusal spam 2,954 → 15 transient pre-settle lines. All #270 probes stripped. **Status:** CLOSED 2026-07-31 — both symptoms user-verified fixed (stuck casts: exhaustion-edge gate `a46c8e65`; missing monster attack animations: spawn settle placement `21b3a3f3` + lost-cell retry `807fdb5f`). Final settle-session log: 14/15 spawn settles grounded; Falling-refusal spam 2,954 → 15 transient pre-settle lines. All #270 probes stripped.
@ -142,7 +366,7 @@ remain in place (ride ACDREAM_DUMP_MOTION=1) until the user gate passes.
## #269 — Slope-stop slide runs too far (post-bounce-rework residual) ## #269 — Slope-stop slide runs too far (post-bounce-rework residual)
**Status:** OPEN (user live gate 2026-07-30 — "almost pass with merits") **Status:** DONE — 2026-07-31 (implementation + user live gate)
**Severity:** LOW-MEDIUM (feel residual; bounce family otherwise accepted) **Severity:** LOW-MEDIUM (feel residual; bounce family otherwise accepted)
**Component:** physics / landing slide decay **Component:** physics / landing slide decay
@ -158,44 +382,58 @@ cos(10°) with base 0.2); the jump chain end-to-end (`GetJumpHeight`
0x006b09b0 exact incl. 1300/22.2/0.05/0.35, `InqJumpVelocity` 0x00592980 0x006b09b0 exact incl. 1300/22.2/0.05/0.35, `InqJumpVelocity` 0x00592980
`vz=sqrt(h*19.6)`, powerbar charge 1.0 s / 0.8 s dual-wield) — the user's `vz=sqrt(h*19.6)`, powerbar charge 1.0 s / 0.8 s dual-wield) — the user's
"we jump too high" hypothesis is REFUTED, jump height is retail-parity "we jump too high" hypothesis is REFUTED, jump height is retail-parity
(our eff skill is 5 lower pending #268's augmentation port). Retail has no (the former five-point effective-skill gap was closed by #268). Retail has no
Sledding auto-toggle (P2 finding re-confirmed; no `state |= 0x800000` Sledding auto-toggle (P2 finding re-confirmed; no `state |= 0x800000`
writer exists). writer exists).
**Next step (behavior question, not code):** live A/B per the cdb **Resolution (2026-07-31):** a 2,184-quantum live capture isolated the
workflow — breakpoints on `calc_friction`/`handle_all_collisions` dumping first divergence. The landing tick produced retail's correct 5% reflect,
velocity per tick while the retail client lands + slides on a slope, vs an but the following quanta repeatedly restored
`ACDREAM_CAPTURE_RESOLVE` capture of the same maneuver on the same slope. `LastKnownContactPlane` while retaining the reflected velocity. The body
Compare the decay curves; the first diverging tick names the mechanism. therefore remained Contact + OnWalkable with `v·n > 0.25`, where retail
Candidates the trace discriminates: hop-chain cadence (friction skips `calc_friction` intentionally does no work, and slid at full speed.
airborne micro-hop ticks), gravity-tangential rebuild timing, quantum
alignment. Named-retail `CTransition::validate_transition @ 0x0050AA70` revealed the
omission: its non-OK remembered-plane recovery calls
`OBJECTINFO::kill_velocity @ 0x0050CFE0` *before* the proximity test and
plane restore (`0x0050AAED0x0050AB42`). It also consumes the remembered
plane only in that non-OK branch and overwrites last-known validity from
the final contact plane at `0x0050ACFF`. ACDream now follows that exact
ordering. Focused collision-recovery and clean-advance pins pass; full
Core (4,107/2 skips) and Runtime (439/0) suites pass; the user repeated the
slope-jump test and accepted the result (“Perfect! Works great!”).
Capture and decode:
`docs/research/2026-07-31-269-slope-stop-capture.md`.
--- ---
## #268 — Character panel: vitae color, buff coloring, and augmentation bonuses ## #268 — Character panel: vitae color, buff coloring, and augmentation bonuses
**Status:** OPEN (user direction 2026-07-30, follow-up to #267) **Status:** DONE — 2026-07-31 (implementation + user visual/live gate).
**Severity:** MEDIUM (presentation parity) **Severity:** MEDIUM (presentation parity)
**Component:** retained UI / character window **Component:** retained UI / character window
**User direction:** (a) the vitae parenthetical (text + number) renders in **Resolution:** the shared Core `PlayerSkillMath` now ports
light blue — VERIFY the exact retail color from the decomp/DAT before `CACQualities::InqSkill @ 0x00592660` in retail order for both the
hardcoding (the chat-color cdb recipe in character panel and Runtime movement: intrinsic skill, positive
`claude-memory/reference_retail_chat_colors.md` is the fallback method); `LumAugAllSkills` (0x16D), the authored +10 melee/missile/magic category
(b) when a buff-like effect is active (including AUGMENTATIONS such as augmentation, `EnchantSkill`, then +5 Jack of All Trades (0x146) and
+Acdream's +5-all-skills), affected skills AND attributes render green `2 × LumAugSkilledSpec` (0x158) for specialized skills. Live player
(buff coloring) — #267's CurrentLevel/BaseLevel split made the green/red PropertyInt updates recompute the Runtime snapshot, so the display and
coloring live, but augmentation bonuses are not part of the effective run/jump prediction cannot drift.
value yet, so the color never triggers for them; (c) augmentation skill
bonuses must be included in the displayed effective values.
**Register link:** this PROMOTES AP-127 — the "two minor unmodeled retail `AttributeInfoRegion::Update @ 0x004F1910`,
bonus properties" (0x146/0x158 family — the all-skills augmentation `Attribute2ndInfoRegion::Update @ 0x004F19E0`, and
terms feeding `CACQualities::InqRunRate`/`InqJumpVelocity` BEFORE `SkillInfoRegion::Update @ 0x004F1AE0` now drive exact value coloring:
`EnchantSkill`) are user-visible in both the panel and potentially green/red compare the non-vitae residual against the base, so a pure vitae
movement speed. Port them into the CACQualities chain (movement) and the penalty remains white. The selected-skill footer uses one shared inline-run
panel's effective-value computation together, decomp-first. text primitive matching retail `AppendTextWithFont`; its vitae fragment uses
the authored LayoutDesc 0x2100002E / FooterTitle 0x1000024E palette index 3
(#7FFFFF), while positive/negative buff fragments use palette indices 1/2
(#00FF00/#FF0000). Attributes, secondary attributes, and skills share those
exact colors. AP-127 and TS-8 are retired by the same stat-chain package.
The user confirmed the live buff values, footer coloring, and immediate skill
row refresh after the final retained-UI invalidation correction.
--- ---
@ -11080,7 +11318,13 @@ The remaining trailer sections (options / shortcuts / hotbars / inventory / equi
**Commit:** `feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow` **Commit:** `feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow`
**Resolution:** Closed alongside #7 in the same commit. `ActiveEnchantmentRecord` extended with optional `StatModType`, `StatModKey`, `StatModValue`, `Bucket` fields. `Spellbook` got an `OnEnchantmentAdded(ActiveEnchantmentRecord)` overload that accepts the full record. `EnchantmentMath.GetMod` aggregator now consumes the StatMod data: multiplicative bucket (1) → multiplier ×= val; additive bucket (2) → additive += val; vitae bucket (8) → multiplier ×= val (applied last, matching retail `CEnchantmentRegistry::EnchantAttribute` semantics). 5 new EnchantmentMath StatMod-aware tests cover: multiplicative buffs aggregate, additive buffs sum, stat-key mismatch is filtered out, vitae applies multiplicatively, family-stacking picks the higher spell-id buff. **Resolution:** Closed alongside #7 in the same commit. `ActiveEnchantmentRecord` extended with optional `StatModType`, `StatModKey`, `StatModValue`, `Bucket` fields. `Spellbook` got an `OnEnchantmentAdded(ActiveEnchantmentRecord)` overload that accepts the full record. `EnchantmentMath.GetMod` aggregator now consumes the StatMod data: multiplicative bucket (1) → multiplier ×= val; additive bucket (2) → additive += val; vitae bucket (8) → multiplier ×= val (applied last, matching retail `CEnchantmentRegistry::EnchantAttribute` semantics). 5 new EnchantmentMath StatMod-aware tests cover: multiplicative buffs aggregate, additive buffs sum, stat-key mismatch is filtered out, vitae applies multiplicatively, family-stacking picks the higher spell-id buff.
`ParseMagicUpdateEnchantment` (the live-update opcode 0x02C2) is **not** yet extended — it still uses the 4-field summary. That's a separate refactor; PlayerDescription's enchantment block is the load-bearing path for issue #6, and that's now flowing. **2026-07-31 live-update closeout:** `ParseMagicUpdateEnchantment`
(0x02C2) now parses the complete record, including start time, DegradeModifier,
degrade limit, last time degraded, StatMod type/key/value, and bucket
classification. `GameEventWiring` maps that immutable wire record into the
same `ActiveEnchantmentRecord` shape as PlayerDescription. The end-to-end
test sends an actual 0x02C2 payload through dispatch and proves the resulting
StatMod changes the local player's effective skill without relogging.
--- ---
@ -11090,7 +11334,9 @@ The remaining trailer sections (options / shortcuts / hotbars / inventory / equi
**Commit:** `feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath` **Commit:** `feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath`
**Resolution:** Ported `CEnchantmentRegistry::EnchantAttribute` (PDB `0x00594570`) as `EnchantmentMath.GetMod(IEnumerable<ActiveEnchantmentRecord>, SpellTable, statKey)` returning `(Multiplier, Additive)`. Family-stacking dedup via `SpellTable.Family` (only one buff per family bucket wins, by highest spell-id as a generation proxy). `Spellbook.GetVitalMod(statKey)` delegates. `LocalPlayerState.GetMaxApprox` reworked to apply `(unbuffed × mult) + add` with retail's min-vital clamp (`>= 5` if base ≥ 5 else `>= 1`, matches `CreatureVital::GetMaxValue` at PDB `0x0058F2DD`). Stat-key constants (`MaxHealth=1`, `MaxStamina=3`, `MaxMana=5`) verified against `docs/research/named-retail/acclient.h` line 37287-37301. **Resolution:** Ported `CEnchantmentRegistry::EnchantAttribute` (PDB `0x00594570`) as `EnchantmentMath.GetMod(IEnumerable<ActiveEnchantmentRecord>, SpellTable, statKey)` returning `(Multiplier, Additive)`. Family-stacking dedup via `SpellTable.Family` (only one buff per family bucket wins, by highest spell-id as a generation proxy). `Spellbook.GetVitalMod(statKey)` delegates. `LocalPlayerState.GetMaxApprox` reworked to apply `(unbuffed × mult) + add` with retail's min-vital clamp (`>= 5` if base ≥ 5 else `>= 1`, matches `CreatureVital::GetMaxValue` at PDB `0x0058F2DD`). Stat-key constants (`MaxHealth=1`, `MaxStamina=3`, `MaxMana=5`) verified against `docs/research/named-retail/acclient.h` line 37287-37301.
**Architecture in place; data still flat.** Until ISSUES.md #12 lands the wire-format extension that captures `StatMod (type/key/val)` on `ActiveEnchantmentRecord`, the per-enchantment modifier value isn't aggregated yet — `EnchantmentMath.GetMod` returns `Identity (1.0, 0.0)` for every stat key. Once #12 wires the data, the existing aggregator + formula light up automatically. Live `+Acdream` Stam/Mana percent will continue to read ~95% until #12 lands. **Data path complete:** both PlayerDescription and live 0x02C2 updates carry
the full StatMod into `ActiveEnchantmentRecord`; the shared aggregator applies
it immediately to attributes, vitals, skills, movement, and retained UI.
6 new EnchantmentMathTests cover: empty list returns Identity, no-table-entries returns Identity, stat-key constants match ACE enum, Identity is `(1, 0)`, family-stacking dedup, family=0 (no-bucket) treated as separate. 6 new EnchantmentMathTests cover: empty list returns Identity, no-table-entries returns Identity, stat-key constants match ACE enum, Identity is `(1, 0)`, family-stacking dedup, family=0 (no-bucket) treated as separate.

View file

@ -225,8 +225,17 @@ src/
RuntimeEntityObjectLifetime.cs -> one entity/object lifetime root RuntimeEntityObjectLifetime.cs -> one entity/object lifetime root
RuntimeEntityObjectEventStream.cs -> canonical ordered entity/object deltas RuntimeEntityObjectEventStream.cs -> canonical ordered entity/object deltas
RuntimeEntityObjectViews.cs -> direct allocation-free borrowed views RuntimeEntityObjectViews.cs -> direct allocation-free borrowed views
InboundPhysicsStateController.cs -> retail timestamp/snapshot authority InboundPhysicsStateController.cs -> retail timestamp/snapshot authority,
ParentAttachmentState.cs -> generation-exact parent relations including gate-only dormant acceptance
ParentAttachmentState.cs -> generation-exact parent relations plus raw
missing-parent Create admission
RuntimeInitialCreateAdmissionFreezer.cs -> immutable parser-payload copy
boundary for dormant initial placement
RuntimeInitialCreateResidenceState.cs -> exact-incarnation initial
placement lease and accepted mixed-update FIFO
RuntimeInitialCreateContinuationExecutor.cs -> retry-idempotent
adoption + retail Create tail + strict-order
FIFO/replay execution over the residence
Gameplay/ Gameplay/
RuntimeCommunicationState.cs -> one chat/social owner + ordered stream RuntimeCommunicationState.cs -> one chat/social owner + ordered stream
RuntimeInventoryState.cs -> exact object-table borrower + inventory RuntimeInventoryState.cs -> exact object-table borrower + inventory
@ -241,6 +250,15 @@ src/
Physics/ Physics/
RuntimePhysicsState.cs -> per-session engine/cache/scratch/shadows, RuntimePhysicsState.cs -> per-session engine/cache/scratch/shadows,
collision receipts, bodies/hosts/worksets collision receipts, bodies/hosts/worksets
RuntimeCollisionReportingState.cs -> exact-key retail collision table,
environment latch, ordered callbacks, and
SetPosition report-result ownership
RuntimeSetPositionState.cs -> exact placement/lost-cell operations,
authored mover retention, ordered host
receipts, and collision-generation wake
RuntimePlacementProjectionChannel.cs -> generation-gated public host
observation/retry/exact-ack seam over the
one Runtime SetPosition receipt owner
RuntimeRemotePhysicsUpdater.cs -> presentation-free remote simulation RuntimeRemotePhysicsUpdater.cs -> presentation-free remote simulation
RuntimeOrdinaryPhysicsUpdater.cs -> presentation-free object simulation RuntimeOrdinaryPhysicsUpdater.cs -> presentation-free object simulation
RuntimeProjectile.cs -> canonical projectile component/prediction owner RuntimeProjectile.cs -> canonical projectile component/prediction owner
@ -329,6 +347,13 @@ src/
AppPluginHost.cs -> done AppPluginHost.cs -> done
``` ```
The 4B2 production SetPosition routes and shared local-controller body remain
dormant. Runtime now owns the exact collision table, environment latch, and
report-result semantics needed by that cutover. Activation still waits for
exact authored mover preparation, presentation-only rebucketing,
placement-prefix quiescence, and an atomic Runtime body/controller publication
transaction to land as one reviewed cutover.
--- ---
## Movement And Collision Architecture ## Movement And Collision Architecture
@ -485,8 +510,60 @@ What exists and is active:
collision assets loaded from the validated prepared package. Production collision assets loaded from the validated prepared package. Production
retains no parsed DAT collision graph; graph construction is restricted to retains no parsed DAT collision graph; graph construction is restricted to
bake/equivalence tools and explicit test oracles. bake/equivalence tools and explicit test oracles.
- `ShadowObjectRegistry` gives movement a broadphase over nearby objects and - Landblock collision activation is generation-owned by
buildings. `RuntimePhysicsState`. Graphical and no-window hosts populate a private
`PreparedLandblockCollisionGeneration` over bounded cursors; its cache,
`CellGraph`, engine landblock, buildings, static shadows, and retained-owner
refloods are never visible through the borrowed live engine. Retained owners
comprise every non-suspended dynamic or adjacent-root static touching the
target prefix (including a withdrawn repair marker); target-root statics come
from the authored replacement. Runtime mutation-gates their exact capture,
refreshes each through the host work meter, and builds every cache/graph/
shadow replacement through one-work-unit seal cursors. Stable per-prefix
owner slots replace the former registry-global mutation gate. One Runtime-
scoped versioned journal coalesces repeated live mutations by owner instead
of copying the owner into every draft on every event. Each draft reconciles
only the latest exact state for owners changed during its lifetime, one owner
per seal step, so unrelated or continuously moving owners cannot restart the
target cursors. Once discovered, a relevant owner receives exact subscribed
updates without restoring global fanout. First entry to or departure from a
target after the global slot cursor has passed is routed through the owner's
changed prefix to that one matching draft. During topology construction a
visited unrelated owner receives only a cheap coalesced dirty notification;
its exact mirror is deferred to one metered seal unit. Once the topology seal
exists, observed owners temporarily write through exactly until activation,
so the finite pre-seal queue drains even when several unrelated owners move
continuously. Production activates in that same update-thread call. Slots older than a
newer draft's captured root are superseded at the tail rather than reused
behind live cursors; new drafts start at their captured suffix, obsolete
slots compact incrementally, and the journal clears with the last draft.
Empty prefix containers are reclaimed under GUID churn; seal cursors retain
their captured slot lists.
Cache, CellGraph, engine, and shadow topology share one complete off-side
`CollisionWorldState`. Admission captures the current root reference in O(1)
and materializes the non-target leaves through the same one-work-unit frame
meter; a dense resident world is never cloned synchronously. After an older
preparation commits, its exact landblock delta queues into every later draft
and drains one cache, graph, landblock, or owner leaf per seal step. A later
demotion or withdrawal cancels matching queued/active rebases and tombstones
that prefix in unfinished source scans, then retires one owner/cache/graph/
outdoor leaf per seal step from growable retirement storage. Commit rechecks
both retirement and rebase state after sealing, so retired topology cannot
return or cause a drafts-times-world-size update spike.
The host immediately performs the zero-work root transfer in the same update-
thread call that completes final reconciliation, so continuous unrelated
movement cannot manufacture a required quiet frame between seal and commit.
Deterministic preparation order prevents a later draft from exposing early,
inheriting cancelled topology, or overwriting a committed prefix. Final activation is one
zero-allocation volatile root transfer that preserves public facade identity,
revokes staging, and then emits `CollisionGenerationCommitted`. Cancellation disposes only the named
staging generation and never withdraws the previous active world. Thus
readers see the complete old generation or complete new generation, never a
mixed cell/cache/shadow world.
- `ShadowObjectRegistry` gives movement a per-cell broadphase over nearby
objects and buildings. Streaming reflood is structurally part of the Runtime
collision-generation commit; there is no independent post-publication
reflood suffix.
- `TerrainSurface` uses triangle-aware terrain contact; older "bilinear terrain - `TerrainSurface` uses triangle-aware terrain contact; older "bilinear terrain
Z" descriptions are historical B.3 language, not current architecture. Z" descriptions are historical B.3 language, not current architecture.
@ -523,6 +600,37 @@ server-GUID/incarnation map, Runtime local-ID allocation/reverse lookup,
accepted snapshots and timestamp gates, parent state, session/operation accepted snapshots and timestamp gates, parent state, session/operation
versions, and exact tombstones. `RuntimeEntityRecord` is presentation-free. versions, and exact tombstones. `RuntimeEntityRecord` is presentation-free.
Initial world placement has one deliberate dormant exception to ordinary
snapshot publication. While an exact `RuntimeInitialCreateResidenceState`
lease is waiting for its first canonical placement, retail timestamp gates may
accept later same-incarnation Create, ObjDesc, Parent, Pickup, Position,
Movement, State, and Vector packets, but neither the canonical record, public
accepted snapshot, event stream, nor presentation changes. Runtime retains
deep-frozen typed actions in one monotonic arrival-ordered FIFO under the exact
entity key. A Create whose parent is not yet addressable is retained even
earlier as a complete raw packet, before child timestamp admission, and is
guarded by a non-reused admission token. Delete, generation replacement,
reset, GUID reuse, and reentrant teardown discard only the matching ownership.
The admission checkpoint (`30012361`) intentionally stopped before executing
the FIFO. The continuation executor (`5db3de3c`,
`RuntimeInitialCreateContinuationExecutor`) completes the mechanism: one
synchronous, retry-idempotent `Execute` transaction adopts the acknowledged
initial placement exactly once, emits the local player's after-enter-world
hook request, replays deferred missing-parent raw Creates and queued parent
relations by parent GUID (whole-bucket detach, FIFO dispatch,
cancellation-aware restore windows), and drains the mixed FIFO strictly by
sequence — classifying each retained Position at execution time with live
inputs and driving authored placements through the canonical
`RuntimeSetPositionState` lifecycle with retryable yields. Apply bodies are
shared with the legacy fused inbound paths through gate-less instance seams
that keep the one snapshot store in lockstep; same-incarnation Create
envelopes apply atomically with buffered publication; every abandonment path
retires the residence and converges the combined ownership ledger. The
executor has NO production caller yet — graphical and no-window Create still
use legacy `RegisterEntity` — and the next checkpoint must switch both
production routes onto this owner rather than create another snapshot or
placement path.
`LiveEntityRuntime` is the App projection/lifecycle host. `LiveEntityRuntime` is the App projection/lifecycle host.
`RegisterLiveEntity` first creates or refreshes canonical Runtime state without `RegisterLiveEntity` first creates or refreshes canonical Runtime state without
an App record. `MaterializeLiveEntity` claims the Runtime local ID and creates an App record. `MaterializeLiveEntity` claims the Runtime local ID and creates

View file

@ -1,4 +1,4 @@
# Retail Divergence Register — current through 2026-07-30 # Retail Divergence Register — current through 2026-07-31
**What this is.** The single auditable register of every known place acdream's **What this is.** The single auditable register of every known place acdream's
runtime behavior can deviate from the retail client (Sept 2013 EoR build, runtime behavior can deviate from the retail client (Sept 2013 EoR build,
@ -62,12 +62,51 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 2. Adaptation (AD) — 46 rows (AD-25 retired 2026-07-30 at Campaign P Slice P3 — the remote dead-reckoning post-resolve now calls the exact ported `PhysicsObjUpdate.HandleAllCollisions` (the same function the local player and every ordinary body already use) instead of its own hand-inlined, narrower reflect gate; the row's own premise ("the remote DR sweep hasn't been rebuilt yet") no longer holds; AD-55 filed 2026-07-30 at Campaign P Slice P2, split out of the retired AP-7 row — the open cos(10°)-vs-0.99999536f Sledding slope-flatness constant; AD-53/AD-54 filed the same slice, split out of the retired TS-1 row — CliffSlide's three-source reference-normal fallback chain and the walkable-steepness reroute to CliffSlide; AD-52 filed 2026-07-29 at Campaign N slice N6 — the fragment-assembler 60 s partial TTL + completed-sequence ring; AD-51 filed 2026-07-29 at Campaign N slice N4 — the reclaimed-word pool for ACE's fresh-sequence cleartext RejectRetransmit; AD-50 filed 2026-07-29 at Campaign N slice N2 — the inbound-watermark ACE init; AD-49 stays reserved for Campaign N §5's blob-layer ordering deferral, filed when its slice lands; AD-47 and AD-48 filed 2026-07-29 at Campaign V slice V11 — the MSAA sample-position and present-pacing rows the campaign's risk register scheduled for the GL deletion; AD-11 retired 2026-07-23 — exact low-bit ItemUses predicate; AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) ## 2. Adaptation (AD) — 47 active rows (AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-42 refreshed same round — its cited App-side login resolve split was deleted by the C3c flip, the split survives only on the unflipped remote-teleport/headless portal-resync paths; AD-59/AD-60 filed 2026-08-02, continuation-executor slice)
Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate
visible-cell availability, full-catalog containment-root validation, and the
zero-portals point-in-cell guard (rootless payloads are quarantined; only a
missing positive child below a valid root is the inside base case); AD-25 retired 2026-07-30 by the
shared `PhysicsObjUpdate.HandleAllCollisions` remote path; AD-11 retired
2026-07-23 by the exact low-bit ItemUses predicate; AD-31 retired 2026-07-15
by the DAT-authored portal-space viewport. Recent additions and splits:
AD-47/AD-48 (Vulkan sample/present behavior), AD-50..AD-52 (Campaign N), and
AD-53..AD-55 (Campaign P response-layer findings).
AD-2 clarification (placement Slice 4A, 2026-07-31): Core never creates cells
during placement, so retail `DoNotCreateCells` has no differential synchronous
loader branch there. Slice 4B must preserve the flag while mapping successful
deferred placement to exact-cell, generation-scoped asynchronous admission;
the presence of the flag in the immutable request is not claimed as exactness.
AD-2 retirement-receipt refinement (2026-08-02): a pending-only live
projection bucket survives the atomic origin swap but is not a landblock
presentation generation and emits no second full cleanup receipt. A genuine
receipt-ledger invariant after spatial detachment is a committed terminal
failure, never resumable detach work. This preserves the existing adaptation:
one exact asynchronous cleanup owner for each synchronously destroyed retail
landblock, while logical live objects survive streaming residence changes.
AP-1/AD-1 checkpoint (placement Slice 4B2 checkpoint 1, 2026-07-31): Runtime now owns the
exact accepted placement/lost-cell transaction, atomic body/contact/cell/
shadow/workset commit, adjusted retained frame, authored mover preparation,
exact-cell/generation wake, append/swap lost buckets, a bounded indexed
deadline heap, independent root/direct-child deadlines, and revisioned ordered
host receipts. A public generation-gated observe/retry/exact-ack channel now
projects that one receipt owner. Shared local-controller body adoption remains
deferred to the atomic all-route ownership cutover. Both rows remain open until
4B2 cuts graphical and no-window
production routes over, quiesces active placement before invoking the dormant
collision-retirement entry, and binds portal
authority to `RuntimeWorldTransitState`. AD-2 remains the deliberate async
readiness/requeue adaptation. See
`docs/research/2026-07-31-canonical-set-position.md`.
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| AD-53 | `Transition.CliffSlide`'s reference-normal cross-product operand tries THREE sources in priority order (`LastWalkablePlane` if `Normal.Z >= FloorZ`, then `LastKnownContactPlane` at the same threshold, then world-up `UnitZ`); retail's `CTransition::cliff_slide` uses `this->collision_info.last_known_contact_plane.N` directly, with no fallback chain | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`, the `referenceNormal`/`refSource` selection above the cross-product) | Filed 2026-07-30 splitting TS-1's retirement (Campaign P Slice P2). A fresh read of `last_known_contact_plane`'s own maintenance (pc:272659-272668) confirms retail overwrites it unconditionally from `contact_plane` every `validate_transition` pass — including with a steep plane — so retail keeps NO separately-preserved flat-ground history there either; this fallback chain is a genuine acdream invention, not a retail-matching read. Kept because it compensates for AP-4's incomplete `OnWalkable` bookkeeping (L.4-cliffslide-fallback, 2026-04-30): without it, `cross(currentSteep, lastKnownSteep)` degenerates to zero after >1 frame on a continuous steep slope, and CliffSlide returns `OK` (no deflection) instead of downhill drift — the "stay on the roof" wedge the L.4 session fought | If AP-4's `OnWalkable` reordering is ever completed/removed, `last_known_contact_plane` should carry the same information retail's does and this fallback chain becomes unneeded ballast (or, worse, silently picks a stale `LastWalkablePlane` over the now-correct current one) — re-audit together with AP-4 | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2 gap #2 | | ~~AD-53~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `Transition.CliffSlide` now consumes only `collision_info.last_known_contact_plane.N`, exactly as retail does. The invented `LastWalkablePlane -> LastKnownContactPlane -> UnitZ` fallback chain is gone; invalid/default or parallel data takes retail's degenerate `OK_TS` return. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07) |
| AD-54 | `Transition.EdgeSlideAfterStepDownFailed` reroutes to `CliffSlide` instead of `PrecipiceSlide` when the stored walkable polygon itself is steeper than `FloorZ` (`sp.WalkablePlane.Normal.Z < PhysicsGlobals.FloorZ`); retail's raw `SPHEREPATH::edge_slide` has no steepness branch here — `if (walkable != null) { ... precipice_slide(...) }` unconditionally | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`, the `L.4-walkable-steep` block) | Filed 2026-07-30 splitting TS-1's retirement (Campaign P Slice P2). The permissive `LandingZ` walkable-acceptance threshold that lets a steep roof become "walkable" in the first place IS confirmed retail-faithful (`BSPTREE::find_collisions` unconditional `walkable_allowance = LandingZ`, pc:323740-323783, TS-4's own citation) — so a steep-roof walkable polygon is a real state retail also reaches. What is NOT independently verified is whether retail's outer `transitional_insert` caller absorbs a same-polygon-standing `COLLIDED_TS` from `precipice_slide` (its raw `find_crossed_edge` returning false while standing on, not crossing, the polygon) some other way that avoids the acdream "stuck in a Collided revert loop" this reroute prevents | If retail's outer retry loop turns out to already handle the no-crossed-edge-while-standing-on-a-steep-poly case without a reroute, this branch is an unnecessary compensating layer that could route a genuinely PrecipiceSlide-bound case (a shallow polygon edge that happens to sit at exactly `FloorZ`) into CliffSlide instead | `SPHEREPATH::edge_slide` pc:273001-273090 (0050b3d0, direct walkable branch quoted at pc:364-370 in the P2 research doc); `BSPTREE::find_collisions` pc:323740-323783 (0053a730, unconditional `LandingZ`); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2 gap #3 | | ~~AD-54~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** Every stored walkable polygon now routes unconditionally to `PrecipiceSlide`, including a plane steeper than `FloorZ`; the invented steep-walkable reroute to `CliffSlide` is gone. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` pc:273001-273090 (0050b3d0) |
| AD-55 | `calc_friction`'s Sledding slope-flatness test compares `GroundNormal.Z > 0.99999536f` (≈0.175° from flat); the raw retail decomp literally computes `__fcos(0.17453292519943295)` (= cos(10°) ≈ 0.984808) and compares that against `contact_plane.N.z` — physically very different tests (0.175° accepts only essentially-perfectly-flat ground; 10° accepts any modest slope) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`, the Sledding near-flat branch) | Filed 2026-07-30 splitting AP-7's retirement (Campaign P Slice P2). Two hypotheses, neither confirmed this pass: (a) BN misdecompiled a raw float-constant load as an `__fcos()` call (a known BN artifact class), or (b) ACE's own port made an independent error and cos(10°) is correct. `0.99999536f` is kept provisionally — least churn, since it is what acdream's own prior (structurally unreachable) dead code already had — pending a live Ghidra decompile of `0050ee70` checking whether the FCOS opcode is real or a raw `FLD` of one of these two constants | Currently harmless in production: nothing sets `PhysicsState.Sledding` client-side (see #166 research), so this branch is unreachable either way. The moment a data-authored Sledding toggle exists, the wrong constant changes which slopes get the light 0.2f sled-friction override vs. the heavier default | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70), the `__fcos(0.17453292519943295)` slope-flatness comparison; ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141 (`0.99999536f`); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1, §7 item 3 | | AD-55 | `calc_friction`'s Sledding slope-flatness test compares `GroundNormal.Z > 0.99999536f` (≈0.175° from flat); the raw retail decomp literally computes `__fcos(0.17453292519943295)` (= cos(10°) ≈ 0.984808) and compares that against `contact_plane.N.z` — physically very different tests (0.175° accepts only essentially-perfectly-flat ground; 10° accepts any modest slope) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`, the Sledding near-flat branch) | Filed 2026-07-30 splitting AP-7's retirement (Campaign P Slice P2). Two hypotheses, neither confirmed this pass: (a) BN misdecompiled a raw float-constant load as an `__fcos()` call (a known BN artifact class), or (b) ACE's own port made an independent error and cos(10°) is correct. `0.99999536f` is kept provisionally — least churn, since it is what acdream's own prior (structurally unreachable) dead code already had — pending a live Ghidra decompile of `0050ee70` checking whether the FCOS opcode is real or a raw `FLD` of one of these two constants | Currently harmless in production: nothing sets `PhysicsState.Sledding` client-side (see #166 research), so this branch is unreachable either way. The moment a data-authored Sledding toggle exists, the wrong constant changes which slopes get the light 0.2f sled-friction override vs. the heavier default | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70), the `__fcos(0.17453292519943295)` slope-flatness comparison; ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141 (`0.99999536f`); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1, §7 item 3 |
| AD-46 | **LIVE. Reframed at Campaign V slice V11 (2026-07-29), when GL was deleted and the comparison that discovered this row ceased to exist.** Dense alpha-blended distant scenery (the treeline) may read slightly denser than retail's, because the anisotropic TAP PATTERN is implementation-defined and acdream's Vulkan driver does not tap identically to retail's D3D9 one. Both request the same sampler state — trilinear, clamp-and-repeat, the device's maximum anisotropy. **What changed at V11 is only the left-hand side of the comparison**: this was measured GL-vs-Vulkan (~15% of the pixels in the band), and it is now a Vulkan-vs-retail question against the D3D oracle in the last column. The measurement below is retained as the evidence that the residual is a tap pattern and not a bug, even though one of its two arms no longer exists. | `src/AcDream.App/Rendering/Wb/WorldTextureArray.cs` (`RhiWorldTextureArray.WorldArrayAnisotropy`); measured in plan §5.5.19, reframed §5.5.24 | Not assumed — narrowed by measurement while both backends still existed, on an offline capture with no session, no entities and both clocks pinned. Anisotropy 1 → 41,509 differing pixels in the tree band; anisotropy 16 (GL's value, and retail's `m_D3DCaps.MaxAnisotropy`) → 22,266, and the rest of the frame fell to 497 px of 563,200, i.e. 8.8e-04, inside the campaign's 0.001 threshold. The residual was not a sub-pixel shift (an integer shift search found none), not a sharpness change (high-frequency energy matched within 5%), and not depth precision (forcing Vulkan's window-depth range to GL's compressed [0.5, 1] moved it by 3%). Monotone improvement toward GL's own anisotropy with no knob left is what made it a driver property rather than a bug. | Distant foliage shimmers or reads denser than retail's. The class is confined to alpha-blended dense overlap: opaque terrain, roofs, walls, water, statics, the character and the whole retained UI are inside threshold. **Now unfalsifiable by self-differential** — with GL gone, the only way to retire this row is a side-by-side against the retail client, not against another acdream backend. | `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`, whose `SetSamplerState(stage, 0xA /* D3DSAMP_MAXANISOTROPY */, m_D3DCaps.MaxAnisotropy)` at `0x005a4230` is the value acdream requests | | AD-46 | **LIVE. Reframed at Campaign V slice V11 (2026-07-29), when GL was deleted and the comparison that discovered this row ceased to exist.** Dense alpha-blended distant scenery (the treeline) may read slightly denser than retail's, because the anisotropic TAP PATTERN is implementation-defined and acdream's Vulkan driver does not tap identically to retail's D3D9 one. Both request the same sampler state — trilinear, clamp-and-repeat, the device's maximum anisotropy. **What changed at V11 is only the left-hand side of the comparison**: this was measured GL-vs-Vulkan (~15% of the pixels in the band), and it is now a Vulkan-vs-retail question against the D3D oracle in the last column. The measurement below is retained as the evidence that the residual is a tap pattern and not a bug, even though one of its two arms no longer exists. | `src/AcDream.App/Rendering/Wb/WorldTextureArray.cs` (`RhiWorldTextureArray.WorldArrayAnisotropy`); measured in plan §5.5.19, reframed §5.5.24 | Not assumed — narrowed by measurement while both backends still existed, on an offline capture with no session, no entities and both clocks pinned. Anisotropy 1 → 41,509 differing pixels in the tree band; anisotropy 16 (GL's value, and retail's `m_D3DCaps.MaxAnisotropy`) → 22,266, and the rest of the frame fell to 497 px of 563,200, i.e. 8.8e-04, inside the campaign's 0.001 threshold. The residual was not a sub-pixel shift (an integer shift search found none), not a sharpness change (high-frequency energy matched within 5%), and not depth precision (forcing Vulkan's window-depth range to GL's compressed [0.5, 1] moved it by 3%). Monotone improvement toward GL's own anisotropy with no knob left is what made it a driver property rather than a bug. | Distant foliage shimmers or reads denser than retail's. The class is confined to alpha-blended dense overlap: opaque terrain, roofs, walls, water, statics, the character and the whole retained UI are inside threshold. **Now unfalsifiable by self-differential** — with GL gone, the only way to retire this row is a side-by-side against the retail client, not against another acdream backend. | `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`, whose `SetSamplerState(stage, 0xA /* D3DSAMP_MAXANISOTROPY */, m_D3DCaps.MaxAnisotropy)` at `0x005a4230` is the value acdream requests |
| AD-47 | **Filed at Campaign V slice V11 (2026-07-29); the campaign's risk register scheduled this row here.** Multisample resolve sample POSITIONS are unspecified by both the Vulkan and D3D9 specifications, so acdream's MSAA-on silhouette edges do not match retail's pixel-for-pixel even at the same sample count. acdream's strict pixel gates therefore run with MSAA forced OFF on every arm, and MSAA-on gets only a relaxed visual smoke. | `src/AcDream.App/RuntimeOptions.cs` (`ACDREAM_MSAA_SAMPLES`); forced to 0 in `tools/run-offline-pixel-gate.ps1` | Measured, not assumed: plan §5.5.16 compared two backends at 4x and found **8.83% of the frame differing — 81,359 px of 921,600 — essentially all of it hugging foliage and silhouette edges**, which is ninety-fold over the 0.001 gate threshold. That is two implementations' sample patterns, not a renderer divergence, which is why forcing MSAA off is what makes the remaining difference attributable rather than a threshold relaxation. | Edge quality on thin geometry (fence rails, foliage, distant railings) differs from retail at the sub-pixel level whenever MSAA is on, which is the ordinary player configuration. Because the gates run MSAA off, **a real regression confined to the multisample path would not be caught by them** — that is the actual exposure this row records. | D3D9 `D3DRS_MULTISAMPLEANTIALIAS` / `D3DMULTISAMPLE_TYPE` as set by `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`; retail's sample pattern is the driver's, exactly as ours is | | AD-47 | **Filed at Campaign V slice V11 (2026-07-29); the campaign's risk register scheduled this row here.** Multisample resolve sample POSITIONS are unspecified by both the Vulkan and D3D9 specifications, so acdream's MSAA-on silhouette edges do not match retail's pixel-for-pixel even at the same sample count. acdream's strict pixel gates therefore run with MSAA forced OFF on every arm, and MSAA-on gets only a relaxed visual smoke. | `src/AcDream.App/RuntimeOptions.cs` (`ACDREAM_MSAA_SAMPLES`); forced to 0 in `tools/run-offline-pixel-gate.ps1` | Measured, not assumed: plan §5.5.16 compared two backends at 4x and found **8.83% of the frame differing — 81,359 px of 921,600 — essentially all of it hugging foliage and silhouette edges**, which is ninety-fold over the 0.001 gate threshold. That is two implementations' sample patterns, not a renderer divergence, which is why forcing MSAA off is what makes the remaining difference attributable rather than a threshold relaxation. | Edge quality on thin geometry (fence rails, foliage, distant railings) differs from retail at the sub-pixel level whenever MSAA is on, which is the ordinary player configuration. Because the gates run MSAA off, **a real regression confined to the multisample path would not be caught by them** — that is the actual exposure this row records. | D3D9 `D3DRS_MULTISAMPLEANTIALIAS` / `D3DMULTISAMPLE_TYPE` as set by `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`; retail's sample pattern is the driver's, exactly as ours is |
@ -76,12 +115,10 @@ accepted-divergence entries (#96, #49, #50).
| AD-51 | **Filed at Campaign N slice N4 (2026-07-29).** The inbound sequence tracker keeps a reclaimed-word pool (per-parked-word draw ordinals + `PriorityQueue` consumed lowest-draw-order-first) that retail has no counterpart for: on a VALIDATED cleartext `RejectRetransmit`, the word the gap walk parked for the reject packet's OWN sequence is removed, every later-drawn parked word is shifted down one position, and the excess word feeds the next fresh draws. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`OnCleartextRejectSequence`, `NextWord`, `ParkedWord`); trigger at `src/AcDream.Core.Net/WorldSession.cs` (RejectRetransmit consumption) | Retail's inbound invariant is "every missing id was an encrypted packet whose keystream word the server drew" — true against retail servers, whose cleartext packets always borrow live sequences (acks/NAKs reuse `highestIDSent_`; `FlowQueue::TransmitNewPackets @ 0x00547A60` sequences only reliable packets). ACE breaks it in exactly one place: `RejectRetransmit` takes a FRESH sequence through FlushPackets, cleartext, drawing NO S2C keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748). Without the reclaim, our gap walk pre-draws a word for that id, the inbound stream runs permanently one word ahead, and every later encrypted packet fails checksum — the N2 desync class reintroduced through the reject path. The pool is provably empty against a retail server, so retail behavior is untouched. Reject BODY ids keep the N2 discard (their words were drawn on both sides — consumed-in-place). Known unreachable corner: a reject whose own id later appears inside another reject's body (first reject pruned after 120 s of sustained loss with the session alive) would discard a never-drawn word; probabilistically impossible against ACE's 60 s silence timeout and the 0.6 s NAK cadence. | Against a hypothetical non-ACE server that assigns fresh cleartext sequences to packets OTHER than RejectRetransmit, those ids would still mis-park with no reclaim trigger — inbound desync. Only ACE-family servers exist for this client today, and ACE has exactly the one path. | `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the gap walk whose invariant ACE breaks); `SharedNet::HandleEmptyAck @ 0x005448F0` (retail's reject consumption — body ids only, no own-sequence machinery because retail never needs it) | | AD-51 | **Filed at Campaign N slice N4 (2026-07-29).** The inbound sequence tracker keeps a reclaimed-word pool (per-parked-word draw ordinals + `PriorityQueue` consumed lowest-draw-order-first) that retail has no counterpart for: on a VALIDATED cleartext `RejectRetransmit`, the word the gap walk parked for the reject packet's OWN sequence is removed, every later-drawn parked word is shifted down one position, and the excess word feeds the next fresh draws. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`OnCleartextRejectSequence`, `NextWord`, `ParkedWord`); trigger at `src/AcDream.Core.Net/WorldSession.cs` (RejectRetransmit consumption) | Retail's inbound invariant is "every missing id was an encrypted packet whose keystream word the server drew" — true against retail servers, whose cleartext packets always borrow live sequences (acks/NAKs reuse `highestIDSent_`; `FlowQueue::TransmitNewPackets @ 0x00547A60` sequences only reliable packets). ACE breaks it in exactly one place: `RejectRetransmit` takes a FRESH sequence through FlushPackets, cleartext, drawing NO S2C keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748). Without the reclaim, our gap walk pre-draws a word for that id, the inbound stream runs permanently one word ahead, and every later encrypted packet fails checksum — the N2 desync class reintroduced through the reject path. The pool is provably empty against a retail server, so retail behavior is untouched. Reject BODY ids keep the N2 discard (their words were drawn on both sides — consumed-in-place). Known unreachable corner: a reject whose own id later appears inside another reject's body (first reject pruned after 120 s of sustained loss with the session alive) would discard a never-drawn word; probabilistically impossible against ACE's 60 s silence timeout and the 0.6 s NAK cadence. | Against a hypothetical non-ACE server that assigns fresh cleartext sequences to packets OTHER than RejectRetransmit, those ids would still mis-park with no reclaim trigger — inbound desync. Only ACE-family servers exist for this client today, and ACE has exactly the one path. | `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the gap walk whose invariant ACE breaks); `SharedNet::HandleEmptyAck @ 0x005448F0` (retail's reject consumption — body ids only, no own-sequence machinery because retail never needs it) |
| AD-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks | | AD-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks |
| AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` | | AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` |
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | | AD-1 | **NARROWED 2026-07-31 (placement Slice 4B2 checkpoint 2).** Runtime now owns exact lost-cell residence, adjusted frame retention, 25-second root/direct-child lifetime, generation-scoped wake, revisioned Withdraw/Place receipts, one public generation-gated observe/retry/exact-ack seam, and the retail collision-table/report-result state needed by SetPosition. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production authoritative placement still routes through the legacy recoverable outdoor demote and outdoor-restore `max(terrainZ, z)` lift until the remaining authored-mover, rebucketing, prefix-quiescence, body-publication, and route-cutover prerequisites land atomically. | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; legacy route in `src/AcDream.Core/Physics/PhysicsEngine.cs` | The canonical owners remain dormant and separately gated, so this ownership checkpoint cannot partially change the accepted production world. | Until 4B2, a production gap can still commit an outdoor approximation inside/under a building or lift a legitimate below-heightmap restore instead of entering the now-available Runtime lost-cell owner. | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945; `CPhysicsObj::handle_all_collisions` 0x00514780 |
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | | AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects``recalc_cross_cells`, 0x0052b420 / 0x00515a30 | | ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build one shared off-side `CollisionWorldState` through one-work-unit preparation/capture/seal cursors. Admission captures the active root in O(1); a stable landblock/owner slot suffix materializes non-target leaves incrementally, so resident-world size cannot become a synchronous clone spike. Reusable per-prefix owner slots and one Runtime-scoped versioned journal replace event-time exact-copy fanout: repeated live mutations coalesce by owner, every draft reconciles only that owner's latest exact state one owner per seal call, discovered relevant owners receive scoped exact updates, and visited unrelated owners receive only a cheap coalesced dirty notification before metered replay. Once topology sealing finishes, observed owners temporarily write through exactly until same-call activation; the finite pre-seal queue therefore drains even under continuous multi-owner movement. New drafts start at their captured journal suffix; old slots are superseded rather than reused behind live cursors and compact through the same meter. Unrelated churn therefore never restarts or starves target capture/sealing. Deterministically ordered concurrent preparations receive committed—not merely sealed—peer deltas and rebase one cache, graph, landblock, or owner leaf per seal step; cancellation therefore cannot leak unpublished topology. Demotion/withdrawal cancels a matching queued or active rebase, suppresses the prefix in unfinished source scans, and retires one owner/cache/graph/outdoor leaf per seal call. The complete previous generation remains queryable until one zero-managed-byte volatile root transfer in the same update-thread call as final reconciliation; that preserves PhysicsDataCache, CellGraph, PhysicsEngine, and ShadowObjectRegistry facade identity, revokes staging, and requires no quiet frame. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. Authored same-ID target statics, live-current-cell changes, owner departure/reuse, newly relevant seam-crossing statics, and teardown remain coherent across drafts; empty per-prefix owner containers are reclaimed without invalidating captured seal cursors. The commit clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationPreparation`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/CollisionWorldState.cs`; `PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects``CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 |
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |
| ~~AD-11~~ | **RETIRED 2026-07-23** — the matching binary disproved the old nonzero interpretation: `ItemUses::IsUseable` executes `not bitfield; and eax,1`, so absent/reset zero is usable and only `USEABLE_NO` disables use. Toolbar, item policy, and world interaction now share that exact Core predicate. | `src/AcDream.Core/Items/ClientObject.cs` (`ItemUseability.IsUseable`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/Interaction/WorldSelectionQuery.cs` | — | — | `ItemUses::IsUseable @ 0x004FCCC0`; matching v11.4186 instructions recorded in `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` | | ~~AD-11~~ | **RETIRED 2026-07-23** — the matching binary disproved the old nonzero interpretation: `ItemUses::IsUseable` executes `not bitfield; and eax,1`, so absent/reset zero is usable and only `USEABLE_NO` disables use. Toolbar, item policy, and world interaction now share that exact Core predicate. | `src/AcDream.Core/Items/ClientObject.cs` (`ItemUseability.IsUseable`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/Interaction/WorldSelectionQuery.cs` | — | — | `ItemUses::IsUseable @ 0x004FCCC0`; matching v11.4186 instructions recorded in `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` |
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD | | AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |
@ -109,16 +146,19 @@ accepted-divergence entries (#96, #49, #50).
| AD-39 | The `frames_stationary_fall` ladder + fsf≥3 UP-contact-plane manufacture runs AFTER acdream's fused LKCP-restore/contact-marking block, deriving retail's `_redo` as `cleanAdvance \|\| OnWalkable`; retail (ACE Transition.cs:1029-1061) interleaves the fsf block BETWEEN the LKCP-restore (sets `_redo`) and the contact-marking (reads the manufactured plane) (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` fsf tail) | acdream deliberately fused ACE's separate LKCP-restore + contact-mark blocks (the L.2.3c/L.2.4/A6.P3 contact-retention divergences); running the ladder after them and re-marking grounding inside the manufacture branch is semantically equal (a grounded wall-slide is not a stuck-fall in either arrangement) without disturbing those hard-won fixes | If a future contact-retention change alters when OnWalkable is set relative to the ladder, `_redo` could misclassify a frame (grounded-jam mistaken for stuck-fall → spurious velocity zero, or vice-versa) — the fsf conformance tests pin the current arrangement | `CTransition::validate_transition` 0x0050aa70 pc:272625-656; ACE Transition.cs:1029-1061 | | AD-39 | The `frames_stationary_fall` ladder + fsf≥3 UP-contact-plane manufacture runs AFTER acdream's fused LKCP-restore/contact-marking block, deriving retail's `_redo` as `cleanAdvance \|\| OnWalkable`; retail (ACE Transition.cs:1029-1061) interleaves the fsf block BETWEEN the LKCP-restore (sets `_redo`) and the contact-marking (reads the manufactured plane) (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` fsf tail) | acdream deliberately fused ACE's separate LKCP-restore + contact-mark blocks (the L.2.3c/L.2.4/A6.P3 contact-retention divergences); running the ladder after them and re-marking grounding inside the manufacture branch is semantically equal (a grounded wall-slide is not a stuck-fall in either arrangement) without disturbing those hard-won fixes | If a future contact-retention change alters when OnWalkable is set relative to the ladder, `_redo` could misclassify a frame (grounded-jam mistaken for stuck-fall → spurious velocity zero, or vice-versa) — the fsf conformance tests pin the current arrangement | `CTransition::validate_transition` 0x0050aa70 pc:272625-656; ACE Transition.cs:1029-1061 |
| AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) | | AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) |
| AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses the WHOLE SetPositionInternal-shaped commit (contact/walkable flags, HitGround/LeaveGround, `handle_all_collisions`, `cached_velocity`) on a no-move frame — narrowed 2026-07-30 (#265 bounce rework) from "only handle_all_collisions"; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) | | AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses the WHOLE SetPositionInternal-shaped commit (contact/walkable flags, HitGround/LeaveGround, `handle_all_collisions`, `cached_velocity`) on a no-move frame — narrowed 2026-07-30 (#265 bounce rework) from "only handle_all_collisions"; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) |
| AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 | | AD-42 | **Refreshed 2026-08-02 (C3c review round 1).** The two-call enter-world placement split (legacy `Resolve` = retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` = the verbatim object-aware `find_placement_pos` ring search) survives ONLY on the unflipped portal-arrival paths: the remote-teleport controller and the headless portal-arrival resync. The LOCAL login first-entry no longer uses it — the C3c flip routes it through the single canonical Runtime SetPosition transaction (the faithful placement family), retiring the row's original `GameWindow.EnterPlayerModeNow` citation. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Physics/RemoteTeleportController.cs` (`ResolvePlacement`); `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs` (`ResynchronizeLocalPlayerForPortalArrival`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. The surviving split paths are C4's portal-route flip scope | A teleport arrival that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 |
| AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 | | AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 |
| AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | | AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` |
| AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` | | AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` |
| AD-57 | **Re-argued from TS-24 at Campaign P P7 (2026-07-30).** Outbound `RawMotionState.Actions` is always empty at runtime. The packer emits `num_actions` + per-action pairs (L.2b, `RawMotionState::Pack` 0x0051ed10) and the R3-W1 action FIFO capability exists (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`); no production input path ENQUEUES autonomous actions yet because the emote/autonomous-motion feature surface is unimplemented. An empty list is byte-identical to retail's own no-pending-actions state, so this is a feature gap, not a divergence of existing behavior. | packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`; FIFO `src/AcDream.Core/Physics/RawMotionState.cs` | Every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented. | When emotes land, forgetting to route them through the FIFO would silently drop them from the wire. | `RawMotionState::Pack` 0x0051ed10 | | AD-57 | **Re-argued from TS-24 at Campaign P P7 (2026-07-30).** Outbound `RawMotionState.Actions` is always empty at runtime. The packer emits `num_actions` + per-action pairs (L.2b, `RawMotionState::Pack` 0x0051ed10) and the R3-W1 action FIFO capability exists (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`); no production input path ENQUEUES autonomous actions yet because the emote/autonomous-motion feature surface is unimplemented. An empty list is byte-identical to retail's own no-pending-actions state, so this is a feature gap, not a divergence of existing behavior. | packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`; FIFO `src/AcDream.Core/Physics/RawMotionState.cs` | Every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented. | When emotes land, forgetting to route them through the FIFO would silently drop them from the wire. | `RawMotionState::Pack` 0x0051ed10 |
| AD-58 | **Re-argued from TS-40 at Campaign P P7 (2026-07-30).** Retail's `physics_obj->cell` null test ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` and `RemoteMotion` construction, consumed by `CMotionInterp`'s detached-object link-strip guards. Equivalence: every acdream body that would have a null retail cell pointer has `InWorld == false` (bodies exist only for world entities; the flag flips exactly at placement/withdrawal), so the guards fire on the same population. A structural adaptation of retail's pointer-as-state idiom to acdream's explicit-flag idiom, not scheduled debt. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | If a future path creates a body before world placement without clearing `InWorld`, the link-strip guards misfire where retail's null-cell test would not. | `CMotionInterp` link-strip guards raw @305xxx | | AD-58 | **Re-argued from TS-40 at Campaign P P7 (2026-07-30).** Retail's `physics_obj->cell` null test ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` and `RemoteMotion` construction, consumed by `CMotionInterp`'s detached-object link-strip guards. Equivalence: every acdream body that would have a null retail cell pointer has `InWorld == false` (bodies exist only for world entities; the flag flips exactly at placement/withdrawal), so the guards fire on the same population. A structural adaptation of retail's pointer-as-state idiom to acdream's explicit-flag idiom, not scheduled debt. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | If a future path creates a body before world placement without clearing `InWorld`, the link-strip guards misfire where retail's null-cell test would not. | `CMotionInterp` link-strip guards raw @305xxx |
| AD-59 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The `SameIncarnationCreate` envelope buffers one publish per committed stage and flushes them ALL, in stage order, only after the LAST stage commits (constant-true per-field predicate, `IsCurrent`-checked at flush - the per-field closure variant was invalidated by WeenieDescription's six-field `AdvanceCreateAuthority`). A subscriber sees N back-to-back events with no interleaved observation point, each carrying the FINAL merged post-envelope record state, not per-stage state. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyEnvelope` buffered-publish tail; `Publish`/`PublishNow`) | Retail's own tail is one synchronous critical section, and retail emits ONE notice per Create (`ECM_Physics::SendNotice_CreateObject`, fired whenever a weenie exists, independent of the physics-registration outcome) - never N per-internal-step notices. The buffered flush is closer to retail's one-signal model than per-step publication would be, though not a literal 1:1 match. | A subscriber diffing consecutive `Updated` events from the SAME envelope to isolate one stage's delta gets every stage's cumulative state on each event - silently wrong incremental-diff logic, not a crash. | `SmartBox::HandleCreateObject` 0x00454C80 same-incarnation tail (one synchronous critical section); `ACCObjectMaint::CreateObject` 0x00558870 step 11 (`ECM_Physics::SendNotice_CreateObject`) |
| AD-60 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** Executor Position-continuation merges never directly commit residency: `ApplyPositionAction` refreshes `canonical.Snapshot.Position` with the retained wire pose but withholds the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`); only a Runtime `SetPosition` commit (the continuation's own classified placement) or a later simulation full-cell commit may change residency. The LEGACY immediate-apply path's `RefreshSnapshot(canonical, snapshot, refreshPosition: acceptedPosition)` (`RuntimeEntityObjectLifetime.cs:1338`) still derives `FullCellId` from bare wire acceptance - that coarser rule is part of the AP-1 divergence this campaign is removing, not something this row blesses. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment) | Matches retail exactly: `HandleReceivedPosition` never writes a resident cell - `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. | If a future change passes `refreshPosition: true` here, a wire Position would make a cellless canonical body resident without any placement/collision commit - the classic AP-1-shaped bug this campaign exists to close. | `SmartBox::HandleReceivedPosition` 0x00453FD0; `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` comment |
| AD-61 | **Filed 2026-08-02 (C3c review round 1).** The #270 settle-timing compression now covers the LOCAL player: `RuntimeLocalPlayerPhysicsPublicationState.SettleFirstEntryGroundContact` runs the shared `SpawnPlacementSettler` exactly once after the dormant activation's final commit (suffix-current authority only), compressing retail's first post-`enter_world` gravity frame — which grants CONTACT/ON_WALKABLE from a real touch — into the placement transaction. The legacy App-era force-seed (`Contact\|OnWalkable\|Active` in `PlayerMovementController.SetPositionCore`) still RUNS during publication-candidate preparation and is then OVERWRITTEN by the faithful activation commit + settle (it was never deleted). Caveat (review minor M2): the settler commits `settle.Position` but discards `settle.CellId` — a settle whose few-cm sweep crosses a cell boundary keeps the placement cell until the next resolve corrects it (inherited #270 semantics; ISSUES entry filed) | `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs` (`SettleFirstEntryGroundContact`); `src/AcDream.Core/Physics/SpawnPlacementSettler.cs` (`TrySettle`); overwritten seed `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`SetPositionCore`) | Timing compression only: contact comes exclusively from the sweep's real touch (no caller-bool seeding, no forced transients), an airborne spawn stays genuinely airborne, and the overwritten force-seed leaves no observable residue past the activation commit — the committed state is exactly what retail's first gravity frame produces | A settle crossing a cell boundary reports the stale placement cell for the frames before the next resolve; a future reader trusting `SetPositionCore`'s "treat as grounded" seed comment could reintroduce the Contact-without-plane state the landing family calls unrepresentable | `CPhysicsObj::enter_world` 0x00516170; `SmartBox::HandleCreateObject` 0x00454C80 |
--- ---
## 3. Documented approximation (AP) — 91 active rows (AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-127 filed same slice for the two minor unmodeled bonus properties; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) ## 3. Documented approximation (AP) — 90 active rows (AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@ -126,11 +166,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal``find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position | | AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B2 checkpoint 2).** Core exposes the pure retail `SetPosition` transaction; Runtime owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, revisioned host receipts, and exact-key retail collision table/environment-latch/report-result state; and one public generation-gated channel exposes observe/retry/exact-head acknowledgement without another placement queue. Collision starts, expiry/force ends, static and `ReportAsEnvironment` routing, reciprocal eligibility, missile-state clearing, callback ordering, and failed-placement `Collided` versus `NoValidPosition` classification now share one presentation-free owner. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies exact authored mover preparation, presentation-only rebucketing, placement-prefix quiescence, and the atomic graphical/headless route cutover. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs`; `docs/research/2026-07-31-canonical-set-position.md`; `docs/research/2026-07-31-runtime-set-position-collision-reporting.md` | The mechanism, ownership, report-result oracle, and host seam land independently without partially changing production placement behavior. | Until 4B2, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation despite the canonical owners now existing. | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 |
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 | | ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191273307 |
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) | | ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001273090 |
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 | | ~~AP-5~~ | **RETIRED 2026-07-31 (Campaign P Slice 2A).** `DoStepDown` no longer accepts a caller-controlled `runPlacement` bypass. It resets `walk_interp` once at entry; after the transitional support probe and retail `check_walkables` gate succeed, ordinary contact maintenance, edge-slide back-probes, and StepUp all switch to `PLACEMENT_INSERT` with the exact carried interpolation value, run the final insertion, restore the prior insert type, and accept only `OK_TS`. Nested `DoCheckWalkable` uses local current-position saves and preserves the outer `SPHEREPATH` backup pair needed by edge-slide. The former wall-slide justification is addressed at the actual placement dispatcher boundary: its retail epsilon-shaved overlap test permits exact wall tangency but rejects real penetration; no StepDown path skips validation. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`DoStepDown`, `DoCheckWalkable`); `src/AcDream.Core/Physics/BSPQuery.cs` / `FlatBspQuery.cs` (Placement dispatcher); `tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs`; `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::check_walkable` 0x0050AFF0 pc:272811272856; `CTransition::step_down` 0x0050B2A0 pc:272946272998; `BSPTREE::find_collisions` Placement branch 0x0053A440 pc:323742; `CSphere::intersects_sphere` 0x00537A80; `CCylSphere::intersects_sphere` 0x0053B440 |
| ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. **CORRECTION (2026-07-30, same day, #265/#166 capture bisect):** the sentence above undersold the gap — `calc_friction` wasn't merely "no horizontal velocity to hammer," it was structurally UNREACHABLE with meaningful data on ANY grounded path: (a) the animation-root-motion path zeroed `Velocity.X/Y` outright every tick (the actual #265/#166 root cause, ten days pre-existing, not a Campaign-P regression), and (b) `PhysicsBody.GroundNormal` — the vector `calc_friction` dots velocity against — had ZERO production writers anywhere and silently defaulted to `Vector3.UnitZ` forever, so even surviving velocity would have been tested against a fake flat-ground normal on any real slope. Both gaps are now closed: `PlayerMovementController.cs`'s grounded block no longer reconstructs `Velocity` for the animation-root-motion case, and `PhysicsEngine.cs` syncs `body.GroundNormal` from the committed `ContactPlane.Normal` at the same commit point that already publishes `ContactPlane`. The 0.25f threshold port itself (this row's original subject) was always correct — it just had nothing real to operate on until this fix. See `docs/research/2026-07-30-265-capture-bisect.md`'s as-fixed addendum. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`GroundNormal` wiring); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (grounded-velocity fix); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block); `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`; `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1; `docs/research/2026-07-30-265-capture-bisect.md` | | ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. **CORRECTION (2026-07-30, same day, #265/#166 capture bisect):** the sentence above undersold the gap — `calc_friction` wasn't merely "no horizontal velocity to hammer," it was structurally UNREACHABLE with meaningful data on ANY grounded path: (a) the animation-root-motion path zeroed `Velocity.X/Y` outright every tick (the actual #265/#166 root cause, ten days pre-existing, not a Campaign-P regression), and (b) `PhysicsBody.GroundNormal` — the vector `calc_friction` dots velocity against — had ZERO production writers anywhere and silently defaulted to `Vector3.UnitZ` forever, so even surviving velocity would have been tested against a fake flat-ground normal on any real slope. Both gaps are now closed: `PlayerMovementController.cs`'s grounded block no longer reconstructs `Velocity` for the animation-root-motion case, and `PhysicsEngine.cs` syncs `body.GroundNormal` from the committed `ContactPlane.Normal` at the same commit point that already publishes `ContactPlane`. The 0.25f threshold port itself (this row's original subject) was always correct — it just had nothing real to operate on until this fix. See `docs/research/2026-07-30-265-capture-bisect.md`'s as-fixed addendum. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`GroundNormal` wiring); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (grounded-velocity fix); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block); `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`; `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1; `docs/research/2026-07-30-265-capture-bisect.md` |
| ~~AP-10~~ | **RETIRED 2026-07-30 (Campaign P Slice P4) — the retail 0.1 m dry-corner water sink-in is restored.** `TerrainSurface.SampleWaterDepth` (`src/AcDream.Core/Physics/TerrainSurface.cs`) now returns 0.1 for a partially-water cell's dry corner instead of the collapsed 0. The row's own "destabilizes the touch check" justification turned out to be structurally true of retail too (a skipped `SetContactPlane` reassertion is not a fall in ANY of retail/ACE/acdream, because `Contact`/`OnWalkable` are STICKY — `PhysicsEngine.ResolveWithTransition`'s `onGround` computation ORs the fresh per-call `ContactPlaneValid` with the seeded, persistent `PhysicsBody.TransientState.OnWalkable` bit) — traced and confirmed in this slice; see `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.2. `PhysicsEngine.SampleTerrainWalkable`'s `isWater = waterDepth >= 0.45f` threshold means the restore does not flip the dry corner's water classification (0.1 still < 0.45) only the sink-in depth changes. Full Release suite green (no regression) proves the sticky-bit argument held in practice, not just in theory. | `src/AcDream.Core/Physics/TerrainSurface.cs` (`SampleWaterDepth`) | | | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port); `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.1-5.2 | | ~~AP-10~~ | **RETIRED 2026-07-30 (Campaign P Slice P4) — the retail 0.1 m dry-corner water sink-in is restored.** `TerrainSurface.SampleWaterDepth` (`src/AcDream.Core/Physics/TerrainSurface.cs`) now returns 0.1 for a partially-water cell's dry corner instead of the collapsed 0. The row's own "destabilizes the touch check" justification turned out to be structurally true of retail too (a skipped `SetContactPlane` reassertion is not a fall in ANY of retail/ACE/acdream, because `Contact`/`OnWalkable` are STICKY — `PhysicsEngine.ResolveWithTransition`'s `onGround` computation ORs the fresh per-call `ContactPlaneValid` with the seeded, persistent `PhysicsBody.TransientState.OnWalkable` bit) — traced and confirmed in this slice; see `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.2. `PhysicsEngine.SampleTerrainWalkable`'s `isWater = waterDepth >= 0.45f` threshold means the restore does not flip the dry corner's water classification (0.1 still < 0.45) only the sink-in depth changes. Full Release suite green (no regression) proves the sticky-bit argument held in practice, not just in theory. | `src/AcDream.Core/Physics/TerrainSurface.cs` (`SampleWaterDepth`) | | | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port); `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.1-5.2 |
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 | | AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
@ -233,18 +273,21 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-124 | Local ACE omits the new object's CreateObject to the initiating session after `StackableSplitTo3D`, sending only F748 Position for the previously unknown GUID. acdream retains retail's one pending split source/count/time identity for ten seconds and, only for that otherwise-impossible unknown Position, hydrates a canonical clone of the source description with the new GUID and authoritative world placement. | `src/AcDream.App/World/InventoryWorldDropProjectionController.cs`; `src/AcDream.App/UI/ItemInteractionController.cs`; `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` | Nearby/reconnecting clients receive the ordinary CreateObject, while the initiator otherwise cannot render the authoritative object until relog. A server that sends CreateObject never enters this path; the pending identity is consumed before normal hydration and all other unknown Positions remain rejected. | ACE's F748 does not carry WCID or stack size, so an unrelated unknown Position arriving during the exact pending ten-second window could be associated with the split. Retail confirms WCID/count from CreateObject; removing the approximation requires ACE to send that packet to the initiator. | `ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850`; `ACCWeenieObject::DeclareValid @ 0x0058E340`; ACE `Player.HandleActionStackableSplitTo3D` / `TryDropItem`; `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` | | AP-124 | Local ACE omits the new object's CreateObject to the initiating session after `StackableSplitTo3D`, sending only F748 Position for the previously unknown GUID. acdream retains retail's one pending split source/count/time identity for ten seconds and, only for that otherwise-impossible unknown Position, hydrates a canonical clone of the source description with the new GUID and authoritative world placement. | `src/AcDream.App/World/InventoryWorldDropProjectionController.cs`; `src/AcDream.App/UI/ItemInteractionController.cs`; `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` | Nearby/reconnecting clients receive the ordinary CreateObject, while the initiator otherwise cannot render the authoritative object until relog. A server that sends CreateObject never enters this path; the pending identity is consumed before normal hydration and all other unknown Positions remain rejected. | ACE's F748 does not carry WCID or stack size, so an unrelated unknown Position arriving during the exact pending ten-second window could be associated with the split. Retail confirms WCID/count from CreateObject; removing the approximation requires ACE to send that packet to the initiator. | `ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850`; `ACCWeenieObject::DeclareValid @ 0x0058E340`; ACE `Player.HandleActionStackableSplitTo3D` / `TryDropItem`; `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` |
| AP-125 | Transport control packets (the 2.0 s cumulative AckSequence and the 0.6 s RequestRetransmit) are emitted STANDALONE; retail piggybacks optional headers onto queued outbound packets first-fit (`FlowQueue::CoalesceData @ 0x00547740`, invoked at `TransmitNewPackets @ 0x00547A6E`), and `EnqueueNaks` hands the NAK to `PacketController::EnqueueOptionalHeader @ 0x00543C84` rather than emitting directly. | `src/AcDream.Core.Net/Transport/AckNakScheduler.cs` (`EmitCumulativeAck`, `EmitNakRequest`) | ACE honours a RequestRetransmit ONLY when EncryptedChecksum is absent (NetworkSession.cs:283-284) — a retail-style piggyback onto a sequenced packet encrypts the NAK and ACE silently ignores it, making S2C loss unrecoverable; ACE likewise advances its client-sequence watermark on any packet whose flags are not exactly AckSequence (:474-476), so coalesced control content on a borrowed sequence risks skipping a real packet. Standalone exact-flag emission is the only ACE-safe shape; it also keeps reliable packets free of optional headers, making the resend cache strip provably a no-op. | Slightly higher C2S datagram count than retail (one extra small packet per 2.0 s / per NAK window); marginally more loss exposure for the control packets themselves on a metered path. | `FlowQueue::CoalesceData @ 0x00547740`; `SharedNet::EnqueuePak @ 0x00543B10`; `SharedNet::EnqueueNaks @ 0x00543BD0`; ACE `NetworkSession.cs:283-284,:342-343,:474-476` | | AP-125 | Transport control packets (the 2.0 s cumulative AckSequence and the 0.6 s RequestRetransmit) are emitted STANDALONE; retail piggybacks optional headers onto queued outbound packets first-fit (`FlowQueue::CoalesceData @ 0x00547740`, invoked at `TransmitNewPackets @ 0x00547A6E`), and `EnqueueNaks` hands the NAK to `PacketController::EnqueueOptionalHeader @ 0x00543C84` rather than emitting directly. | `src/AcDream.Core.Net/Transport/AckNakScheduler.cs` (`EmitCumulativeAck`, `EmitNakRequest`) | ACE honours a RequestRetransmit ONLY when EncryptedChecksum is absent (NetworkSession.cs:283-284) — a retail-style piggyback onto a sequenced packet encrypts the NAK and ACE silently ignores it, making S2C loss unrecoverable; ACE likewise advances its client-sequence watermark on any packet whose flags are not exactly AckSequence (:474-476), so coalesced control content on a borrowed sequence risks skipping a real packet. Standalone exact-flag emission is the only ACE-safe shape; it also keeps reliable packets free of optional headers, making the resend cache strip provably a no-op. | Slightly higher C2S datagram count than retail (one extra small packet per 2.0 s / per NAK window); marginally more loss exposure for the control packets themselves on a metered path. | `FlowQueue::CoalesceData @ 0x00547740`; `SharedNet::EnqueuePak @ 0x00543B10`; `SharedNet::EnqueueNaks @ 0x00543BD0`; ACE `NetworkSession.cs:283-284,:342-343,:474-476` |
| AP-126 | One monotonic Stopwatch-backed clock (`TransportClock`) drives every transport gate (2.0 s ack, 0.6 s NAK, 0.333 s handshake retry, 0.5 s interval, 5 s assembler sweep); retail splits gates between `Timer::cur_time` (server-adjusted) and `Timer::local_time`. | `src/AcDream.Core.Net/Transport/TransportClock.cs` | The cur/local split only matters for gates that must track server clock adjustments; none of the ported gates semantically depend on server time — they are local cadences. A single injectable source also gives the virtual-clock test seam every conformance suite relies on. | A future port of a genuinely server-clock-relative gate could silently use the wrong clock if it reuses TransportClock without checking this row. | `SharedNet::EnqueuePak @ 0x00543B10` (cur_time); `ClientNet::ProcessConnection @ 0x00545450` (local_time for the 140 s check) | | AP-126 | One monotonic Stopwatch-backed clock (`TransportClock`) drives every transport gate (2.0 s ack, 0.6 s NAK, 0.333 s handshake retry, 0.5 s interval, 5 s assembler sweep); retail splits gates between `Timer::cur_time` (server-adjusted) and `Timer::local_time`. | `src/AcDream.Core.Net/Transport/TransportClock.cs` | The cur/local split only matters for gates that must track server clock adjustments; none of the ported gates semantically depend on server time — they are local cadences. A single injectable source also gives the virtual-clock test seam every conformance suite relies on. | A future port of a genuinely server-clock-relative gate could silently use the wrong clock if it reuses TransportClock without checking this row. | `SharedNet::EnqueuePak @ 0x00543B10` (cur_time); `ClientNet::ProcessConnection @ 0x00545450` (local_time for the 140 s check) |
| AP-127 | Campaign P Slice P1's run/jump base-skill chain omits two minor retail additive/multiplier terms feeding `CACQualities::InqRunRate`/`InqJumpVelocity` BEFORE `EnchantSkill` runs (property `0x146` "> 0 → +5" bonus; property `0x158` "specialized skill" doubling of a PP-derived term), and reads the raw wire current-stamina value for the zero-skill gate rather than the retail-adjusted local copy (`EnchantAttribute2nd(ATTR2ND_STAMINA)` can apply a Stamina-buff to that check's own copy without changing the displayed vital) | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`ApplySkillEnchantments`); `src/AcDream.Core/Physics/PlayerWeenie.cs` (`InqRunRate`/`InqJumpVelocity` stamina==0 gate) | Bounded per the P1 plan's explicit scope ("port only what the run/jump query path needs... not a general effective-skill engine"); both terms are rare/small relative to the dominant formulaBonus+init+ranks+vitae chain, which IS fully ported | A character with the specific rare property set (0x146/0x158) or an active Stamina-buff at exactly 0 raw stamina predicts a slightly different run/jump skill than retail; low practical impact | `CACQualities::InqRunRate` 0x00592800 pc 413824 (0x146/0x158 reads); `CEnchantmentRegistry::EnchantAttribute2nd` 0x00594670 pc 416169 | | ~~AP-127~~ | **RETIRED 2026-07-31 (#268).** `PlayerSkillMath` now owns retail `CACQualities::InqSkill` ordering for both panel values and Runtime run/jump prediction: intrinsic + positive 0x16D all-skills + the exact +10 category switch, then `EnchantSkill`, then 0x146 Jack of All Trades +5 and specialized-only `2 × 0x158`. Live player PropertyInt changes refresh the immutable Runtime augmentation snapshot. The separately described current-stamina local-copy nuance was re-audited: the query reads current stamina, but ordinary max-vital buffs target the max-secondary key and do not create stamina when current is zero; no independently observable residual remains. | `src/AcDream.Core/Player/PlayerSkillMath.cs`; `src/AcDream.Core/Player/LocalPlayerState.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs`; `src/AcDream.Runtime/Session/LiveSessionEventRouter.cs` | — | — | `CACQualities::InqSkill @ 0x00592660`; `CACQualities::InqRunRate @ 0x00592800`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0` |
| AP-128 | **PK-timer jump-cost clock basis unconfirmed** (filed at the P3 Opus review, 2026-07-30): `PlayerWeenie.JumpStaminaCost` evaluates retail's 20-second PK-recency window (`LastPkAttackTimestamp` PropertyFloat 0x91 + 20.0 >= now) against `Environment.TickCount64` process-uptime seconds. The magnitude argument is sound (a 32-bit float cannot hold a Unix epoch with sub-second precision — a conformance test caught the ±128 s swallow), but the wire timestamp's own basis is the SERVER's, so a cross-base compare is latent. INERT today: ACE models neither property, so `_lastPkAttackTimestamp` is never pushed and the branch never fires. | `src/AcDream.Core/Physics/PlayerWeenie.cs` (`JumpStaminaCost` remarks) | Branch unreachable against every ACE-family server; non-PK cost is bit-identical to pre-P3. The basis question is cdb-answerable (`Timer::cur_time` epoch) if a PK server is ever targeted. | Against a hypothetical server that sends PropertyFloat 0x91, the PK cost bump fires arbitrarily (always/never) instead of on the 20-second window. | `CACQualities::JumpStaminaCost 0x00591b90` pc 412934-412968; `Timer::cur_time`; stat-coupled pseudocode doc §12b | | AP-128 | **PK-timer jump-cost clock basis unconfirmed** (filed at the P3 Opus review, 2026-07-30): `PlayerWeenie.JumpStaminaCost` evaluates retail's 20-second PK-recency window (`LastPkAttackTimestamp` PropertyFloat 0x91 + 20.0 >= now) against `Environment.TickCount64` process-uptime seconds. The magnitude argument is sound (a 32-bit float cannot hold a Unix epoch with sub-second precision — a conformance test caught the ±128 s swallow), but the wire timestamp's own basis is the SERVER's, so a cross-base compare is latent. INERT today: ACE models neither property, so `_lastPkAttackTimestamp` is never pushed and the branch never fires. | `src/AcDream.Core/Physics/PlayerWeenie.cs` (`JumpStaminaCost` remarks) | Branch unreachable against every ACE-family server; non-PK cost is bit-identical to pre-P3. The basis question is cdb-answerable (`Timer::cur_time` epoch) if a PK server is ever targeted. | Against a hypothetical server that sends PropertyFloat 0x91, the PK cost bump fires arbitrarily (always/never) instead of on the 20-second window. | `CACQualities::JumpStaminaCost 0x00591b90` pc 412934-412968; `Timer::cur_time`; stat-coupled pseudocode doc §12b |
| AP-130 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The classifier's `HasAnimations` input is the static proxy `(Snapshot.MotionTableId ?? Snapshot.Physics?.MotionTableId) != 0` - "does the Create carry a nonzero motion table" - uniformly for every position source. Retail's `HasAnims` bit is live animation-QUEUE non-emptiness (`CSequence::has_anims` = `anim_list.head_ != 0`), which can differ from mere table assignment. The only confirmed retail `HasAnims` call site on this path is inside `HandleReceivedPosition` itself. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, `hasAnimations` local) | Best static proxy available without wiring a live animation-queue read into presentation-independent Position classification; deterministic and testable; gates only `ApplyPlacementFrameBeforeRouting` (placement-FRAME install), never pose or cell placement. | An entity with an assigned motion table but an empty animation queue (or vice versa) gets the wrong placement-frame decision - a one-frame animation-blend glitch on a Position-driven correction where retail would have done the opposite. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `HasAnims` gate, pseudo-C ~92992); `CPhysicsObj::HasAnims` 0x0050F770 -> `CSequence::has_anims` 0x00524BD0 |
| AP-131 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The legacy Position merge (`TryApplyPosition`, today's ONLY production Position wire caller) passes `installPlacementFrame: true, clearParent: true` to the shared `ApplyAcceptedPosition` body - byte-identical to its pre-refactor unconditional behavior. Retail gates `SetPlacementFrame` on `!HasAnims` and skips `unset_parent`/`SetPlacementFrame` entirely on the FORCE_POSITION early return (Gate A); the continuation executor's caller threads the classified route's real flags and is retail-exact. | `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs` (`TryApplyPosition` call site) | Exact pre-existing production behavior, deliberately unchanged by the executor slice; the retail-gated behavior exists in the same shared body and is exercised by the executor's tests. The legacy caller is deleted at the production cutover, retiring this row by construction. | Until cutover, an animated entity's ordinary Position update installs a placement frame retail would skip (animation snap/reset), and a ForcePosition on a parented entity unparents where retail's Gate A never reaches `unset_parent`. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `!HasAnims` `SetPlacementFrame` gate ~92992; the FORCE_POSITION early return ~92932 before `unset_parent` ~92990) |
| AP-132 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** acdream gates queued parent relations on parent INCARNATION where retail's queue-by-GUID replay is pointer-only. Retail queues a missing-parent relation blob under the PARENT's GUID (`QueueBlobForObject` ~92326; GUID-keyed `CObjectMaint` placeholder bucket ~271082-271088) and replays it on GUID (re)creation with only an addressability check (~92312) - no PARENT INSTANCE_TS comparison anywhere on that path (retail's only instance check there is on the CHILD, ~92316-92317). acdream additionally compares the relation's `ParentInstanceSequence` at admission (pre-existing `TryApplyParent`/`Resolve` rules) and at executor replay (`ApplyReplayedParentRelation`): live-parent-newer discards, relation-newer stays queued for an exact match. The replay's child-missing arm also drops where retail would re-queue under the child's GUID; child-scoped bucket filtering (`RemoveObject`/`RemoveChild`) proactively covers the same ledger tradeoff. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyReplayedParentRelation`); `RuntimeEntityObjectLifetime.cs` (`TryApplyParent` admission gate); `ParentAttachmentState.cs` (`Resolve` staleness rules) | The wire event names a SPECIFIC parent incarnation (`ParentEvent.Parsed.ParentInstanceSequence`) - the gate honors data the server explicitly sent. acdream's own established admission-time rules (`ParentAttachmentState.Resolve`, predating this slice) already fixed incarnation-gating as the project's parent-staleness posture; the replay path only extends that SAME posture for consistency. | Server GUID reuse between admission and replay: retail would attach the old queued relation to whatever NEW object now holds the GUID (retail's own recycling quirk); acdream discards it (parent newer) or leaves it queued (parent older) - silent loss of a relation retail would have applied, tied to server GUID-recycling cadence, not ordinary play. | Standalone parent handler 0x004535D0 (~92310-92326); `CObjectMaint::QueueBlobForObject` 0x005092D0 (~271082-271088); child instance check ~92316-92317 |
## 4. Temporary stopgap (TS) — 35 active rows (Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-4/TS-5/TS-23/TS-46 retired by ports — ZERO goal-enumerated physics stopgaps remain; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port, see the AD-53/AD-54 rows for the two compensating branches it left registered; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) ## 4. Temporary stopgap (TS) — 36 active rows (TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| ~~TS-1~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the row was stale, not the code.** The cited `:1254` line is unrelated stepping-loop code; the file moved substantially since the row was written. Retail's `EdgeSlide → PrecipiceSlide / CliffSlide` chain is already a real, tested port: `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:943-970`, retail `SPHEREPATH::precipice_slide` pc:274316), `Transition.CliffSlide` (`:2080-2164`, retail `CTransition::cliff_slide` pc:272397, return-value mapping verified against `acclient.h:6100-6108`), and `Transition.EdgeSlideAfterStepDownFailed` (`:1907-2078`, mirrors `CTransition::edge_slide` pc:273001-273090). The one real gap (back-probe fallback skipping retail's `walkable_check_pos`/`localspace_sphere` recache, pc:274318-274326) needed no code change: acdream's `WalkableVertices`/`GlobalSphere` are populated in unified world space at assignment time (`SetWalkable`/`SetWalkableTransformed`, `SetCheckPos`/`RestoreCheckPos`), so both operands `BSPQuery.FindCrossedEdge` compares are already commensurable — retail's per-cell local-frame reprojection is a no-op correction here. Documented in-code at the back-probe site and pinned by `EdgeSlideBackProbePrecipiceSlideTests`. The chain's two acdream-only compensating branches (CliffSlide's three-source reference-normal fallback; the walkable-steepness reroute to CliffSlide before PrecipiceSlide) are real, non-retail additions — filed as AD-53 / AD-54 rather than folded into this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SpherePath.PrecipiceSlide`, `Transition.CliffSlide`, `Transition.EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/EdgeSlideBackProbePrecipiceSlideTests.cs` | — | — | `SPHEREPATH::precipice_slide` pc:274316 (0050cc80); `CTransition::cliff_slide` pc:272397 (0050a6d0); `CTransition::edge_slide` pc:273001-273090 (0050b3d0); `SPHEREPATH::get_walkable_pos`/`cache_localspace_sphere`/`set_walkable_check_pos` pc:274318-274326 (0050a8f0/0050c9d0/00509ce0); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2, §6 Step 1 | | ~~TS-1~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the row was stale, not the code.** The cited `:1254` line is unrelated stepping-loop code; the file moved substantially since the row was written. Retail's `EdgeSlide → PrecipiceSlide / CliffSlide` chain is already a real, tested port: `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:943-970`, retail `SPHEREPATH::precipice_slide` pc:274316), `Transition.CliffSlide` (`:2080-2164`, retail `CTransition::cliff_slide` pc:272397, return-value mapping verified against `acclient.h:6100-6108`), and `Transition.EdgeSlideAfterStepDownFailed` (`:1907-2078`, mirrors `CTransition::edge_slide` pc:273001-273090). The one real gap (back-probe fallback skipping retail's `walkable_check_pos`/`localspace_sphere` recache, pc:274318-274326) needed no code change: acdream's `WalkableVertices`/`GlobalSphere` are populated in unified world space at assignment time (`SetWalkable`/`SetWalkableTransformed`, `SetCheckPos`/`RestoreCheckPos`), so both operands `BSPQuery.FindCrossedEdge` compares are already commensurable — retail's per-cell local-frame reprojection is a no-op correction here. Documented in-code at the back-probe site and pinned by `EdgeSlideBackProbePrecipiceSlideTests`. The chain's two acdream-only compensating branches (CliffSlide's three-source reference-normal fallback; the walkable-steepness reroute to CliffSlide before PrecipiceSlide) are real, non-retail additions — filed as AD-53 / AD-54 rather than folded into this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SpherePath.PrecipiceSlide`, `Transition.CliffSlide`, `Transition.EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/EdgeSlideBackProbePrecipiceSlideTests.cs` | — | — | `SPHEREPATH::precipice_slide` pc:274316 (0050cc80); `CTransition::cliff_slide` pc:272397 (0050a6d0); `CTransition::edge_slide` pc:273001-273090 (0050b3d0); `SPHEREPATH::get_walkable_pos`/`cache_localspace_sphere`/`set_walkable_check_pos` pc:274318-274326 (0050a8f0/0050c9d0/00509ce0); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2, §6 Step 1 |
| TS-4 | **RE-OPENED 2026-07-30 after the matrix live gate**: the fixture-gated removal shipped and the user immediately hit the wedge live ("stuck sliding on an edge") plus a non-retail uphill-jump bounce and lost roof slides — the horizontal-velocity convergence claim under-modeled real trajectories. Removal reverted; the oracle plan §7 degenerate analysis needs live-capture-driven rework before any retry. Original row: Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 | | ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B; corrective acceptance complete).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and every BSP-layer `SetSlidingNormal` write are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. A corrective 90-tick already-airborne, zero-root-motion Core suite executes acceleration, body integration, transition resolution, exact commit, and `handle_all_collisions` while retaining every behavior-bearing collision/body field used by that specialized quantum. Vertical, inward, tangential, downhill, and positive-Z uphill-jump traces match graph/flat by raw bits, reject penetration/fixed points/second launches, and pin exact terminal velocity, contact, sliding, and contact-plane state. The older resolver-only capture is explicitly historical and restored to its three-second bound. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 |
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | | TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
| TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs | | ~~TS-8~~ | **RETIRED 2026-07-31 (#268 stat-chain closeout).** `EnchantmentWireReader` parses the complete 0x02C2 payload and `GameEventWiring` publishes its StatMod type/key/value and bucket through the same `ActiveEnchantmentRecord` used at login. An end-to-end dispatch test proves a mid-session skill modifier changes `LocalPlayerState.GetEffectiveSkill` immediately. | `src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` | — | — | `CEnchantmentRegistry::EnchantAttribute @ 0x00594570`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0`; holtburger `messages/magic/types.rs` |
| TS-9 | MP3 (0x55) and MS-ADPCM (0x02) waves undecoded — affected sounds skipped; retail decoded both via winmm ACM | `src/AcDream.Core/Audio/WaveDecoder.cs:33` | Managed decoder (NAudio or similar) deferred; PCM covers the vast majority of ~3500 waves | Any MP3 (common for music-ish clips) or ADPCM cue plays as silence where retail plays it | winmm ACM path (r05 §2.1) | | TS-9 | MP3 (0x55) and MS-ADPCM (0x02) waves undecoded — affected sounds skipped; retail decoded both via winmm ACM | `src/AcDream.Core/Audio/WaveDecoder.cs:33` | Managed decoder (NAudio or similar) deferred; PCM covers the vast majority of ~3500 waves | Any MP3 (common for music-ish clips) or ADPCM cue plays as silence where retail plays it | winmm ACM path (r05 §2.1) |
| TS-14 | Setup `Flatten` ignores ParentIndex part hierarchy (treats every placement as root-local); still in production use (GameWindow hydration, SkyRenderer) | `src/AcDream.Core/Meshing/SetupMesh.cs:15` | Most Setups are flat single-level rigs where root-local equals composed; hierarchical composition deferred ("Phase 3") | Any Setup with genuinely nested parts renders them at wrong offsets — mis-assembled multi-part objects in the Flatten paths | retail Setup ParentIndex chain composition | | TS-14 | Setup `Flatten` ignores ParentIndex part hierarchy (treats every placement as root-local); still in production use (GameWindow hydration, SkyRenderer) | `src/AcDream.Core/Meshing/SetupMesh.cs:15` | Most Setups are flat single-level rigs where root-local equals composed; hierarchical composition deferred ("Phase 3") | Any Setup with genuinely nested parts renders them at wrong offsets — mis-assembled multi-part objects in the Flatten paths | retail Setup ParentIndex chain composition |
| TS-15 | No distance-driven degrade (LOD): always close-detail slot 0; plus the **#47** static `Degrades[0]` swap for 34-part humanoids only (structural sentinel detector) | `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:57` (+ `src/AcDream.App/Rendering/GameWindow.cs:2608`) | LOD plumbing doesn't exist; slot 0 is correct for player + nearby NPCs; #47 closed the visible low-detail-arms bug without porting UpdateViewerDistance | Distant objects render max-detail (perf + wrong visuals where far meshes intentionally differ/hide parts); a future 34-part non-humanoid matching the sentinel gets the wrong mesh swap | `CPhysicsPart::UpdateViewerDistance` 0x0050E030; ::Draw 0x0050D7A0; ::LoadGfxObjArray 0x0050DCF0 | | TS-15 | No distance-driven degrade (LOD): always close-detail slot 0; plus the **#47** static `Degrades[0]` swap for 34-part humanoids only (structural sentinel detector) | `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:57` (+ `src/AcDream.App/Rendering/GameWindow.cs:2608`) | LOD plumbing doesn't exist; slot 0 is correct for player + nearby NPCs; #47 closed the visible low-detail-arms bug without porting UpdateViewerDistance | Distant objects render max-detail (perf + wrong visuals where far meshes intentionally differ/hide parts); a future 34-part non-humanoid matching the sentinel gets the wrong mesh swap | `CPhysicsPart::UpdateViewerDistance` 0x0050E030; ::Draw 0x0050D7A0; ::LoadGfxObjArray 0x0050DCF0 |
@ -254,7 +297,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~TS-20~~ | **RETIRED AS A FALSE ATTRIBUTION 2026-07-16**`CGfxObj::InitLoad` passes the complete polygon array to `D3DPolyRender::ConstructMesh`; ordinary GfxObj rendering does not filter it through DrawingBSP. Building DrawingBSP traversal discovers and orders portal apertures after `RemoveNonPortalNodes`; it is not a global visible-polygon selector. The alleged building-shell "orphans" are `DrawingBSPNode.Portals`, omitted by the old diagnostic collector; the corrected node-polygons portal-polygons audit finds no true orphans. Applying the proposed filter would repeat the door disappearance regression from `e46d3d9`. | `docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md`; `docs/research/2026-06-11-holistic-map/wf1-building-shells.md`; `tests/AcDream.Core.Tests/Rendering/Wb/Issue113DoorVanishDiagnosticTests.cs` | — | — | `CGfxObj::InitLoad @ 0x005346B0`; `D3DPolyRender::ConstructMesh @ 0x0059DFA0`; `BSPTREE::build_draw_portals_only @ 0x00539860` | | ~~TS-20~~ | **RETIRED AS A FALSE ATTRIBUTION 2026-07-16**`CGfxObj::InitLoad` passes the complete polygon array to `D3DPolyRender::ConstructMesh`; ordinary GfxObj rendering does not filter it through DrawingBSP. Building DrawingBSP traversal discovers and orders portal apertures after `RemoveNonPortalNodes`; it is not a global visible-polygon selector. The alleged building-shell "orphans" are `DrawingBSPNode.Portals`, omitted by the old diagnostic collector; the corrected node-polygons portal-polygons audit finds no true orphans. Applying the proposed filter would repeat the door disappearance regression from `e46d3d9`. | `docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md`; `docs/research/2026-06-11-holistic-map/wf1-building-shells.md`; `tests/AcDream.Core.Tests/Rendering/Wb/Issue113DoorVanishDiagnosticTests.cs` | — | — | `CGfxObj::InitLoad @ 0x005346B0`; `D3DPolyRender::ConstructMesh @ 0x0059DFA0`; `BSPTREE::build_draw_portals_only @ 0x00539860` |
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 | | TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
| TS-27 | **NARROWED 2026-07-29 (Campaign N Slice N1)** — OUTBOUND is ported: sent-packet cache + header-rebuilt resend on server `RequestRetransmit`, `ids[0]` implicit ack, wrap-safe watermark prune (`src/AcDream.Core.Net/Transport/`). Residual: INBOUND loss is still fatal — no sequence-aligned inbound ISAAC discipline, no client NAK emission, no `RejectRetransmit` consumption (Campaign N slices N2/N4) | `src/AcDream.Core.Net/WorldSession.cs` (`ProcessDatagram` inbound path); `docs/plans/2026-07-29-network-transport-campaign.md` §2.2/§2.3 | Campaign N executes the port one direction per slice; the N0 ACE double grades each slice before the next lands | One lost S2C packet still shifts the inbound keystream permanently — every later encrypted packet fails checksum and the session goes silently deaf until timeout | `SharedNet::ProcessPacket @ 0x00544790`; `ReceiverData::AddNakked @ 0x00549240`; `SharedNet::EnqueueNaks @ 0x00543BD0` | | TS-27 | **NARROWED 2026-07-29 (Campaign N Slice N1)** — OUTBOUND is ported: sent-packet cache + header-rebuilt resend on server `RequestRetransmit`, `ids[0]` implicit ack, wrap-safe watermark prune (`src/AcDream.Core.Net/Transport/`). Residual: INBOUND loss is still fatal — no sequence-aligned inbound ISAAC discipline, no client NAK emission, no `RejectRetransmit` consumption (Campaign N slices N2/N4) | `src/AcDream.Core.Net/WorldSession.cs` (`ProcessDatagram` inbound path); `docs/plans/2026-07-29-network-transport-campaign.md` §2.2/§2.3 | Campaign N executes the port one direction per slice; the N0 ACE double grades each slice before the next lands | One lost S2C packet still shifts the inbound keystream permanently — every later encrypted packet fails checksum and the session goes silently deaf until timeout | `SharedNet::ProcessPacket @ 0x00544790`; `ReceiverData::AddNakked @ 0x00549240`; `SharedNet::EnqueueNaks @ 0x00543BD0` |
| TS-28 | **NARROWED 2026-07-15** — F751 teleports now resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login still sends LoginComplete directly from the PlayerCreate (0xF746) handler and does not enter the portal-space presentation. | `src/AcDream.Core.Net/WorldSession.cs` (PlayerCreate branch); `src/AcDream.App/Rendering/GameWindow.cs` (F751 `FireLoginComplete`) | The live-session bootstrap currently needs the acknowledgement to unlock the initial authoritative object/property stream; moving initial login behind the App presentation requires an explicit session→presentation readiness contract rather than withholding it inside Core.Net | Initial login can expose server updates earlier than retail and skips the wormhole presentation; recalls/portals now have retail ordering | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` | | TS-28 | **NARROWED 2026-08-03** — F751 teleports resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login no longer acknowledges raw PlayerCreate receipt: graphical and prepared headless hosts send exactly once after canonical local-player first placement; content-less headless sends after its accepted direct Create because it has no placement conductor. Residual: initial login still does not enter the full portal-space presentation. | `src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs`; `src/AcDream.App/Net/GraphicalSessionEventRoute.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs`; `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs`; `src/AcDream.Core.Net/WorldSession.cs` | Initial placement is now the shared readiness contract that releases ACE's intentional Hidden/pink-bubble state without racing presentation. The content-less direct host uses its only truthful admission edge. | The persistent login materialization haze is fixed and server updates no longer unlock before canonical placement. The remaining difference is presentation-only: initial login skips retail's wormhole sequence. | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` |
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) | | TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
| TS-30 | Chat DAT elements `0x10000522``0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | TS-30 | Chat DAT elements `0x10000522``0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
| TS-31 | **NARROWED 2026-07-13**`/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | | TS-31 | **NARROWED 2026-07-13**`/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
@ -280,6 +323,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-60 | No 140 s dead-link declaration or referral auto-reconnect in the transport; a silent server is only visible through `LinkStatusSnapshot.SecondsSinceLastPacket` (presentational). | `src/AcDream.Core.Net/Transport/ReliableTransport.cs`; `src/AcDream.Core.Net/WorldSession.cs` (`BuildLinkStatus`) | The input (seconds since last inbound) is already exposed; session lifecycle/reconnect is Runtime's ownership domain and deserves its own campaign rather than a transport-embedded side effect. Every ACE transport death is silence, so nothing server-side depends on the client reacting at 140 s. | A dead link idles until the user acts; no automatic recall/referral reconnect where retail would attempt one. | `ClientNet::ProcessConnection @ 0x00545450` tail (the two 140.0 literals) | | TS-60 | No 140 s dead-link declaration or referral auto-reconnect in the transport; a silent server is only visible through `LinkStatusSnapshot.SecondsSinceLastPacket` (presentational). | `src/AcDream.Core.Net/Transport/ReliableTransport.cs`; `src/AcDream.Core.Net/WorldSession.cs` (`BuildLinkStatus`) | The input (seconds since last inbound) is already exposed; session lifecycle/reconnect is Runtime's ownership domain and deserves its own campaign rather than a transport-embedded side effect. Every ACE transport death is silence, so nothing server-side depends on the client reacting at 140 s. | A dead link idles until the user acts; no automatic recall/referral reconnect where retail would attempt one. | `ClientNet::ProcessConnection @ 0x00545450` tail (the two 140.0 literals) |
| TS-61 | A UDP send failure burns the reliable sequence and its ISAAC word (the encode commits before `_net.Send`); retail keeps the sealed packet at the queue head and retries with the same key. | `src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs` (`SendGameMessage`) | A connectionless-socket `SendTo` failure is effectively unreachable in practice (no route/ICMP errors surface on later receives, not sends, on Windows UDP); recovering it faithfully needs a full outbound packet queue. The N1 review accepted the exposure explicitly. | One `SocketException` on send would desync the outbound cipher permanently (session death; observable as `[net-out-EX]` followed by silence). | `FlowQueue::TransmitNewPackets @ 0x00547C2C` (retry-from-head) | | TS-61 | A UDP send failure burns the reliable sequence and its ISAAC word (the encode commits before `_net.Send`); retail keeps the sealed packet at the queue head and retries with the same key. | `src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs` (`SendGameMessage`) | A connectionless-socket `SendTo` failure is effectively unreachable in practice (no route/ICMP errors surface on later receives, not sends, on Windows UDP); recovering it faithfully needs a full outbound packet queue. The N1 review accepted the exposure explicitly. | One `SocketException` on send would desync the outbound cipher permanently (session death; observable as `[net-out-EX]` followed by silence). | `FlowQueue::TransmitNewPackets @ 0x00547C2C` (retry-from-head) |
| TS-56 | Chase-camera mouse input retains acdream's invented post-filter yaw/pitch scalars (`0.004`/`0.003` radians per count), and held-key pitch/zoom retain their non-retail integration shapes. Retail mouse look passes `FilterMouseInput(delta) × configured sensitivity × 1/15` as the replacement scale to `CameraSet::Rotate`, which then applies the shared 8° angle; retail held pitch uses the same angle and zoom scales the viewer offset multiplicatively. | `src/AcDream.App/Input/CameraPointerInputController.cs`; `src/AcDream.App/Input/MouseLookController.cs`; `src/AcDream.App/Rendering/CameraFrameController.cs` | Slice 8 is behavior-preserving ownership work. The named-retail audit proves the mismatch but has not yet extracted the configured mouse-sensitivity default or the exact caller flags needed for a complete feel port; changing only one scalar here would create a mixed input model. | RMB/MMB orbit, held pitch, and zoom can feel slower, faster, or differently accelerated than retail even though callback ordering and filtering are correct. | `CameraSet::Rotate @ 0x00458310`; `CameraSet::MouseLookHandler` call at `0x00458EF9`; `CameraSet::Raise @ 0x00457B00`; `CameraSet::Closer @ 0x004586D0`; `docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md` | | TS-56 | Chase-camera mouse input retains acdream's invented post-filter yaw/pitch scalars (`0.004`/`0.003` radians per count), and held-key pitch/zoom retain their non-retail integration shapes. Retail mouse look passes `FilterMouseInput(delta) × configured sensitivity × 1/15` as the replacement scale to `CameraSet::Rotate`, which then applies the shared 8° angle; retail held pitch uses the same angle and zoom scales the viewer offset multiplicatively. | `src/AcDream.App/Input/CameraPointerInputController.cs`; `src/AcDream.App/Input/MouseLookController.cs`; `src/AcDream.App/Rendering/CameraFrameController.cs` | Slice 8 is behavior-preserving ownership work. The named-retail audit proves the mismatch but has not yet extracted the configured mouse-sensitivity default or the exact caller flags needed for a complete feel port; changing only one scalar here would create a mixed input model. | RMB/MMB orbit, held pitch, and zoom can feel slower, faster, or differently accelerated than retail even though callback ordering and filtering are correct. | `CameraSet::Rotate @ 0x00458310`; `CameraSet::MouseLookHandler` call at `0x00458EF9`; `CameraSet::Raise @ 0x00457B00`; `CameraSet::Closer @ 0x004586D0`; `docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md` |
| TS-62 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** NO Position route in the dormant executor runs a live `ConstrainTo` binding - including the `SetPosition`/`SetPositionSimple` routes. `RuntimeAuthoritativePositionRoute.ConstrainPhase` (None/Before/After) is classified for EVERY accepted route and recorded into the execution trace, but the constrain-before-vs-after distinction exists purely as classified metadata pending a live binding at the production cutover. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`/`BuildPositionTrace`); `RuntimeAuthoritativePositionRouteClassifier.cs` (`ConstrainPhase`) | Host-cutover work with no Runtime-side owner to bind to yet; the canonical snapshot's Position IS refreshed on every accepted route, so the fact is retained - only the live constrain/smoothing behavior is deferred. The trace carries the exact phase a host must bind. | Until a host wires it, ANY Position continuation applies its raw pose with no constrain-distance clamp or smoothing - a visible pop instead of retail's constrained correction, on exactly the entities created while an authored placement was in flight. | `SmartBox::HandleReceivedPosition` 0x00453FD0, the three `ConstrainTo` sites (~93007 remote-after, ~93024 teleport-after, ~93041 local-ordinary-before) |
| TS-63 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** `ApplyResidentCellCleanup`'s three branches: (1) claimed-cell + celless + NOT under lost-cell/deferred ownership - retail's genuine `AddObjectToBeDestroyed` case - has no safe Runtime destruction owner yet, so the executor performs a typed ABANDONMENT (`RejectedAuthority`) instead of destroying; (2) claimed + celless + deferred returns `DeferredUnderLostCellOwnership` - retail's destruction bookkeeping for this exact entity is already owned by the lost-cell/deferred `SetPosition` lifetime (a statement, not a parallel mechanism); (3) claimedCell==0 returns `CelllessNoWeenieMarkUnreachable` and is NOT a divergence - every admitted envelope structurally carries a WeenieDescription (`HasValidShape`), so retail's no-weenie destruction alternative is unreachable through this construction. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyResidentCellCleanup`; the Abandon conversion in `ApplyEnvelope`) | No production caller yet; every branch is typed and test-observable; building a parallel destruction mechanism ahead of the object-table/lost-cell cutover wiring would be the exact workaround class CLAUDE.md forbids - failing closed is the honest interim. | Branch (1): a genuinely claimed-but-celless-undeferred entity aborts the drain and SURVIVES where retail destroys it, until the cutover wiring lands. Branch (3): a future envelope construction without a WeenieDescription would break the premise and needs re-examination. | `SmartBox::HandleCreateObject` 0x00454C80 tail (~93933 destruction mark; ~93942-93943 un-mark/no-weenie) |
--- ---
@ -306,13 +351,11 @@ phase-gated — they carry their trigger in their row and should land
WITH that phase, not before. WITH that phase, not before.
1. **TS-27 — INBOUND retransmit handling** — the outbound sent-packet cache + resend landed with Campaign N Slice N1 (2026-07-29, class-doc gap list fixed same commit); the inbound sequence-aligned ISAAC + client NAK emission (N2/N4) remain the hard blocker for non-loopback play — one lost S2C packet still deafens the session permanently. 1. **TS-27 — INBOUND retransmit handling** — the outbound sent-packet cache + resend landed with Campaign N Slice N1 (2026-07-29, class-doc gap list fixed same commit); the inbound sequence-aligned ISAAC + client NAK emission (N2/N4) remain the hard blocker for non-loopback play — one lost S2C packet still deafens the session permanently.
2. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis. 2. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
3. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). 3. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
4. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. 4. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
5. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. 5. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together.
6. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger. 6. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
7. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together.
8. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** **Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
M2 combat must land TS-25 M2 combat must land TS-25

View file

@ -178,6 +178,20 @@ package schema, bake, DAT reader, collision formula, or render portal graph
changed. Evidence: changed. Evidence:
`docs/research/2026-07-26-prepared-indoor-transit-regression.md`. `docs/research/2026-07-26-prepared-indoor-transit-regression.md`.
**Cell availability semantics (2026-07-31, corrected after full-catalog
audit).** Raw and prepared CellStruct publication retains a `CellPhysics`
record when the physics root is empty but requires a valid containment root.
The installed 729,888-record raw and prepared catalogs contain zero rootless
containment payloads. A malformed null/-1 root is quarantined atomically; the
recursive inside base case applies only to a missing positive child below a
valid root. Registration-side outdoor floods still add outside cells but skip
transit when the active CLandCell is unavailable, and every later outdoor
candidate independently requires its own visible landcell before building
transit. The existing reflood retries after terrain/cell hydration. Both raw
and prepared point-in-cell paths preserve retail's zero-portals guard. No
package schema or DAT reader changed.
Evidence: `docs/research/2026-07-31-cell-availability-semantics.md`.
**Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter **Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter
2.1.7 models `CreateBlockingParticleHook` as the common hook header only, while 2.1.7 models `CreateBlockingParticleHook` as the common hook header only, while
retail inherits the complete `CreateParticleHook` payload. The narrow readers in retail inherits the complete `CreateParticleHook` payload. The narrow readers in

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap # acdream — strategic roadmap
**Status:** Living document. Updated 2026-07-27. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Modern Runtime J3 canonical entity/object lifetime and J4 gameplay-state ownership are closed at `89e6b207`; J5.1 canonical selection/combat/target-mode ownership is closed at `b298f99f`, J5.2 interaction transactions at `f5f7b417`, J5.3 combat/magic intent at `20df9d15`, J5.4 local movement/outbound cadence at `aa3f4a60`, J5.5 per-session physics/remote simulation at `7e6033d0`, J5.6 projectile simulation at `2aee3356`, and J5.7 combined simulation closeout at `cdee7a4b`. J6.1 world-environment ownership is closed at `902076c0`; J6.2 canonical reveal generation and typed destination readiness is closed at `a6860d55` plus `acb845d8`; J6.3 exact F751/Position destination correlation is closed at `6a063a27`; J6.4 exact graphical-host acknowledgement and owner cleanup is closed at `18d17d8b`. J7's one graphical `GameRuntime` root is closed at `ce41efb9`, including the user's 2026-07-27 exact post-cutover visual acceptance. J8 closed Slice J at `a9a822f2` with one shared graphical/no-window root and generation-reset transaction. Slice K Linux headless/multi-session work is closed. K0's tested no-presentation Windows/Linux boundary closed at `aada8a37`, K1's portable single-session host at `f8cb840f`, K2's deterministic scheduler and shared bot API at `7e8acb74` plus `38e83640`, and K3's shared-content/isolation plus connected observer gate at `3f340125`. K4 closed through `776482da`: 1/5/10/30-root isolation and two-hour simulated endurance, death/randomized cancellation, committed resource ceilings, ten minutes of exact native Linux two-account connected sampling, ACE-confirmed graceful logout, and zero-debt Runtime/content convergence all pass. Slice L Linux graphical/platform work is parked at its L1 implementation checkpoint by user direction on 2026-07-27. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Status:** Living document. Updated 2026-08-03. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Modern Runtime J3 canonical entity/object lifetime and J4 gameplay-state ownership are closed at `89e6b207`; J5.1 canonical selection/combat/target-mode ownership is closed at `b298f99f`, J5.2 interaction transactions at `f5f7b417`, J5.3 combat/magic intent at `20df9d15`, J5.4 local movement/outbound cadence at `aa3f4a60`, J5.5 per-session physics/remote simulation at `7e6033d0`, J5.6 projectile simulation at `2aee3356`, and J5.7 combined simulation closeout at `cdee7a4b`. J6.1 world-environment ownership is closed at `902076c0`; J6.2 canonical reveal generation and typed destination readiness is closed at `a6860d55` plus `acb845d8`; J6.3 exact F751/Position destination correlation is closed at `6a063a27`; J6.4 exact graphical-host acknowledgement and owner cleanup is closed at `18d17d8b`. J7's one graphical `GameRuntime` root is closed at `ce41efb9`, including the user's 2026-07-27 exact post-cutover visual acceptance. J8 closed Slice J at `a9a822f2` with one shared graphical/no-window root and generation-reset transaction. Slice K Linux headless/multi-session work is closed. K0's tested no-presentation Windows/Linux boundary closed at `aada8a37`, K1's portable single-session host at `f8cb840f`, K2's deterministic scheduler and shared bot API at `7e8acb74` plus `38e83640`, and K3's shared-content/isolation plus connected observer gate at `3f340125`. K4 closed through `776482da`: 1/5/10/30-root isolation and two-hour simulated endurance, death/randomized cancellation, committed resource ceilings, ten minutes of exact native Linux two-account connected sampling, ACE-confirmed graceful logout, and zero-debt Runtime/content convergence all pass. Slice L Linux graphical/platform work is parked at its L1 implementation checkpoint by user direction on 2026-07-27. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
**Slice L checkpoint:** L0 closed at `66f114b2` with one typed graphical **Slice L checkpoint:** L0 closed at `66f114b2` with one typed graphical
@ -35,7 +35,7 @@ N0N6 with a permanent loss-injection gate at N5 and a user Coldeve
endurance session as final acceptance. The plan is endurance session as final acceptance. The plan is
[`2026-07-29-network-transport-campaign.md`](2026-07-29-network-transport-campaign.md). [`2026-07-29-network-transport-campaign.md`](2026-07-29-network-transport-campaign.md).
**Campaign P — physics retail-feel parity (ACTIVE, started 2026-07-29):** **Campaign P — physics retail-feel parity (CLOSED 2026-07-31):**
user-directed pre-vendor detour closing every physics-scope gap the user-directed pre-vendor detour closing every physics-scope gap the
2026-07-29 audit found: stat-coupled movement (burden/stamina/vitae → 2026-07-29 audit found: stat-coupled movement (burden/stamina/vitae →
run/jump), the collision response-layer edge family (friction gate, run/jump), the collision response-layer edge family (friction gate,
@ -46,10 +46,59 @@ a ledger pass. Goal: zero physics TS rows, no unargued feel-affecting AP
rows, one final batched connected visual matrix. Sonnet implements, Opus rows, one final batched connected visual matrix. Sonnet implements, Opus
reviews. The plan is reviews. The plan is
[`2026-07-29-physics-parity-campaign.md`](2026-07-29-physics-parity-campaign.md). [`2026-07-29-physics-parity-campaign.md`](2026-07-29-physics-parity-campaign.md).
The 2026-07-31 #268 stat-chain package is implemented and user-accepted:
panel and Runtime movement share retail's complete augmentation ordering,
the authored per-fragment vitae/buff/debuff colors are live, and AP-127 plus
TS-8 are retired by focused and end-to-end packet tests. #269's
capture-driven slope-slide residual is also closed and user-accepted:
`CTransition::validate_transition` now performs retail's non-OK-only
remembered-plane restore with the preceding `OBJECTINFO::kill_velocity`.
The matrix then exposed #272: burden was invalidated by base Strength but not
by Strength enchantment add/purge. Runtime movement plus both retained burden
surfaces now use effective Strength and the canonical enchantment-change edge;
automated gates pass and the connected buff/death gate was user-accepted on
2026-07-31. The final session also accepted burden/exhaustion, wall/corner,
crowd, two-client remote/door/portal, and shallow-water behavior. The user
waived the general sweep and explicitly deferred the barred-house gate as
#274. The later exact-location #273 tight-gap gate is now fixed and accepted.
**Remaining physics-divergence closeout (ACTIVE, checkpoint 2026-08-03):** the user then
authorized retirement of the remaining proven collision/placement gaps before
vendor work resumes. Nested retry, edge/StepDown/Path-6 ordering, exact cell
availability, atomic collision generations, canonical Core SetPosition,
Runtime lost-cell residence, authored mover/body preparation, placement
receipts and observers, collision-prefix replacement, authoritative route
classification, and initial Create residence are landed as bisectable
checkpoints. Commit `38fd4b8d` retains the accepted Create placement plus
fresher Position FIFO until exact placement and ordered adoption complete.
Commit `30012361` completes the bounded inbound-admission checkpoint: all
accepted mixed updates remain deep-frozen in exact arrival order while the
initial placement waits, with no early canonical/public snapshot, event, or
presentation mutation. Missing-parent raw Create, delete, reconnect/reset,
GUID reuse, malformed projections, saturation, and reentrant teardown are
covered. Commit `5db3de3c` (2026-08-02) completes the continuation executor:
retry-idempotent single adoption of the acknowledged initial placement,
retail's exact Create tail, GUID-keyed deferred-child and parent-relation
replay with cancellation-aware windows, strict-sequence mixed-FIFO drain with
execution-time retail Position routing, shared apply bodies keeping one
snapshot store in lockstep, and converged ownership ledgers on every
abandonment path — dual independent reviews PASS; register rows
AD-59/AD-60/AP-130/AP-131/AP-132/TS-62/TS-63 filed in the same commit.
Production initial Create registration is now cut over by C3c (`529e0e9d`).
The O(changed) collision-publication checkpoint and five stabilization fixes
through `175ad6b0` restore recenter convergence, remote world-frame placement
and targeting, distant Use, one-shot spell/projectile/static effects, and
login materialization; the corresponding connected user gates passed. The
campaign remains open for six fixture reconciliations, C4 routes 27, portal
destination prefetch #280, the final-binary C5 suite/soak/visual matrix,
AP-22 authored object shapes, and AD-10 remote contact-plane projection.
Current plan and copy-ready handoff:
[`2026-08-02-placement-cutover.md`](2026-08-02-placement-cutover.md) and
[`NEXT-AGENT-PROMPT.md`](../research/2026-08-02-collision-throughput-handoff/NEXT-AGENT-PROMPT.md).
--- ---
## Current program: world interaction completion (M4 prelude) ## Paused program: world interaction completion (M4 prelude)
The active work order is The active work order is
[`2026-07-23-world-interaction-completion.md`](2026-07-23-world-interaction-completion.md). [`2026-07-23-world-interaction-completion.md`](2026-07-23-world-interaction-completion.md).

View file

@ -87,8 +87,40 @@ program: spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor equipped-child picking, vendor browsing, and authoritative vendor
transactions. This is deliberately using the extracted interaction owners and transactions. This is deliberately using the extracted interaction owners and
canonical shared main-panel host before quest/emote/character-creation bodies canonical shared main-panel host before quest/emote/character-creation bodies
broaden the feature surface. Slices 13 are user-accepted; resume at Slice 4 broaden the feature surface. Slices 14 are user-accepted. Campaign P's
equipped-child picking. connected feel matrix closed on 2026-07-31 with tight-gap collision clearance
(#273) and the deferred restricted-house gate (#274) explicitly carried. The
user subsequently authorized the remaining physics-divergence closeout before
vendor work. Placement Slice 4B2 is now complete through dormant SetPosition
activation, graphical/no-window placement receipts, collision-prefix
replacement, authoritative route classification, pre-placement App staging,
and Runtime's initial Create residence/FIFO transaction at `38fd4b8d`.
The bounded admission checkpoint is complete at `30012361`: every accepted
same-incarnation Create, ObjDesc, Parent, Pickup, Position, Movement, State,
and Vector update is retained as a deep-frozen, arrival-ordered Runtime action
without changing the canonical/public snapshot or presentation while initial
placement waits. The continuation executor is complete at `5db3de3c`
(2026-08-02): one retry-idempotent Runtime `Execute` transaction adopts the
acknowledged initial placement exactly once, applies retail's Create tail,
replays deferred missing-parent raw Creates and queued parent relations by
parent GUID with cancellation-aware detach/restore windows, and drains the
mixed FIFO strictly by sequence with execution-time retail Position routing
through the canonical SetPosition lifecycle. Independent retail-conformance
and architecture/adversarial reviews both PASS after five implementation
rounds; register rows AD-59/AD-60/AP-130/AP-131/AP-132/TS-62/TS-63 document
the slice's deviations; Runtime tests 903/903, complete Release solution
10,696/4 skips. Production initial Create registration is now cut over by C3c
(`529e0e9d`). The O(changed) collision-publication checkpoint and
stabilization fixes `01f4791e`, `670f307c`, `1fc529cd`, `f24532ad`, and
`175ad6b0` are connected user-accepted for recenter convergence, remote
monster/static placement and targeting, distant Use, spell/projectile/static
VFX, and login materialization. The remaining order is six selected-fixture
reconciliations, C4 routes 27, portal destination prefetch #280, C5's
final-binary complete suite/soak/two-client matrix, AP-22 shape fidelity,
AD-10 remote contact-plane projection, and final ledger closeout. Resume Slice
5 vendor browsing only after that closeout or a new explicit user direction.
Canonical checkpoint:
[`2026-08-02-placement-cutover.md`](2026-08-02-placement-cutover.md).
The separately authorized modern-runtime performance program has completed The separately authorized modern-runtime performance program has completed
Slices AD: corrected measurement, prepared-package bake/dedup, package-only Slices AD: corrected measurement, prepared-package bake/dedup, package-only

View file

@ -1,5 +1,9 @@
# Campaign P — Physics Retail-Feel Parity # Campaign P — Physics Retail-Feel Parity
**Status:** CLOSED 2026-07-31 — final user matrix accepted; tight-gap
clearance issue #273 and deferred restricted-house gate #274 are explicitly
carried follow-ups.
**Filed:** 2026-07-29. **Directed by the user** as a pre-vendor-management **Filed:** 2026-07-29. **Directed by the user** as a pre-vendor-management
detour after the same-day physics/collision retail-fidelity audit. The detour after the same-day physics/collision retail-fidelity audit. The
world-interaction program (Slice 5, vendor browsing) resumes when this world-interaction program (Slice 5, vendor browsing) resumes when this
@ -54,8 +58,9 @@ PK-timer jump-cost decode for P3). Full Release suite 9,880/0/5 at the
slice gate. Retail's vitae/enchant chain reuses the M3 bucket-4 slice gate. Retail's vitae/enchant chain reuses the M3 bucket-4
representation; `JumpStaminaCost` never refuses (weak-jump only) — the representation; `JumpStaminaCost` never refuses (weak-jump only) — the
plan's formula shorthand had the `+0.5` operand wrong and the plan's formula shorthand had the `+0.5` operand wrong and the
implementation follows the decomp's `(load+0.5)*power*8+2`. AP-127 filed implementation follows the decomp's `(load+0.5)*power*8+2`. AP-127 was
(two minor bonus properties, out of bounded scope). filed for the then-bounded bonus properties and retired by #268 on
2026-07-31.
Today `PlayerWeenie.SetBurden` has zero callers, `CanJump` is always Today `PlayerWeenie.SetBurden` has zero callers, `CanJump` is always
true, `JumpStaminaCost` is 0, and pushed run/jump skill is true, `JumpStaminaCost` is 0, and pushed run/jump skill is
@ -94,36 +99,30 @@ character state continuously.
### P2 — Response-layer edge family — retires TS-1, TS-4, AP-7; closes #166, #116 ### P2 — Response-layer edge family — retires TS-1, TS-4, AP-7; closes #166, #116
**Status (2026-07-30, FINAL — Campaign P final physics slice):** TS-1 and **Status (2026-07-31, FINAL):** TS-1 and AP-7 retired as originally
AP-7 retired same-day as originally recorded. **TS-4 is now ALSO recorded. TS-4's first 2026-07-30 removal was accepted by an incomplete
RETIRED** — the oracle follow-up pass resolver-only fixture, failed the live matrix with a roof wedge/uphill-bounce
(`docs/research/2026-07-30-ts4-116-oracle-plan.md`) found the freeze the regression, and was reverted. The 2026-07-31 closure began from a fresh
first implementation attempt hit was one layer downstream of Path 6 `BSPTREE::find_collisions` read and ports the exact asymmetric Path-6 split:
(inside `AdjustOffset`'s crease projection against a purely-vertical primary/foot hits use `SetCollide` + `LandingZ` + `Adjusted`, while
offset — a genuine retail-identical degeneracy, not a bug) and ran the secondary/head hits use `CollisionNormal` + `Collided`; neither writes a
plan's own decisive confirming test: `Ts4SteepRoofWedgeCaptureTests`'s sliding normal. Both graph and prepared-flat implementations match that
horizontal-velocity variant (matching the realistic live-play input that oracle.
originally validated the shortcut) converges cleanly with the shortcut
removed. The Path-6 steep-poly shortcut is deleted from both The corrective acceptance no longer calls the old horizontal-input fixture
`BSPQuery.cs` and `FlatBspQuery.cs`; the pure-vertical degenerate case is "production-shaped." `Ts4ProductionQuantumConformanceTests` executes the
pinned (not fixed) as register row AD-56. **#116 is narrowed, not already-airborne, zero-root-motion 30 Hz Core collision tail — acceleration,
closed**: shape-2 (D4 first-airborne-frame hard-stop) is CLOSED — the body integration, transition resolve, exact body/cell commit, then
oracle plan's structural dispatch-routing hypothesis was confirmed by `handle_all_collisions` — and retains its behavior-bearing cell, contact,
instrumentation with no cdb session needed, and the D4 pin is un-skipped. sliding, stationary-fall, and velocity state for 90 ticks. Graph and flat
Shape-1 (tick-22760 lateral-slide loss) got a real, independently-decomp- match by raw bits for vertical/inward/tangential/downhill cases and a genuine
confirmed fix (Path 6's foot-clear/head-hit branch now returns `Collided` positive-Z uphill jump; exact terminal state, non-penetration, no fixed point,
+ `SetCollisionNormal` directly, matching pc:323824-323834/ACE and no second launch are pinned. No further product-code correction was
`BSPTree.cs:221-230`), but the confirming replay showed this does NOT needed after that test became faithful, and there is no active AD-56 row. The
explain tick-22760 itself — that mover is grounded (dispatches through older resolver-only wedge test remains only as a historical three-second
Path 5, not Path 6) and the actual "no normal recorded" mechanism signature control. #116 shape-2 remains closed; shape-1 remains narrowed as
(`SpherePath.PrecipiceSlide`'s `find_crossed_edge`-false fallback) is recorded in its issue history. AD-55 remains retired by the raw-byte
independently confirmed byte-exact retail behavior too. The remaining `cos(10°)` proof.
divergence is most likely this test's simplified door-registration
fixture, not the response layer — see ISSUES.md #116 and the oracle
plan's Addendum 2 for the full trace and the concrete next step (re-run
against the faithful Setup-based door registration). AD-55 (the sled
slope-flatness constant, split out of AP-7's retirement) is ALSO retired
this same slice — byte-proven `cos(10°)` per the oracle plan's Addendum.
The collision *response* layer (what happens after a hit): ground The collision *response* layer (what happens after a hit): ground
friction, cliff edges, downhill landings, near-perpendicular wall friction, cliff edges, downhill landings, near-perpendicular wall
@ -140,10 +139,11 @@ binds every subagent here.
stop-at-edge. stop-at-edge.
3. **#166:** port the landing "sled" (Sledding state set/clear sites; 3. **#166:** port the landing "sled" (Sledding state set/clear sites;
the sled friction constants already sit in `calc_friction`). the sled friction constants already sit in `calc_friction`).
4. **TS-4:** replace the Path-6 steep-poly in-place-slide shortcut with 4. **TS-4:** remove the Path-6 steep-poly shortcuts and port retail's exact
retail's `SetCollide → Path-4 → ContactPlane` landing chain, and sphere split: primary/foot uses `SetCollide` + `LandingZ` + `Adjusted`;
remove the two BSP-layer `SetSlidingNormal` writes (retail's only secondary/head uses `CollisionNormal` + `Collided`. Remove every BSP-layer
in-transition writer is `validate_transition`). `SetSlidingNormal` write (retail's only in-transition writer is
`validate_transition`).
5. **#116:** the near-perpendicular lateral-slide loss + first-airborne- 5. **#116:** the near-perpendicular lateral-slide loss + first-airborne-
frame divergence, driven by the existing tick-22760 replay and D4 frame divergence, driven by the existing tick-22760 replay and D4
pins. pins.
@ -296,7 +296,8 @@ waits on the single user gate below.
TS-4 (+ its FlatBspQuery twin), TS-5, TS-23, TS-35, TS-46 retired by TS-4 (+ its FlatBspQuery twin), TS-5, TS-23, TS-35, TS-46 retired by
ports; TS-25 retired on #219 evidence; TS-24→AD-57, TS-40→AD-58 ports; TS-25 retired on #219 evidence; TS-24→AD-57, TS-40→AD-58
re-argued. AP-7, AP-10, AP-25, AP-71 retired; UN-8 and AD-55 retired by re-argued. AP-7, AP-10, AP-25, AP-71 retired; UN-8 and AD-55 retired by
raw-byte proof; AD-25 retired. New argued rows: AP-127/128/129, raw-byte proof; AD-25 retired. AP-127 was subsequently retired by #268;
the remaining new argued rows are AP-128/129,
AD-53/54/55(retired)/56/57/58. AD-53/54/55(retired)/56/57/58.
**Issues:** #72, #153, #167, #255 closed; #116 shape-2 closed /shape-1 **Issues:** #72, #153, #167, #255 closed; #116 shape-2 closed /shape-1
@ -369,18 +370,51 @@ root-caused, retail-ported, and user-accepted in the same session:
Downhill bounce chain, flat-ground pop, and clean uphill landings all Downhill bounce chain, flat-ground pop, and clean uphill landings all
user-accepted ("almost pass with merits"). Investigation + byte-decode user-accepted ("almost pass with merits"). Investigation + byte-decode
record: `docs/research/2026-07-30-landing-bounce-family.md`. record: `docs/research/2026-07-30-landing-bounce-family.md`.
- **#267 shipped** (vitae/buff panel values; attributes vitae-immune) — - **#267 shipped** (vitae/buff panel values; attributes vitae-immune).
visual pass; **#268 filed** (light-blue #7FFFFF vitae parenthetical **#268 closed 2026-07-31**: the complete
from the authored 0x1B palette, green/red buff coloring, augmentation augmentation chain is shared by panel and Runtime movement; AP-127 is
properties 0x146/0x158/0x16d — promotes AP-127; retail color indices retired. Attributes, secondary attributes, and skills use retail's
and the aug chain fully decoded, implementation pending). vitae-excluded green/red comparison. The selected-skill footer now renders
- **#269 filed** — slope-stop slide sometimes runs long. `calc_friction` per-fragment colors through the shared retained text primitive, using the
(0x0050ee70) and the complete jump chain (`GetJumpHeight` 0x006b09b0, authored 0x1B palette exactly: #7FFFFF vitae, #00FF00 buff, #FF0000
`InqJumpVelocity` vz=sqrt(h·19.6), 1.0 s/0.8 s powerbar charge) are debuff. TS-8 is also retired: a real live 0x02C2 payload carries its full
byte-verified identical — the user's jump-height hypothesis is StatMod through dispatch and changes the effective skill immediately. The
refuted; next step is the live cdb A/B decay-curve trace. user accepted the live colors, values, footer, and immediate row refresh.
- **#269 closed 2026-07-31** — the live 2,184-quantum trace proved the
landing reflect and friction math were correct. ACDream omitted retail's
`OBJECTINFO::kill_velocity` before restoring a remembered contact plane
in `CTransition::validate_transition @ 0x0050AA70`, retaining full
downhill velocity while repeatedly re-grounding the mover. The exact
non-OK-only restore/kill order and final last-known validity overwrite
are now ported, focused/full gates pass, and the user accepted repeated
slope jumps. Evidence:
`docs/research/2026-07-31-269-slope-stop-capture.md`.
- **#271 closed 2026-07-31** — a bounded stair-side
trace proved ACDream could bypass retail's current-position edge back-probe
by promoting a stale `LastWalkable` tread. That made PrecipiceSlide reverse
an uphill tangent and rapidly carry the player down the stairs. The two
stale-history substitutions are removed; current-walkable, back-probe, and
no-walkable outcomes now follow `CTransition::edge_slide @ 0x0050B3D0`.
The exact captured frame is pinned in the existing installed-stair fixture
and the complete Release suite passes 10,062 tests / 5 skips. The user
accepted repeated uphill runs while pressing into the stair sides. Evidence:
`docs/research/2026-07-31-271-stair-side-slide-capture.md`.
- **#272 complete and user-accepted 2026-07-31** —
`CACQualities::InqLoad` consumes enchantment-adjusted Strength through
`InqAttribute`, but Runtime movement and both retained burden displays read
raw Strength and did not share the enchantment invalidation edge. They now
consume `GetEffectiveAttribute(Strength)` and
`Spellbook.EnchantmentsChanged`, so buff, dispel, expiration, and death
purge recompute the same burden state immediately. Focused and full
Runtime/App tests pass.
Matrix rows accepted so far: speed parity, roof slide, downhill bounce, Matrix rows accepted so far: speed parity, roof slide, downhill bounce,
flat pop, uphill landing (rows 3/4/5-partial/12-partial). Remaining flat pop, uphill landing, and #269's slope-stop feel
rows: 1, 2, 6, 7, 8, 9, 10, 11 plus #269's slide feel. Suite at this (rows 3/4/5-partial/12-partial). The 2026-07-31 final session accepted
checkpoint: 10,031 passed / 5 skips / 0 failures. burdened movement, exhausted jumping, wall/corner response, crowded-monster
movement, two-client remote/door/portal behavior, and shallow water. The user
waived the general sweep, deferred restricted-house validation as #274, and
retained the separate tight-gap clearance mismatch as #273. Automated
scenario 11 remains 20/20 passing. The #269 checkpoint passes 4,107 Core tests / 2 skips and 439
Runtime tests / 0 skips; the complete Release suite passes 10,061 tests /
5 skips / 0 failures.

View file

@ -72,3 +72,24 @@ manual halves of 11-12.
threading at a specific site, or the #116 head-sphere change — both threading at a specific site, or the #116 head-sphere change — both
P3/final-slice deltas). P3/final-slice deltas).
- Other scenarios: not yet reported. - Other scenarios: not yet reported.
## User matrix session 2 results (2026-07-31)
- Scenario 1 (burdened movement): **PASS**.
- Scenario 2 (exhausted jumping): **PASS**.
- Scenario 6 (wall graze/corner movement): **PASS**.
- Scenario 7 (crowded-monster movement): **PASS**.
- Scenario 8 (two-client remote movement, doors, portals, and its collision
checks): **PASS**.
- Scenario 10 (shallow-water sink-in): **PASS**.
- Scenario 9 (restricted/barred house): **DEFERRED BY USER** and retained as
issue #274.
- Scenario 12 (general movement sweep): **WAIVED BY USER**; the accepted
focused rows and existing automated soak are sufficient for this campaign.
- A separate live mismatch remains: acdream can squeeze through some tight
gaps that block retail. This is outside the accepted wall-graze response
check and is retained as issue #273 pending an exact-location capture.
Together with the previously accepted scenarios 35 and the automated
20-login scenario 11, the Campaign P matrix is closed with #273 and #274 as
explicit carried follow-ups.

View file

@ -0,0 +1,267 @@
# Placement production cutover — campaign plan (2026-08-02)
The final leg of the remaining physics-divergence campaign before AP-22 and
AD-10: route graphical AND headless production placement through the
residence + continuation-executor owner (`38fd4b8d` / `30012361` /
`5db3de3c`), delete the legacy duplicate authorities, and retire AP-1/AD-1
behind connected + user-visual gates.
## Handoff checkpoint — 2026-08-03
**Status: stabilization checkpoint accepted; campaign closeout is not yet
complete.** The C3c production cutover and the O(changed) collision
publication checkpoint are now playable after five separately committed
root-cause fixes:
- `01f4791e` stops origin recenter from manufacturing and replaying a second
retirement receipt for a pending-only live-projection bucket. Its exact
binary passed the complete Release suite, lifecycle route, and canonical
nine-stop soak (`connected-r6-soak-20260802-204309`, nine stops, zero
failures/wait cues/pending retirements).
- `670f307c` keeps remote Create placement, the local-player physics host,
targeting, chasing, and attacks in the same world-coordinate frame. The
user accepted monster placement/chase/hit behavior and static placement
after portals.
- `1fc529cd` materializes the canonical minimal static physics host before a
distant Use/MoveTo route and reconciles the pre-PartArray startup motion
suffix. The user accepted near and distant object use.
- `f24532ad` defers one-shot F754/F755 effects until canonical placement has
bound presentation, retries projectile/static-animation sidecars on the
committed visibility edge, and keeps effect cells synchronized. The user
accepted buffs, recalls, arrows, combat spell projectiles, portals, and
static animation.
- `175ad6b0` sends LoginComplete from the local first-placement terminal edge
instead of raw PlayerCreate receipt, so ACE's intentional login Hidden/
materialization state cannot race placement. The user accepted the login
haze behavior.
Focused verification after the final fix passed 90 App effect/projectile/
static-scheduler tests, two Runtime login tests, the exact live-entity cell
tracking regression, all 79 Headless tests, and the Release solution build
with zero errors. The long connected soak and complete solution suite have
**not** been rerun on the final `175ad6b0` binary. A broader selected fixture
run also exposed five `LiveEntityRuntimeTests` failures tied to the still-open
placement cutover plus one old remote first-entry fixture that supplies an
empty collision source; classify and fix those before claiming C5 closure.
Remaining campaign work, in order:
1. Reproduce and repair the six fixture failures without weakening their
assertions or adding compatibility bypasses.
2. Finish C4's routes 27 and remove their legacy placement writers; fold in
#276 and #277 where their route becomes authoritative.
3. Resolve #280 with retail's configured destination-prefetch window so the
portal viewport never reveals visibly constructing far terrain.
4. Run C5's complete Release suite, lifecycle/reconnect route, latest-binary
nine-stop soak, two-client observation, and the remaining #269 slope-glide
visual check. A pass from `01f4791e` is evidence for that fix, not a
substitute for the final-binary soak.
5. Delete the superseded paths, retire AP-1/AD-1/AP-131 and AD-60's legacy
half only when the code proves they are gone, then complete AP-22 and
AD-10 and close the campaign ledger.
**Inputs (read in order):**
1. [`2026-08-02-runtime-continuation-executor-handoff.md`](../research/2026-08-02-runtime-continuation-executor-handoff.md)
— the completed dormant mechanism and its cutover notes.
2. [`2026-08-02-cutover-route-inventory.md`](../research/2026-08-02-cutover-route-inventory.md)
— the full 8-route, both-host call-chain inventory with exact file:line
for every duplicate authority to remove. THE map for all slices below.
3. [`2026-07-31-remaining-physics-campaign-handoff.md`](../research/2026-07-31-remaining-physics-campaign-handoff.md)
— the original per-route requirements and prerequisite definitions.
**Standing discipline per slice:** pinned contract → single implementer →
independent retail-conformance + architecture/adversarial reviews (both must
PASS on the final diff) → focused + complete Runtime + Release build +
complete solution gates → bisectable behavior commit (register rows in the
same commit) → docs/handoff commit. No workarounds; no fused slices.
## Confirmed pre-cutover gaps (from the inventory)
- The executor publishes only generic entity deltas; nothing bridges its
completion to `RuntimePlacementProjectionChannel`, so no host can learn
"my initial placement committed" through the built observer seam.
- No atomic controller/body publication owner exists (prerequisite C);
App and headless hand-write divergent `PlayerMovementController`
construction, and `SubmitPreparedPlacement` requires a canonical
`PhysicsBody` that nothing currently publishes atomically.
- The dormant placement path's 1,880 B/operation (2,048 cap) allocation
remains the activation blocker for frame-frequency routes.
- `Execute`'s live inputs (`UsePositionFromServer`, `PlayerDistance`) are
computed by no host; they must derive from Runtime's own character-option
and local-player owners.
- `RuntimePortalPlacementAuthority` has zero producing call sites; the
adapter from `RuntimeWorldTransitState` does not exist.
- The exact-Setup mover chain (`PrepareMover` /
`RuntimeSetPositionMoverPreparer.TryBuild` /
`IPreparedCollisionSource.ReadSetupCollision`) exists piecewise, unwired.
- Route-6 split-recovery creates need an effect-replay suppression signal;
route-7 needs `TryCommitParent`/`CommitWithdrawal` cancellation-symmetry
fixes and host-visible cancellation receipts; headless lacks any
parent-realize sequence (pre-existing, adjacent).
## Slices
- **C0 — Runtime bridge + live inputs — COMPLETE at `67f63e85`
(2026-08-02, dual reviews PASS).** The executor publishes an
acknowledge-only `ExecutorCompleted` receipt through the one placement
stream (registered before dispatch; correlation reaped on
acknowledgement/discard/clear; `PendingCompletionReceiptCount` in
`IsConverged`); all three production sinks acknowledge-and-ignore the
kind via early returns proven behavior-preserving for every other kind
(sanctioned seam completion — provably inert, no production publisher);
`UsePositionFromServer` derives retail-exactly from
`RuntimeCharacterState.AutonomyLevel != 2` and `PlayerDistance` from the
live movement controller with null-safe fallback to the caller struct;
`TryPrepareAndSubmitAuthoredPlacement` chains the prepared-collision
Setup read through `PrepareMover` to submission with zero validation
changes; `TryCommitParent`/`CommitWithdrawal` gained the sibling
cancellation flow (the `LeaveWorld` omission in `TryCommitParent` is
retail-REQUIRED per `set_parent` 0x00515A90:283832-283833's single gated
`leave_world`). Not fully dormant by design: the two cancellation fixes
change live Runtime paths production already calls; everything else has
no production caller.
**C3 prerequisites recorded from C0's reviews:** (a) the completion
receipt/trace surface is internal-only — C3 must define the public host
consumption shape when it wires the hosts; (b) `PlayerDistance` is
resolved once per `Execute` entry, not per continuation — a multi-Position
FIFO classifies later entries against entry-time distance (documented
deferral; refine at C3/C4 if the connected gates show it matters);
(c) any future host exposure of `TrySetAutonomyLevel` must carry retail's
`SendAutonomyLevelEvent` (699550).
- **C1 — atomic controller/body publication — SATISFIED BY EXISTING
MECHANISM (research finding 2026-08-02, plan amended same session).**
`RuntimeLocalPlayerPhysicsPublicationState` (1,033 lines) plus the
~15-method dormant local-activation family on `RuntimeSetPositionState`
already implement the full sanctioned option-2 transaction:
off-canonical preparation against a scratch quantum clock and a sealed
candidate controller, one validated atomic Commit, and a staged
Evaluate/Commit/FinalizeActivation chain re-validated against
PhysicsOwnershipEpoch/ObjectClockEpoch/ControllerOwnershipEpoch/session
identity at every entry — with zero production callers. See
[`2026-08-02-canonical-body-writer-map.md`](../research/2026-08-02-canonical-body-writer-map.md)
(6 canonical body writers; the two host escape hatches; both hosts'
divergences). The remaining work — routing both hosts' local-player
construction through the publication lifecycle, sealing the public
`RuntimeLocalPlayerMovementState.Controller` setter, retiring App's
direct object-clock bypasses, and containing headless's uncaught
prepared-collision `InvalidDataException` — IS the C3 route-1 flip and
moves there. No separate C1 commit.
- **C2 — placement allocation budget — COMPLETE at `63c601ff`
(2026-08-02, dual reviews PASS after two fix rounds).** 2,032 → 944
B/op via pooled operation envelopes (bounded, reset-at-rent, double-
retire guarded, reset/dispose-cleared, ledger-visible), a cached
collision-callback delegate over an explicit context stack, and a
non-boxing pending-head read; gate tightened to 1,536. The pooling
forced a class-wide staleness rework: captured-token-vs-fresh-lookup at
every reentrancy-spanning frame (26-site audit), hoisted stack locals
for retail's handle_all_collisions bits, token-gated bookkeeping
writes, and a deliberately identity-agnostic settle path (retail's
SetPositionInternal completes unconditionally even for displaced
operations).
**Residual floor (documented at the gate, decision deferred to the C3
activation gate where the user is in the loop):** ~520 B/op inside
Core's `PhysicsEngine.SetPosition` (transition init / query-footprint
materialization — a potential C2b if C3's connected profile shows it
matters) and ~208 B/op of sorted-tree node per pending receipt.
**Maintenance notes from review (no action):** the no-reentrancy
proofs on the 15 surviving reference-based currency checks are
comment-enforced; `IsCurrent(Operation)` remains available and a new
reentrancy-spanning call site would silently inherit the tautology —
its doc comment warns.
- **C3 — spawn-frequency cutover: routes 1 + 8 — DECOMPOSED 2026-08-02
after the first implementation pass stopped with findings.** C3-1 (the
public executor-completion surface via
`RuntimePlacementProjectionChannel.TryGetInitialCreateCompletion`)
landed separately. Two structural gaps halted the flip, both real and
neither in the planning docs:
**(B)** the local player's residence lease opens its SetPosition
operation at Create time, but `SubmitPreparedPlacementCore` requires a
pre-existing canonical body that only the zero-caller publication chain
can attach — first-entry needs an explicit resumable sequence
(begin-placement → publication Prepare/Commit attaches the body →
authored-mover submit → Place receipt → Execute), which matches the
campaign handoff's route-1 required order but exists nowhere as a
driveable state machine;
**(C)** ordinary remote-creature Creates classify to `SetPosition` but
have NO production body-construction path at Create time (bodies arrive
with first motion today; retail constructs physics at CreateObject via
`ACCObjectMaint::CreateObject`/`set_description`, which our retail
notes fully document — the defaults come from the wire PhysicsDesc,
not invention).
Sub-slices, each with the standing contract/dual-review/gate
discipline:
- **C3a — Runtime first-entry sequencing — COMPLETE at `960373df`
(2026-08-02, dual reviews PASS).** `RuntimeLocalPlayerFirstEntryState`:
five stages (mover-prep → publication Prepare/Commit → activation →
acknowledgement → Execute) in retail's own order — mover shapes
BEFORE placement, matching makeObject/set_description preceding
enter_world; the original contract prose had it backwards and the
tested preconditions forced the faithful order. Acknowledge-stage
authority discrimination, automatic convergence through the (now
multicast, snapshot-iterated) retirement fan-out, ownership-ledger
fold, transactional late-bind Publication seam. Dormant: C3c's first
act is the GameRuntime binding + production Advance drive.
**Carried findings for C3c:** the controller is live from the
activation commit onward (abandonment leaves it to ordinary entity
teardown — retail has no entry-flow rollback); EvaluateActivation's
post-commit DeferredCell overload is encapsulated behind Advance.
- **C3b — remote body construction at Create — COMPLETE at `0934a121`
(2026-08-02, dual reviews PASS).** `RuntimeRemoteBodyDescription` +
`RuntimeRemoteFirstEntryState`: the full `set_description` order with
the byte-certain gates (friction [0,1] inclusive, NaN sanctioned-skip;
elasticity clamp with retail's unordered-to-zero; translucency
!= 0.0f), the movement-branch discriminator on retail's
`movement_buffer != 0` (empty-buffer → placement branch, no autonomy),
motion-table zero-id pass, ctor-defaults for absent wire fields, and
never-clobber coexistence with the build-at-first-motion production
path. The acknowledge discriminator is one shared body
(`RuntimeFirstEntryAcknowledgement`) for both conductors. Dormant.
- **C3c — the host flips (production) — COMPLETE at `529e0e9d`
(2026-08-02, dual Opus reviews: initial FAIL 2+2 MAJOR → R1 fix
round → delta PASS both).** Both hosts register initial Creates
through residence + conductors via the shared
`RuntimeFirstEntryDriveController`; Controller setter sealed;
rebucketing presentation-only strictly while the residence is
ACTIVE (post-residence entities take the full legacy path including
the `prepare_to_enter_world` clock edges); content-less headless
keeps pre-flip direct registration. Five fix slices landed inside
the cutover, each connected-gated: F1 (Runtime ownership seam for
movement stats/server physics — the post-logout retired-controller
crash), F2 (the login activation wedge: admission-prefix gate
factored from the seal, rearm generation identity, auto-entry
requires the published controller), F3 (landblock-prefix 0-sentinel
→ explicit absent-id; corner landblocks legal), F4 (diagnosis only:
the nine-stop soak's convergence failure is pre-existing `6b28ff99`
whole-world collision-clone throughput — its fix is the next slice
before C5), F5 (local-player first-entry ground contact via the
shared `SpawnPlacementSettler` at `FinalizeActivation`; the
standing-cast airborne rejections are gone; register AD-61). R1
additionally armed the login constraint leash at the committed
placement (`HandleReceivedPosition` 0x00453FD0 analog) and
refreshed AD-42. Final gates: complete solution 10,816/0/4 skips;
lifecycle/reconnect gate PASS (`connected-world-gate-20260802-
175401`). Closeout:
[`2026-08-02-c3c-cutover-closeout.md`](../research/2026-08-02-c3c-cutover-closeout.md).
**Carried to C4/C5:** route-1 far-Create service-window conversion
if either streaming/broadcast radius changes (#277); the
window-departure park narrowing; `NotifyRetirement`-on-active-entry
subscriber invariant; the reachable equip-mid-conductor fail-fast;
settle-CellId discard (#276-adjacent, see ISSUES).
- **C4 — remaining routes: 2 (ForcePosition), 3 (portal, with the
`RuntimeWorldTransitState``RuntimePortalPlacementAuthority` adapter),
4 (remote Create/Position; delete `RemoteTeleportController`/`Placement`
and the inline MoveOrTeleport duplicate), 5 (projectile authoritative),
6 (drops + split-recovery marking), 7 (residual pickup/parent/delete
polish). — OPEN at the 2026-08-03 handoff.** May land as more than one
commit if a route proves large;
each sub-landing keeps the full review discipline.
- **C5 — legacy deletion + closeout gates — OPEN.** Delete every superseded legacy
path; parity tests; exact lifecycle/reconnect + canonical nine-stop
connected routes; two-client observation; **user visual matrix** (the
campaign's stopping point for user acceptance). Retire AP-1, AD-1,
AP-131, AD-60's legacy half, and close #275. Update register/roadmap/
milestones/architecture/memory + successor handoff.
After C5: AP-22 (authored collision shapes), then AD-10 (remote
contact-plane projection), then the campaign's final matrix and ledger
closeout; vendor Slice 5 resumes.

View file

@ -1,14 +1,17 @@
# P2 — Collision response-layer edge family: port-ready pseudocode # P2 — Collision response-layer edge family: port-ready pseudocode
**Status: RESEARCH PASS COMPLETE (2026-07-30); IMPLEMENTATION PASS PARTIAL **Status: RETAIL RESPONSE ORDER COMPLETE (2026-07-31).** Originally a
(2026-07-30).** Originally a research-only doc for Campaign P Slice P2 research-only doc for Campaign P Slice P2
(`docs/plans/2026-07-29-physics-parity-campaign.md` §P2); a same-day (`docs/plans/2026-07-29-physics-parity-campaign.md` §P2). The first TS-4
implementation session landed TS-1's retirement and AP-7's fix, attempted attempt reproduced a steep-roof fixed point because its fixture discarded
TS-4 per this doc's own §6 Step 3 fixture-first order, reproduced the the accepted contact state between frames; §7 item 6 preserves that useful
historical wedge, and stopped — see §7 item 6 for the full capture and failure analysis. Slice 1B then completed the exact nested edge/StepDown
root-cause diagnosis. TS-4 is NOT retired; its shortcut stays in place. dispatcher, Slice 2A restored StepDown's mandatory Placement tail, and Slice
2B repeated the removal with production-shaped state carry and the complete
direction matrix. TS-4 is now retired; see §10.
#166 got a reattribution note in ISSUES.md rather than new code (per §3). #166 got a reattribution note in ISSUES.md rather than new code (per §3).
#116 remains untouched (oracle-first, out of implementation scope). #116's former skipped wall control is now active with the retail Path-6
first-frame hard stop and next-frame slide chronology.
**Headline findings that change the plan's assumptions:** TS-1 was already **Headline findings that change the plan's assumptions:** TS-1 was already
substantially ported (the register row and plan phrasing were stale — see substantially ported (the register row and plan phrasing were stale — see
§2); the one real gap needed no code change (acdream's unified world-space §2); the one real gap needed no code change (acdream's unified world-space
@ -17,14 +20,11 @@ substantially ported (the register row and plan phrasing were stale — see
NOT about a literal `PhysicsState.Sledding` auto-toggle at all (see §3); NOT about a literal `PhysicsState.Sledding` auto-toggle at all (see §3);
AP-7's L.3c regression does not reproduce on the production graphical AP-7's L.3c regression does not reproduce on the production graphical
root-motion path post-R6, and now ports retail's confirmed 0.25f threshold root-motion path post-R6, and now ports retail's confirmed 0.25f threshold
(see §1); TS-4's shortcut removal is coupled to TS-1's completion and (see §1). The apparent TS-4 wedge was a harness-state defect, not a reason to
reproduces a wedge even after TS-1 lands — see the §7 item 6 update for the retain a product compensation: production-shaped vertical, inward,
precise mechanism (Phase 3 of `TransitionalInsert` is structurally tangential, uphill, and downhill histories now pass graph/flat raw-bit
unreachable from Path 6's unconditional `SetCollide`, which returns parity without the shortcut. Campaign P Slice 1B removed AP-3, AP-4, AD-53,
`Adjusted` without repositioning the sphere) and what a future attempt and AD-54 (§8); Slice 2A retired AP-5 (§9); Slice 2B retired TS-4 (§10).
needs to check first; #116 remains a genuine oracle-first research item
needing live cdb/Ghidra, not an implementation item (see §5). Read §6 (port
order) before starting further implementation on this family.
Every claim below is tagged **FACT** (grep/read-verified against the Every claim below is tagged **FACT** (grep/read-verified against the
named-retail decomp, the register, ISSUES.md, or current acdream source named-retail decomp, the register, ISSUES.md, or current acdream source
@ -72,12 +72,11 @@ These bind the P2 implementer. Do not re-attempt any of these shapes.
once already (2026-06-12) for the EPSILON-vs-EpsilonSq bug; Ghidra MCP once already (2026-06-12) for the EPSILON-vs-EpsilonSq bug; Ghidra MCP
is DOWN for this research pass — mark any residual x87-ambiguous claim is DOWN for this research pass — mark any residual x87-ambiguous claim
Ghidra-verify, cite ACE as the fallback tiebreaker, do not silently guess. Ghidra-verify, cite ACE as the fallback tiebreaker, do not silently guess.
9. **AP-4 (CliffSlide check moved before retail's Branch-1 gate)** is a 9. **SUPERSEDED 2026-07-31 by Campaign P Slice 1B.** AP-4's CliffSlide-first
live, load-bearing reordering compensating for acdream's incomplete compensation was removed only after the complete retail
OnWalkable bookkeeping — touches the same code region as TS-1. Do not `transitional_insert`/`edge_slide` order was read and branch-order plus
revert AP-4's reordering without re-verifying OnWalkable is complete; graph/flat multi-frame roof/ledge controls passed. Do not reintroduce the
read AP-4's full row before changing `TransitionTypes.cs:1316` control compensation; see §8 and the retired AP-4 row.
flow.
10. **TS-46 (two-scalar sphere reconstruction) is OUT OF SCOPE for P2** 10. **TS-46 (two-scalar sphere reconstruction) is OUT OF SCOPE for P2**
(it's P3) but shares files (`TransitionTypes.cs` `InitPath`) — do not (it's P3) but shares files (`TransitionTypes.cs` `InitPath`) — do not
fold TS-46 sphere-list work into a P2 commit. fold TS-46 sphere-list work into a P2 commit.
@ -597,19 +596,19 @@ manufacture a caller that sets it.
## 4. TS-4 — Path-6 steep-poly shortcut removal ## 4. TS-4 — Path-6 steep-poly shortcut removal
### The current shortcut (FACT, `src/AcDream.Core/Physics/BSPQuery.cs:2149-2266`) ### The former shortcut (HISTORICAL FACT; removed by Slice 2B)
Path-6 (the default `sphere_intersects_poly → collide_with_pt / SetCollide` Before Slice 2B, Path-6 (the default
dispatch) tests each hit polygon's world-space normal. For BOTH sphere0 `sphere_intersects_poly → collide_with_pt / SetCollide` dispatch) was not
(feet) and sphere1 (head), if `worldNormal.Z < PhysicsGlobals.FloorZ` symmetrically wrong. The parsed-graph primary/foot branch tested the hit
(steeper than ~49° from horizontal), acdream takes a SPECIAL BRANCH: polygon's world-space normal and, below `PhysicsGlobals.FloorZ`, projected
projects the move along the steep face, writes the move along the face, wrote both collision and sliding normals, and
`collisions.SetCollisionNormal(worldNormal)` **and** returned `Slid`. Its secondary/head branch had already been corrected by
`collisions.SetSlidingNormal(worldNormal)`, and returns #116 to retail's unconditional `CollisionNormal` + `Collided` response. The
`TransitionState.Slid` immediately — bypassing `SetCollide` entirely for prepared-flat port still applied the steep shortcut to both spheres and sent
steep hits. Only the shallow case (`worldNormal.Z >= FloorZ`) reaches both shallow cases through `SetCollide` + `Adjusted`; its head branch was
`path.SetCollide(worldNormal); path.WalkableAllowance = LandingZ; return therefore wrong for every slope. This distinction matters: retail does not
Adjusted;`. apply one common response to both spheres.
The in-code comment is unusually candid about why: **"This is a The in-code comment is unusually candid about why: **"This is a
deliberate deviation from retail... Validated against retail debugger deliberate deviation from retail... Validated against retail debugger
@ -621,10 +620,11 @@ was shipped SAME-DAY as (and BECAUSE) the retail-faithful
§2 above — also dated 2026-04-30, tagged "L.4") still wedged in testing §2 above — also dated 2026-04-30, tagged "L.4") still wedged in testing
when tried without this shortcut. when tried without this shortcut.
### Retail: NO steepness branch at the BSP layer (FACT, pseudo-C:323740-323783, `0053a730` region) ### Retail: NO steepness branch in either Path-6 sphere response (FACT, `0053a730` region)
Read directly from the named decomp (the `sphere_intersects_poly` / Read directly from the named decomp (the `sphere_intersects_poly` /
`set_collide` dispatch inside `BSPTREE::find_collisions`'s default path): `set_collide` dispatch for the primary/foot sphere inside
`BSPTREE::find_collisions`'s default path):
``` ```
if (sphere_intersects_poly(...) || eax_26 != 0) { if (sphere_intersects_poly(...) || eax_26 != 0) {
@ -635,11 +635,21 @@ if (sphere_intersects_poly(...) || eax_26 != 0) {
} }
``` ```
**There is no steepness test here at all.** Retail's BSP layer calls **There is no steepness test in this foot branch.** It calls `set_collide`
`set_collide` and returns `ADJUSTED_TS` **unconditionally**, for a steep and returns `ADJUSTED_TS` for a steep roof exactly as for a shallow ramp.
roof exactly the same as a shallow ramp. `walkable_allowance` is always `walkable_allowance` is always set to `LandingZ` (the permissive landing
set to `LandingZ` (the permissive landing threshold) at this layer, threshold), regardless of polygon slope. If the foot is clear but the
regardless of the actual polygon slope. This directly confirms the P2 secondary/head sphere hits, retail instead performs the other slope-agnostic
response:
```
localtoglobalvec(sphere_path.localspace_pos, &normal, &head_poly->plane.N);
COLLISIONINFO::set_collision_normal(&collision_info, &normal);
return 2; // COLLIDED_TS
```
The head branch neither calls `set_collide` nor returns `ADJUSTED_TS`.
Neither sphere response has a steepness branch. This directly confirms the P2
plan's description and the digest's #137-mechanism-2 finding plan's description and the digest's #137-mechanism-2 finding
(`memory/project_physics_collision_digest.md:1008-1014`): **retail's (`memory/project_physics_collision_digest.md:1008-1014`): **retail's
BSP/sphere collision layer never writes `collision_info.sliding_normal` BSP/sphere collision layer never writes `collision_info.sliding_normal`
@ -651,20 +661,17 @@ and — when that surface turns out too steep to be walkable —
`EdgeSlideAfterStepDownFailed``CliffSlide`/`PrecipiceSlide` (TS-1's `EdgeSlideAfterStepDownFailed``CliffSlide`/`PrecipiceSlide` (TS-1's
domain, §2 above). domain, §2 above).
### TS-4 port shape (FACT-grounded, mechanically simple) ### TS-4 port shape (COMPLETED 2026-07-31)
Delete both `if (worldNormal{0,1}.Z < PhysicsGlobals.FloorZ) { ... return Delete the primary/foot steep shortcut in both representations. Every foot
TransitionState.Slid; }` blocks (`BSPQuery.cs:2200-2215` and hit must call `SetCollide`, set `WalkableAllowance=LandingZ`, and return
`:2240-2255`) entirely. Both sphere0 and sphere1 hits should fall straight `Adjusted`. Preserve the parsed-graph head branch corrected by #116, and
through to the existing `path.SetCollide(worldNormal); path.WalkableAllowance replace the prepared-flat head steep/shallow split with the same unconditional
= PhysicsGlobals.LandingZ; return TransitionState.Adjusted;` — i.e., make `SetCollisionNormal` + `Collided` response. No Path-6 branch writes a sliding
Path-6 do EXACTLY what its own shallow branch already does, for every normal. This is the exact retail foot/head split, not a shared fallback to the
hit, matching retail's unconditional `set_collide`. This mechanically foot behavior.
retires both `SetSlidingNormal` write sites (satisfying DO-NOT-RETRY §0
item 1 permanently — deleted, not just avoided) with no replacement logic
needed at this layer.
### ⚠️ Port-order coupling with TS-1 (INFERENCE, but directly evidenced by the shortcut's own commit history) ### Port-order coupling with TS-1 (historical guard, now satisfied)
**This is the single most important sequencing fact in this whole **This is the single most important sequencing fact in this whole
document.** The shortcut's comment proves TS-1's retail-faithful chain document.** The shortcut's comment proves TS-1's retail-faithful chain
@ -1066,3 +1073,174 @@ it blocks.
behavior." behavior."
--- ---
## 8. Campaign P Slice 1B closeout — exact response ordering (2026-07-31)
The follow-up read used the complete named-retail bodies, not the earlier
excerpt summaries:
- `CTransition::transitional_insert` at `0x0050B6F0`
(pseudo-C:273137 onward) returns `OK_TS` as soon as
`contact_plane_valid != 0`. Only an invalid contact reaches the ordinary
StepDown tail, whose remaining gates are Contact,
`!sphere_path.step_down`, a non-null check cell, and ObjectInfo.StepDown.
- Its StepDown schedule is asymmetric by authored sphere count. For a
one-sphere mover whose requested height exceeds the foot diameter, retail
clamps the probe to half the foot radius and performs one probe. Otherwise
a request within the diameter probes once; an over-diameter request on a
two-sphere mover is halved and probes twice in sequence.
- `CTransition::edge_slide` at `0x0050B3D0`
(pseudo-C:273001-273090) runs `!OnWalkable || !EdgeSlide` restore-and-OK
before its steep-contact CliffSlide branch. Any stored walkable polygon
routes to PrecipiceSlide without a steepness test.
- `CTransition::cliff_slide` at `0x0050A6D0`
(pseudo-C:272397 onward) crosses the supplied contact normal only with
`collision_info.last_known_contact_plane.N`. It has no remembered-walkable
or world-up substitute. A default, invalid, parallel, or otherwise
degenerate cross naturally returns `OK_TS` through the retail normalization
guard.
`TransitionTypes.cs` now follows that order exactly. AP-3, AP-4, AD-53, and
AD-54 are retired together. Slice 1B deliberately left the ordinary-tail
`runPlacement: false` choice for the following AP-5-specific slice and did not
alter TS-4's Path-6 steep-polygon shortcut. Slice 2A below closes AP-5.
`RetailEdgeResponseOrderingTests` pins every distinguishing branch: valid
steep-contact early return, the one/two-sphere probe schedule,
not-OnWalkable-before-CliffSlide, last-known-only source selection, degenerate
last-known handling, and stored-steep-walkable-to-Precipice routing. It also
runs multi-frame steep-roof and flat-roof-edge controls through both parsed
graph and prepared-flat collision traversal, requires exact trace parity, and
rejects a greater-than-15-tick frozen streak. The earlier dedicated
`Ts4SteepRoofWedgeCaptureTests` remains green, so retiring these four
compensations did not require weakening or deleting the TS-4 control.
### Corrective review: edge_slide has two outputs
The first Slice 1B commit collapsed `CTransition::edge_slide`'s function
return into its out `TransitionState`. That loses a material retail case:
the steep-contact branch writes the result of `cliff_slide` to the out state
but returns false independently. A degenerate/parallel CliffSlide therefore
writes `OK_TS` **and still tells `transitional_insert` to continue its outer
retry**. Branch 1 and the contact-without-remembered-walkable branch stop with
`OK_TS`; a `COLLIDED_TS` Precipice result stops; `SLID_TS` and `ADJUSTED_TS`
retain their ordinary retry handling.
The corrective implementation preserves this exact bool-plus-out-state seam.
Its end-to-end test drives an invalid-contact StepDown probe into a steep,
parallel-last-known CliffSlide, then proves a second outer object pass occurs
before success. The roof controls were also hardened: the flat-roof fixture
must first land and publish its persistent contact/walkable chronology, fails
its control arm when the roof is removed, and proves outward-X rejection plus
continued edge tangency. The steep-roof fixture now rejects non-finite or
oversized frame steps and signed-plane penetration until polygon exit. Parsed
graph and prepared-flat runs compare every `ResolveResult` field and every
persistent `PhysicsBody` field by raw float/double bits, including the ordered
walkable vertex payload.
## 9. Campaign P Slice 2A closeout — mandatory StepDown placement (2026-07-31)
The complete `CTransition::step_down` body at `0x0050B2A0`
(pseudo-C:272946272998) has no caller-controlled validation choice. It resets
`walk_interp` to 1, performs the transitional downward/support probe, applies
the `EdgeSlide && !StepUp` walkable-support gate, and then always:
1. saves `sphere_path.insert_type`;
2. installs `PLACEMENT_INSERT`;
3. calls `CTransition::transitional_insert(this, 1)`;
4. restores the saved insert type; and
5. returns true only for `OK_TS`.
The `walk_interp = 1` assignment occurs once at `step_down` entry. Retail does
not reset it again before the final Placement insertion: that insertion sees
and carries the exact interpolation value left by the support probe. Likewise,
`CTransition::check_walkable` (`0x0050AFF0`, pseudo-C:272811272856) saves its
temporary `check_pos` and cell in stack locals. It does not overwrite
`SPHEREPATH::backup_check_pos`/`backup_cell`, because the enclosing edge-slide
still needs that outer failed candidate after the nested support probe.
This is the same tail for normal grounded contact maintenance, the
`edge_slide` current-position back-probe, and StepUp. The former acdream
`runPlacement: false` argument on the first two callers was therefore a real
behavioral divergence, not a caller-specific retail mode.
The historical justification for the bypass was a wall-slide candidate that
Placement rejected. The correct boundary is the Placement dispatcher, not
StepDown. Retail's BSP, sphere, and cylinder Placement branches are pure
occupancy tests and shave `PhysicsGlobals.EPSILON` from their effective reach.
The current graph and prepared-flat dispatchers already implement that rule:
an exactly tangent sphere remains valid while penetration beyond the retail
epsilon is rejected. With the response-order fixes from §8 in place, restoring
the mandatory placement tail does not reproduce the old wall stall.
`RetailStepDownPlacementTests` pins the mechanism end to end for one- and
two-sphere movers, including raw-bit `walk_interp` carry, nested
`check_walkable` backup preservation, ordinary contact maintenance, StepUp,
and a supported candidate that overlaps only during final Placement. It also
compares graph and prepared-flat Placement at exact wall tangency and
beyond-epsilon overlap. The full-engine floor-plus-wall replay drives ten
grounded maintenance frames for both sphere counts, requires one Placement for
every successful support maintenance, proves no freeze or penetration plus
tangential progress, and compares complete `ResolveResult` and persistent
`PhysicsBody` state by raw bits between parsed-graph and prepared-flat paths.
The #273, #271, #185, StepUp, transition-retry, and TS-4 controls remain
unchanged. AP-5 is retired; TS-4 is intentionally untouched.
---
## 10. Campaign P Slice 2B closeout — TS-4 retired (2026-07-31)
A fresh read of `BSPTREE::find_collisions` (`0x0053A440`) confirms the exact
Path-6 split. A primary/foot-sphere polygon hit transforms the polygon normal,
calls `SPHEREPATH::set_collide`, writes `LandingZ`, and returns `ADJUSTED_TS`
(`0x0053A7B3..0x0053A7DC`) regardless of steepness. Only when that sphere is
clear does a secondary/head-sphere hit write `collision_normal` and return
`COLLIDED_TS` (`0x0053A793..0x0053A7A4`). Neither branch writes
`sliding_normal`.
Both parsed-graph and prepared-flat Path-6 implementations now follow that
split exactly. The steepness branches, in-place tangent projection, and BSP-
layer `SetSlidingNormal` writes are deleted. Exact site tests compare the two
representations by raw bits and pin every mutated and preserved field:
- foot: `SetCollide`, candidate backup, transformed `StepUpNormal`,
`WalkInterp=1`, `WalkableAllowance=LandingZ`, `Adjusted`;
- head: `CollisionNormal`, `Collided`, with no `SetCollide` state mutation;
- both: a pre-existing sliding normal is preserved byte-for-byte.
The failed first removal was a test-harness lesson, not a retail exception.
Its gravity-only replay always passed `isOnGround:false`, manually zeroed
vertical velocity, and only copied part of the accepted contact state, so it
could not reproduce the live caller's next-frame chronology. The replacement
`Ts4ProductionQuantumConformanceTests` replay executes each already-airborne,
zero-root-motion 30 Hz Core collision quantum in the production order:
`calc_acceleration`, `UpdatePhysicsInternal`,
`ResolveWithTransition`, exact body/cell commit, then
`PhysicsObjUpdate.CommitSetPositionTransition` (the Core owner of retail's
Contact/OnWalkable replacement plus `handle_all_collisions`). It carries the
same body, cell, contact plane, walkable plane, sliding state, stationary-fall
counter, cached velocity, and transient flags for all 90 ticks. PositionManager
root composition, animation hooks, movement callbacks, and fresh-airborne
`LeaveGround`/`HitGround` edges are deliberately outside this already-airborne
dat-free fixture; it does not claim to replay those presentation/motion stages.
Parsed graph and prepared flat match by raw result/body bits for vertical,
inward, tangential, and downhill roof motion plus a genuine positive-Z uphill
jump. The foot-contact jump's apex precedes roof contact and every later
candidate velocity remains non-positive in Z. A separate elevated head-only
collision reaches the wall while Z velocity is still positive and pins the
valid-normal, inward-dot retail 5%-elastic reflection without adding vertical
velocity. Every direction rejects a half-second fixed point and signed-plane
penetration, while exact terminal velocity, Contact/OnWalkable/Sliding bits,
sliding normal, and contact plane are fixed by raw float bits. The older
resolver-only wedge capture remains a deliberately weaker historical control
and is restored to its original three-second bound. The former #116 D4
control remains active: its primary-sphere hit hard-stops frame one through
SetCollide, then the accepted persistent normal permits the downward slide on
frame two.
The complete historical #273/#271/#269/#265/#185/#137/#116/cellar/roof
matrix and the full Core Release suite pass without a replacement
compensation. TS-4 is retired.
---

View file

@ -54,11 +54,16 @@ InqLoad(this, &loadOut):
return 1 // always succeeds for CACQualities (has vtable) return 1 // always succeeds for CACQualities (has vtable)
``` ```
This EXACTLY matches acdream's existing `IndicatorBarController.UpdateBurden()` The property/capacity shape matches acdream's
/ `InventoryController.RefreshBurden()` pattern (Strength attribute + prop `IndicatorBarController.UpdateBurden()` /
0xE6 aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the `InventoryController.RefreshBurden()` pattern (Strength attribute + prop 0xE6
wire value is absent) — already ported, already correct, already tested via aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the wire
the UI. **`AcDream.Core.Items.BurdenMath` value is absent). A 2026-07-31 connected gate exposed one omitted retail
detail: `InqAttribute` returns the enchantment-adjusted attribute, while all
three acdream burden consumers still read raw `AttributeValue.Current`.
Issue #272 corrects them to `LocalPlayerState.GetEffectiveAttribute(Strength)`
and invalidates burden on the canonical `Spellbook.EnchantmentsChanged` edge.
**`AcDream.Core.Items.BurdenMath`
(`EncumbranceCapacity`/`LoadRatio`/`LoadModifier`) is the SAME formulas at (`EncumbranceCapacity`/`LoadRatio`/`LoadModifier`) is the SAME formulas at
the SAME addresses.** P1's `EncumbranceSystem` (Physics-namespaced, for the SAME addresses.** P1's `EncumbranceSystem` (Physics-namespaced, for
citation clarity next to `MovementSystem`) delegates to `BurdenMath` rather citation clarity next to `MovementSystem`) delegates to `BurdenMath` rather
@ -141,10 +146,13 @@ InqRunRate(this, &rateOut):
EnchantAttribute2nd(this, 4, &currentStamina) // vital-buff adjusts the LOCAL COPY only EnchantAttribute2nd(this, 4, &currentStamina) // vital-buff adjusts the LOCAL COPY only
// (not the wire "current stamina" state) // (not the wire "current stamina" state)
skill = InqSkillBaseLevel(this, SKILL_RUN=0x18) // base: formula-bonus + init + ranks skill = InqSkillBaseLevel(this, SKILL_RUN=0x18) // formula-bonus + init + ranks
// (+ two minor bonus properties 0x146, skill += max(PropertyInt 0x16D, 0) // LumAugAllSkills
// 0x158 — NOT ported, see §6 AP-127) skill += matching category augmentation ? 10 : 0 // 0x12C melee / 0x12D missile /
EnchantSkill(this, 0x18, &skill) // vitae * skill-enchantments, floor@0.5, round // 0x12E magic; exact skill-id switch
EnchantSkill(this, 0x18, &skill) // vitae * skill-enchantments, floor@0.5, truncate
if (PropertyInt 0x146 > 0) skill += 5 // Jack of All Trades
if (skill is specialized) skill += 2 * max(PropertyInt 0x158, 0)
if (currentStamina == 0) skill = 0 // THE stamina-gates-movement mechanism if (currentStamina == 0) skill = 0 // THE stamina-gates-movement mechanism
@ -189,16 +197,21 @@ skill... reading vitae + relevant skill enchantments from the M3
active-effect state" without a general effective-skill engine — the only new active-effect state" without a general effective-skill engine — the only new
code is the type-flag filter and the skill-id key. code is the type-flag filter and the skill-id key.
**Two things P1 deliberately does NOT port** (bounded scope, register row **2026-07-31 #268 closeout:** the previously bounded augmentation terms are
AP-127): now ported in shared `PlayerSkillMath`, after a complete read of
1. Two minor additive skill-bonus properties inside `InqSkillBaseLevel`'s `CACQualities::InqSkill @ 0x00592660`. The exact order matters:
surrounding block (property `0x146` "> 0 → +5", property `0x158`
"specialized-skill → double a PP-derived term") — small, rare bonuses 1. intrinsic formula/init/ranks;
unrelated to burden/stamina/vitae. 2. positive property 0x16D plus the exact category +10 switch;
2. `EnchantAttribute2nd`'s buff-adjustment of the LOCAL stamina-current copy 3. `EnchantSkill`;
used only for the `== 0` gate (i.e. a Stamina-boosting buff could 4. property 0x146 contributes +5 when positive;
theoretically keep that local copy above 0 even at true-zero wire 5. specialized skills receive `2 × max(property 0x158, 0)`.
stamina). We gate on the raw wire "current stamina" value directly.
The character panel and Runtime movement both consume this one Core
calculation. AP-127 is retired. The apparent current-stamina-copy residual
does not create an independently reachable effect for ordinary stat
enchantments: current and maximum stamina use distinct secondary-attribute
keys, and a max-stamina enchantment cannot turn zero current stamina nonzero.
## 6. GetRunRate / GetJumpHeight / JumpStaminaCost formula bodies (MovementSystem, pc 695958+) ## 6. GetRunRate / GetJumpHeight / JumpStaminaCost formula bodies (MovementSystem, pc 695958+)
@ -309,8 +322,10 @@ Runtime (AcDream.Runtime, presentation-free):
- onSkillsUpdated callback -> character.Character.UpdateMovementSkillBase(...) - onSkillsUpdated callback -> character.Character.UpdateMovementSkillBase(...)
- NEW: inventory.Objects.{ObjectAdded,ObjectUpdated,ObjectRemoved,ObjectMoved, - NEW: inventory.Objects.{ObjectAdded,ObjectUpdated,ObjectRemoved,ObjectMoved,
ContainerContentsReplaced,Cleared} + LocalPlayer.AttributeChanged(Strength) ContainerContentsReplaced,Cleared} + LocalPlayer.AttributeChanged(Strength)
+ Spellbook.EnchantmentsChanged
-> recompute burden (Strength + prop 0xE6 aug + prop 5 EncumbranceVal, -> recompute burden (Strength + prop 0xE6 aug + prop 5 EncumbranceVal,
SAME shape as IndicatorBarController.UpdateBurden/InventoryController.RefreshBurden) SAME shape as IndicatorBarController.UpdateBurden/InventoryController.RefreshBurden)
using effective/enchantment-adjusted Strength
-> character.Character.MovementSkills.UpdateBurden(ratio) -> character.Character.MovementSkills.UpdateBurden(ratio)
- NEW: character.Character.LocalPlayer.Changed(VitalKind.Stamina) - NEW: character.Character.LocalPlayer.Changed(VitalKind.Stamina)
-> character.Character.MovementSkills.UpdateStamina(current) -> character.Character.MovementSkills.UpdateStamina(current)
@ -365,10 +380,8 @@ every pre-P1 `PlayerWeenieTests.cs` expectation unchanged.
- **TS-23 extended** (not a new row) — its "PlayerKillerStatus not parsed" - **TS-23 extended** (not a new row) — its "PlayerKillerStatus not parsed"
scope now also covers the new `MovementSystem.JumpStaminaCost` `pk` scope now also covers the new `MovementSystem.JumpStaminaCost` `pk`
parameter, hardcoded `false` at the `PlayerWeenie` call site pending P3. parameter, hardcoded `false` at the `PlayerWeenie` call site pending P3.
- **New AP-127** — two minor retail skill-bonus properties (0x146, 0x158) - **AP-127 retired 2026-07-31 (#268)** — the complete 0x16D/category/
and the stamina-buff-adjusts-local-copy nuance are not ported (§5, §9 0x146/0x158 chain is shared by panel and movement (§5 closeout).
bullet list) — bounded, deliberate, low-risk (rare bonus terms, not
burden/stamina/vitae).
- **New UN-8**`CACQualities::CanJump`'s x87 comparison polarity resolved - **New UN-8**`CACQualities::CanJump`'s x87 comparison polarity resolved
by domain plausibility rather than a literal BN read (§3); Ghidra MCP by domain plausibility rather than a literal BN read (§3); Ghidra MCP
confirmation is the retire path. confirmation is the retire path.

View file

@ -0,0 +1,104 @@
# Issue #269 — slope-stop capture and retail correction
**Date:** 2026-07-31
**Status:** implemented; user live gate passed
**Scope:** landing-bounce follow-up, `CTransition::validate_transition`
## Symptom
After the retail 5%-elasticity landing reflection was restored for #265,
the character could retain too much downhill speed after landing on a
walkable slope. The user described the residual as “slides too far on
landing.”
## ACDream live capture
`ACDREAM_CAPTURE_PLAYER_QUANTA=<jsonl-path>` records the local player's
complete admitted object quantum without changing simulation order:
1. quantum start;
2. root/PositionManager composition;
3. pre- and post-`UpdatePhysicsInternal`;
4. transition result;
5. final collision-response commit.
The accepted repro contained 2,184 quanta. The clearest landing was:
| Quantum | Event | Velocity |
|---|---|---|
| 1740 | final airborne quantum | `(-12.316, 8.187, -26.266)` |
| 1741 | slope collision, normal `(-0.236, 0.236, 0.943)` | |
| 1741 post-response | correct 5% reflect | `(-17.391, 13.262, -6.576)` |
| 17421758 | still Contact + OnWalkable, no new collision normal | velocity unchanged |
| 1759+ | contact relationship changes | friction finally begins decaying |
The reflected velocity had `dot(v, normal) = +1.0252`: it pointed away
from the slope. Retail `calc_friction` correctly skips while this value is
at least `0.25`, so friction was not the defect. ACDream was repeatedly
restoring the remembered slope plane and re-grounding the body without
performing retail's accompanying velocity stop.
## Retail oracle
Named-retail:
- `CPhysicsObj::check_contact` `0x0050F5B0`
- `CPhysicsObj::get_object_info` `0x00511CC0`
- `CTransition::validate_transition` `0x0050AA70`
- `OBJECTINFO::kill_velocity` `0x0050CFE0`
The exact `validate_transition` order at
`0x0050AAED0x0050AB42` is:
1. enter only for a non-OK collision/adjusted/slid result;
2. if `last_known_contact_plane_valid`, call
`OBJECTINFO::kill_velocity`;
3. test the current sphere center against the remembered plane using
`radius + 0.0002`;
4. restore the contact plane only when still within that distance;
5. later, at `0x0050ACFF`, overwrite last-known validity with final
contact-plane validity.
`OBJECTINFO::kill_velocity` calls
`CPhysicsObj::set_velocity({0,0,0}, 0)`. ACDream had ported the proximity
test and plane restore but omitted this call. It also allowed the
last-known plane to re-ground clean accepted moves, although retail only
consumes it in the non-OK recovery branch.
## Correction
`Transition.ValidateTransition` now:
- calls `ObjectInfo.StopVelocity()` before the remembered-plane
proximity/restore test on a non-OK recovery;
- performs that restore only in the retail branch;
- overwrites last-known validity from final contact validity, so a clean
move away cannot be re-grounded from stale memory.
The existing `PhysicsEngine.ResolveWithTransition` consumption of
`VelocityKilled` applies the zero to the canonical `PhysicsBody` before
the collision-response tail. The initial 5% landing reflection remains;
only a following collision recovery performs the retail stop.
## Gates
- New focused pins:
- collision recovery with a remembered plane kills velocity;
- clean advance with a remembered plane neither kills nor re-grounds.
- Full `AcDream.Core.Tests`: 4,107 passed / 2 skipped.
- Full `AcDream.Runtime.Tests`: 439 passed.
- `AcDream.App` Release build: 0 warnings / 0 errors.
- Complete Release suite: 10,061 passed / 5 skipped / 0 failed.
- User live gate: **PASS** — repeated slope jumps now settle correctly
(“Perfect! Works great!”).
## Diagnostic tools retained
- `tools/analyze_269_slope_stop_capture.py`
- `tools/cdb/run-issue269-slope-stop.ps1`
- `tools/cdb/issue269-slope-stop.cdb`
The cdb runner refuses to attach unless the live retail executable matches
the Sept 2013 named PDB. The locally installed 2015 retail executable does
not match; the static named-retail decode above is therefore the retail
oracle used for this correction.

View file

@ -0,0 +1,108 @@
# #271 — Stair-side uphill reversal capture
**Date:** 2026-07-31
**Status:** closed; retail control flow restored and user live gate passed
## Symptom
When the local player ran diagonally uphill while pressing into the side of
an outdoor staircase, the character could suddenly move backward and rapidly
slide to the bottom. The symptom was intermittent because it required the
forward candidate to hit the side wall while the step-down recovery crossed a
tread edge.
This is not an RDP, render-rate, animation, or gravity symptom. It reproduced
inside the pure Core collision resolver from one captured input frame.
## Live evidence
The bounded capture is under the ignored local artifact pointer:
`artifacts/issue271-stair-side/LATEST.txt`
It contains 677 local-player physics quanta plus the matching resolver stream.
The first decisive frame is quantum 310:
```text
current = (133.03775, 75.53931, 59.608147)
target = (133.33783, 76.42308, 59.608147)
input = forward + run
result = (133.18779, 75.19872, 59.316677)
normal = (-1, approximately 0, approximately 0)
```
The X side-wall collision was valid, but the tangential Y component reversed:
an uphill request of `+0.88377` produced `-0.34059`. Three frames later,
quantum 313 snapped from Z `59.52598` to terrain Z `58.005`. A second attempt
reproduced the same family at quanta 479482, falling from Z `60.96376` to
`58.005`.
## Retail oracle
Named retail:
- `CTransition::edge_slide` at `0x0050B3D0`
- current-walkable branch at `0x0050B44A`
- no-walkable back-probe at `0x0050B4580x0050B50F`
- `SPHEREPATH::precipice_slide` at `0x0050CC80`
Retail tests only the current `SPHEREPATH::walkable` pointer. If it is null,
retail:
1. offsets the failed candidate back to the current sphere center;
2. runs `step_down` there to rediscover the surface actually under the mover;
3. restores the failed candidate;
4. runs `precipice_slide` against the newly discovered polygon; or
5. returns `COLLIDED_TS` when the back-probe found no walkable polygon.
Retail has no substitution of an older saved walkable polygon in either null
case.
## ACDream divergence and root cause
`EdgeSlideAfterStepDownFailed` previously called
`SpherePath.RestoreLastWalkable()`:
- before deciding whether to enter the retail back-probe; and
- again when the back-probe found no current walkable polygon.
`LastWalkable` is a separate ACDream history used by the still-open
CliffSlide compatibility path. At a staircase side wall it could describe the
preceding tread rather than the surface below the current player position.
Promoting it into the current slot bypassed retail's back-probe.
`PrecipiceSlide` then projected the failed forward candidate along the stale
tread edge, producing the backward/downhill displacement seen in the capture.
The fix removes both stale-history promotions from the edge-slide dispatch.
Current walkable state still takes retail's direct precipice path; absent
state now always takes retail's current-position back-probe.
## Deterministic regression
`Issue185OutdoorStairsSeamReplayTests` reuses the captured
`0x01000AC5` staircase collision fixture and the exact quantum-310 position,
contact plane, movement delta, player flags, and 1.5 m Setup step-down height.
Pre-fix:
```text
out = (133.187790, 75.346481, 59.430882)
```
Fixed:
```text
out = (133.187790, 76.078186, 60.016247)
```
The regression requires meaningful positive uphill progress and forbids a
downhill Z displacement. The complete Core Release suite passes 4,108 tests /
2 skips; the complete Release solution passes 10,062 tests / 5 skips.
## Live acceptance
The user repeatedly ran uphill while pressing into both sides of the affected
staircase. Movement remained stable and the former rapid downhill reversal did
not recur. The client then closed through the normal logout path, with ACE
confirming graceful logout.

View file

@ -0,0 +1,174 @@
# Atomic collision-generation activation (Slice 3B)
## Retail anchor
Retail hydrates a cell synchronously. `CObjCell::init_objects`
(`0x0052B420`) visits objects associated with that cell and invokes
`CPhysicsObj::recalc_cross_cells` (`0x00515A30`). The final position path also
replaces shadows as one `SetPositionInternal` operation (`0x00515330`). Retail
therefore never exposes a world where the new cell exists but the objects that
overlap it still have their old cross-cell set.
Acdream streams a landblock over several update frames. Literal per-cell
mutation during those frames was not equivalent: the active `PhysicsDataCache`,
`CellGraph`, `PhysicsEngine`, buildings, static shadows, and retained-object
refloods changed at different cursors. Collision queries could observe a mixed
generation, and correctness depended on a later optional landblock callback.
## Ported adaptation
The asynchronous unit is now one Runtime-owned collision generation:
1. `BeginCollisionAdmission` issues the exact Runtime/landblock generation.
2. `PrepareCollisionGeneration` creates empty private cache, graph, engine, and
shadow facades and retains the active aggregate root reference in O(1).
Global immutable GfxObj/Setup catalogs are not copied; the accepted build's
exact closure is populated by the existing cursors.
3. Stable
landblock and logical-owner slot suffixes then materialize each non-target
cache, CellGraph, engine, and shadow leaf into an empty private root under
the host's existing frame meter. The 32-resident-landblock gate proves
admission performs no resident copy and every advance reports at most one
work unit.
4. App and Headless publish terrain, EnvCells, topology, buildings, prepared
collision assets, and target-root static owners only into that private
generation.
5. Stable per-prefix owner slots capture every non-suspended owner that touches
or has a withdrawn repair marker for the target prefix. That includes live
dynamic owners and statics rooted in an adjacent landblock. Only a
target-root static is omitted, because the authored replacement supersedes
it. The scan has a fixed slot suffix and is unaffected by mutations in other
prefixes. Vacated slots are tombstoned and reused rather than retained for
the whole session. A single Runtime-scoped versioned journal records each
mutation once and coalesces repeated changes by logical owner, independently
of the number of live drafts. After topology sealing, each draft reconciles
the latest exact state of owners changed during that draft's lifetime one
owner per seal call. A discovered relevant owner then receives scoped exact
updates, preserving continuous-motion progress without restoring global
fanout. A membership transition is routed by the owner's changed landblock
prefix to the one matching draft, so an owner first entering or leaving the
target after its global journal slot was visited is still reconciled once.
During topology construction, a visited unrelated owner retains only a cheap
coalesced notification; its exact mirror runs later as one metered seal unit
rather than once per draft on the mutation path. Once the topology seal
exists, observed owners temporarily write through exactly until same-call
activation. The finite pre-seal queue therefore drains even when two or more
unrelated owners mutate before every host step. Slots predating a newer root snapshot are superseded
by a tail slot, not reused behind live cursors. New drafts begin at their
captured suffix, obsolete slots compact one visit per seal call, and the
journal clears when its last draft closes. Unrelated and continuously moving
owners therefore never restart capture or sealing.
6. Explicit one-work-unit cursors build the complete replacement before the
activation frame: requested global collision records, cells/topology,
buildings, cell graph removals, affected static owners, retained-owner
states, and removal lists. A late unarmed relevant owner consumes at most
one refresh unit on a seal call; when that drains the queue an already-built
seal is immediately ready. Immutable global GfxObj/Setup closure entries are
preinstalled during these metered steps, not during activation.
7. Cache, CellGraph, engine-landblock, and shadow topology share one
`CollisionWorldStateSlot`. `CommitCollisionGeneration` transfers the
complete off-side aggregate through one volatile reference on the update
thread, then revokes the staging slot. The public `PhysicsDataCache`,
`CellGraph`, `PhysicsEngine`, and `ShadowObjectRegistry` facade identities
stay stable. Warm 256-owner, cold
first-load, changed EnvCell/building, and new static-bucket gates all measure
exactly zero managed bytes in final activation. Only afterwards does Runtime
emit `CollisionGenerationCommitted` and a ready acknowledgement.
8. Multiple landblocks may prepare concurrently. Preparation order is the
activation order. Only after an older generation commits is its exact delta
queued into every later draft. Each additional seal call applies at most one
cache, CellGraph, synthesized outdoor-cell, engine-landblock, or logical
owner leaf. Later generations retain their own completed target seal but
cannot activate before every committed delta drains. Cancelled older drafts
therefore contribute nothing, final activation performs no peer work, and a
later root cannot overwrite or expose an older snapshot. Seam-crossing
statics are forcibly re-evaluated against the later topology. Demotion and
withdrawal cancel a matching queued or active rebase, suppress that prefix
in unfinished source scans, and retire one shadow owner, cache/graph leaf,
authored outdoor cell, or landblock leaf per later seal call before
activation. Retirement storage is growable rather than coupled to the
concurrent-preparation limit, and final commit rechecks both pending rebase
and retirement work after the seal-to-commit gap.
The host performs the zero-work root transfer in the same update-thread call
that completes final reconciliation, eliminating a seal-to-next-frame quiet
window for continuously moving unrelated owners.
9. GfxObj/Setup closure entries are immutable content-addressed catalog data,
not world topology. Their metered early installation may survive a cancelled
generation as ordinary process cache residency; no cell, building,
landblock, or shadow becomes visible through that catalog alone.
Presentation and no-window hosts use the same Runtime transaction. Network
workers still enqueue immutable messages and cannot mutate collision or shadow
state.
## Failure and lifetime rules
- A newer admission invalidates an older prepared generation.
- Cancellation names one admission and its private staging generation. It can
never withdraw or demote the active landblock, and cancelling a stale receipt
cannot invalidate a newer admission.
- Demotion, withdrawal, reset, and disposal invalidate the admission before
changing the active generation.
- Disposing a stale/cancelled prepared generation clears only its private
engine/cache/shadows.
- The prior complete generation remains queryable throughout preparation.
- The commit notification is the future lost-cell-registry seam. Slice 3B does
not implement `GotoLostCell` or change `SetPosition` recovery behavior.
## Deterministic evidence
The focused Runtime/App tests pin:
- previous terrain/cells/buildings/statics remain visible until commit;
- exactly one notification after a successful complete activation;
- stale admission replacement has no active-world side effect;
- unrelated movement on every capture/seal step never restarts the target;
- two relevant owners moving on every seal step converge without restarting
the topology meter and install their latest positions at activation;
- an authoritative state change on a retained rowless owner updates an
already-sealed generation without a global restart;
- a neighboring static whose shadow crossed the seam is restored atomically
on reload and its withdrawn-prefix marker clears only at activation;
- a late spawn blocks activation until its one metered refresh; deletion of an
armed owner writes through directly;
- Headless faults immediately after admission and after staging preserve the
prior complete world and leave no collision admission behind;
- dense sealing consumes at most one work unit per call, while warm 256-owner,
cold first-load, changed EnvCell/building, and new static-bucket activation
all allocate zero managed bytes;
- concurrently prepared landblocks rebase and preserve both terrain roots and
static-shadow owners across their activation order, with zero-byte final
commits and revoked staging access;
- dense 32-landblock admission performs no resident copy, stays within its
constant allocation envelope, and materializes at most one leaf per advance;
- cancelled older drafts contribute no topology to later roots, while a live
owner mutation after the older commit wins over the queued rebase;
- a newly committed seam-crossing static refloods against the later draft's
topology before that draft may activate;
- post-seal arrivals drain one owner per seal call without resetting capture;
- an unrelated owner entering the target after its journal slot was visited is
routed by prefix and reconciled in one metered seal unit;
- target departure and same-ID reuse preserve the exact new-prefix owner rows;
- unrelated state mutation publishes only after every row changes;
- unrelated demotion/withdrawal and the live `CurrCell` cannot be resurrected
or rolled back by a later draft;
- queued and partially applied peer rebases cannot resurrect a later demoted or
withdrawn landblock;
- deleting an outgoing target static before, during, or after staging cannot
erase an authored same-ID replacement;
- 10,000 repeated mutations with 32 drafts retain one coalesced journal entry
and allocate no more than the owner mutation itself;
- 512 unique changed owners reconcile in exactly 512 metered seal units and
the final activation still allocates zero managed bytes;
- compacted journal slots are never reused behind a live cursor, while a
4,096-slot obsolete tail retires incrementally and a later draft starts at
its captured suffix rather than scanning old tombstones;
- post-seal retirement blocks activation until its cursor drains, and more
than 256 distinct retirements remain metered and lossless;
- prefix-owner slots remain bounded under GUID churn and empty containers are
reclaimed across unique prefixes without invalidating a live seal cursor;
- graphical and no-window publishers use the same Runtime transaction;
- removal and terminal teardown converge the active ownership ledger.
This retires divergence row AD-6. The remaining lost-cell state-machine work is
deliberately outside this slice.

View file

@ -0,0 +1,333 @@
# Canonical retail `SetPosition` — placement/streaming Slice 4A
## Scope
This note pins the pure physics half of the placement/streaming closeout.
Slice 4A lands the retail placement transaction as a separately testable Core
mechanism. It deliberately does **not** replace the production snap-only
resolver yet: Runtime lost-cell ownership and the complete inbound-route
cutover remain Slice 4B. AP-1 and AD-1 therefore remain active until that
cutover is complete.
Named-retail oracle, Sept 2013 EoR:
- `CPhysicsObj::SetPosition` `0x005160C0`
- `CPhysicsObj::SetPositionInternal` `0x00515BD0`
- `CPhysicsObj::AdjustPosition` `0x00511D80`
- `CPhysicsObj::CheckPositionInternal` `0x00511E90`
- `CTransition::find_valid_position` `0x0050C310`
- `CTransition::find_placement_position` `0x0050C170`
- `CTransition::find_placement_pos` `0x0050BA50`
- `CTransition::validate_placement_transition` `0x0050ADC0`
- `CTransition::validate_placement` `0x0050B210`
- `CPhysicsObj::ForceIntoCell` `0x00515660`
- `CPhysicsObj::handle_all_collisions` `0x00514780`
## Retail transaction
```text
SetPosition(request):
transition = makeTransition() // GENERAL_FAILURE if none
init_object(transition, object)
if the PartArray has no spheres:
init_sphere(1, dummy center=(0,0,0.1), radius=0.1, scale=1)
else:
init_sphere(first min(count,2) authored spheres, exact object scale)
if flags & RANDOM_SCATTER (0x200):
return scatter only
result = SetPositionInternal(request)
if result != OK and flags & SCATTER (0x100):
return scatter
return result
SetPositionInternal(request):
AdjustPosition(request frame, first sphere, noCreate=(flags & 0x20))
if no resident cell:
store the adjusted authoritative frame and enter lost-cell lifetime
return OK
if the live weenie is Hook, Storage, or Corpse:
return ForceIntoCell(resident cell, frame)
set do_not_load_cells from flag 0x20
if !CheckPositionInternal(...):
handled = handle_all_collisions(...)
return handled ? COLLIDED : NO_VALID_POSITION
if transition.curr_cell == null:
return NO_CELL
commit the complete transition
return OK
```
`AdjustPosition` branches on the claimed cell shape. A direct outdoor claim
runs pure `LandDefs::adjust_to_outside` normalization before visible-cell
lookup. An indoor claim first resolves the visible cell and child; only a
resident indoor cell marked `seen_outside` falls back to outdoor
normalization. An absent indoor cell (including the `0xFFFF` sentinel) remains
the exact claimed cell/frame. A map-edge outdoor normalization failure stores
cell zero with the otherwise unchanged frame. The old `max(terrainZ, z)` lift
and nearest-in-Z scan do not occur in this canonical mechanism.
`CheckPositionInternal` calls the complete placement transition. Without the
slide flag, retail accepts the result only when signed
`resolvedX-requestedX <= 0.0500000007`, the same signed Y condition holds, and
the cell is unchanged. Z is not part of that predicate. The resolved origin is
accepted while the requested orientation remains intact.
## Two validators, not one
The similarly named retail helpers have different contracts and stay separate
in the port:
- `validate_placement_transition` is the inner `find_placement_pos` validator.
Any non-OK state from `COLLIDED` through `SLID`, when sliding is permitted,
resets `COLLISIONINFO`; it never retries placement.
- `validate_placement` is the outer initial/final validator. Only `ADJUSTED`
or `SLID`, and only while its retry argument is true, performs one
`placement_insert`; `COLLIDED` neither resets nor retries.
Step-down is disabled only for missiles. For fewer than two spheres retail
first clamps the requested height to half the radius when the sphere diameter
is less than or equal to that height. It then performs one full probe when the
diameter is greater than the resulting height, otherwise two half probes.
Equality belongs to the two-half-probe branch.
## Modern seam
`PhysicsEngine.SetPosition` returns one immutable
`PhysicsSetPositionResult`. `SetPositionError` retains the header values
(`OK=0`, `GENERAL=1`, `NO_VALID=2`, `NO_CELL=3`, `COLLIDED=4`,
`INVALID_ARGS=0x100`) while `PhysicsResidenceDisposition` separately reports
`Committed`, `DeferredCell`, or `Unchanged`. Missing content is therefore
successful-but-deferred, never misreported as a placement failure.
The result carries the complete commit packet: root/cell-local frame,
contact/walkable/water state, sliding and collision normals, stationary-fall
counter, a complete immutable `COLLISIONINFO` snapshot for the real
`handle_all_collisions` callback (including contact/last-contact, sliding,
collision normal, stationary-fall, environment, adjustment, and object
fields), the callback result, and an explicit shadow action. A PhysicsBSP or changed-cell force commit
requests canonical shadow recalculation; a non-BSP transition replaces its
shadows only when the transition produced a nonempty cell array, otherwise it
preserves the prior list. Unchanged force placement changes only the frame.
Core never creates cells synchronously, so retail flag `0x20`
(`DoNotCreateCells`) has no differential loader branch inside this pure
mechanism. Both flag states can only observe already-published immutable cell
content and otherwise return `DeferredCell`. Carrying the flag into exact-cell,
generation-scoped async admission is part of the still-open Slice 4B
adaptation tracked with AD-2; this slice does not claim a dead SpherePath field
as exact behavior.
The existing public `Resolve` compatibility entry remains entirely unchanged
for production movement and zero-delta callers. Its result cannot represent a
successful-but-deferred residence, so hiding `DeferredCell` inside
`ResolveResult.Ok` would corrupt the contract. Slice 4B will route every
authoritative placement family through `SetPosition`, atomically install its
packet, and own exact lost-cell wakeup/commit.
## Automated oracle
`PhysicsSetPositionTests` pins error values, absent/invalid outdoor and indoor
claims, cross-landblock frame normalization, map-edge failure and the `0xFFFF`
sentinel, dummy/authored sphere setup, nonpositive scale, Ethereal seeding,
explicit-only PathClipped, missile step-down, exact equality schedules, both validators,
the late compass sample's float bits, signed no-slide behavior, actual
collision-handler mapping, force-class policy, explicit shadow actions, null
current-cell wakeup, ten-record scratch exhaustion, exact scatter ordering,
failed-probe scratch lifetime, and deferred-scatter stop. The legacy public
Resolve fixture remains unchanged until Slice 4B.
## Slice 4B1 — Runtime residence owner
Slice 4B1 adds the presentation-independent half of the cutover without
changing a production graphical route yet. `RuntimeSetPositionState` accepts
an exact entity/position token before graphical DAT preparation, consumes the
immutable Core result, and commits body, contact, full cell, shadows, object
clock, and Runtime spatial worksets before publishing one ordered placement
delta. The delta carries the exact `RuntimeEntityKey`, session lifetime,
position/spatial/placement versions, adjusted cell, collision generation, and
optional portal-authority shape. A throwing or unavailable host does not roll
simulation back: Runtime republishes the same projection token until the
exact FIFO head is acknowledged. A newer operation changes an already-
published token to `Discard` and increments its projection revision, so an
acknowledgement of the previously observed Place/Withdraw cannot consume an
unseen Discard. It is never silently forgotten. An unacknowledged lost-cell
Withdraw transfers intact to a replacing accepted operation and remains the
FIFO head before that replacement may publish Place.
The successful missing-cell path owns retail's residence shape:
```text
SetPosition -> OK + DeferredCell
retain adjusted Position and the same PhysicsBody/components
clear only Active and suspend the object clock
withdraw Runtime spatial worksets and shadow rows
retain shadow registration and exact authored mover request
append parentless root to (exact cell, collision generation)
arm independent exact-key 25 s deadlines for root + direct children
publish Withdraw
exact cell generation resident + Withdraw acknowledged
re-run SetPosition with retained authored spheres and CurrentCellId=null
atomically install complete result
publish Place
```
Lost-cell membership buckets use retail-shaped append plus swap-remove.
Destruction deadlines use an exact-key hash plus a bounded indexed min-heap,
the allocation-bounded modern equivalent of retail's hash +
`PQueueArray<double>` priority owner. Rearm/removal updates the exact heap node;
there are no stale tombstones. Only committed, current direct children from
the parent-incarnation ordered CHILDLIST participate; unresolved or future
relations cannot inherit a deadline. Parent, pickup, delete, GUID
replacement, newer Position, reset, and disposal cancel the exact incarnation
and use leave-world semantics rather than a wakeable lost entry. The dormant
collision-retirement entry parks non-static parentless indoor roots and, for
complete withdrawal, affected outdoor roots. It performs a complete preflight
and rejects overlap with any active accepted/host-ack-pending placement before
mutating one resident. It then installs every affected canonical lost
residence and operation before publishing the first synchronous Withdraw, so
an observer re-entering for a later root inherits that root's exact pending
Withdraw instead of having its newly accepted placement cancelled by the
retirement loop. 4B2 must quiesce that placement prefix before invoking the
entry in the same transaction that installs host acknowledgements.
Runtime retains the last accepted prepared mover request, including exact
off-center/two-sphere payloads, scale, flags, and step values. A cold resident
with no prepared request still withdraws atomically but remains explicitly in
`AwaitingPreparation`; it cannot wake through an invented empty-sphere shape.
Preparation is cached only after Core accepts it as Committed or DeferredCell.
A rejected/malformed preparation keeps the same accepted token retryable and
cannot replace the last validated mover used by later collision retirement.
Runtime's host boundary rejects only non-finite consumed frame/shape values;
retail-valid oddities such as nonpositive authored scale remain untouched.
Likewise, a non-deferred wake failure retains the withdrawn body, independent
25-second lifetime, and exact operation: invalid arguments return to
AwaitingPreparation and re-index for the next exact generation, while other
world-placement failures also re-index. The last successful DeferredCell
result and adjusted frame remain canonical across the failed attempt. No
failed wake can leave a live entity withdrawn without a Runtime owner. The
graphical/no-window cutover in 4B2 supplies that exact preparation token.
Retail `CPhysicsObj::SetPositionInternal` (`0x00515BD0`) calls
`prepare_to_enter_world` (`0x00511FA0`) only when `this->cell == 0`.
Consequently the physics `update_time` (`PhysicsBody.LastUpdateTime`) and
active bit are reset only on the cellless-to-world edge. Ordinary same-cell or
cross-cell in-world SetPosition preserves the already-consumed physics clock;
entering the lost-cell residence also preserves it until the eventual
cellless wake commit. Runtime pins both sides and does not inherit the older
graphical teleport helper's unconditional timer reset.
The wake timestamp is in the Runtime simulation-time domain, never Unix/UTC:
`GameRuntime` binds its instance `GameRuntimeClock` through the entity/physics
owner, and RetryDeferred samples `SimulationTimeSeconds`. Standalone Runtime
fixtures without a bound game clock retain the accepted command time. This is
a Runtime dependency only; no App delegate enters the owner.
The canonical commit also installs every SetPosition-derived body invariant
before host publication: Contact/OnWalkable/WaterContact, the current contact
plane and slope `GroundNormal`, Sliding plus its normal, and the complete
StationaryFall/Stop/Stuck encoding. Named retail
`CPhysicsObj::SetPositionInternal(CTransition const*)` (`0x00515330`) copies
only the transition's current contact plane/water flag, walkability, sliding
normal/valid flag, and collision state (`0x005153E50x005154FE`). It does not
publish `last_known_contact_plane` or the SpherePath walkable polygon on this
path, so those ordinary-update-only fields are intentionally absent from the
immutable SetPosition result and remain unchanged. A zero expected velocity
version in host preparation preserves the nonzero version captured when the
operation was accepted; an intervening Vector/Movement therefore suppresses
only the stale collision-velocity response. A bodyless cancellation terminates
without fabricating a PhysicsBody or a host Withdraw projection. The two time
domains remain explicit: `PhysicsBody.LastUpdateTime` consumes the instance
simulation clock, while `IRuntimeRemotePlacement.LastServerPositionTime`
remains Unix-UTC receipt time because the remote stale-velocity owner ages it
against `RuntimePhysicsState.UtcNowSeconds`. A deferred wake therefore cannot
make fresh authoritative remote velocity appear years old. Runtime preparation
also applies the existing retail `PositionFrameValidation` before Core or
prepared-mover caching, and caps synchronous Scatter/RandomScatter work at 64
attempts; this keeps valid authored retail request shapes while rejecting a
hostile `uint.MaxValue` loop at the authority boundary.
One authority boundary intentionally remains open for 4B2:
- `RuntimePortalPlacementAuthority` validates immutable token shape only;
4B2 must bind it to active `RuntimeWorldTransitState` generation, teleport
sequence, destination, and host acknowledgement before reveal.
The former collision-report boundary is closed by
`RuntimeCollisionReportingState`. Runtime now owns retail's exact-key object
contact table, environment latch, strict ordinary/ethereal expiry, force-end,
static and `ReportAsEnvironment` routing, reciprocal callback eligibility,
missile-state clearing, ordered reentrant dispatch, and the report-result
boolean which distinguishes placement `Collided` from `NoValidPosition`.
Successful SetPosition commits reporting after Contact/OnWalkable and ground
callbacks but before its single physical response and shadow reflood. See
`docs/research/2026-07-31-runtime-set-position-collision-reporting.md`.
### Slice 4B2 checkpoint 1 — public dormant host seam
The first 4B2 checkpoint exposes the dormant receipt owner through
`RuntimePlacementProjectionChannel`. Graphical and no-window hosts can observe
the one ordered placement stream, retry the exact immutable pending receipts,
peek the FIFO head, measure pending debt, and acknowledge only the exact head.
Mutation and retry calls require the current `RuntimeGenerationToken`; a stale
generation, stale revision, reordered token, duplicate acknowledgement, or
reused GUID cannot consume current placement debt. The channel delegates to
`RuntimeSetPositionState` and `RuntimeEntityObjectEventStream`; it owns no
second queue, mirror, or rollback path.
Shared local-controller body adoption is deliberately deferred. A reviewed
prototype that prepared directly on the canonical body was rejected: a
snapshot/rollback lease cannot safely coexist with reentrant SetPosition,
remote/projectile binding, deletion/GUID reuse, owner replacement, object-clock
epoch changes, or disposal. Correct adoption requires either an exclusive
Runtime transaction integrated with every canonical writer, or off-canonical
preparation followed by one validated atomic body/controller publication.
Either choice belongs to the all-route ownership cutover, not this narrow
dormant-seam checkpoint.
This remains a deliberately non-activating checkpoint. Production spawn,
Position, projectile, drop/pickup/parent, and portal routes do not submit to
the dormant SetPosition owner yet. The cutover remains blocked on exact
ordered Setup spheres/scale/step heights/flags/cell-local preparation,
presentation-only rebucketing, and placement-prefix quiescence before
collision retirement. AP-1 and AD-1 remain open until those prerequisites and
every production route land together.
AD-2 remains the explicit async adaptation: collision readiness can publish in
a different frame from retail's blocking load. A failed wake is safely re-
indexed to the next exact generation instead of inheriting retail's
synchronous assumption. When older unbound survivors meet newer entities
already indexed into that future generation, Runtime merges them into one
bucket with the older survivor order first and retains one bucket-order entry.
AP-1 and AD-1 remain open until 4B2 removes the legacy graphical/headless
placement paths.
`RuntimeSetPositionStateTests` pins accepted-before-preparation ownership,
portal-shape rejection, canonical-before-projection ordering, retry and
reentrant discard, cross-landblock adjusted-cell park/wake, same-body
identity, retained contact/water/sliding/velocity, exact generation gating,
authored two-sphere retention, cold preparation, bounded priority-deadline
rearm/cancel, zero-allocation empty ticks, independent ordered direct-child
deadlines, actual collision-admission supersession/invalidation, missing exact
indoor-cell generation rebind/wake, collision demotion/withdrawal preflight,
failed-wake retry, malformed-preparation retry without cache poisoning,
simulation-clock-domain wake, velocity-version preservation, derived body-bit
writeback, immediate remote-velocity survival across the simulation/UTC clock
boundary, invalid cell/frame/quaternion and extreme-scatter rejection before
Core/caching, bodyless cancellation, newer Position/pickup/parent/delete, GUID
reuse, reset, and complete index/node terminal convergence. Existing zero-
allocation collision-generation gates remain unchanged on the no-deferred
fast path.
The warmed immediate commit/ack route currently measures exactly **1,880
managed bytes per operation** in the Release Runtime test host (1,000
iterations after 64 warmups); the regression gate caps it at 2,048 bytes.
This dormant-path result is an explicit 4B2 activation blocker rather than a
claim of allocation-free production readiness: 4B2 must either pool/remove
the operation and projection envelopes or record an approved measured budget
before routing frame-frequency placement through this owner.

View file

@ -0,0 +1,92 @@
# Retail cell availability and containment-root validation — 2026-07-31
## Scope
This note closes divergence rows AD-3 and AD-4. It does not begin AD-6's
atomic streaming-generation work.
The corrected port distinguishes these states:
1. no visible cell payload is loaded;
2. a malformed raw/prepared payload has no containment root;
3. a loaded CellStruct has a valid authored containment root (its physics root
may independently be absent).
Only (3) is published. State (1) remains unavailable and retryable. State (2)
is quarantined atomically so a later valid hydration can retry; it must not
become a world-wide containing cell.
## Installed-data audit
The complete installed EoR catalog and matching prepared package were audited
before choosing this invariant:
- enumerated EnvCells: **729,888**;
- raw: 0 missing EnvCells, 0 missing Environments, 0 missing CellStructs,
0 null `CellBSP` objects, **0 null `CellBSP.Root`**, 729,888 valid roots;
- prepared `acdream.pak`: 0 missing aliases, 0 corrupt payloads,
**0 `ContainmentBsp.RootIndex < 0`**, 729,888 valid roots;
- 6,940 raw EnvCells have zero portals, so the retail portal-pointer guard is
a real catalog path rather than dead defensive code.
There are therefore no root-null record IDs to preserve in either source.
## Retail oracle
`CObjCell::find_cell_list @ 0x0052B4E0` in
`docs/research/named-retail/acclient_2013_pseudo_c.txt:308742` establishes the
availability gates:
- `CEnvCell::GetVisible` / `CLandCell::GetVisible` resolves the active seed at
`0x0052B50C..0x0052B515`;
- the outdoor branch still calls `CLandCell::add_all_outside_cells` at
`0x0052B53F`, even when that seed lookup returned null;
- the complete growing-array transit walk and containing-cell pick are gated
by `seed != null && num_spheres != 0` at `0x0052B576`;
- each later candidate is independently skipped when its stored cell pointer
is null at `0x0052B58E`.
`CEnvCell::point_in_cell @ 0x0052C300` first returns false when
`this->portals == 0`, then transforms the point and calls
`CCellStruct::point_in_cell`.
`CCellStruct::point_in_cell @ 0x005338F0` calls
`BSPTREE::point_inside_cell_bsp @ 0x005398C0`, which immediately invokes
`BSPNODE::point_inside_cell_bsp(this->root_node, ...)`. The BSP node method at
`0x0053C1F0` dereferences `this` before walking positive children. Only a
missing **positive child below a valid root** is the inside terminal case. A
missing root is not.
## Ported behavior
- `PhysicsDataCache` publishes graph, collision, and prepared records only
after a valid raw/prepared containment root is present. A missing physics
root is retained as a valid non-colliding cell. Invalid containment
publication changes no cache, so later hydration can retry.
- `CollisionTraversal.HasCellContainment` tests `Root` / `RootIndex`.
- Both raw and prepared `EnvCell.PointInCell` paths apply the zero-portals
guard before containment. `CellTransit` applies the same guard to its
`CellPhysics` representation.
- `CellTransit.BuildShadowCellSet` still seeds all overlapped outdoor cells,
but skips the transit walk when the active outdoor seed cannot be resolved
from `CellGraph`. Every later outdoor candidate independently resolves via
`GetVisible` before building transit, so a stale building cannot promote an
object through an unavailable adjacent landcell.
- The existing reflood lifecycle remains the recovery mechanism. Once terrain
or a valid indoor CellStruct publishes, the next reflood walks the authored
portal/building relationships without reconstructing a different rule.
## Gates
Focused tests cover raw/prepared rootless quarantine and valid retry, valid
containment with missing physics, raw/prepared zero-portal parity, indoor and
outdoor seeds, preservation of outside-cell seeding, per-candidate adjacent
landcell availability, suppression of stale-building promotion, and
hydration/reflood recovery. The corrective checkpoint passes:
- focused cell-availability suite: **54/54**;
- Core Release: **4,165 passed / 1 skipped**;
- Runtime Release: **440/440**;
- App Release: **4,002 passed / 3 skipped**;
- complete Release solution: **10,122 passed / 4 skipped**;
- `dotnet build AcDream.slnx -c Release`: **0 warnings / 0 errors**.

View file

@ -0,0 +1,95 @@
# Issue #273 — Holtburg tight-gap support validation
**Date:** 2026-07-31
**Status:** implementation, automated gates, and exact live gate pass
**Scope:** grounded player step-down support at a floor edge beside a static
cylinder
## Captured scene
The reproducible gap is in outdoor cell `0xA9B40032`, between:
- building shell GfxObj `0x01000F69`, placed at
`(158.178, 37.7055, 94.0)` with quaternion
`(w=.939319, x=0, y=0, z=-.343045)`;
- static post `0xCA9B4027`, placed at `(160.173, 34.487, 95.975)`,
represented by its Setup-authored cylinder (`radius=.282`,
`height=5.564`);
- the local player Setup's exact two spheres (`radius=.48`, origins
`z=.475` and `z=1.35`).
The building's supporting ledge terminates at local `x=4`. The first
post-side response moved the player's foot-sphere center to approximately
local `x=4.33`. The full `.48` movement sphere still overlapped the floor, so
the existing step-down path accepted the candidate. Repeated frames then
carried the player around the post and outside the building shell.
The fixture
`tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json` preserves
the installed DAT PhysicsBSP. The replay in
`Issue273HoltburgTightGapReplayTests` uses the captured object placement,
player spheres, static posts, and movement offsets.
## Retail mechanism
The missing rule is not extra collision padding and is not a larger player
sphere. It is retail's second-stage support validation:
1. `CTransition::step_down` (`0x0050B2A0`) performs the ordinary downward
collision probe.
2. After finding a walkable contact plane, an EdgeSlide mover that is not in
StepUp calls `CTransition::check_walkable` (`0x0050AFF0`). The binary
sequence is `test ah,2` at `0x0050B36A`, which is state bit `0x200`
(`EdgeSlide`), followed by the `step_up == 0` test and call at
`0x0050B380`.
3. `CTransition::check_walkable` first calls
`SPHEREPATH::check_walkables` (`0x0050C3E0`).
4. `SPHEREPATH::check_walkables` halves the saved foot-sphere radius and
calls `CPolygon::check_walkable` (`0x00538E60`).
5. If the remembered polygon does not support that smaller sphere,
`CTransition::check_walkable` performs a downward CheckWalkable insertion.
BSP leaves require both `walkable_hits_sphere` and
`CPolygon::check_small_walkable` (`BSPLEAF::hits_walkable`,
`0x0053D670`).
6. If neither check finds support, `CTransition::step_down` rejects the
candidate and the existing edge-response chain handles it.
ACDream already had the small-radius BSP-leaf test, but
`DoCheckWalkable` treated the mere presence of a remembered polygon as
success, and the ordinary `DoStepDown(..., runPlacement:false)` path never
called it. This let a full-radius overlap stand in for actual foot support.
## Port
- `BSPQuery.CheckWalkableSupport` is the shared resolved-polygon form of
retail `CPolygon::check_walkable`.
- `SpherePath.CheckWalkables` implements the retail half-radius remembered
polygon check without mutating canonical sphere state.
- `Transition.DoCheckWalkable` now tests the remembered polygon rather than
treating a non-null polygon as sufficient.
- `Transition.DoStepDown` restores the EdgeSlide/non-StepUp support gate
before the existing placement-policy seam.
There are no location checks, object IDs, guessed radii, widened collision
shapes, or gap-specific tolerances in the production fix.
## Regression impact
The existing #271 staircase-side replay begins with its center `.288 m`
outside a tread whose retail half-radius support boundary is `.24 m`.
Retail may therefore stop that exact candidate. The test now preserves the
original user-visible invariant—never reverse or accelerate downhill—without
requiring forward progress beyond retail's support boundary. The ordinary
continuous staircase replay still requires and achieves forward progress.
## Gates
- issue #273 fixture/replay: 3 passed;
- focused BSP, step-up, edge-slide, #185/#271 family: 42 passed / 1 skipped;
- complete Core tests: 4,111 passed / 2 skipped;
- Release solution build: passed;
- complete Release solution tests: 10,068 passed / 5 skipped.
The user accepted the exact in-client Holtburg gap gate on 2026-07-31: the
gap blocks from the tested approach, and the adjacent movement checks remain
healthy.

View file

@ -0,0 +1,497 @@
# Remaining physics-divergence campaign handoff — 2026-07-31
> **Checkpoint 2 update:** Slice 4B2 prerequisite A, Runtime SetPosition
> collision-report ownership, is implemented in the next checkpoint. Continue
> with the dedicated
> [`runtime SetPosition collision-reporting handoff`](2026-07-31-runtime-set-position-collision-reporting-handoff.md),
> not the prerequisite-A instructions preserved below as historical context.
## Purpose and stopping point
This is the deliberate handoff boundary requested after placement Slice 4B2
checkpoint 1. The repository is stopped before any production graphical or
headless route submits to the canonical Runtime SetPosition owner.
The completed foundation is useful and tested, but the overall campaign is
**not complete**. AP-1 and AD-1 remain narrowed/open. AP-22 and AD-10 remain
open. Do not retire those rows until their exact automated and connected gates
pass.
### Exact workspace
- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream`
- Branch: `codex/port-claude-agents`
- Handoff code checkpoint: `270f5154`
(`feat(runtime): expose dormant placement receipts`)
- Immediately preceding residence-owner checkpoint: `4c02ac42`
(`feat(runtime): own deferred set-position residence`)
- Pure Core SetPosition checkpoint: `e84a388e`
(`feat(physics): port canonical retail set-position core`)
- No upstream is configured for this worktree branch.
- Remotes:
- `origin`: `https://git.snakedesert.se/erik/acdream.git`
- `github`: `git@github.com:eriknihlen/acdream.git`
The handoff was written in this same worktree. The next agent should continue
there rather than creating a different worktree unless the user explicitly
requests it.
## Worktree hygiene
The worktree intentionally reports unrelated modifications. Preserve them.
Never use `git add -A`, `git reset --hard`, or checkout/revert commands against
these paths.
At the checkpoint, `AGENTS.md` has a real unrelated content diff. The following
paths report modified due to existing line-ending/stat noise but have no
content diff against the index:
- `src/AcDream.App/Input/PlayerModeController.cs`
- `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs`
- `src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs`
- `src/AcDream.App/World/LiveEntityRuntime.cs`
- `src/AcDream.Core/Physics/CellArray.cs`
- `src/AcDream.Core/Physics/PhysicsBody.cs`
- `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`
- `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`
- `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs`
- `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`
- `tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs`
- `tests/AcDream.Core.Tests/Physics/CellTransitTests.cs`
- `tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs`
- `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs`
- `tools/A8CellAudit/A8CellAudit.csproj`
Before every commit, stage exact paths and inspect:
```powershell
git diff --check
git diff --cached --check
git diff --cached --stat
git status --short
```
## What is complete
### Campaign baseline and issue #273
- `c24bc571` — retail StepDown support-radius behavior for tight gaps.
- `10b55d74` — tight-gap controls and diagnostics.
### Retail retry and edge/StepDown dispatcher
- `e5f855ac` — nested per-cell collision retries.
- `67d1e9b3` — refreshed-cell retry state.
- `4ca7230b` — retained cell across inner retries.
- `c559c48d` — retail edge-response ordering.
- `4fbd93ec` — edge-slide stop semantics.
- `1fd5da67` — StepDown placement validation.
- `acec33ec` — StepDown probe state.
- `75b6f6b6` — Path-6 collision response.
- `d3c0d9ec` — TS-4 production chronology gate.
These retire AP-3, AP-4, AP-5, AD-53, AD-54, and TS-4 under the tests and
research already recorded in the divergence register.
### Exact cell availability and atomic collision generations
- `7716c2ee` — retail cell-availability semantics.
- `3e0f3b62` — containment-root validation.
- `be94bc9b` — atomic collision-generation activation.
- `d94145e6` — seal before activation.
- `6b28ff99` — starvation-free activation.
These retire AD-3, AD-4, and AD-6. The active collision world remains visible
until a complete replacement generation is atomically committed.
### Canonical SetPosition Core and Runtime residence owner
- `e84a388e` ports the pure Core SetPosition transaction.
- `4c02ac42` adds `RuntimeSetPositionState`, including:
- exact accepted operation ownership;
- canonical body/contact/cell/shadow/workset commit;
- authored mover retention;
- exact-cell and collision-generation wake;
- 25-second root/direct-child lost-cell lifetime;
- bounded indexed deadline structures;
- revisioned ordered Withdraw/Place/Discard host receipts;
- cancellation/GUID-reuse/reset/disposal convergence;
- structural cell/quaternion validation and bounded scatter work.
- `270f5154` adds `RuntimePlacementProjectionChannel`, a public,
generation-gated host seam over the one existing receipt owner:
- subscribe to ordered immutable receipts;
- peek the exact FIFO head;
- retry pending receipts without recommitting Runtime state;
- acknowledge only the exact current FIFO-head token;
- observe pending receipt debt.
The channel owns no second queue and has no App or Headless production
consumer. A source guard pins that dormancy. This is intentional.
### Validation at the stopping point
The final channel-only checkpoint passed:
- Release solution build: 0 errors, 18 existing warnings.
- Complete Release suite: 10,279 passed, 4 skipped, 0 failed.
- Runtime SetPosition focused tests: 47/47.
- App dormancy/ownership guards: 3/3.
- `git diff --check`: clean.
- Retail-conformance re-review: clean.
- Architecture/adversarial re-review: clean.
## Rejected prototype — do not resurrect it
A prototype made graphical and headless `PlayerMovementController` instances
borrow the canonical `RuntimeEntityRecord.PhysicsBody` immediately and used a
snapshot/rollback lease to recover from late construction failures. It was
fully removed before `270f5154`.
The design was rejected because it was failure-atomic only without
reentrancy. While the lease was open, a nested SetPosition, remote/projectile
bind or update, deletion/GUID replacement, object-clock epoch change, or
disposal could establish newer authority. The outer rollback could then erase
that newer commit or detach a body already used by another canonical owner.
The next implementation must use one of these complete solutions:
1. A Runtime-owned exclusive/versioned controller/body publication
transaction respected by **every** canonical body writer, binding path,
SetPosition operation, clock epoch transition, deletion, reset, and
disposal; or
2. Off-canonical preparation followed by one validated atomic Runtime commit
that publishes the prepared controller/body relationship without copying
stale state over newer authority.
Because every writer must participate, this belongs to the atomic production
route cutover. Do not reintroduce a local snapshot lease or a Commit method
that merely checks the body reference at the end.
## What remains — required execution order
### Slice 4B2 prerequisite A — real collision-report ownership
`RuntimePhysicsState.HandleSetPositionCollisions` still returns `false`.
Retail `CPhysicsObj::SetPositionInternal` (`0x00515330`) returns the real
per-object report/tracking result. Establish the Runtime owner for that result
and return it exactly. Do not restore the former environment/object-presence
guess.
Required tests:
- report/no-report objects;
- collided object set ordering and lifetime;
- reentrant deletion/reset;
- graphical/headless equality;
- no report state surviving GUID reuse.
### Slice 4B2 prerequisite B — exact authored mover preparation
Every production preparation must pass the full SetPosition request using:
- Setup's exact ordered authored spheres;
- exact scale, including valid zero/presence semantics;
- exact StepUp and StepDown heights;
- exact flags;
- exact cell-local frame and orientation;
- current position/vector/state authority versions.
Do not reconstruct a cylinder from visual radius/height, clamp a positive
scale, use the projectile mover as a generic object fallback, or pre-mutate
`FullCellId`, `PhysicsBody`, `WorldEntity`, App buckets, or shadows.
### Slice 4B2 prerequisite C — atomic local controller/body publication
Implement the complete transaction described in the rejected-prototype note.
Graphical and no-window controllers must end with the exact same Runtime body,
but construction cannot expose or mutate canonical state before the validated
atomic commit.
Adversarial gates must include:
- nested construction;
- reentrant SetPosition;
- remote and projectile binding/update;
- deletion and same-GUID new incarnation;
- projection-owner replacement;
- object-clock epoch change;
- reset and disposal;
- commit and rollback after replacement;
- late graphical camera/shadow/host failure;
- late headless prepared-collision failure.
### Slice 4B2 prerequisite D — presentation-only host projection
Add one graphical and one headless `IRuntimePlacementObserver` using
`GameRuntime.Placements`.
Host receipt rules:
- `Withdraw`: remove render/spatial presentation, picking, radar, audio, and
targeting while retaining logical Runtime ownership.
- `Place`: project only the immutable Runtime-committed frame, then
acknowledge the exact token.
- `Discard`: discard the older projection revision, then acknowledge it.
- A host exception or unavailable backend does not roll Runtime back; retry
the same FIFO head.
`LiveEntityRuntime.RebucketLiveEntity` must become presentation-only. Its
current `CommitRebucket` call is a second spatial authority and must be removed
as part of the same cutover.
### Slice 4B2 prerequisite E — collision-prefix quiescence
Before landblock collision demotion, removal, or replacement:
1. quiesce the prefix;
2. drain/ack the existing placement receipt prefix;
3. call `RuntimeSetPositionState.ParkCollisionResidents`;
4. commit/withdraw the collision generation atomically;
5. wake only exact cell+generation residents.
Cover `LandblockPhysicsPublisher.DemoteToTerrain`, `RemoveLandblock`,
replacement commit, and the headless collision-retirement path. No partially
observable collision generation is allowed.
## Production route cutover
Cut routes only after all prerequisites above are present. The canonical chain
for every route is:
```text
wire acceptance
-> BeginAcceptedPlacement exact token
-> exact DAT/Setup preparation
-> Runtime SetPosition canonical commit or deferred residence
-> immutable host projection receipt
-> exact host acknowledgement
```
### 1. Initial login and CreateObject
Current duplicate authority:
- `DatLiveEntityProjectionMaterializer.MaterializeProjection` immediately
positions/rebuckets the world entity.
- `PlayerModeController.BuildControllerAndCamera` builds its own body and runs
`Resolve`/`ResolvePlacement`.
- Headless performs its own initial resolve/placement/body construction.
Required order:
1. register identity cellless;
2. begin initial/remote-create placement before hydration;
3. load exact Setup mover;
4. prepare the atomic Runtime controller/body relationship;
5. submit canonical SetPosition;
6. publish presentation only from `Place`;
7. acknowledge, then enable player mode/simulation.
Tests: outdoor/indoor login, unavailable destination then exact-generation
wake, malformed or delayed Setup, one body identity, one enter-world clock
reset, no early visible entity, graphical/headless identical snapshots.
### 2. Local ForcePosition
Delete the placement authority in `LocalForcePositionTransaction` and the
direct `BlipPosition`/pre-commit acknowledgement in
`LiveEntityNetworkUpdateController.OnPosition`.
Required order: accept timestamp and preserve heading; begin
`LocalAuthoritative`; canonical SetPosition; host `Place` acknowledgement;
then send the outbound Position acknowledgement. A missing destination cell
must not acknowledge ACE early.
Tests: same/cross cell, preserved heading/velocity, missing-cell wake,
reentrant newer Position, stale host ack, exactly one outbound ack.
### 3. Portal transit and materialization
Remove placement authority from `LocalPlayerTeleportPlacement.Place` and its
direct resolve/controller/world-entity/rebucket/spatial mutations.
Bind a Runtime portal-placement authority to the active
`RuntimeWorldTransitState` reveal generation, teleport sequence, exact
destination cell, and placement token. Readiness permits submission only.
Materialization and simulation release happen only after canonical commit,
host projection, and exact acknowledgement. Cancellation/replacement produces
`Discard`; a stale generation/sequence/cell/token can never reveal.
Tests: `/ls`, spell recall, ordinary portal, same-location revisit, missing
destination, cancelled/replaced reveal, host throw/retry, no early world reveal
or LoginComplete.
### 4. Remote CreateObject and Position
Delete `RemoteTeleportController`, `RemoteTeleportPlacement`, their pending
dictionary/rollback/lost-cell ownership, and pre-placement
`WorldEntity.SetPosition`/rebucket calls.
Preserve retail `MoveOrTeleport` classification:
- fresh Teleport timestamp or cellless body: teleport hook then SetPosition;
- ordinary nearby grounded update: interpolation remains;
- distant update: stop interpolation then SetPosition.
Accept the timestamp, begin the exact token before hydration/body/App changes,
unparent first, run the retail teleport hook when required, submit the exact
mover, project after Runtime commit, then re-arm constraints.
Tests: visible/hidden/parented CreateObject, first Position, Teleport timestamp,
near interpolation, >96 m far placement, unloaded indoor destination, racing
velocity, delete/GUID reuse during host callback.
### 5. Projectile authoritative create/corrections
Remove authoritative placement from App `ProjectileController` and the direct
SnapToCell/cell/shadow commit in `RuntimeProjectilePhysicsUpdater`.
Use `ProjectileAuthoritative` with the same Runtime body and exact projectile
Setup sphere for initial create and authoritative corrections. Preserve
prediction/component/effect identity. Do not route ordinary per-quantum
projectile integration through SetPosition.
Tests: arrow, bolt, spell projectile, mid-flight correction, unloaded cell,
landblock crossing, delete during ack, no duplicate body/projectile/effect.
### 6. Drops and unparent-to-world
`InventoryWorldDropProjectionController.TryRecoverUnknownPosition` may create
the logical object, but it must enter the same canonical create-placement
transaction. Do not expose a stale source position or replay create-time
effects.
Tests: whole item, split stack, new GUID, second drop position, attached child
becoming a world root, unavailable destination, newer Position while waiting.
### 7. Pickup, Parent, and Delete
Runtime hooks already exist in `RuntimeEntityObjectLifetime`, but the route
cutover must ensure pickup/parent/delete cancel the exact active
placement/lost-cell family first and publish `Discard`/`Withdraw` before the
later entity/inventory delta.
Tests: pickup during preparation/deferred residence, parent during pending
withdrawal, delete during host callback, GUID reuse, reset/disposal ownership
convergence.
### 8. Headless parity
Delete the independent resolve/placement/direct SetPosition and Blip logic in
`HeadlessSessionWorldProjection`. Headless must prepare/commit/ack through the
same Runtime operations as graphical presentation. Portal completion also
waits for the exact placement acknowledgement.
Tests: byte-identical login, ForcePosition, portal, missing-cell wake,
reconnect, and teardown snapshots.
## AP-22 — retail-authored collision shapes
After AP-1/AD-1 production cutover is stable:
- Make `ShadowShapeBuilder` the single Core authority for prepared Setup
primitives.
- Preserve authored cylinder order.
- If no cylinders exist, preserve authored spheres as spheres.
- Mixed data uses retail cylinder-first precedence.
- A truly shapeless Setup emits no world shadow.
- Remove `Setup.Radius/Height` collision synthesis, `Radius * 2` height guesses,
and sphere-to-cylinder coercion.
- Cut graphical static, headless static, and live-entity publication over
together.
- Do not alter transition dummy spheres, sticky/range radius, or projectile
mover shapes.
Automated gates: raw/prepared parity, cylinder order, sphere-only, mixed,
shapeless, scale, graphical/headless equality, representative installed DATs,
and dropped/portal/sign/door behavior.
## AD-10 — canonical remote slope projection
After AP-22:
- Prove remote movement uses the full `ResolveWithTransition` sweep.
- Remove terrain-normal preprojection from `RemoteMotionCombiner`.
- Remove Runtime terrain-normal sampling calls and delete the sampler if no
longer used.
- Let `CTransition::adjust_offset` project against the retained actual contact
plane.
- Preserve interpolation queues, correction replacement, Hidden behavior,
network cadence, and graphical/headless parity.
Tests must deliberately make terrain normals disagree with BSP/prop contact
normals, then cover uphill/downhill motion, seams, stairs, jumping, landing,
queue-empty/head-reached boundaries, and two-client observation.
## Closeout gates
Do not mark the campaign complete from narrow tests alone.
Automated:
```powershell
dotnet build AcDream.slnx -c Release
dotnet test AcDream.slnx -c Release --no-build --nologo
```
Also run every focused fixture named in the campaign plan: #273 tight gap,
#271 stair side, #269 slope, #265 landing, #185 stairs, #137 sliding normal,
#116 head collision, roof/cellar wedge, missing-cell, generation replacement,
GUID reuse, graphical/headless parity, and allocation/quiescence gates.
Connected/visual:
- login and portal arrival at outdoor, indoor, dungeon, stair-lip, and world
edge locations;
- repeated `/ls`, spell recall, ordinary portals, same-location revisit, and
reconnect;
- no early world reveal, outdoor demotion, floor snap, terrain-Z lift, or void;
- tight gaps, stairs, steep roofs, ledges, doors, crowds, shallow water, and
landblock seams;
- dropped objects, portals, signs, doors, and shapeless decorations;
- two-client uphill/downhill movement and sloped props;
- headless/graphical trace equality and graceful zero-residue teardown.
Only then retire AP-1, AD-1, AP-22, and AD-10, update the architecture,
divergence register, campaign/roadmap/milestones, research notes, durable
memory, `CLAUDE.md`, and `AGENTS.md`, and record final rollback SHAs.
## Review procedure for every remaining behavior commit
1. Implement one bisectable mechanism and run focused tests.
2. Run a retail-conformance reviewer against named retail symbols/addresses.
3. Run an architecture/adversarial reviewer against reentrancy, stale
sequences, malformed data, GUID reuse, streaming replacement, host failure,
reset, and disposal.
4. Fix every confirmed finding at its root cause.
5. Re-run the same reviewers until clean.
6. Run Release build plus the complete Release test suite.
7. Update the divergence/docs in the same behavior commit.
8. Stage exact paths only and commit.
## Rollback points
Newest first:
```powershell
git revert 270f5154 # dormant public placement receipt channel
git revert 4c02ac42 # Runtime SetPosition/lost-cell residence owner
git revert e84a388e # pure Core retail SetPosition transaction
```
Earlier campaign commits are individually bisectable and listed in the
completed sections above. Revert only the responsible mechanism; do not
restore the rejected snapshot lease or revive legacy compensation elsewhere.
## First action for the next agent
1. Read this file completely.
2. Read `docs/research/2026-07-31-canonical-set-position.md` and the AP-1/AD-1
rows in `docs/architecture/retail-divergence-register.md`.
3. Confirm `HEAD` contains `270f5154` in the exact worktree above.
4. Confirm only `AGENTS.md` has a real unrelated unstaged diff.
5. Implement prerequisite A (real Runtime collision-report ownership) as its
own reviewed commit.
6. Then design prerequisites B/C together so exact mover preparation and the
atomic controller/body transaction cannot create another partial ownership
state.

View file

@ -0,0 +1,245 @@
# Runtime SetPosition collision-report ownership handoff - 2026-07-31
## Purpose and exact stopping point
This handoff records placement Slice 4B2 checkpoint 2: the isolated Runtime
owner for retail SetPosition collision tracking and report-result semantics.
The checkpoint intentionally stops before authored mover preparation, shared
local-controller body publication, graphical/headless placement projection,
collision-prefix quiescence, or any production SetPosition route cutover.
Production behavior is therefore unchanged by this checkpoint. The new owner
is populated only by the dormant `RuntimeSetPositionState` and focused tests.
AP-1 and AD-1 remain narrowed/open; AP-22 and AD-10 remain open.
## Exact workspace
- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream`
- Branch: `codex/port-claude-agents`
- Starting checkpoint: `ec627c13`
(`docs(physics): hand off remaining divergence campaign`)
- This handoff belongs to the same behavior commit as the implementation.
- No upstream is configured for this worktree branch.
- Remotes:
- `origin`: `https://git.snakedesert.se/erik/acdream.git`
- `github`: `git@github.com:eriknihlen/acdream.git`
Continue in this worktree unless the user explicitly requests otherwise.
`AGENTS.md` has an unrelated pre-existing content diff and must not be staged,
restored, or rewritten as part of this checkpoint. Several other paths report
line-ending/stat noise without a content diff; stage only the exact paths
listed in the final commit.
## Retail oracle
The complete readable oracle is
[`2026-07-31-runtime-set-position-collision-reporting.md`](2026-07-31-runtime-set-position-collision-reporting.md).
The named-retail anchors are:
- `CPhysicsObj::report_object_collision_end` `0x00510A90`
- `CPhysicsObj::report_environment_collision` `0x00512FC0`
- `CPhysicsObj::report_object_collision` `0x00513060`
- `CPhysicsObj::track_object_collision` `0x00513F10`
- `CPhysicsObj::report_collision_start` `0x00513FD0`
- `CPhysicsObj::report_collision_end` `0x00514620`
- `CPhysicsObj::handle_all_collisions` `0x00514780`
- successful `CPhysicsObj::SetPositionInternal(CTransition const*)`
`0x00515330`
- `CPhysicsObj::leave_world` `0x005155A0`
- placement failure in `CPhysicsObj::SetPositionInternal` `0x00515BD0`
The source is `docs/research/named-retail/acclient_2013_pseudo_c.txt`; the
struct authority is `docs/research/named-retail/acclient.h`.
## What this checkpoint implements
`RuntimeCollisionReportingState` is the sole per-session owner of:
- one environment-collision latch per exact `RuntimeEntityKey`;
- one ordered object-contact table per exact owner incarnation;
- retained peer server GUID, touch time, and ethereal-at-touch state;
- static and `ReportAsEnvironment` routing;
- asymmetric `IgnoreCollisions` and reciprocal `ReportCollisions` eligibility;
- strict ordinary `age > 1.0` and ethereal `age > 0.0` expiry;
- force-end-before-callback mutation for reentrant safety;
- missing-peer self-only end reports without resolving a later GUID reuse;
- exact `Missile | AlignPath | PathClipped` clearing on the canonical record,
borrowed body, retained shadow state, and mutation version;
- a monotonic immutable report FIFO with observer-failure isolation;
- the retail callback-eligibility boolean used by failed placement to choose
`Collided` versus `NoValidPosition`;
- terminal ownership diagnostics and deterministic session/disposal cleanup.
Successful dormant SetPosition commits contact, water/walkable and ground
edges first, runs reporting next, applies physical response once, and then
refloods the shadow. An intervening Vector or Movement update suppresses only
the stale physical response; it does not erase collision tracking or reports.
Failed placement always supplies retail's `previousContact = false` and
`previousOnWalkable = false`, reports once, applies its one response pass, and
maps the report result exactly.
Hidden, teleport/withdrawal, deletion, session reset, and disposal use distinct
lifetime edges. Leaving the world force-ends the departing owner's table but
retains its environment latch and incoming peer records. Destruction then
forgets only the departing owner state. Other owners retain exact-key contacts
until their own expiry/force pass and can emit a missing-target end using the
preserved server GUID. Hidden and session-clear paths force-end while the old
report flags and bodies are still eligible, before state/reset teardown.
## Architectural boundaries
- Runtime owns all canonical collision-report state and report-result logic.
- Core exposes only the exact successful SetPosition ordering seam and the
retained-shadow collision identity required by Runtime.
- App and Headless gain no report table, queue, heuristic, or production
placement consumer.
- Reports are presentation-free and keyed by exact Runtime identity.
- Network/update callbacks may re-enter, but every later mutation revalidates
current identity, body, and the relevant authority version.
- Physical-response velocity authority is deliberately separate from report
authority, matching retail's ordering without overwriting a newer vector.
## Validation and independent review
The saved final diff passed:
- combined focused Runtime collision-report and SetPosition tests: 76/76;
- complete Runtime project: 562/562;
- graphical/headless Runtime-physics ownership and dormancy guards: 4/4;
- focused Core SetPosition/contact/response ordering tests: 29/29;
- complete Core project: 4,224 passed / 1 intentional skip;
- from-source Release solution rebuild: 0 errors and 21 pre-existing test-
project nullable/analyzer warnings; this checkpoint introduces none;
- complete Release solution: 10,309 passed / 4 intentional skips;
- warmed steady-contact refresh: 0 managed bytes across 10,000 calls;
- warmed immediate dormant SetPosition commit: still below the existing
2,048-byte-per-operation ceiling, with no new captured-delegate cost;
- architecture/adversarial re-review: clean after fixing Hidden/session/delete
reentrancy, stale shadow-state authority, allocation churn, and batch cost;
- retail-conformance re-review: clean against every named address above.
The final retail re-review found and closed two last ordering defects before
sign-off: object collision now snapshots the mover's Missile bit before the
source callback and, when that snapshot was set, unconditionally masks the
current `Missile | AlignPath | PathClipped` bits afterward. Thus an ordinary
callback-added Missile is retained when the mover was not previously a missile,
but a callback which clears Missile and re-adds path bits cannot evade the
pre-gated retail mask. Environment collision retains retail's post-callback
current-Missile test. Successful SetPosition now
publishes reports before installing the new stationary-fall counter, applies
the physical response next, installs StationaryFall/Stop/Stuck transient bits
after response, and only then refloods the shadow.
The host guard reads both production source trees. It proves App and Headless
borrow `GameRuntime.EntityObjects.Physics`, declare no second collision table
or return heuristic, and still contain no placement-channel consumer. No
connected/live gate is required for this dormant checkpoint because no
production route can populate or publish the new report owner.
## Exact implementation and test paths
The behavior commit containing this handoff changes exactly these ten code and
test paths:
- `src/AcDream.Core/Physics/PhysicsObjUpdate.cs`
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs`
- `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`
- `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs`
- `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`
- `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs`
- `tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs`
The same commit synchronizes the architecture, divergence register, canonical
SetPosition research, roadmap, milestones, project memory, prior campaign
handoff pointer, retail-oracle note, and this detailed handoff. `AGENTS.md` and
the pre-existing line-ending/stat-noise paths are deliberately excluded.
## Remaining work - required order
### 1. Exact authored mover preparation - complete 2026-08-01
The dormant preparation contract is implemented and independently reviewed.
It binds the Runtime-owned accepted frame and exact Setup DID, preserves the
ordered authored spheres and scale/step semantics, seals the returned command,
and forces stale deferred residents through exact re-preparation without
pre-mutating canonical state. See
[`2026-08-01-runtime-set-position-authored-mover-preparation.md`](2026-08-01-runtime-set-position-authored-mover-preparation.md).
### 2. Atomic local-controller/body publication - next
Prepare off-canonical, then perform one Runtime-validated atomic transaction
which publishes the exact same body to graphical and no-window controllers.
Every body writer, remote/projectile binding, SetPosition operation, clock
epoch, deletion, reset and disposal path must participate. Do not resurrect
the rejected snapshot/rollback lease documented in the prior handoff.
### 3. Presentation-only host projection
Implement graphical and headless observers over the existing dormant placement
receipt channel. Withdraw removes presentation/spatial consumers while
retaining Runtime identity; Place projects only the immutable committed frame;
Discard retires the older revision. Host failure retries the exact FIFO head
and never rolls Runtime back.
### 4. Collision-prefix quiescence and atomic route activation
Park SetPosition residents before retiring their collision prefix, publish the
complete replacement generation, wake exact matching residents, and cut every
spawn/Position/portal/projectile/drop/pickup/parent/delete route over together.
Only then may AP-1 and AD-1 retire.
### 5. Remaining campaign slices
- Port retail-authored object collision shape precedence and retire AP-22.
- Remove remote terrain-normal preprojection and let the transition resolver
use the retained contact plane, retiring AD-10.
- Run the full automated and connected matrix, update all ledgers, and close
the remaining physics campaign only with direct evidence.
## Rollback
This checkpoint is one bisectable commit. Revert the commit containing this
file to remove collision-report ownership without disturbing the earlier
SetPosition residence and receipt-channel checkpoints. Do not revive the old
collision-presence guess or the rejected body snapshot lease.
Because a Git commit cannot embed its own final hash, resolve the exact
checkpoint and revert command without ambiguity using:
```powershell
$checkpoint = git log -1 --format=%H -- `
docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md
git show --stat $checkpoint
git revert $checkpoint
```
Earlier rollback points remain:
```powershell
git revert 270f5154 # dormant public placement receipt channel
git revert 4c02ac42 # Runtime SetPosition/lost-cell residence owner
git revert e84a388e # pure Core retail SetPosition transaction
```
## Resume procedure
1. Continue in
`C:\Users\erikn\.codex\worktrees\af5e\acdream` and verify
`git branch --show-current` reports `codex/port-claude-agents`.
2. Resolve the exact checkpoint with the `git log` command above and confirm
it is the current `HEAD` before starting the next behavior slice.
3. Read `AGENTS.md`, `docs/architecture/acdream-architecture.md`, this file,
the collision-report oracle, the canonical SetPosition research, and the
prior remaining-campaign handoff completely.
4. Run `git status --short`. Preserve the unrelated `AGENTS.md` content diff
and every documented line-ending/stat-noise path. Never stage by blanket.
5. Begin only **Exact authored mover preparation**, the first remaining item
above. Do not activate production routes, retire AP-1/AD-1, begin AP-22 or
AD-10, or resurrect the rejected body snapshot/rollback lease.
6. Use exact-path staging and rerun the matching focused projects,
`dotnet build AcDream.slnx -c Release --nologo`, and
`dotnet test AcDream.slnx -c Release --no-build --nologo` before the next
reviewed checkpoint.

View file

@ -0,0 +1,182 @@
# Runtime SetPosition collision-report ownership
**Scope:** placement/streaming Slice 4B2 prerequisite A only. This closes the
missing Runtime owner for retail collision tracking and the boolean returned by
`CPhysicsObj::handle_all_collisions`. It does **not** activate any graphical or
headless production SetPosition route.
## Named-retail oracle
Primary sources:
- `CPhysicsObj::report_object_collision_end` `0x00510A90`
- `CPhysicsObj::report_environment_collision` `0x00512FC0`
- `CPhysicsObj::report_object_collision` `0x00513060`
- `CPhysicsObj::track_object_collision` `0x00513F10`
- `CPhysicsObj::report_collision_start` `0x00513FD0`
- `CPhysicsObj::report_collision_end` `0x00514620`
- `CPhysicsObj::handle_all_collisions` `0x00514780`
- `CPhysicsObj::SetPositionInternal(CTransition const*)` `0x00515330`
- `CPhysicsObj::leave_world` `0x005155A0`
- placement failure path in `CPhysicsObj::SetPositionInternal` `0x00515BD0`
- `CPhysicsObj::CollisionRecord`, `EnvCollisionProfile`,
`ObjCollisionProfile`, and `AtkCollisionProfile` in
`docs/research/named-retail/acclient.h`
The source text is
`docs/research/named-retail/acclient_2013_pseudo_c.txt`. The addresses above
are the behavioral authority; the older unnamed chunks remain fallback only.
### Environment reporting
```text
report_environment_collision(meInContact):
reported = false
if !colliding_with_environment:
if self.ReportCollisions && self.weenie != null:
DoCollision(EnvCollisionProfile(self.velocity, meInContact))
reported = true
colliding_with_environment = true
if self.Missile:
self.state &= ~(Missile | AlignPath | PathClipped)
return reported
```
The latch is independent of callback eligibility. An object with no collision
callback still latches its environment contact, and a repeated environment hit
returns false. Retail has no environment-end callback. `leave_world` does not
clear this latch; the next `handle_all_collisions` call re-arms it only after a
non-environment frame.
### Object reporting and tracking
```text
track_object_collision(other, meInContact):
if other.Static:
return report_environment_collision(meInContact)
record = { touched_time = PhysicsTimer.curr_time,
ethereal = other.Ethereal }
existed = collision_table.clobber(other.id, record)
if existed:
return false
return report_object_collision(other, meInContact)
```
The table insert/refresh precedes callbacks. Duplicate contacts refresh their
time but never replay a start callback. DAT/static classification and physics
state come from the exact shadow object which produced the collision; object-ID
presence is not a valid substitute.
`report_object_collision` first maps `ReportAsEnvironment` to the environment
path. Otherwise:
- the mover reports only when the other object is not `IgnoreCollisions` and
the mover has `ReportCollisions` plus a weenie;
- a mover which had Missile set before the source callback unconditionally
masks its current `Missile | AlignPath | PathClipped` bits after striking a
non-ignored object, even when the callback cleared Missile but re-added path
bits; when pre-callback Missile was clear, callback-added Missile is retained;
- the reciprocal report occurs only when the other has `ReportCollisions`, the
mover is not `IgnoreCollisions`, and the other has a weenie;
- the return is true when at least one callback is attempted. It is never a
collision-presence boolean.
### Expiry and end reporting
`report_collision_end(force)` removes records before dispatching callbacks.
This ordering is required for safe reentrancy.
```text
ordinary record: remove when age > 1.0, or force
ethereal record: remove when age > 0.0, or force
```
Equality remains alive. A still-resolvable non-`ReportAsEnvironment` peer may
receive reciprocal collision-end callbacks. When the peer no longer resolves,
the owner can still receive its self-only end using the stored retail object
ID. A later incarnation must never satisfy the old contact record.
### `handle_all_collisions` and SetPosition ordering
```text
handle_all_collisions(info, previousContact, previousOnWalkable):
reported = false
for other in info.collidedObjects, in encounter order:
reported |= track_object_collision(other, previousContact)
report_collision_end(force = false)
if environment latch is already set:
latch = info.collided_with_environment
else if info.collided_with_environment
|| (!previousOnWalkable && self.OnWalkable):
reported |= report_environment_collision(previousContact)
apply retail collision velocity/stationary response
return reported
```
Successful `SetPositionInternal(CTransition const*)` commits the resolved
cell/frame, Contact/WaterContact/OnWalkable state, and HitGround/LeaveGround
edge before `handle_all_collisions`; it ignores the returned boolean and only
then replaces/refloods shadows. Collision reports observe the old stationary-
fall state; the new counter is installed before physical response, while the
StationaryFall/Stop/Stuck transient bits are replaced after response and before
shadow reflood. The placement failure path calls
`handle_all_collisions(info, false, false)` and maps true to
`SetPositionError::Collided` (`4`) and false to `NoValidPosition` (`2`).
Consequently acdream must keep report/tracking separate from the physical
response: failed placement runs both once, while successful Runtime commit
runs reporting between the contact/ground commit and shadow reflood without
double-applying velocity response.
## Runtime ownership contract
The implementation is presentation-free and belongs to the per-session
`RuntimePhysicsState` graph. Its invariants are:
- owner and peer identities are exact `RuntimeEntityKey` values, not server
GUID or local ID alone;
- each tracked record retains the peer server GUID, touch time in the Runtime
simulation-clock domain, and ethereal-at-touch bit;
- collided IDs and authored/static ownership are admitted through the exact
retained `ShadowObjectRegistry` registration which produced the collision;
every dynamic Static/Ethereal/Ignore/ReportAsEnvironment decision then reads
the current canonical `PhysicsBody.State`, never a stale shadow snapshot;
- immutable reports preserve encounter order and dispatch through a retained,
reentrancy-safe FIFO;
- callback exceptions are isolated, while the retail report-result boolean is
determined by callback eligibility and does not depend on subscribers;
- every callback boundary revalidates the exact record/body/authority before
any later canonical mutation;
- force-end mutates the complete expired set before publishing ends; exact-key
admission guards prevent callback reentry from recreating a leaving owner,
and session teardown blocks the whole owner batch before its first callback;
- one source lifetime token covers a complete precollected end batch, so a
callback-accepted delete stops every later peer report even while teardown
sidecars remain resolvable;
- lifetime forget, session reset, and disposal cannot donate state to GUID
reuse;
- terminal ownership diagnostics include contact/report state and converge to
zero;
- graphical and no-window hosts borrow the same Runtime owner. No host owns a
second collision table or report-result heuristic.
The warmed steady-contact refresh path allocates zero managed bytes. Expired
contact storage is allocated lazily only after the first actual expiry, and
session-batch teardown is linear in owner count.
## Deliberately deferred
The canonical SetPosition owner remains dormant in production. The following
belong to later 4B2 commits and are not part of this checkpoint:
- exact ordered Setup spheres, authored scale and step-height preparation;
- the atomic shared local-controller body transaction;
- presentation-only rebucketing and placement-prefix quiescence;
- graphical/headless spawn, Position, portal, projectile, drop, pickup,
parent, and delete route cutover.
AP-1 and AD-1 therefore remain open, narrowed only by removal of the
collision-report prerequisite.

View file

@ -0,0 +1,302 @@
# Runtime initial Create residence handoff - 2026-08-01
> **Status:** this remains the `38fd4b8d` residence-foundation history. The
> completed inbound-admission checkpoint and current continuation boundary are
> recorded in
> [`2026-08-01-runtime-initial-placement-admission-handoff.md`](2026-08-01-runtime-initial-placement-admission-handoff.md).
## Purpose and exact stopping point
Commit `38fd4b8dc952236d4b98518c67335026c7815656` adds the dormant Runtime
transaction which retains an entity's initial authored CreateObject placement
until canonical SetPosition succeeds and the ordered remainder of the Create
packet can be adopted. It does not yet cut the production App/Headless Create
route over, so AP-1 and AD-1 remain open.
This is the deliberate clean handoff requested by the user. In plain terms,
Runtime now has a tested holding area for a newly created world object while
its exact collision placement is being resolved. The object cannot become
half-visible, consume later position packets, or be silently replaced during
that interval. The next model starts at the executor/cutover boundary; it does
not need to repair or redesign this ownership transaction.
Do not start production cutover from an earlier checkpoint. Do not call this
campaign complete: AP-1, AD-1, AP-22, and AD-10 remain open.
## Exact workspace and Git state
- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream`
- Branch: `codex/port-claude-agents`
- Code checkpoint: `38fd4b8dc952236d4b98518c67335026c7815656`
- Immediately preceding host-staging checkpoint: `74103f75`
- No push or merge is part of this stopping point.
The worktree intentionally contains unrelated user changes/stat noise. Do not
stage, restore, normalize, or rewrite these paths as part of the continuation:
- `AGENTS.md` (real unrelated content change);
- `src/AcDream.App/Input/PlayerModeController.cs`;
- `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs`;
- `src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs`;
- `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`;
- `tests/AcDream.Core.Tests/Physics/CellTransitTests.cs`;
- `tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs`;
- `tools/A8CellAudit/A8CellAudit.csproj`.
The paths after `AGENTS.md` currently have no content diff and are reported
because of pre-existing line-ending/stat noise. Always stage exact paths;
never use `git add -A`.
## Placement checkpoint chain
The current mechanism was built as bisectable commits. The directly relevant
chain, oldest first, is:
- `e84a388e` - pure Core retail SetPosition transaction;
- `4c02ac42` - Runtime deferred/lost-cell residence owner;
- `270f5154` - dormant placement receipt channel;
- `237d1184` - retail SetPosition collision-report owner;
- `442cb8f9` - exact authored mover preparation;
- `22651c82` - dormant Runtime local physics publication;
- `99f867f0` - sealed dormant SetPosition evaluation;
- `5785a07b` - dormant SetPosition activation;
- `ef436678` - placement acknowledgement ownership;
- `74c9b155`, `378ca95a`, `f05ed5c3` - graphical/headless projection seams;
- `99bf1751`, `9b0f59bd` - collision-prefix quiescence and atomic replacement;
- `0fbc7a1f` - hidden-object SetPosition ownership correction;
- `3f800a4a` - authoritative route classification;
- `74103f75` - inert App materialization before Runtime placement;
- `38fd4b8d` - initial Create residence, continuation FIFO, and adoption.
## Owned mechanism in `38fd4b8d`
`RuntimeInitialCreateResidenceState` now owns, per exact entity incarnation:
- the accepted initial Create frame and exact SetPosition operation;
- a cellless logical entity while authored placement is pending;
- a monotonic immutable FIFO for fresher Position continuations;
- accepted timestamp, position, vector, rotation, placement, and wire payloads;
- completion/adoption tokens and a revision which reject stale observers;
- exact authority revalidation across generation, identity, Create, position,
placement, full-cell, deletion, reset, GUID reuse, and disposal;
- reentrant-safe cancellation at the lifetime commit boundary.
The public legacy registration path is intentionally unchanged. Production
behavior remains on the previous route until the continuation executor and
all-host cutover land together.
### Exact behavior now protected
- Initial/New Create admission is previewed without consuming timestamps;
Existing and Stale packets still use the established gates.
- No collision generation is guessed. An initial residence can exist only
after binding a real, nonzero generation.
- Fresh Parent wins over Position, matching the packet's relation priority.
- An absent or present-zero position cell remains cellless instead of being
fabricated as an outdoor placement.
- Later accepted Position packets append to one immutable ordered FIFO. They
cannot mutate the original placement operation or bypass it.
- A completed but not yet adopted transaction remains exclusive. A later
Position revises the retained batch and invalidates the old adoption token;
it cannot disappear between completion and acknowledgement.
- Placement acknowledgement uses exact identity, operation, generation,
position authority, Create integration, full-cell, and placement-commit
versions.
- Reset first detaches and clears ownership, then publishes cancellation, so a
reentrant observer cannot invalidate enumeration or resurrect an owner.
- Delete, replacement, pickup, parent, withdrawal, reset, and disposal return
cancellation receipts to the caller's safe publication boundary instead of
invoking observers before later canonical mutation.
- Malformed initial or continuation packets fail before timestamp or canonical
state consumption. A corrected packet with the same instance can recover.
The FIFO stores raw accepted Position facts rather than prematurely choosing
a final movement route. That is intentional: contact, animation state, the
server-position option, and player distance must be sampled at the same point
where retail makes the routing decision.
## Exact files in `38fd4b8d`
- `src/AcDream.Core/Physics/PhysicsTimestampGate.cs`
- `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs`
- `src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs`
- `src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs`
- `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`
- `tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs`
- `tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs`
- `tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs`
## Retail order for the next slice
The next slice must preserve `SmartBox::HandleCreateObject` at `0x00454C80`:
1. visual description;
2. exactly one of Parent, Position, or Pickup relation;
3. Movement;
4. State;
5. Vector;
6. Weenie description;
7. final resident-cell validity cleanup.
Position routing must also preserve these named-retail distinctions:
- a same-incarnation Create position is not equivalent to standalone F748;
- ForcePosition performs its own timestamp/parent/placement route;
- remote near-contact interpolates, remote far-contact stops interpolation and
performs SetPosition, and remote teleport invokes the teleport hook before
SetPosition;
- local teleport performs SetPosition, then the player-teleported hook, then
constrains to the authoritative frame and clears velocity;
- local ordinary Position constrains first and interpolates only when the
server-position option and contact gate permit it.
Therefore every retained continuation must include its
`RuntimeAcceptedPositionSource`, and executor-time inputs must pin HasAnims,
UsePositionFromServer, contact, and distance before mutation. Parent/Pickup and
the same-Create Movement -> State -> Vector order must be part of the same
synchronous adoption transaction.
The principal named-retail anchors are:
- `SmartBox::HandleCreateObject` `0x00454C80`;
- `CPhysicsObj::SetPositionInternal` `0x00515330`;
- `PhysicsDesc::UnPack` `0x0051DDD0`;
- `CPhysicsObj::set_description` `0x00514F40`.
Use `docs/research/named-retail/acclient_2013_pseudo_c.txt` first and the older
Ghidra chunks only as a fallback.
## Validation and reviews
The implementation agent and reviewers reported:
- focused initial-residence and classifier tests: 79/79;
- complete Runtime tests: 819/819;
- Runtime Release build: zero warnings and errors;
- focused Core timestamp tests: 31/31;
- retail-conformance review: clean;
- architecture/adversarial review: clean;
- `git diff --check`: clean.
Primary-agent final gates after the behavior commit:
- complete Release solution build: succeeded, 0 errors;
- complete Release solution tests: 10,612 passed / 4 intentional skips;
- App: 4,027 passed / 3 skips;
- Core: 4,242 passed / 1 skip;
- Runtime: 819 passed;
- Core.Net: 762 passed;
- UI abstractions: 543 passed;
- Headless: 76 passed;
- Content: 124 passed;
- Bake: 15 passed;
- CLI: 4 passed.
The build reports 21 pre-existing test-project nullable/analyzer warnings. The
checkpoint introduces no build errors or new production warning.
Both independent reviews initially found real edge cases and the final code
includes their root-cause fixes:
- completed-but-unadopted Position packets could bypass the FIFO;
- cancellation callbacks could re-enter before the caller's canonical mutation;
- reset could enumerate live dictionaries while a callback mutated them;
- adoption did not initially validate every spatial/authority version.
Final retail-conformance and architecture/adversarial rereviews both passed.
No connected visual gate was required because the new API is dormant and no
production App or Headless route calls it yet.
## Production routes intentionally unchanged
This is the key handoff boundary. At this checkpoint:
- graphical Create still flows through
`LiveEntityHydrationController.OnCreateCore`,
`LiveEntityRuntime.RegisterLiveEntity`, and legacy `RegisterEntity`;
- graphical materialization still defaults to `LegacyImmediate` rather than
the new `AwaitRuntimePlacement` residence;
- graphical Position still performs its existing world-position, rebucket,
projectile, remote-motion, and shadow work;
- headless Create still uses `RuntimeLiveEntitySessionController.OnSpawned`,
`HeadlessSessionWorldProjection.ProjectSpawn`, and its independent initial
resolve/body construction;
- headless Position still uses its existing projection path;
- the new Runtime initial-residence API is reached by focused tests only.
Existing host adapters already observe Runtime placement receipts. Do not add
another observer architecture or a second GUID map.
## Next implementation boundary
Implement one Runtime continuation executor and exact ordered Create tail,
then route graphical and no-window registration through it without a mirror.
The executor must be synchronous or retry-idempotent around adoption revision;
failure must leave the exact FIFO head retryable. Only after both production
hosts and every Create/Position/ForcePosition/parent/pickup route use the same
owner may AP-1 and AD-1 retire.
### Required order for the next model
1. Add `RuntimeAcceptedPositionSource` to every retained continuation. A
same-incarnation Create position and standalone F748 are not interchangeable.
2. Implement one Runtime-owned synchronous continuation executor. Capture
`UsePositionFromServer`, animation/contact state, and player distance at the
retail-equivalent decision point.
3. Execute initial placement once, consume its exact host acknowledgement,
then drain the continuation FIFO in order with retail's hook ordering.
4. Serialize one Create packet as relation
(Parent/Position/Pickup), Movement, State, Vector, WeenieDesc, cleanup.
5. Keep every side effect exactly-once. If execution can yield, make adoption
revision/idempotence explicit so retry cannot replay hooks or position sends.
6. Switch graphical and headless registration together to the same Runtime
owner. Hosts may project immutable results only; they may not resolve a
second placement or create another body.
7. Route later Create, Position, ForcePosition, teleport, parent, pickup,
withdrawal, delete, remote, projectile, and dropped-item edges through the
same owner before deleting legacy paths.
8. Run focused tests, full Release build/tests, exact lifecycle/reconnect and
nine-stop connected gates, then perform the user visual matrix. Only then
retire AP-1 and AD-1.
Do not begin AP-22 or AD-10 until the production placement cutover is green.
### Subsequent independent slices
- **AP-22:** make `ShadowShapeBuilder` the only prepared Setup-shape authority;
preserve authored cylinder order, use spheres only when cylinders are absent,
allow truly shapeless Setups, and remove radius/height synthesis and sphere-
to-cylinder coercion across graphical/headless/live publication.
- **AD-10:** remove terrain-normal preprojection from remote motion. Let the
canonical transition resolver project against the actual retained contact
plane, with tests where terrain and BSP/prop normals deliberately differ.
- Run the final connected matrix, synchronize ledgers/docs, and only then close
the remaining physics-divergence campaign.
## Rollback
Revert the behavior checkpoint without disturbing the earlier placement
foundation:
```powershell
git revert 38fd4b8dc952236d4b98518c67335026c7815656
```
The documentation checkpoint containing this file is a separate commit and
can be reverted independently if only the handoff text needs correction.
## Resume checklist
1. Continue in the exact worktree and branch recorded above.
2. Confirm `git rev-parse HEAD` includes both the behavior and documentation
checkpoint commits.
3. Read this file, `docs/architecture/acdream-architecture.md`,
`docs/research/2026-07-31-canonical-set-position.md`, and
`docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md`.
4. Run `git status --short` and preserve every unrelated path listed above.
5. Re-run the focused 79-test residence/classifier gate before modifying the
transaction.
6. Begin only the continuation executor and ordered Create tail. Do not start
AP-22/AD-10 or vendor work in the same commit.

View file

@ -0,0 +1,320 @@
# Runtime initial-placement admission handoff - 2026-08-01
> **Status:** this remains the `30012361` admission-checkpoint history. The
> continuation executor this file scoped as "the next implementation
> boundary" is complete at `5db3de3c`; the current boundary (production
> cutover) is recorded in
> [`2026-08-02-runtime-continuation-executor-handoff.md`](2026-08-02-runtime-continuation-executor-handoff.md).
## Purpose and exact stopping point
Behavior commit `30012361e12222e8271b1531574257ba910c77cb`
completes the bounded Runtime admission checkpoint requested by the user.
While an entity's first authored placement is waiting, every later accepted
same-incarnation update is preserved in exact arrival order without changing
or displaying the entity early.
In plain terms, Runtime now has a sealed mailbox behind the pending initial
placement. Network sequence checks still decide which messages are fresh, but
accepted messages wait in that mailbox. The visible/canonical entity remains
at its original frozen Create state until a later executor is authorized to
apply the mailbox.
This checkpoint deliberately does **not** implement that executor, switch the
graphical or headless production routes, begin AP-22 authored shape work, or
begin AD-10 remote slope projection. AP-1 and AD-1 therefore remain open.
This file supersedes the admission-status portions of
`2026-08-01-runtime-initial-create-residence-handoff.md`; that earlier file
remains the foundation history for commit `38fd4b8d`.
## Exact workspace and Git state
- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream`
- Branch: `codex/port-claude-agents`
- Behavior checkpoint: `30012361e12222e8271b1531574257ba910c77cb`
- Residence foundation: `38fd4b8dc952236d4b98518c67335026c7815656`
- Documentation checkpoint: the commit containing this file
- No push or merge is part of this checkpoint.
The worktree intentionally contains unrelated user changes or pre-existing
stat/line-ending noise. Do not stage, restore, normalize, or rewrite these
paths when continuing:
- `AGENTS.md` (real unrelated content change);
- `src/AcDream.App/Input/PlayerModeController.cs`;
- `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs`;
- `src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs`;
- `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`;
- `tests/AcDream.Core.Tests/Physics/CellTransitTests.cs`;
- `tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs`;
- `tools/A8CellAudit/A8CellAudit.csproj`.
Always stage exact paths. Never use `git add -A` in this worktree.
## What `30012361` owns
### One exact pending owner
`RuntimeInitialCreateResidenceState` owns one transaction per exact
`RuntimeEntityKey`, not per server GUID alone. It retains:
- the deep-frozen initial Create packet and placement operation;
- the exact residence token, generation, placement authority, and revision;
- a monotonic sequence for accepted continuations;
- one immutable, mixed-kind FIFO in original arrival order;
- completion/adoption and teardown receipts.
The accepted continuation kinds are:
1. same-incarnation Create;
2. ObjDesc;
3. Parent;
4. Pickup;
5. Position;
6. Movement;
7. State;
8. Vector.
There is no coalescing, sorting by message type, or replacement of an earlier
accepted FIFO item by a later one.
### Frozen public state
While the initial residence is pending:
- retail timestamp gates advance for accepted updates;
- `RuntimeEntityRecord.Snapshot` remains unchanged;
- the public accepted-snapshot view remains unchanged;
- no entity/object event is published;
- no projection acknowledgement callback runs;
- parent commitment, world placement, rendering, radar, picking, physics,
audio, and other presentation remain unchanged;
- every accepted payload is retained as an immutable typed action.
This is intentional gate-only acceptance. It is not an alternative canonical
snapshot and must not grow into one.
### Immutable payload boundary
`RuntimeInitialCreateAdmissionFreezer` copies every parser-owned mutable
collection that can outlive packet dispatch:
- EntitySpawn animation-part, texture, and sub-palette arrays;
- ObjDesc model arrays;
- motion command lists;
- Physics Movement raw bytes and motion commands;
- Physics child attachments.
Same-incarnation Create is retained as one atomic envelope. Its actions retain
retail's packet-tail order:
1. AP-119 pre-tail description adaptation;
2. ObjDesc;
3. exactly one of Parent, Position, or Pickup;
4. Movement;
5. State;
6. Vector;
7. Weenie description;
8. resident-cell cleanup.
### Position facts remain raw
A deferred Position retains the typed packet plus the timestamp disposition
and accepted gate facts. It does not prematurely choose interpolation,
teleport hooks, or final movement behavior. The new explicit
`RuntimePositionConstrainPhase` distinguishes retail's local ordinary
constrain-before route from remote/teleport constrain-after routes, but the
future executor must still sample the required live inputs at the retail
decision point.
No selected UI target, presentation state, or host-specific route is stored in
the admission owner.
## Missing-parent behavior
Named retail resolves a nonzero parent before child object lookup and child
timestamp admission. Runtime now follows that order:
- a child Create whose parent is not addressable is stored as the complete raw
frozen Create packet;
- no child entity record, accepted snapshot, timestamp gate, local ID, event,
or residence lease exists yet;
- the queue is keyed by parent GUID but each entry also has a monotonic
`AdmissionId` which is never reset, preventing reset/reconnect ABA reuse;
- a later parent Create may consume only the exact admission token it peeked;
- deleting or replacing a still-missing parent does not discard its queued
child Create, matching retail's GUID-keyed placeholder behavior;
- an exact child Delete removes an equal/older deferred child generation even
if no child timestamp gate exists;
- generation cleanup preserves an equal or newer deferred child Create and
discards only older ownership.
Raw missing-parent replay and actual child creation belong to the future
continuation executor/cutover. They are not performed by this checkpoint.
## Malformed and saturation behavior
All structural and capacity checks run before consuming a timestamp gate.
- Non-finite Vector and Position payloads are rejected without sequence
consumption.
- A full/saturated continuation owner fails before gate acceptance; there is
no fallback to ordinary immediate mutation.
- Flattened EntitySpawn projections must exactly agree with the embedded
PhysicsDesc for identity, Position, relevant timestamps, parent, and
placement.
- When PhysicsDesc is absent, every flattened PhysicsDesc projection must also
be absent or zero: Position, Setup, Motion, PhysicsState, scale, friction,
elasticity, timestamps, parent, and placement.
- Instance sequence zero remains legal and is covered on the active pending
FIFO path.
The last rule prevents synthetic or corrupt packets from creating two
contradictory placement authorities even though the production parser normally
constructs those projections from one source.
## Lifetime and failure guarantees
- Delete cancels the matching residence and its FIFO before the exact entity
can be reused.
- New incarnation/GUID reuse cannot observe or adopt an older incarnation's
FIFO.
- Session reset/reconnect clears active residence, completed-unadopted batches,
deferred raw creates, accepted timestamp ownership, and operation state.
- Reentrant teardown callbacks cannot resurrect the detached owner.
- Completion/adoption revisions cannot wrap into a valid stale token.
- Parent raw-admission IDs cannot wrap or reset into an ABA match.
- Every ownership ledger converges to zero on reset/disposal.
## Named-retail oracle
The behavior and reviews used these named-retail anchors:
- `SmartBox::HandleCreateObject` `0x00454C80` - Create packet ordering and
missing-parent precondition;
- `SmartBox::ProcessObjectNetBlobs` `0x00454B20` - queued packet replay order;
- `SmartBox::HandleReceivedPosition` `0x00453FD0` - standalone Position route;
- `SmartBox::HandleDeleteObject` `0x00451EA0` - GUID-keyed delete behavior;
- `ACCObjectMaint::CreateObject` `0x00558870` - logical object creation;
- `CPhysicsObj::set_description` `0x00514F40` - PhysicsDesc application order;
- `CPhysicsObj::SetPositionInternal` `0x00515330` - canonical placement.
Research must continue from
`docs/research/named-retail/acclient_2013_pseudo_c.txt`; use the older Ghidra
chunks only as a fallback.
## Files in the behavior checkpoint
- `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs`
- `src/AcDream.Runtime/Entities/ParentAttachmentState.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs`
- `src/AcDream.Runtime/Entities/RuntimeInitialCreateAdmissionFreezer.cs`
- `src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs`
- `src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs`
- `tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs`
- `tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs`
## Automated evidence
Final primary-agent gates on the exact behavior diff:
- focused initial-residence/classifier tests: **89 passed, 0 failed**;
- complete Runtime tests: **829 passed, 0 failed**;
- complete Release build: **0 warnings, 0 errors**;
- complete Release solution: **10,622 passed, 4 intentional skips**;
- `git diff --check`: clean.
Per-project final solution totals:
- App: 4,027 passed / 3 skipped;
- Bake: 15 passed;
- CLI: 4 passed;
- Content: 124 passed;
- Core.Net: 762 passed;
- Core: 4,242 passed / 1 skipped;
- Headless: 76 passed;
- Runtime: 829 passed;
- UI abstractions: 543 passed.
Independent final results:
- retail-conformance reviewer: **PASS**;
- architecture/adversarial reviewer: **PASS**.
The reviews explicitly checked exact retail order, gate-only frozen state,
missing-parent placeholder lifetime, zero instance, delete/reset/GUID reuse,
deep freezing, malformed duplicated projections, capacity preflight,
reentrancy, and the absence of executor/cutover work.
No connected visual gate is required for this checkpoint because production
graphical and headless routes remain unchanged and the new owner is exercised
only through deterministic Runtime tests.
## Deliberately unchanged production routes
At this checkpoint:
- graphical Create/Position still use the existing App route;
- headless Create/Position still use the existing no-window projection route;
- no host drains `RuntimeInitialCreateResidenceState.Continuations`;
- no raw missing-parent child Create is replayed;
- no new gameplay/presentation callback is emitted;
- no AP-1 or AD-1 divergence row is retired;
- AP-22 and AD-10 are untouched.
Do not mistake the stored FIFO for completed game behavior. The clean next
boundary is the executor that applies it.
## Next implementation boundary
Implement only the Runtime continuation executor and retail Create tail.
The executor must:
1. consume the exact initial placement acknowledgement once;
2. capture executor-time inputs at the retail decision point;
3. apply the initial Create tail in retail order;
4. drain mixed continuations strictly by retained sequence;
5. preserve same-Create atomicity;
6. keep the FIFO head retryable if an external host receipt is temporarily
unavailable;
7. make every hook, timestamp, placement, and event side effect exactly once;
8. consume a raw missing-parent Create only through its exact `AdmissionId`;
9. abandon safely on delete, reset, replacement, or generation mismatch;
10. produce host-independent immutable results rather than calling App or
headless presentation directly.
Do not combine the executor with graphical/headless cutover. After the
executor is independently green, the following checkpoint may switch every
Create, Position, ForcePosition, Parent, Pickup, withdrawal, remote,
projectile, dropped-item, and teardown route across both hosts together.
Only after executor plus all-host cutover and connected gates pass may AP-1
and AD-1 retire. AP-22 and AD-10 remain later independent slices.
## Rollback
Revert this behavior checkpoint without disturbing the prior residence
foundation:
```powershell
git revert 30012361e12222e8271b1531574257ba910c77cb
```
The documentation checkpoint containing this file is separate and may be
reverted independently if only the handoff text needs correction.
## Resume checklist
1. Open the exact worktree and branch above.
2. Confirm `git log -3 --oneline` contains behavior `30012361` and the
documentation commit containing this file.
3. Preserve every unrelated dirty path listed above.
4. Read this file, `docs/architecture/acdream-architecture.md`,
`docs/research/2026-08-01-runtime-initial-create-residence-handoff.md`, and
`docs/research/2026-07-31-canonical-set-position.md`.
5. Re-run the focused 89-test gate before changing admission/execution code.
6. Begin only the continuation executor. Do not begin production cutover,
AP-22, AD-10, or vendor work in the same checkpoint.

View file

@ -0,0 +1,287 @@
# Runtime local-player physics publication - 2026-08-01
## Scope
This is placement Slice 4B2 checkpoints 4-6. It adds the dormant,
presentation-independent transaction which prepares and assigns ownership of
one local-player `PhysicsBody` and `PlayerMovementController`, retains an exact
post-ownership evaluation lease, and commits the canonical Runtime SetPosition
activation in retail order. No App or Headless production route invokes this
transaction yet, so graphical and no-window game behavior is unchanged and
AP-1/AD-1 remain open until their hosts cut over.
Checkpoint 6 consumes the prepared placement operation only after the exact
body/controller/identity/collision envelope is current. It publishes FullCell,
world residence, host, shadow, workset, object-clock, and ordered Place state
from that same Runtime-owned dormant body. There is no second body, mirrored
gameplay owner, rollback mutation, or presentation callback inside the
canonical tail.
## Ownership contract
`RuntimeLocalPlayerPhysicsPublicationState` is the sole owner of unpublished
local-player body/controller candidates. Each candidate is bound to a token
containing:
- The exact `RuntimeEntityKey` and authored SetPosition placement token.
- A monotonic publication ID.
- The nonzero canonical local-player server GUID and exact identity revision.
- The record's physics-body and object-clock ownership epochs.
- The movement state's controller ownership epoch.
- The entity directory's session-lifetime authority.
Preparation constructs a private controller, body, and object clock. It applies
the exact authored cell frame, orientation, Setup sphere list, scale, step
heights, and accepted final physics state without mutating the canonical entity,
shared object clock, engine/worksets, shadow registry, FullCell, host state, or
presentation. The candidate remains explicitly out of world and inactive. No
method exposes its controller, body, clock, or another mutable reference while
it is owned by the publication transaction.
This checkpoint accepts only a pristine initial graph: no canonical body,
movement controller, physics host, remote motion, projectile, acquisition or
binding operation, or remote-placement contract may exist. It cannot replace or
upgrade a live graph. The local-player identity must be live, nonzero, and name
the same server GUID as the exact entity incarnation.
Unpublished candidates and ownership-committed dormant controllers reject live
movement operations: update, public SetPosition, blip, outbound-position
capture, movement/position send tracking, and shared-engine position commit.
Only the checkpoint-6 activation transaction may promote `RuntimeOwnedDormant`
to `RuntimePublished`; preparation and evaluation never invoke that transition.
Once a
Runtime-owned dormant or published controller is replaced, reset, or disposed,
its terminal retirement state rejects the same operations plus
body/configuration mutation and manager acquisition. Publicly constructed
legacy controllers keep their existing standalone behavior.
## Failure-atomic commit
Commit revalidates every authority after preparation:
- The entity record is the current incarnation and is not accepted for delete.
- The local-player identity still has the token's exact GUID and revision and
has not been disposed.
- The exact authored SetPosition operation and sealed command remain current.
- Session, body, object-clock, and controller ownership epochs still match.
- The body/controller/host/remote/projectile graph remains completely pristine,
with no acquisition, binding, or remote-placement operation in progress.
Only after validation completes does the callback-free update-thread tail:
1. Rebind the candidate controller from its private clock to the record's exact
canonical object clock and mark it Runtime-owned but dormant.
2. Store the candidate's exact body on the canonical record, advancing the
physics ownership epoch once.
3. Store the same controller in `RuntimeLocalPlayerMovementState`, advancing the
controller ownership epoch once.
The dormant controller rejects every live/configuration operation after these
stores; ownership commit alone cannot tick physics, mutate the canonical clock,
or publish an outbound frame. These stores allocate no new gameplay owner,
invoke no host or presentation callback, and cannot replay an older
incarnation. Replacing SetPosition,
changing any accepted physics authority, binding remote/projectile state,
replacing body/clock/controller ownership, delete plus GUID reuse, reset, or
disposal causes the token to reject. An identity switch away and back also
rejects because its revision changed. A rejected or superseded candidate is
discarded and cannot perform a later live operation. Reset and disposal converge
the publication ledger to zero candidates.
Repeated stores of the same body/controller do not advance their epochs; real
bind, replacement, and unbind edges do. This makes ABA-shaped reference changes
observable even if a later value happens to equal an earlier reference.
## Dormant SetPosition evaluation lease
Ownership commit now returns one private activation token captured only after
the canonical body and controller stores. It binds the exact entity key,
authored placement token and sealed command, local identity GUID/revision,
session lifetime, and the post-store physics-body, object-clock, and controller
ownership epochs. The owner retains the same record, body, controller, and
command behind that token; no caller can substitute an equivalent-looking
body or rebuild the mover.
`EvaluateActivation` revalidates that complete lease and calls Core
`PhysicsEngine.SetPosition` synchronously with an immutable request. Core's
transaction is pure: it returns committed, deferred-cell, or rejected
placement data without writing the canonical body, FullCell, clock, spatial
worksets, shadows, collision-report owners, host, operation stage, or Place
projection. A missing cell therefore leaves the exact body dormant and the
authored operation retryable. During evaluation, a valid result likewise
remains only an immutable receipt; checkpoint 6's separate commit API consumes
that receipt only after revalidating the complete activation envelope.
Each evaluation carries an append-only, stable-order union of every cell read
by the complete Core transaction: the AdjustPosition seed and adjusted cell,
visible-child probes (including rejected lateral siblings), rejected
portal/building containment probes, transition/compass retries, and every
normal or scatter attempt. Rejected probes enter only the authority union and
never the final successful shadow/CrossCell footprint. Scatter keeps that union
in retained scratch and materializes its
immutable receipt exactly once after the final attempt, avoiding quadratic
copy/allocation growth at the 64-attempt retail ceiling. The final
`CrossCellIds` remains the successful placement's authored
shadow footprint; failed scatter probes cannot leak into that commit payload.
Runtime seals every distinct queried landblock against the exact collision
generation, the global collision-world authority, and the dynamic-shadow
mutation revision. An active replacement admission rejects evaluation even
before it commits, while begin/cancel, a re-entrant generation commit, or any
owner insert/remove/move/state/suspend/reflood mutation invalidates an older
receipt.
Entry restrictions also consult the live `ClientObjectTable` for the resolved
house object, owner and complete restriction record, plus the mover's monarch.
The receipt therefore seals the exact object-table reference, the engine's
monotonic binding epoch, and the table's synchronous mutation revision. Object
creation/removal, owner-property, guest-list, or mover-monarch updates invalidate
the receipt; a null/fresh replacement and an equal-revision A-B-A binding cycle
cannot resurrect it. Retained `ClientObject` owner, monarch, and restriction
setters synchronously advance every exact owning table even when callers mutate
the object directly rather than re-submit it through `AddOrUpdate`. Replacement,
removal, and clear detach that observer exactly, and every
`HouseRestrictionRecord` freezes a defensive snapshot of its input guest map so
no caller-owned dictionary or mutable downcast can alter entry authority behind
the revision.
`IsEvaluationCurrent` accepts only the newest receipt for the exact activation
lease and rejects it after a position/vector/state/object-description/Create
authority change, identity revision, body/controller replacement, session or
incarnation change, or any sealed collision/shadow authority change.
Re-evaluation supersedes the older receipt without mutating world state.
Re-entrant reset or delete-plus-GUID-reuse during Core evaluation immediately
retires the invalid lease instead of leaving an orphaned dormant graph. An
existing activation lease also blocks candidate preparation even if an
external owner has already cleared the body/controller references; explicit
discard is required before a new candidate can be prepared. Reset and disposal
retire the lease, body, and dormant controller and include the pending
activation in the ownership convergence ledger.
## Canonical activation and retail ordering
The implementation follows the named-retail chain rather than treating
SetPosition as a single opaque callback:
- `CPhysicsObj::SetPosition` at `0x005160C0` owns the outer placement call.
- The internal wrapper at `0x00515BD0` evaluates residence and collision.
- `CPhysicsObj::SetPositionInternal(CTransition*)` at `0x00515330` commits the
accepted frame/contact prefix and later shadow/cell state.
- `CPhysicsObj::enter_world` at `0x00516170` is the final live edge.
- `CPhysicsObj::leave_world` at `0x005155A0` is the canonical retirement edge.
Runtime splits that chain into a prepared, callback-free transaction and an
ordered notification suffix:
1. Install the accepted frame and contact prefix on the still-dormant body and
perform the first acceleration calculation.
2. Open one narrow dormant ground phase and invoke `HitGround` or
`LeaveGround`. Movement reapplication may call retail `set_velocity`, but
the phase closes with `Active=false`; the body is still out of world, has no
host/spatial membership, and its object clock is inactive.
3. Synchronize accepted State and Vector authorities, run the post-ground
acceleration/sliding phase, and dispatch the already-installed collision
batch.
4. Revalidate the complete ownership/collision envelope. Accepted State and
Vector updates are synchronized; Position, ObjDesc, Create, Setup,
incarnation, identity, collision-generation, body, controller, host, or
session displacement aborts the old transaction.
5. Apply velocity-current physical response and stationary bits, prepare the
final shadow mutation and Place receipt, then perform the callback-free
FullCell/body/host/controller/spatial/object-clock tail.
6. Dispatch exact shadow notifications and the ordered Place projection only
after the complete live graph is visible.
Collision and shadow mutations use explicit prepare/apply/dispatch receipts.
Receipt dispatch is exact-once and owner-local, so reverse-order receipts for
different owners remain valid while a superseding mutation of the same owner
stops the stale suffix. Collision owner states carry the exact SetPosition
batch ID. Reentrant Position or newer-batch replacement suppresses remaining
reciprocal/environment callbacks, and abort cleanup force-ends/removes only the
still-exact old batch, including reverse rows and the environment latch. The
combined Runtime physics ownership ledger includes pending collision and
shadow SetPosition receipts; teardown cannot report convergence while either
receipt remains.
Candidate construction applies the accepted `PhysicsDesc` values in retail
`CPhysicsObj::set_description` order before sealing ownership: final state,
friction, clamped elasticity, `set_velocity` (including the 50-unit clamp),
and angular velocity. Network acceleration remains parse-only because retail
recalculates it from the final physics state. This initial vector bootstrap is
required even when the SetPosition receipt's source Vector authority is still
current; the later refresh intentionally skips in that case. Collision
callbacks may advance State/Vector authority without invalidating the
immutable geometry/identity envelope, and a changed Vector authority refreshes
the dormant body through the same `set_velocity` path before physical response.
A deferred-cell commit atomically suspends an authored shadow registration and
consumes its notification receipt. Explicit publication discard cancels the
exact SetPosition lease and body/controller ownership, while the suspended
registration remains owned by the live entity/shadow registry and is reusable
by a later activation. A deterministic discard -> generation-ready -> new
activation gate proves the same registration restores without stale rows or a
pending receipt. Entity/lifetime teardown remains the terminal owner of that
suspended registration.
## Gates
- Candidate privacy and live-operation rejection.
- Pristine-only admission for body, controller, host, remote/projectile,
acquisition/binding, and remote-placement ownership.
- Exact local-player identity, identity-switch, and disposed-identity rejection.
- Exact same-body ownership in entity record and dormant movement controller.
- Initial PhysicsDesc velocity, angular velocity, friction, and elasticity
bootstrap, including activation with the retail 50-unit velocity clamp.
- Dormant rejection after ownership commit plus the controller-level
`dormant -> activated -> live` lifecycle contract exercised by checkpoint 6.
- No mutation of SetPosition, FullCell, spatial roots, host projections,
shadows, worksets, world residence, or presentation during preparation or
evaluation; the separately gated activation commit owns those mutations.
- Replacement by position, vector, final physics state, object description,
CreateObject, remote/projectile/body/clock/controller ownership, and explicit
placement cancellation.
- Delete plus same-GUID reincarnation.
- Candidate replacement, reset, disposal, and ownership convergence.
- Publication/activation sequence exhaustion is preflighted before candidate
allocation or replacement, leaving no private or canonical owner behind.
- Shadow-registry reset invalidates even a prepared, unapplied shapeless
transaction which owns no logical rows or pending dispatch receipt.
- Pure committed/deferred/rejected SetPosition evaluation with bit-exact
body-state snapshots and no canonical, collision-report, projection,
clock, FullCell, host, shadow, workset, or operation-stage mutation.
- Complete stable-order queried-cell capture across AdjustPosition,
visible-child lookup, normal/scatter retries, map-edge/deferred, rejected,
committed, and defensive NoCell outcomes. Scatter deliberately retains
retail's RNG consumption; only its authority footprint and commit payload
are deterministic for a fixed draw sequence.
- Newest-receipt selection, active-admission rejection, collision-generation
replacement, re-entrant begin/cancel and commit invalidation, plus dynamic
shadow insert/move/state/suspend/remove invalidation.
- Exact object-table reference/revision/binding authority, including
post-evaluation and re-entrant house-object, owner, guest-list, and mover-
monarch mutations plus null/fresh/equal-revision ABA replacement. Direct
retained-object setters, replacement/removal/clear observer lifetime, shared
multi-table ownership, and frozen guest-map input are covered explicitly.
- Re-entrant reset and delete/GUID-reuse convergence plus activation-lease
overwrite prevention after an external body/controller clear.
- Post-ownership position, vector, object-description, Create, identity,
body, and controller authority replacement.
- Terminal stale-controller rejection after replacement, reset, and disposal.
- Body/controller epochs advance only on actual ownership changes.
The checkpoint-6 focused publication/collision suite passes 129/129, the
focused Core shadow transaction suite passes 16/16, and the complete Runtime
project passes 695/695 under invariant globalization. The Runtime Release build
passes with zero warnings and zero errors. Broader Core/App/solution and
connected gates remain for the parent integration checkpoint. Under the
machine's Swedish current culture, the three previously known formatting
assertions remain unrelated (`0,5` versus `0.5` and localized sky text), so the
canonical Runtime gate runs under invariant globalization.
## Next checkpoint
Cut the graphical and no-window local-player hosts over to this Runtime-owned
activation transaction, then delete their duplicate SetPosition
activation/publication paths. The cutover must preserve the same exact body,
controller, shadow payload, deferred-cell lease, collision receipt ordering,
and graceful teardown proven here; no host may reconstruct or replay the
canonical transaction.

View file

@ -0,0 +1,106 @@
# Runtime SetPosition authored mover preparation - 2026-08-01
## Scope
This is placement Slice 4B2 checkpoint 3. It adds the dormant,
presentation-independent preparation contract used to turn an accepted Runtime
position into retail's exact `CPhysicsObj::SetPosition` mover input. No App or
Headless production route consumes the contract yet, so game behavior is
unchanged and AP-1/AD-1 remain open.
## Retail oracle
The implementation was checked against the named September 2013 client:
- `PhysicsDesc::PhysicsDesc` `0x0051D4D0`
- `PhysicsDesc::UnPack` `0x0051DDD0`
- `CPhysicsObj::set_description` `0x00514F40`
- `CPhysicsObj::SetPosition` `0x005160C0`
- `SPHEREPATH::init_sphere` `0x0050C670`
- `CPartArray::GetNumSphere` `0x00518060`
- `CPartArray::GetSphere` `0x00518070`
- `CPartArray::GetStepUpHeight` `0x005180D0`
- `CPartArray::GetStepDownHeight` `0x005180F0`
- `CTransition::init_object` `0x00509E40`
- `OBJECTINFO::init` `0x0050CF30`
SetPosition calls `CTransition::init_object(..., state = 0)` directly. It does
not use the ordinary-movement `CPhysicsObj::get_object_info` path. Consequently
the SetPosition state carries the player/PK/PKLite/impenetrable classifications
(plus acdream's pointer-free entry-restriction carrier), but it does not add
Contact, OnWalkable, PathClipped, FreeRotate, or EdgeSlide. Ethereal and
`step_down = !Missile` are separate `OBJECTINFO` fields derived from the current
physics state.
## Exact preparation contract
- Runtime captures the complete accepted server frame under the exact entity,
session, position, vector/velocity, wire-state, final-physics-state mutation,
object-description, and create-integration authorities. A host cannot
substitute a second position.
- Collision-world X/Y uses the target landblock's active live-centered offsets;
full cell ID, cell-local XYZ, and the complete quaternion remain unchanged.
- Setup resolution is bound to the canonical Setup DID. A known but unavailable
Setup remains retryable. Resolved-absent is valid only when the canonical
object has no Setup. An authored empty Setup remains distinct and still
contributes its scaled StepUp/StepDown heights.
- The complete ordered Setup sphere list is retained. Core later applies
retail's `min(count, 2)` traversal cap. The successfully resolved no-PartArray
or zero-sphere arm reaches Core with an empty list, where SetPosition supplies
the retail dummy sphere `(0,0,0.1)`, radius `0.1`, scale `1.0`.
- Scale precedence is `PhysicsDesc.Scale ?? EntitySpawn.ObjScale ?? 1.0`.
Present zero and finite negative values are preserved. Scale is not consumed
by a resolved-absent dummy mover.
- Every authored command is sealed to the exact preparation operation. Manual,
stale, replaced, or merely value-equivalent commands cannot bypass the seal.
- A wire-state, final-state mutation (including NoDraw and missile-stop),
vector/velocity, description, or create change during a deferred cell wait
returns the resident to `AwaitingPreparation`; the stale mover is never
replayed when the collision generation wakes.
- Preparation mutates no body, clock, FullCell, spatial/shadow registration,
bucket, camera, world entity, or presentation resource.
Legacy direct SetPosition remains a distinct token mode so the dormant slice
does not change existing call sites or their warmed allocation ceiling. If a
legacy operation becomes deferred and later needs new authored data, the
presence of Runtime's preparation authority makes the exact seal mandatory.
## Ownership and validation
`RuntimeSetPositionState` owns one exact-key preparation-authority entry only
for operations which require authored preparation. The entry dies with the
operation on replacement, acknowledgement, cancellation, delete, session
reset, or disposal and participates in terminal convergence accounting.
Preparation-only validation checks the exact cell frame, live-centered world
position, values consumed by the first two retail spheres, Setup-derived step
heights, line/scatter inputs, and bounded scatter attempts. The legacy direct
validator retains its prior behavior, including retail's dummy-sphere and
first-two-sphere semantics.
## Gates and review
- Focused authored-mover plus SetPosition residence tests: 80/80.
- Runtime Release build: zero warnings and zero errors.
- Complete Runtime project under invariant culture: 595/595.
- Complete Release solution with installed DAT/pak fixtures: 10,342 passed /
4 intentional skips.
- Retail-conformance review: canonical frame, DID binding, scale/step/sphere
behavior, exact SetPosition flags, and deferred wake checked against the
named addresses above.
- Architecture/adversarial review: command sealing, legacy promotion,
replacement, deferred wake, direct compatibility, allocation, reset, GUID
reuse, and ownership convergence checked.
The three ordinary current-culture Runtime failures are pre-existing Swedish-
locale formatting assumptions (`0,5` versus `0.5` and localized sky text); the
same complete project passes under invariant culture.
## Next checkpoint
Implement the dormant atomic local-player physics publication transaction:
prepare a private controller/body/clock without canonical mutation, evaluate
SetPosition against that candidate, then publish the exact same body relation
to `RuntimeEntityRecord` and `RuntimeLocalPlayerMovementState` in one callback-
free Runtime commit. App and Headless production activation remains a later
checkpoint.

View file

@ -0,0 +1,104 @@
# C3c production placement cutover — closeout (2026-08-02)
Behavior commit: `529e0e9d` (68 files, +5,979/833, register rows AD-61 +
AD-42 refresh in-commit). Plan:
[`2026-08-02-placement-cutover.md`](../plans/2026-08-02-placement-cutover.md).
Session evidence trail: the campaign scratchpad's `implementer-progress.md`
sections `Continuation 1-4`, `C3c-F1`..`C3c-F5`, `C3c-R1` (not committed;
summarized here).
## What shipped
Both production hosts (graphical + headless) register every initial
wire Create through the C0-C3b residence/executor/conductor machinery.
One shared `RuntimeFirstEntryDriveController` pumps the local-player and
remote conductors from the placement-receipt flow (per-frame graphical,
per-tick headless). `MaterializeProjection`/`RebucketLiveEntity` are
presentation-only strictly while the initial-create residence is ACTIVE
(exact-token check; `ExecutorCompleted` is the presentation-binding
receipt); post-residence entities take the full legacy path including
retail's `prepare_to_enter_world` (0x00511FA0) clock rebase.
`RuntimeLocalPlayerMovementState.Controller`'s setter is sealed; every
controller mutation flows through the publication lifecycle. Content-less
headless sessions (validated-legal config) keep the pre-flip direct
registration until C4/C5 revisit.
## The five fix slices (each connected-gated inside the cutover)
- **F1** — live movement-stat + server-physics application moved behind
Runtime ownership (`RuntimeMovementStatsApplication`,
`ApplyServerPhysicsState`); the post-logout ingest crash on the
retired controller is eliminated; `RuntimeMovementSkillProjection`
deleted.
- **F2** — the login activation wedge (world never revealed): the
collision-admission prefix gate factored out of the seal (reentrant
commit could yield terminal `RejectedAuthority`), the rearm's
generation identity corrected (parked G vs post-retirement G+1), and
`PlayerModeAutoEntry` now requires the Runtime-published controller
(`IsPlayerControllerReady` was a constant `true` — one early attempt
permanently sealed the reveal).
- **F3** — landblock-prefix `0`-sentinel replaced by explicit absent-id
representation; map-corner landblocks (grid row/col 0, e.g.
`0x0000FFFF`) are legal through admission, park/rearm/retire,
quiescence, and outdoor shadow seeds.
- **F4** — diagnosis only: the nine-stop soak's convergence failure
(pendingPublications=1, farBacklog nonzero, landblock/mesh dimensions)
is **pre-existing `6b28ff99`** (2026-07-31, "make collision activation
starvation-free"): every far publication clones the complete collision
world (median ~19.7k leaves / 3.64 ms), so the queue drains ~10
landblocks/s and never catches its window. Fix requires an O(changed)
clone (structural sharing or per-landblock atomic unit) — a semantics
change to that slice's asserted one-leaf-per-step invariant; scheduled
as its own slice BEFORE C5 (whose gate matrix includes the soak).
- **F5** — local-player first-entry ground contact: retail seeds contact
from the first gravity frame's transition touch (`enter_world`
0x00516170 carries no seed; local player and remotes share the
mechanism via `HandleCreateObject` 0x00454C80). The shared
`SpawnPlacementSettler` (moved App→Core) runs at `FinalizeActivation`
exactly once; genuinely airborne spawns stay airborne; the outbound
contact bit chain is asserted end-to-end. The legacy path's
unconditional `Contact|OnWalkable|Active` force-seed (non-retail, no
plane) still runs during candidate preparation and is OVERWRITTEN by
the faithful settle (register AD-61). Fixes the user-observed
standing-cast "You can't do that while in the air!" rejections.
## Review round R1 (dual Opus: initial FAIL 2+2 MAJOR → delta PASS both)
Retail MAJORs: the login constraint leash (deleted with the legacy
resolve path; re-armed at the committed placement in
`FinalizeActivation``HandleReceivedPosition` 0x00453FD0 arms on every
accepted position) and the post-residence rebucket scope (fixed to
exact-token active-residence). Adversarial MAJORs: content-less headless
(no drive → legacy registration) and the register rows. Nine minors
fixed (owner conversion API with active-residence throw, wire-landblock
guards, drive-pending ledger in `IsConverged`, route attach/detach
latch, celless conversion for far headless remotes, doc-comment truth,
per-incarnation cylinder cache, executor-drain drift model documented +
source-pinned); two tracked (#276, #277 in ISSUES).
## Final gates
Runtime 1,003; App 4,039/3 skips; Headless 79; complete solution
**10,816 / 0 failed / 4 skips** (Release, `-m:1`). Connected
lifecycle/reconnect gate **PASS** (`connected-world-gate-20260802-175401`;
graceful exits, world-visible, zero airborne-rejection strings; run
`-174811` failed on user-interference fingerprint —
`activeTeleportCount=1` at the stable checkpoint — and is attributed,
not counted). The soak stays red for the pre-existing F4 attribution.
## Process lessons (carried to memory)
1. **Report artifacts over marker logs** — three wrong classifications
this campaign came from reading route/marker logs instead of
`report.json` (the soak "clean route" was Passed=false with 37
convergence failures).
2. **Log lifetime before absence claims** — a 26-second, 67-line log's
silence about a defect proves nothing (the 122749 misread inverted a
root-cause classification twice).
3. **User observation is the cheapest gate** — the standing-cast
airborne rejections and the black-screen reveal were both
user-spotted minutes before harness detection.
4. **The seal finds the bypasses** — sealing the controller setter
surfaced a runtime-mutation bypass (F1) the compile-break audit could
not see; expect the same class when sealing any long-lived escape
hatch.

View file

@ -0,0 +1,681 @@
# C1 body/controller-publication writer map (2026-08-02)
Repo: `C:\Users\erikn\.codex\worktrees\af5e\acdream`, branch `codex/port-claude-agents`,
HEAD `ae296393`. READ-ONLY research; this file is the only write target.
Context read: `docs/plans/2026-08-02-placement-cutover.md` (slice C1),
`docs/research/2026-07-31-remaining-physics-campaign-handoff.md` (rejected-prototype
section, lines 143-168; prerequisite C, lines 203-221), and
`docs/research/2026-08-02-cutover-route-inventory.md` route 1 + prerequisite-C
section (lines 174-220) + route 8 (headless).
---
## 1. Every writer of `RuntimeEntityRecord.PhysicsBody`
`PhysicsBody` is `public PhysicsBody? PhysicsBody { get; private set; }`
(`src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs:72`). The ONLY mutator is
the internal method `SetPhysicsBody(PhysicsBody? body)`
(`RuntimeEntityRecord.cs:176-182`):
```
internal void SetPhysicsBody(PhysicsBody? body)
{
if (ReferenceEquals(PhysicsBody, body)) return;
PhysicsBody = body;
PhysicsOwnershipEpoch++; // <-- the ONLY place PhysicsOwnershipEpoch is bumped
}
```
So every "writer" is a caller of `.SetPhysicsBody(...)` (all 6 call sites, confirmed
by full-repo grep, zero others):
1. **`RuntimeEntityDirectory.cs:359`** — inside
`GetOrCreatePhysicsBody(RuntimeEntityRecord record, Func<incarnation,PhysicsBody> factory)`
(need exact surrounding signature — read below). Public/internal API used by
the route-1 "SECOND, narrower body-construction duplicate authority" for
non-player static-animating physics objects
(`DatLiveEntityProjectionMaterializer.cs:1003-1016`, per the route inventory).
Guard: only sets if record has no body yet (idempotent-create pattern) — see
full read below for exact guard.
2. **`RuntimeEntityObjectLifetime.cs:766`** — `Entities.SetPhysicsBody(canonical, null)`
inside a teardown method (need to confirm exact method — likely delete/retire
path, paired with `Entities.SetPhysicsBodyAcquisitionInProgress(canonical, false)`
at line 767 in the SAME method). Clears body on deletion/teardown.
3. **`RuntimeLocalPlayerPhysicsPublicationState.cs:405`** — `candidate.Record.SetPhysicsBody(candidate.Body)`
inside `Commit(token, out activationToken)` (lines 373-411). **THIS IS THE
DORMANT OPTION-2 MECHANISM** — see section 4 below. Guarded by `IsCurrent(candidate)`
(epoch/session/identity/null-state re-check, lines 891-922) immediately before,
and by `_physics.SetPosition.PrepareDormantLocalActivationOwnership(...)` called
first (line 394) as the "seal the exact SetPosition owner before the
irreversible no-fail suffix" step — i.e. this call site DOES chain into
PrepareDormantLocalActivationOwnership per task 4's target.
4. **`RuntimeLocalPlayerPhysicsPublicationState.cs:1025`** — `_entities.SetPhysicsBody(activation.Record, null)`
inside `DiscardActivation()` (994-1032), the rollback/teardown path for the
SAME dormant mechanism — only fires if `_entities.IsCurrent(activation.Record)`
AND `ReferenceEquals(activation.Record.PhysicsBody, activation.Body)` (i.e.
never clobbers a body some OTHER newer owner already installed — the exact
anti-pattern the rejected prototype failed on).
5. **`RuntimePhysicsState.cs:1558`** — `Entities.SetPhysicsBody(record, candidateBody)`
— need full read; this is inside the remote/projectile body-binding family
(see section 3).
6. **`RuntimePhysicsState.cs:1666`** — `Entities.SetPhysicsBody(record, candidate)`
— need full read; this is the OTHER binding site, guarded by
`PhysicsBodyAcquisitionInProgress` (set true at :1645, cleared at :1676/1678).
**Writer count: 6 call sites, across 3 files** (`RuntimeEntityDirectory.cs` x1,
`RuntimeEntityObjectLifetime.cs` x1, `RuntimeLocalPlayerPhysicsPublicationState.cs` x2,
`RuntimePhysicsState.cs` x2).
## Consumers of `PhysicsOwnershipEpoch`
Only bumped in one place (`RuntimeEntityRecord.SetPhysicsBody`, above). Consumers
(all in `RuntimeLocalPlayerPhysicsPublicationState.cs`) treat it as a
compare-and-reject epoch stamped into every token/activation struct:
- `RuntimeLocalPlayerPhysicsPublicationToken.PhysicsOwnershipEpoch` (field, :53)
captured at `Prepare` time (:314).
- `RuntimeLocalPlayerPhysicsActivationToken.PhysicsOwnershipEpoch` (:70) captured
as `token.PhysicsOwnershipEpoch + 1UL` (:328) — i.e. the activation token
encodes "the epoch AFTER my own commit bumps it", so `IsActivationCurrent`
(931-962) and `IsActivationOwnershipEnvelopeCurrent` (717-745) comparing
`activation.Record.PhysicsOwnershipEpoch == activation.Token.PhysicsOwnershipEpoch`
will FAIL (reject) the instant any OTHER writer (remote/projectile bind, GC
clear, non-player static body creation via `RuntimeEntityDirectory` — none of
which should ever touch a local-player record, but the check is defense-in-depth)
touches the same record's PhysicsBody between prepare and commit.
- `IsCurrent(candidate)` (891-922, pre-Commit re-check) also compares
`candidate.Record.PhysicsOwnershipEpoch == candidate.Token.PhysicsOwnershipEpoch`
(unincremented — i.e. "nobody touched the body between Prepare and Commit").
**This IS the reentrancy defense the rejected prototype lacked** — see section 6.
## 2. The two production local-player controller constructions, end to end
### Graphical: `PlayerModeController.BuildControllerAndCamera`
`src/AcDream.App/Input/PlayerModeController.cs:244-525`. Constructor list
(52-74) shows it is injected with `RuntimeLocalPlayerMovementState controllerSlot`
(the SAME slot type headless writes) — confirms the route-inventory's "open
question" (2026-08-02-cutover-route-inventory.md:204-207): **App DOES write
`_controllerSlot.Controller = controller` directly, at line 486.** Not a
mystery/asymmetry — both hosts write the exact same public setter.
Steps, in order:
1. `_approachCompletions.BeginControllerLifetime()` (250) — App-only approach
lifecycle token.
2. Capture rollback snapshots: `_camera.CaptureState()` (255),
`_shadow.Capture()` (256) — presentation-only.
3. `new PlayerMovementController(_physics, playerRecord.ObjectClock, PlayerMovementConstructionOptions.From(_skills.Snapshot))`
(259-262) — **uses the PUBLIC constructor**, whose default publication
lifecycle is `StandalonePublished` (`PlayerMovementController.cs:617-626`),
NOT `CandidatePreparing`/`CreatePublicationCandidate`. This is the key
divergence from the dormant mechanism (section 4): this controller never
enters the `CandidatePreparing -> CandidateSealed -> RuntimeOwnedDormant ->
RuntimePublished` lifecycle at all.
4. Builds `MoveToManager`/`EntityPhysicsHost` closures over captured locals
(267-346) — presentation-adjacent glue, host-specific.
5. `EntityPhysicsHostComposition.SelectStableHostWithoutRebind` (347-350) —
canonical-state read (checks `LiveEntityRecord.PhysicsHost`).
6. `RuntimeMovementSkillProjection.ApplyTo(_skills, controller)` (366-368).
7. `ApplyStepHeights(controller, playerEntity, playerGuid)` (375) — **reads
`DatReaderWriter.DBObjs.Setup` directly** (per headless's own comment
contrasting itself, `HeadlessSessionWorldProjection.cs:685-689`) — NOT
through the prepared-collision/`IPreparedCollisionSource` seam headless
uses. Divergence #1.
8. `_controllerSlot.BeginMotionPreparation(controller, drainPriorAnimationQueue)`
(404-407) — the ONE existing narrow "preparation lease" concept already in
`RuntimeLocalPlayerMovementState` (separate from the dormant physics
publication state) that lets a synchronous PartArray/type-5 completion
reach the candidate `MotionInterpreter` before publish.
9. **Duplicate authority**`_physics.Resolve(...)` (409-413) then
`_physics.ResolvePlacement(...)` (422-430) — direct canonical-state-free
collision resolve, entirely outside `RuntimeSetPositionState`.
10. `controller.PreparePositionForCommit(...)` (434-437),
`controller.SetBodyOrientation(...)` (438).
11. Camera construction + `_camera.EnterChaseMode(...)` (440-447) —
presentation-only, but happens BEFORE the final canonical commit (445-447
precede line 482-484) — i.e. camera activation today is NOT gated on a
Runtime placement acknowledgement.
12. Re-check host stability (449-458) — throws if the host changed during
camera activation (defensive, but ad hoc — not an epoch/token check, a
bespoke `ReferenceEquals` re-read).
13. Shadow sync (`_shadow.SyncPose(...)`, 460-466).
14. `EntityPhysicsHostComposition.InstallOrRebind(...)` (472-475) + another
`ReferenceEquals` stability re-check (476-480).
15. **Duplicate authority — final commit** (482-484):
`playerEntity.SetPosition(initial.Position); playerEntity.ParentCellId =
initial.CellId; controller.CommitPreparedPosition();` — direct writes to
the App-side `WorldEntity`/render sidecar AND `controller`'s own internal
frame, bypassing any Runtime `Place` receipt or `RuntimeEntityRecord`
write. **`RuntimeEntityRecord.PhysicsBody`/`PhysicsOwnershipEpoch` are
NEVER touched anywhere in this method** — `controller.PhysicsBody` (the
`_body` field created in step 3) stays a private field of the
`StandalonePublished` controller; nothing calls
`Entities.SetPhysicsBody(playerRecord, controller.PhysicsBody)`. This
means TODAY the canonical `RuntimeEntityRecord.PhysicsBody` slot for the
graphical local player is **never populated at all** by this path — a
previously-unstated confirmation that `SubmitPreparedPlacement`'s
`operation.Record.PhysicsBody is not { } body` requirement (section 3)
would REJECT any ordinary (non-initial) SetPosition submitted for the
graphical local player today, because no writer ever puts a body on that
record. (Route 2's "ForcePosition" duplicate authority,
`LocalForcePositionTransaction`, works around this by mutating
`PlayerMovementController`'s own body directly via `BlipPosition`, never
touching `RuntimeEntityRecord.PhysicsBody` either — internally consistent
with each other, both equally disconnected from the canonical record.)
16. Slot commits (485-492): `_hostSlot.Host`, `_controllerSlot.Controller =
controller` (the public, unguarded setter — bumps `ControllerOwnershipEpoch`
unconditionally, see section 4), `_chase.Legacy/Retail`, `_mode.IsPlayerMode
= true`.
17. `catch`: rolls back camera + shadow only (494-518); does NOT roll back
steps 15-16 because those are the LAST lines before `lifetimeCommitted =
true` — structurally "hope nothing after this throws" rather than an
explicit no-fail invariant.
**Canonical-state mutations in this method: NONE on `RuntimeEntityRecord`**
(no `SetPhysicsBody`, no `SetFullCell`, no object-clock call) — everything
mutated is App-local (`WorldEntity`, `PlayerMovementController`'s private
body, `RuntimeLocalPlayerMovementState.Controller`,
`LocalPlayerPhysicsHostSlot`, camera, shadow). The ONLY canonical-record
writes for the local player's initial placement happen earlier in the
hydration pipeline (`LiveEntityRuntime.MaterializeLiveEntity`/
`RebucketLiveEntity`, route 1 hops 9-11) — entirely disjoint from this method.
### Headless: `HeadlessSessionWorldProjection.CreateController` + `SynchronizeLocalPlayer`
`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:566-655`
(read in full).
`SynchronizeLocalPlayer` (566-615):
1. Guards on `record.ServerGuid == _runtime.PlayerIdentity.ServerGuid` and a
present `Snapshot.Position` (568-573).
2. `_collision.CenterOn(position.LandblockId)` (575) — headless collision-
neighborhood readiness, no graphical analog.
3. `_runtime.MovementOwner.Controller ?? CreateController(record)` (576-578)
— lazy-construct-once via the SAME public `Controller` getter/setter
`RuntimeLocalPlayerMovementState` exposes; no reentrancy guard against two
concurrent calls both observing `null` (single-threaded host loop makes
this safe in practice today, not structurally).
4. `_runtime.EntityObjects.Physics.Engine.Resolve(...)` (589-594) then
`.ResolvePlacement(...)` (595-605) — **the exact same duplicate-authority
shape as graphical step 9**, hardcoded `DefaultRadius`/`DefaultHeight`
constants (visible in the call, actual values not read here) instead of
`_motionBindings.GetSetupCylinder`.
5. `controller.SetPosition(...)` + `controller.SetBodyOrientation(...)`
(610-614) — **duplicate final commit**, headless's version of graphical
step 15. Also never touches `RuntimeEntityRecord.PhysicsBody`.
`CreateController` (639-655):
1. `new PlayerMovementController(_runtime.EntityObjects.Physics.Engine, record.ObjectClock, PlayerMovementConstructionOptions.From(_runtime.CharacterOwner.MovementSkills.Snapshot))`
(642-646) — **same PUBLIC constructor / `StandalonePublished` lifecycle**
as graphical step 3.
2. `ApplySetupStepHeights(record, controller)` (649, body at 657-691) —
**reads via `_preparedCollision.ReadSetupCollision(setupId)`** (668-679),
the prepared-asset seam, NOT raw DAT — divergence #1 mirrored (headless
uses the "correct"/prerequisite-B-aligned source; graphical does not).
**Throws `InvalidDataException`** if the read status isn't `Loaded`
(672-675) — propagates uncaught up through `SynchronizeLocalPlayer` ->
`ProjectSpawn`/`ProjectPosition` -> the wire-dispatch call chain. This IS
gate 10 (late headless prepared-collision failure) manifesting today as an
unhandled exception, not a retry.
3. `RuntimeMovementSkillProjection.ApplyTo(_runtime.CharacterOwner.MovementSkills, controller)`
(650-652).
4. `_runtime.MovementOwner.Controller = controller;` (653) — **the exact
same public setter graphical step 16 uses.**
**No headless equivalent of graphical steps 1 (approach lifetime), 8 (motion
preparation lease), 11-14 (camera + host-stability re-checks), 17 (camera/
shadow rollback)** — headless has no camera/shadow/approach concept at all
(confirmed, matches the route inventory's "No headless equivalent of
graphical hops 12-14/19").
### Top divergences between the two hosts (summary)
1. **Setup/collision data source**: App reads raw DAT (`ApplyStepHeights` via
`_dats`/`_datLock`); headless reads the prepared/baked asset
(`ApplySetupStepHeights` via `IPreparedCollisionSource`). Same target
values, different pipeline — a real fidelity risk if the two ever diverge
(baking staleness).
2. **Default cylinder fallback**: App falls back to `0.48f`/`1.835f` inline
(`PlayerModeController.cs:416-420`) when `GetSetupCylinder` returns
`< 0.05f` radius; headless uses named `DefaultRadius`/`DefaultHeight`
constants at the `ResolvePlacement` call site (:599-600) — same intended
values, defined in two places.
3. **Failure handling**: App's `BuildControllerAndCamera` has an explicit
try/catch/rollback for camera+shadow; headless's `CreateController`/
`ApplySetupStepHeights` has NO surrounding try/catch — a prepared-collision
read failure is a raw unhandled exception today.
4. **Presentation surface**: App additionally owns approach-completion
lifetime, motion-preparation lease, chase camera, shadow sync — none of
which headless has or needs.
5. **Neither host touches `RuntimeEntityRecord.PhysicsBody`, `PhysicsOwnershipEpoch`,
or any `RuntimeSetPositionState` API** — both are 100% off to the side of
the canonical record, confirmed by exhaustive grep (section 1's 6 writer
call sites do not include either `PlayerModeController.cs` or
`HeadlessSessionWorldProjection.cs`).
---
## 3. Every other body binding/consumer
- **Remote dead-reckoning** (`RuntimeRemotePhysicsUpdater.cs` — flagged
protected/dirty, read-only, NOT modified): grep confirms it only READS
`record.PhysicsBody` via `ReferenceEquals(record.PhysicsBody, remote.Body)`
currency checks (line 817) — it does not call `SetPhysicsBody`. The actual
writer for remote motion is `RuntimePhysicsState.SetRemoteMotion`
(`RuntimePhysicsState.cs:1446-1570`, full read) — throws
`InvalidOperationException` on: binding-already-in-progress (1460-1464),
body-would-be-replaced when a body already exists and doesn't match
(1487-1492), losing an existing remote-placement contract (1493-1498), or
post-callback ownership drift detected via a captured
`sessionVersion`/`expectedBody`/`expectedRuntime` triple re-checked after
the bind callback (1539-1549, "changed ownership during remote-motion
binding"). Calls `Entities.SetPhysicsBody(record, candidateBody)` (:1558)
ONLY when `expectedBody is null` (first bind) via `InitializeNewPhysicsBody`
(:1556) — i.e. this is throw-on-conflict exclusivity (Option-1 flavor), not
epoch/token gating. For a LOCAL PLAYER record this path should never fire
(remote motion is for non-local entities) but the guard is defense-in-depth
and IS one of the explicit gate checks
(`!activation.Record.RemoteMotionBindingInProgress`/`RemoteMotion is null`)
the dormant local-publication mechanism re-validates at every stage
(section 4).
- **Projectile binding**: `RuntimeProjectilePhysicsUpdater.cs` similarly only
READS `record.PhysicsBody` (lines 447, 459, `ReferenceEquals` currency
checks). The writer is `RuntimePhysicsState.BindProjectile`
(`RuntimePhysicsState.cs:1309-1382`, full read) — same throw-on-conflict
shape: binding-in-progress (1343-1347), body-mismatch on rebind
(1330-1337), must-already-own-canonical-body-before-binding
(1348-1352, "projectile must borrow its canonical physics body" — i.e.
UNLIKE remote motion, `BindProjectile` requires `record.PhysicsBody` to
ALREADY be non-null and matching BEFORE it will bind — it never calls
`InitializeNewPhysicsBody`/`SetPhysicsBody` itself for a first-time body;
something else (route 5's `ProjectileController.TryBind`,
`ProjectileController.cs:176-265` per the route inventory) must construct
the body ad hoc first via a DIFFERENT path than `GetOrCreatePhysicsBody`
— worth flagging: **this is a 7th, App-side, ad hoc body-construction site
not funneled through any of the 6 canonical writer methods** — App's
`ProjectileController.TryBind` constructs a body and must be setting it onto
the record through some other route (not confirmed by this pass; App-side
`ProjectileController.cs` was not read in full — flag as open item, but it
is explicitly OUT of C1's local-player scope per the campaign handoff's
gate list item "remote and projectile binding/update" being about
*interaction with* the local-player transaction, not projectile's own
authority).
- **`RuntimeSetPositionState.SubmitPreparedPlacement`** (`RuntimeSetPositionState.cs:2224-2274`,
full read): requires `operation.Record.PhysicsBody is not { } body` to
already be true (line 2254) — i.e. EVERY non-initial-construction
SetPosition submission (ForcePosition, portal, remote Position, projectile
correction) requires a body to already exist on the record, confirming the
6 writer sites in section 1 are the exhaustive set of "who can put the
FIRST body on a record." For the local player specifically, only
`RuntimeLocalPlayerPhysicsPublicationState.Commit` (section 4) does this
today (dormant, unwired); in PRODUCTION, no writer ever populates
`RuntimeEntityRecord.PhysicsBody` for either host's local player (section
2 finding) — meaning `SubmitPreparedPlacement` would reject a local-player
submission in production today, which is consistent with the route
inventory's finding that Route 2 (ForcePosition) and Route 3 (portal) both
bypass `RuntimeSetPositionState` entirely via their own duplicate
authorities instead.
- **`RuntimeSetPositionState.PrepareDormantLocalActivationOwnership`**: see
section 4 — the ONE place the local-player-specific dormant body attach
happens; requires a pre-opened `Operation` in stage `AwaitingPreparation`
from `TryBeginExclusiveAuthoredPlacement`.
- **Object-clock epoch transitions**
(`RuntimeEntityRecord.SuspendObjectClock`/`ResetObjectClockForEnterWorld`,
both `internal`, bump `ObjectClockEpoch`): full call-site grep found BOTH
the expected `RuntimeEntityDirectory` wrapper call sites (which run
`EnsureKnown(record)` first, `RuntimeEntityDirectory.cs:311-321`) AND
**direct unwrapped calls from `src/AcDream.App/World/LiveEntityRuntime.cs`
at lines 879, 891, 897, 1258, 3030, 3033** — App calls
`record.SuspendObjectClock()`/`record.ResetObjectClockForEnterWorld(...)`
straight on the `RuntimeEntityRecord` (accessible because these are
`internal` and `AcDream.App` has `InternalsVisibleTo`), bypassing the
`RuntimeEntityDirectory` facade's `EnsureKnown` check entirely. This is
inside `LiveEntityRuntime`'s `RebucketLiveEntity`-family code (comment
references `prepare_to_enter_world`/retail `update_object`'s parent
early-out — matches the already-known route-1/prerequisite-D
`RebucketLiveEntity` duplicate authority). **Previously-unstated
implication for C1**: the SAME record whose `ObjectClockEpoch` the dormant
publication mechanism gates on can have its epoch bumped by this
direct-call path DURING the window between
`RuntimeLocalPlayerPhysicsPublicationState.Prepare` and `.Commit()`/
`.CommitActivation()` if `RebucketLiveEntity` runs concurrently for the
SAME entity (e.g. a second CreateObject/Position causing a re-rebucket
mid-construction) — the epoch check (`IsCurrent`/`IsActivationOwnershipEnvelopeCurrent`
comparing `record.ObjectClockEpoch == token.ObjectClockEpoch`) WOULD catch
and reject this correctly (fail-safe), but it confirms the gate is load-
bearing against a REAL, already-existing production writer, not a
hypothetical.
- **Deletion/teardown**: `RuntimeEntityObjectLifetime.TryAcceptDelete`
(`RuntimeEntityObjectLifetime.cs:1555+`) calls `Entities.TryDelete` then
`Entities.RemoveActive(active)` (1587) — this IMMEDIATELY flips
`Entities.IsCurrent(record)` to `false` for that record (removes it from
the active-by-guid table), which is the single check
`CanPrepare`/`IsCurrent`/`IsActivationCurrent`/every gate in section 4
depends on — so a delete landing at any point rejects the in-flight
publication transaction on its NEXT check. Full body clear happens later
in `RuntimeEntityObjectLifetime.CompleteProjectionRetirement`
(:745-771, called from `RetireCanonicalOnly`/the graphical teardown-ack
path): `Entities.SetPhysicsBody(canonical, null)` (:766) after
`Physics.SetPosition.Forget(canonical, releasePreparedMover: true)` (:753,
cancels any in-flight ordinary placement) and
`ForgetInitialCreateResidence(canonical)` (:751, cancels any in-flight
residence lease) — i.e. deletion cancels BOTH placement-lease families
before clearing the body, consistent with prerequisite E's "quiesce before
demote" discipline (though for landblock collision, not entity teardown —
the pattern rhymes).
- **`RuntimePhysicsState` per-frame body access**: `RuntimeOrdinaryPhysicsUpdater.cs`,
`RuntimeRemotePhysicsUpdater.cs`, `RuntimeProjectilePhysicsUpdater.cs` each
gate their per-tick work on `record.PhysicsBody is not { } body` /
`ReferenceEquals(record.PhysicsBody, body)` currency checks (grep-confirmed,
e.g. `RuntimeOrdinaryPhysicsUpdater.cs:68,284`) — read-only w.r.t. the
`PhysicsBody` reference itself (they mutate the BODY's internal fields
every tick, which is expected/normal simulation, not an ownership-slot
write). No workset iterates and calls `SetPhysicsBody`. `RuntimePhysicsState.cs`
itself has only two visible "workset" mentions (`ClearSpatialWorksets` at
:1951, a doc-comment at :1307) — the ordinary/remote/projectile worksets
live in their respective `RuntimeXPhysicsUpdater` files, out of this pass's
read budget beyond the grep-confirmed read-only currency pattern above.
---
## 4. `RuntimeSetPositionState.PrepareDormantLocalActivationOwnership` — nucleus or dead end?
**Definition** (`RuntimeSetPositionState.cs:1026-1052`, full read):
```csharp
internal void PrepareDormantLocalActivationOwnership(
RuntimeEntityRecord record, PhysicsBody body,
in RuntimeEntityPlacementToken token)
{
...
if (!token.IsValid
|| record.Key != token.Entity
|| !_operations.TryGetValue(token.Entity, out Operation? operation)
|| operation.Token != token
|| operation.Stage is not RuntimeEntityPlacementStage.AwaitingPreparation
|| !ReferenceEquals(operation.Record, record)
|| record.PhysicsBody is not null // <- record must have NO body yet
|| !IsCurrent(operation)
|| body.InWorld
|| (body.TransientState & TransientStateFlags.Active) != 0)
{
throw new InvalidOperationException(
"Dormant local activation must bind to the exact current placement owner.");
}
operation.Body = body;
operation.DormantLocalActivation = true;
}
```
It THROWS (does not return a status) on any invariant violation — by design a
"this should be structurally impossible if the caller validated first"
assertion, not a retryable rejection. It requires a PRE-EXISTING placement
`Operation` already opened via `TryBeginExclusiveAuthoredPlacement`
(`RuntimeSetPositionState.cs:1004-1024`) in stage `AwaitingPreparation` — i.e.
it is NOT a standalone entry point; it is ONE STEP inside a larger chain that
also needs prerequisite B's mover-preparation authority
(`IsExactPreparedPlacementCurrent`, `RuntimeSetPositionState.cs:1318-1340`)
satisfied for the SAME token/command before
`RuntimeLocalPlayerPhysicsPublicationState.CanPrepare` will even call it.
**What it was built for**: it is called from exactly ONE place in the whole
repo — `RuntimeLocalPlayerPhysicsPublicationState.Commit`
(`RuntimeLocalPlayerPhysicsPublicationState.cs:394-397`), as the "seal the
exact SetPosition owner before the irreversible no-fail suffix" step,
immediately before `candidate.Controller.CommitRuntimeOwnership(...)` and
`candidate.Record.SetPhysicsBody(candidate.Body)`. It exists purely to make
the PLACEMENT OPERATION (owned by `RuntimeSetPositionState`) and the BODY
(owned by `RuntimeEntityRecord`) become mutually aware atomically, so that
the SAME operation can later be walked through the full retail SetPosition
staged commit (ground phase -> collision dispatch -> response -> final
commit) via `TryEvaluateDormantLocalActivation` ->
`TryPrepareDormantLocalActivationCommit` ->
`TryApplyDormantLocalActivationCommit` ->
`TryPrepareDormantLocalActivationFinalCommit` ->
`TryApplyDormantLocalActivationFinalCommit`
(`RuntimeSetPositionState.cs:2025-2077`, full read of the final-commit
method) — the LAST of which is where `_entities.SetFullCell`,
`_entities.AdvancePlacementCommit`, `body.InWorld = true`,
`_entities.SetPhysicsHost`, `controller.CommitRuntimeActivationFrame()`,
`_physics.Engine.UpdatePlayerCurrCell`, `_physics.AcknowledgeSpatialProjection`,
`_entities.ResetObjectClockForEnterWorld` (object-clock epoch bump, task 3),
and `controller.ActivateRuntimePublication()` (controller goes LIVE) ALL
happen in one synchronous, no-branch-for-failure block (:2025-2077), gated
immediately before by `IsDormantLocalActivationPrephaseCurrent`/re-validated
epoch checks. **This is genuinely the full retail SetPosition commit,
already ported, already wired to the same body/controller the dormant
publication candidate built.**
**Verdict: NUCLEUS, not a dead end** — but it is only ONE LOAD-BEARING STEP
inside a much larger, ALREADY-COMPLETE mechanism:
`RuntimeLocalPlayerPhysicsPublicationState` (1033 lines,
`src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs`)
+ its ~15 `RuntimeSetPositionState` dormant-activation methods. Constructed
once at `GameRuntime.cs:261` and exposed via
`RuntimeLocalPlayerMovementState.PhysicsPublication` (:59-61, itself
`internal`, throws if unbound). **Confirmed by exhaustive grep: ZERO
production callers in `src/AcDream.App/` or `src/AcDream.Headless/`** — the
only callers anywhere are `tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs`.
This is, functionally, **Option 2 from the rejected-prototype note ("Off-
canonical preparation followed by one validated atomic Runtime commit that
publishes the prepared controller/body relationship without copying stale
state over newer authority") already built end to end** — complete with:
- a `Prepare`/`Commit`/`Discard` triad for the BODY/CONTROLLER pair
(analogous to, and reusing, the SAME token-epoch pattern as
`RuntimeSetPositionState`'s ordinary placement operations);
- a SEPARATE `EvaluateActivation`/`CommitActivation`/`DiscardActivation` triad
for actually driving the body through retail SetPosition's staged commit
once the body/controller pair is sealed;
- re-validation of `PhysicsOwnershipEpoch`, `ObjectClockEpoch`,
`ControllerOwnershipEpoch`, `SessionLifetimeVersion`, identity
`ServerGuid`+`Revision`, and null/in-progress state for RemoteMotion/
Projectile/PhysicsHost/DeleteAcceptedForTeardown at EVERY external entry
point (`CanPrepare`, `IsCurrent`, `IsActivationCurrent`,
`IsActivationOwnershipEnvelopeCurrent`,
`IsCommittedActivationSuffixCurrent`) — this IS the reentrancy defense the
rejected snapshot-lease prototype explicitly lacked (see section 6).
**What is genuinely missing (the real C1 work), given this mechanism already
exists:**
1. Nobody calls `TryBeginExclusiveAuthoredPlacement` + prerequisite B's
`PrepareMover`/`ReadSetupCollision` chain + `PhysicsPublication.Prepare`/
`Commit`/`EvaluateActivation`/`CommitActivation` from either host — this IS
the wiring gap, exactly like every other route in the cutover.
2. **The public `RuntimeLocalPlayerMovementState.Controller` setter
(`RuntimeLocalPlayerMovementState.cs:37-50`) remains a live, unguarded
escape hatch** — both `PlayerModeController.BuildControllerAndCamera:486`
and `HeadlessSessionWorldProjection.CreateController:653` write it
directly today, and NOTHING stops either host from continuing to do so
even after C1 wires the dormant mechanism, unless that direct-write path
is deleted/sealed off (e.g. made `internal` to only
`RuntimeLocalPlayerPhysicsPublicationState`/`CommitRuntimeOwnedController`).
The setter has NO epoch/token check on write (`CanCommitRuntimeOwnedController`
is a SEPARATE, unused-by-the-setter validation method) — it will happily
accept a second unguarded assignment even while a dormant activation is
in flight, silently retiring whatever the dormant mechanism just
published (`_controller?.RetireRuntimePublication()` at :45, which is a
real no-op for anything not currently `RuntimeOwnedDormant`/
`RuntimePublished` — see section 6 gate 7). **This is the single most
important pre-existing defect C1 must close: the "exclusive" adjective in
prerequisite C's "one Runtime-owned exclusive/versioned...transaction"
is not yet true while this direct setter remains reachable from hosts.**
3. Neither `new PlayerMovementController(physics, objectClock, options)`
(public ctor, `StandalonePublished`) call site in the two hosts has been
swapped for `PlayerMovementController.CreatePublicationCandidate` — until
that swap happens, controllers built by either host never enter the
`CandidatePreparing/CandidateSealed/RuntimeOwnedDormant/RuntimePublished`
lifecycle the dormant mechanism's gates all key off of.
---
## 5. `PlayerMovementController` construction requirements
Constructor needs (from both direct-ctor call sites AND
`CreatePublicationCandidate`, `PlayerMovementController.cs:617-690`):
- `PhysicsEngine physics` (shared engine reference, both hosts pass their own
`RuntimePhysicsState`/`_runtime.EntityObjects.Physics.Engine`).
- `RetailObjectQuantumClock? objectClock` — App passes `playerRecord.ObjectClock`
(the CANONICAL record's clock, `RuntimeEntityRecord.ObjectClock` at
`RuntimeEntityRecord.cs:62`, always non-null per its field initializer);
headless passes `record.ObjectClock` identically. The dormant mechanism's
`CreatePublicationCandidate` instead passes a THROWAWAY
`new RetailObjectQuantumClock()` (`PlayerMovementController.cs:687-688`) at
construction time and only swaps in the REAL
`candidate.Record.ObjectClock` later, inside `Commit`, via
`controller.CommitRuntimeOwnership(candidate.Record.ObjectClock)`
(`RuntimeLocalPlayerPhysicsPublicationState.cs:403-404` ->
`PlayerMovementController.cs:774-786`) — i.e. the dormant candidate is
built against a SCRATCH clock so construction can never observe or mutate
the canonical record's real clock before the atomic commit swaps it in.
This is exactly the "off-canonical preparation" half of Option 2.
- `PlayerMovementConstructionOptions` (RunSkill/JumpSkill) — both hosts build
via `PlayerMovementConstructionOptions.From(<a RuntimeMovementSkillState
snapshot>)`; the dormant mechanism's `Prepare` also takes this as a caller-
supplied parameter (`Prepare(..., PlayerMovementConstructionOptions
options, ...)`, :191) — no divergence in shape, only in WHERE the skill
snapshot is read from (App's local `_skills` field vs. headless's
`_runtime.CharacterOwner.MovementSkills` vs. the dormant mechanism taking
it as a caller parameter either way).
What construction MUTATES (beyond the private `_body`): `LocalEntityId`,
`StepUpHeight`/`StepDownHeight` (Setup-derived), `SphereList` (Setup-derived,
prerequisite B territory), `ObjectScale`, initial position/orientation via
`PreparePositionForCommit`/`SetBodyOrientation`, physics state via
`ApplyPhysicsState`, `MoveToFactory`/`PositionManager`
(`MovementManager`/`MotionInterpreter` wiring). ALL of this is exactly what
`RuntimeLocalPlayerPhysicsPublicationState.Prepare`
(`RuntimeLocalPlayerPhysicsPublicationState.cs:187-355`) already does against
its private `CreatePublicationCandidate`-built controller, reading
`command.Physics.StepUpHeight/StepDownHeight/Spheres/Scale/Position/CellId/
CellLocalPosition/Orientation` from the CALLER-SUPPLIED
`RuntimeSetPositionCommand` (i.e. the command already carries everything
prerequisite B's mover-preparation chain produces) rather than reaching into
DAT/prepared-collision itself.
**What an "off-canonical preparation followed by one validated atomic commit"
must DEFER** (confirmed by the dormant mechanism's own design, section 4):
- The record's REAL `ObjectClock` (use a scratch clock during prep).
- `RuntimeEntityRecord.PhysicsBody`/`PhysicsOwnershipEpoch` (never touch the
canonical record during prep; only `SetPhysicsBody` inside `Commit`, and
only after `PrepareDormantLocalActivationOwnership` succeeds).
- `RuntimeLocalPlayerMovementState.Controller`/`ControllerOwnershipEpoch`
(only via `CommitRuntimeOwnedController`, never the public setter, during
prep).
- `body.InWorld`/`TransientState.Active` (explicitly forced false during prep,
`Prepare`, :292-293) — the body must not be simulatable until the LATER
activation commit flips it (`TryApplyDormantLocalActivationFinalCommit`,
`body.InWorld = true` at :2056).
- World-residence/host/shadow/camera publication (all deferred to the
activation phase / presentation-observer layer, never inside `Prepare`).
---
## 6. Adversarial gate list — exact code paths that would race TODAY
(Campaign handoff's list, `docs/research/2026-07-31-remaining-physics-campaign-handoff.md:210-221`.)
For each: what the ALREADY-BUILT dormant mechanism does (if wired) vs. what
the CURRENT production direct-construction paths do (today, unwired).
1. **Nested construction** — Dormant: `CanPrepare` requires `_activation is
null` AND `record.PhysicsBody is null` AND `_movement.Controller is null`
(`RuntimeLocalPlayerPhysicsPublicationState.cs:862-889`) — a second
`Prepare` while one is in flight is REJECTED structurally. Today: NEITHER
`BuildControllerAndCamera` NOR `CreateController` has any such guard —
`CreateController`'s `_runtime.MovementOwner.Controller ?? CreateController(record)`
(`HeadlessSessionWorldProjection.cs:576-578`) is a bare null-coalesce, not
an atomic test-and-set; only single-threaded host-loop scheduling
prevents an actual race today.
2. **Reentrant SetPosition** — Dormant: every gate re-checks
`PhysicsOwnershipEpoch`/`record.PositionAuthorityVersion` currency.
Today: `BuildControllerAndCamera`'s final mutation
(`playerEntity.SetPosition`/`ParentCellId`/`CommitPreparedPosition`,
PlayerModeController.cs:482-484) has zero epoch check — a same-thread
reentrant call (e.g. from a nested wire dispatch) would silently
clobber with no detection.
3. **Remote and projectile binding/update** — Dormant: `CanPrepare`/
`IsCurrent`/`IsActivationCurrent` all check `record.RemoteMotion is null`,
`record.Projectile is null`, `!RemoteMotionBindingInProgress`,
`!ProjectileBindingInProgress` (defense-in-depth; should never legitimately
fire for a local-player record). Today: no such check exists in either
host's direct construction path.
4. **Deletion and same-GUID new incarnation** — Dormant: `_entities.IsCurrent(record)`
checked at every gate; `TryAcceptDelete` -> `RemoveActive` flips this
immediately (section 3). Today: `BuildControllerAndCamera`/`CreateController`
take a fixed `RuntimeEntityRecord`/`WorldEntity` parameter with NO
re-validation against current canonical identity at the final commit.
5. **Projection-owner replacement** — Dormant: `_entities.SessionLifetimeVersion
== token.SessionGenerationAuthority` checked throughout. Today: no
generation check in either direct path.
6. **Object-clock epoch change** — Dormant: `ObjectClockEpoch` compared at
every gate (section 3/4). Today: no check; AND there is a REAL, live
concurrent writer already in production —
`LiveEntityRuntime.cs:879/891/897/1258/3030/3033`'s direct
`record.SuspendObjectClock()`/`ResetObjectClockForEnterWorld(...)` calls
inside the `RebucketLiveEntity` family (section 3) — this is not a
hypothetical gate, it is a currently-active call path on the SAME record
type.
7. **Reset and disposal** — Dormant: `ResetSession()`/`Dispose()` on
`RuntimeLocalPlayerMovementState` explicitly cascade into
`_physicsPublication?.ResetSession()`/`Dispose()`
(`RuntimeLocalPlayerMovementState.cs:244-295`), which tear down
candidate/activation state via `ReferenceEquals`-gated clears (never
clobbering a newer owner, `DiscardActivation`,
`RuntimeLocalPlayerPhysicsPublicationState.cs:1002-1032`). Today's direct-
construction controllers are built via the PUBLIC constructor
(`StandalonePublished` lifecycle) — **`RetireRuntimePublication()`
(`PlayerMovementController.cs:837-846`) only transitions
`RuntimeOwnedDormant`/`RuntimePublished` state; it is a NO-OP for
`StandalonePublished` controllers** — a previously-unstated finding: TODAY,
`ResetSession()`/`Dispose()`/replacing `.Controller` on either host's
directly-built controller produces NO explicit lifecycle transition at
all; the controller is simply dropped/GC'd. Not a visible bug today
(nothing reads `IsRuntimePublished` for these), but it means today's
controllers are invisible to the exact teardown bookkeeping C1's target
mechanism relies on.
8. **Commit and rollback after replacement** — Dormant: ALL validation
happens before the single canonical mutation
(`PrepareDormantLocalActivationOwnership`, which itself throws leaving
state untouched on failure); everything after is documented as
"callback-free, non-allocating, and cannot fail"
(`RuntimeLocalPlayerPhysicsPublicationState.cs:391-393`) — no rollback-
after-newer-authority path exists BECAUSE nothing after that point can
fail by construction. Today: `BuildControllerAndCamera`'s try/catch rolls
back camera+shadow only; the final `playerEntity.SetPosition`/
`ParentCellId`/`CommitPreparedPosition` triad (482-484) has nothing after
it that can throw, so it's accidentally safe today, not structurally
guaranteed.
9. **Late graphical camera/shadow/host failure** — Today: `BuildControllerAndCamera`
DOES handle this (explicit `_camera.RestoreState`/`_shadow.Restore` in the
catch block, 494-518) — this is the ONE gate the CURRENT graphical path
already handles reasonably. The dormant Runtime-side mechanism has NO
camera/shadow concept (presentation-independent by design) — C1 must
layer this handling in the PRESENTATION/observer phase (post-Runtime-
commit), matching prerequisite D's rule that a host exception must not
roll Runtime back, only retry the FIFO head.
10. **Late headless prepared-collision failure** — Today:
`ApplySetupStepHeights` (`HeadlessSessionWorldProjection.cs:657-691`)
throws a raw, uncaught `InvalidDataException` (672-675) if the prepared
Setup collision isn't `Loaded` — this propagates up through
`CreateController` -> `SynchronizeLocalPlayer` -> `ProjectSpawn`/
`ProjectPosition` with NO try/catch anywhere in between (grep-confirmed
no surrounding try/catch in `HeadlessSessionWorldProjection.cs`'s these
methods) — a genuinely unhandled-exception risk in production headless
TODAY, not just a hypothetical C1 gate.
---
## Summary for the C1 contract
- **Writer count**: 6 confirmed call sites of `RuntimeEntityRecord.SetPhysicsBody`
across 3 files (`RuntimeEntityDirectory.cs:359`,
`RuntimeEntityObjectLifetime.cs:766`,
`RuntimeLocalPlayerPhysicsPublicationState.cs:405,1025`,
`RuntimePhysicsState.cs:1558,1666`) — plus a probable 7th App-side ad hoc
projectile body-construction site not yet traced to a canonical writer
(flagged, out of local-player scope).
- The dormant `RuntimeLocalPlayerPhysicsPublicationState` +
`RuntimeSetPositionState`'s ~15 dormant-activation methods already
implement essentially the COMPLETE Option 2 transaction (off-canonical
prepare against a scratch clock/sealed candidate controller, single
validated atomic commit, full retail-staged SetPosition activation) with
epoch/token/generation/identity re-validation at every external entry
point — it has ZERO production callers in either host.
- The single largest remaining defect even AFTER wiring: the public
`RuntimeLocalPlayerMovementState.Controller` setter is an unguarded escape
hatch both hosts currently use directly; it must be sealed (made
unreachable from hosts, or itself epoch-gated) for the word "exclusive" in
prerequisite C to be true.

View file

@ -0,0 +1,285 @@
# Next-agent prompt — finish the retail placement/collision campaign
Continue acdream from the 2026-08-03 stabilization checkpoint. The code is
modern; behavior must remain retail-faithful. The campaign is playable again,
but it is **not closed**.
## Start here
Use the merged local `main` worktree:
```text
C:\Users\erikn\source\repos\acdream
```
The completed fixes originated on `codex/port-claude-agents` and are merged
into local `main`. Confirm the exact starting commit with `git rev-parse HEAD`
and read the operator's handoff message for the merge SHA. Do not reset,
clean, or overwrite the main worktree's untracked research/reference files.
The original feature worktree remains at:
```text
C:\Users\erikn\.codex\worktrees\af5e\acdream
```
That feature worktree contains protected user-local modifications and is not
the preferred continuation workspace.
Read these files in order before editing:
1. `CLAUDE.md` and `AGENTS.md`.
2. `docs/plans/2026-08-02-placement-cutover.md` — canonical placement-cutover
plan and the 2026-08-03 checkpoint.
3. This prompt.
4. `docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md`
— especially `## P1` and the final stabilization checkpoint.
5. `docs/research/2026-08-02-collision-throughput-handoff/p1-retirement-receipt-loop.md`.
6. `docs/research/2026-08-02-c3c-cutover-closeout.md`.
7. `docs/ISSUES.md` #269 and #276#280.
8. `docs/architecture/retail-divergence-register.md` rows AP-1, AP-22,
AP-131, AD-1, AD-10, AD-60, and TS-28.
`docs-drafts.md` is now explicitly historical. Do **not** apply it wholesale:
it predates the final fixes and incorrectly tries to reuse issue number #280.
## Binding rules
- Grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by named
`class::method` before fresh decompilation. Retail behavior is the oracle.
- Preserve the modern Runtime-owned, presentation-independent architecture.
Graphical and headless hosts must use the same canonical gameplay owners.
- Root causes only. Do not add timeouts, grace periods, suppression flags,
catch-and-swallow paths, duplicated placement writers, or compatibility
bypasses to make a test green.
- The user's connected observations are acceptance facts. A green automated
test cannot overrule a live regression.
- Never use `git add -A`, `git add .`, `git reset --hard`, or
`git checkout -- <path>`. Stage exact paths only.
- Do not delete or normalize unrelated/untracked worktree content.
- Each independent fix must be a bisectable commit whose message records the
root cause and evidence.
- Update the divergence register and issues in the same commit that changes
their truth. Retire a row only when its exact legacy mechanism is gone.
- Do not push unless the user explicitly asks.
- Connected tests require the user's ACE server at `127.0.0.1:9000`. Close
the client gracefully before reconnecting so ACE releases the session.
## What has been completed
### C3c and collision-publication checkpoint
- `529e0e9d` — C3c production first-entry cutover for graphical and headless
hosts.
- `71604331` — O(changed) per-landblock collision publication checkpoint.
Its original commit was deliberately marked WIP after the first feel test;
do not treat that old label as the current product status. The following
fixes addressed the observed failures.
### Stabilization fixes, all user-verified where visual behavior applies
1. `01f4791e` — **retirement-receipt replay loop fixed.**
- Root cause: a pending-only live-projection bucket was promoted to a
second full landblock cleanup receipt after the first detach had already
committed. The duplicate guard threw; a broad resumable path replayed
the detach 243 times.
- Fix: retain pending identities without manufacturing another receipt;
post-commit receipt invariants are terminal, not resumable.
- Evidence: focused recenter tests; complete Release suite 10,815 passed /
4 skipped; lifecycle report
`logs/connected-world-gate-20260802-203751/report.json`; nine-stop report
`logs/connected-r6-soak-20260802-204309.report.json` with `Passed=true`,
nine checkpoints, zero failures, zero wait cues, zero pending
retirements, and no recurrence of the 243x exception.
2. `670f307c` — **remote placement and targeting share one world frame.**
- Root cause: CreateObject positions are landblock-local, but Runtime
submitted remote first-entry placement with zero world offset; the local
physics host also published a landblock-local origin to targeting.
- Fix: Runtime owns the accepted local-player world center, converts remote
Create placement before SetPosition, and publishes the local body's world
position.
- User gate: monsters and statics place correctly; monsters chase and hit
the visible player instead of attacking another coordinate.
3. `1fc529cd` — **distant Use/approach restored.**
- Root cause: Runtime object lookup is intentionally non-constructing, so a
static door/corpse could enter MoveTo without a physics host and its
target snapshot expired at the origin. Startup placement could also
leave an impossible pre-PartArray motion suffix ahead of later actions.
- Fix: ensure the canonical minimal static host before routing MoveTo and
reconcile the startup suffix exactly at presentation attach.
- User gate: near and distant object use works, including approach, turn,
and use after arrival.
4. `f24532ad` — **spell, recall, projectile, and static VFX binding fixed.**
- Root cause: C3c could create effect/projectile/static-animation sidecars
before first SetPosition had bound the entity's mesh, pose, cell, and
visibility. One-shot F754/F755 packets were lost and projectiles could
inherit a cell-less body.
- Fix: exact-incarnation presentation barrier and FIFO replay; retry
projectile/static binding on the committed visibility edge; synchronize
effect cells on rebucket.
- User gate: buffs/protections, recall effects, arrows, combat spell
projectiles, portals, and static animation all work.
5. `175ad6b0` — **login materialization acknowledgement fixed.**
- Root cause: ACE creates the local player Hidden and releases that state on
LoginComplete. Sending LoginComplete from raw F746 receipt raced first
canonical placement and left the purple haze over the character.
- Fix: one completion callback from Runtime's local first-entry terminal
edge; content-less headless retains its only truthful accepted-Create
edge.
- User gate: ordinary login no longer leaves the purple haze; recall still
has the intended materialization presentation.
### Latest focused verification
After the final fix, these passed:
- 90 focused App effect/projectile/static-animation scheduler tests.
- Two focused Runtime login-completion tests.
- The exact live-entity cell-tracking regression.
- All 79 Headless tests.
- `dotnet build AcDream.slnx -c Release --no-restore` with zero errors
(existing warnings remain).
The long complete suite and connected nine-stop soak were **not rerun after
the final four stabilization commits**. The P1 soak proves P1's binary, not
the final campaign binary.
## What remains — execute in this order
### 1. Reconcile the six selected-fixture failures
A broad selected run after cleanup exposed:
- five failures in `LiveEntityRuntimeTests`, associated with the still-open
placement/cell cutover;
- one old `RuntimeLiveEntitySessionControllerTests` remote-first-entry fixture
that provides an empty collision source while the production contract now
requires truthful collision admission.
Re-run these two classes first and record the exact test names and assertions.
Classify each as either a real product failure or a stale fixture. If stale,
update the fixture to provide the same valid prepared collision neighborhood
as production; never weaken the product contract or merely change expected
values. If real, fix the owning production mechanism and add a smaller
regression test.
Suggested first commands:
```powershell
$env:ACDREAM_PAK_PATH='C:\Users\erikn\Documents\Asheron''s Call\acdream.pak'
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Release --no-restore --filter FullyQualifiedName~LiveEntityRuntimeTests -m:1
dotnet test tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj -c Release --no-restore --filter FullyQualifiedName~RuntimeLiveEntitySessionControllerTests -m:1
```
### 2. Finish C4: remaining authoritative placement routes
The plan still marks routes 27 open:
- route 2: ForcePosition;
- route 3: portal placement through `RuntimeWorldTransitState` and
`RuntimePortalPlacementAuthority`;
- route 4: remote Create/Position, deleting the remaining
`RemoteTeleportController`/inline MoveOrTeleport duplicate;
- route 5: authoritative projectile correction;
- route 6: drops and split-recovery marking;
- route 7: pickup, parent-detach, and delete/recreate residue.
Inventory every current writer before editing. For each route, prove:
- one Runtime SetPosition transaction owns accepted frame, exact cell,
collision result, shadow/workset membership, and deferred-cell lifetime;
- App only projects the committed result;
- graphical and headless hosts use the same command and state path;
- stale sequences, delete/GUID reuse, missing cells, portal generations, and
replacement collision generations cannot commit old state;
- no route reconstructs from a stale spawn or uses the legacy outdoor demote/
terrain-Z lift.
Resolve #276 when the spawn settler's resolved `CellId` becomes authoritative.
Resolve #277 with a real service-window/celless lifecycle instead of relying
on ACE's current broadcast radius.
### 3. Fix #280: destination prefetch before portal reveal
Current behavior waits only a hard-coded radius-one (3x3) outdoor
neighborhood, while the visible configured world extends farther. The user
can see distant terrain continue building after portal exit.
Port the retail mechanism, not a larger magic number:
- `CellManager::PreFetchCells @ 0x00455820`;
- `LScape::PreFetchCells @ 0x00505660`;
- `CLandBlock::PreFetchCells` and `CLandBlockInfo::PreFetchCells`;
- `SmartBox::UseTime @ 0x00455410` while `blocking_for_cells`;
- the `TAS_TUNNEL_CONTINUE` resume/reveal order.
Use the quality/view-distance configured destination window. Hold one
generation-scoped reservation across terrain, buildings/statics, EnvCells,
render publication, composite textures, and collision. Keep portal UI and
wait cue responsive. Never reveal early because of a timeout, and do not wait
for an impossible terminal marker for all dynamic ACE objects.
Acceptance: repeated login, `/ls`, spell recall, and portals at every quality
setting reveal no constructing terrain, missing nearby statics/buildings,
unready interiors, missing composites, or absent nearby collision. Dynamic
monsters/items may still arrive later from ACE.
### 4. C5 closeout and live gates
After steps 13:
1. Delete every superseded placement writer and compatibility projection.
2. Run focused Runtime/Core/App tests for every route.
3. Run the complete Release solution suite with the installed pak.
4. Run the exact lifecycle/reconnect route.
5. Run the canonical nine-stop soak on the **final binary**. Read
`report.json`, not marker output. Required: `Passed=true`, zero failures,
every canonical checkpoint, `waitCueShown=false`, zero pending
publication/retirement/reveal debt, graceful exit, and no render-shadow
mismatch. Diagnose any real failure; do not rerun past it.
6. Perform two-client observation for remote creation, chase/attack, doors,
drops/pickups, portal departure/arrival, arrows, and spells.
7. Ask the user for the remaining #269/#278 slope-glide comparison at the
known impassable slope.
Only then retire AP-1, AD-1, AP-131, and the legacy half of AD-60 and close
the corresponding placement issues.
### 5. Finish the original physics-divergence campaign
After placement C5 is green:
- **AP-22:** make `ShadowShapeBuilder` the sole authority for authored Setup
collision shapes. Preserve cylinder order; use authored spheres when there
are no cylinders; cylinder-first for mixed data; truly shapeless means no
shadow. Remove invented `Setup.Radius` cylinders, `Radius * 2` heights, and
sphere-to-cylinder coercion across graphical, headless, static, and live
paths.
- **AD-10:** prove remote motion uses the full transition sweep, remove
terrain-normal preprojection, and let `CTransition::adjust_offset` project
against the actual retained contact plane. Preserve interpolation,
correction replacement, Hidden state, and network cadence.
- Run the final movement/collision matrix and update the divergence ledger,
architecture, roadmap, milestones, memory, `CLAUDE.md`, and `AGENTS.md`.
Resume vendor Slice 5 only after this campaign is genuinely closed.
## Required deliverable
For every remaining item report:
- observed failure and deterministic reproduction;
- retail/reference evidence with named functions and addresses;
- root cause in plain language plus file/line evidence;
- exact fix and why it preserves Runtime ownership;
- tests added or corrected;
- commit SHA;
- complete build/test/connected-gate numbers;
- user visual result where required;
- divergence/issue rows retired, narrowed, or left open.
Finish with an explicit list of anything still open. Do not describe the
campaign as complete while any C4 route, #280, final-binary soak, AP-22,
AD-10, or required user visual gate remains.

View file

@ -0,0 +1,411 @@
# O(changed) collision clone — design note
**Phase:** research + design only. No production edits, nothing staged, no probes left
behind. Worktree `C:\Users\erikn\.codex\worktrees\af5e\acdream`, branch
`codex/port-claude-agents`, HEAD `c52ce14a`.
**Problem:** the collision-generation staging clone is O(resident world) per landblock
publication, so loading an N-landblock ring costs O(N²). The far ring never converges.
C3c made it user-visible (late monster pop-in, extended/stuck portal space, portal-exit
pop-in, failing nine-stop soak) but did not cause it.
---
## (a) What the one-leaf-per-step invariant actually protects
### It is a frame-time bound. Nothing else.
The whole-world copy did not arrive with `6b28ff99`. It arrived one commit earlier, in
`be94bc9b` "fix(physics): activate collision generations atomically" (2026-07-31), as a
**synchronous** copy performed in a single call at admission:
```csharp
// be94bc9b, PhysicsEngine.CreateCollisionStagingCopy
foreach ((uint id, LandblockPhysics landblock) in _landblocks)
staging._landblocks[id] = landblock;
staging.ShadowObjects.CopyCollisionStateFrom(ShadowObjects, stagingCache);
```
`6b28ff99` "make collision activation starvation-free" replaced that with
`CollisionStagingBuilder` (`src/AcDream.Core/Physics/PhysicsEngine.cs:785-941`), which
performs the *same* copy chopped into single leaves across frames. The retired AD-6 row
states the purpose verbatim
(`docs/architecture/retail-divergence-register.md:113`):
> "Admission captures the active root in O(1); a stable landblock/owner slot suffix
> materializes non-target leaves incrementally, **so resident-world size cannot become a
> synchronous clone spike**."
The committed test says the same thing three ways
(`tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:921-991`):
| Assertion | line | What it pins |
|---|---|---|
| `Assert.InRange(admissionAllocation, 1L, 128L*1024L)` | :973 | admission allocates a constant |
| `Assert.Equal(0, prepared.Engine.LandblockCount)` | :974 | admission copies no resident landblock |
| `Assert.InRange(step.WorkUnits, 0, 1)` | :983 | **the copy is chopped to one leaf per host step** |
| `Assert.True(advances > residentLandblocks)` | :988 | it really walked the resident world |
| `Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount)` | :989 | the draft ends up holding the whole world |
So the invariant protects **hitch avoidance**: a dense resident world must not produce
one long synchronous copy inside a single update step. It is a *scheduling* property
asserted as a *mechanism*, which is why the batching lever tripped it.
### What it does NOT protect
- **Not concurrent-reader isolation.** That is `CollisionWorldStateSlot.TransferTo`'s
single `Volatile.Write` (`src/AcDream.Core/Physics/CollisionWorldState.cs:66-84`).
And a full threading audit of every writer and reader of `CollisionWorldState` found
**no concurrent reader or writer exists**: `GameWindow` runs one Silk.NET loop thread;
`UpdateFrameOrchestrator.Tick` runs `_streaming.Tick()``DrainAndApply` and then the
live/physics/camera phases strictly sequentially on that thread; the only background
workers (`LandblockStreamer` worker thread, `EnvCellRenderer` `Parallel.ForEach`,
`ObjectMeshManager` `Task.Run`) never touch `PhysicsDataCache` / `CellGraph` /
`ShadowObjectRegistry` / `PhysicsEngine` — grep of `LandblockBuildFactory.cs` and
`LandblockMesh.cs` for those types returns zero hits. Every one of
`BeginCollisionAdmission` (:2040), `PrepareCollisionGeneration` (:2092),
`AdvanceCollisionGenerationPreparation` (:2123), `StageCollisionAssets` (:2218),
`AdvanceCollisionGenerationSeal` (:2298), `CommitCollisionGeneration` (:2382),
`CancelCollisionGeneration` (:2139) passes through
`RuntimePhysicsState.EnsureCollisionMutationThread` (:2857-2869). The
`ConcurrentDictionary` choices are load-bearing only for *single-threaded*
mutate-while-enumerating (`PhysicsDataCache.cs:958-984`, and the seal cursor holding a
live enumerator across frames at :1081-1160) — the cross-thread rationale in the
`CellGraph.cs:17` and `PhysicsDataCache.cs:14-20` doc comments is **stale after
6b28ff99**.
- **Not admission fairness.** That is the separate journal/coalescing machinery
(`RuntimePhysicsState.cs:475-529`, research doc step 5) — the other half of what
"starvation-free" meant. It is orthogonal to the leaf metering and stays.
### What the pre-6b28ff99 mechanism did
`be94bc9b`'s commit was a **delta apply**, not a root swap:
```csharp
// be94bc9b, PhysicsEngine.CommitLandblockReplacement — deleted by 6b28ff99
DataCache.CommitLandblockReplacement(replacement.DataCache); // O(changed)
_landblocks[replacement.LandblockId] = replacement.Landblock;
ShadowObjects.CommitLandblockReplacement(replacement.Shadows);
```
`6b28ff99` replaced those three lines with
`stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld)`
(`PhysicsEngine.cs:304-321`). **That is the change that made the clone load-bearing.**
Before it, the clone was a build sandbox; after it, the clone *is* the world that gets
published, so every leaf not cloned is a leaf deleted from the world.
Before `be94bc9b` the client mutated the active maps in place across many frames — the
genuinely non-equivalent state the research doc describes ("the active `PhysicsDataCache`,
`CellGraph`, `PhysicsEngine`, buildings, static shadows, and retained-object refloods
changed at different cursors", `docs/research/2026-07-31-atomic-collision-generation.md:12-16`).
**The atomicity requirement is "the multi-frame build must not be observable", not "the
whole world must be swapped".** A delta applied inside one synchronous update-thread call
satisfies it.
### The cost is worse than F4 measured
F4 attributed median 19,736 / p90 32,135 / max 38,021 leaves and median 3.64 ms per
publication to the staging clone. The **seal** does the same walk again: the replacement
builder holds live enumerators over four `_staging` maps *and* four `_active` maps
(`PhysicsDataCache.cs:1081, 1092, 1103, 1114, 1125, 1136, 1147, 1158`) plus two in
`CellGraph.cs:184, 203`, using `CapturePrefixOne` / `CaptureRemovalOne` — a full scan of
each map to find O(target) keys. Real per-publication cost is therefore roughly **23×
resident world**, not 1×. **Fixing only the clone leaves O(N²) in the seal.** Any design
that does not also scope the removal capture is not a fix.
---
## (b) Candidate designs
### D1 — Structural sharing (persistent/immutable `CollisionWorldState`)
Replace the ~20 mutable maps with persistent maps (HAMT / `ImmutableDictionary`) so a
staging clone shares unchanged subtrees and copies only the changed path.
- **Blast radius:** every read and write site of every map in `CollisionWorldState`,
`PhysicsDataCache`, `CellGraph`, `ShadowObjectRegistry`, `PhysicsEngine`.
- **Invariant changes:** none semantically; the root swap survives unchanged, so the
atomicity story is untouched.
- **Throughput:** admission O(1), clone O(1), commit O(changed · log N). Excellent on the
copy axis.
- **Why rejected:** it pays for the copy with the *query*. A HAMT probe is several times a
`Dictionary` probe and allocates on write; the resolver runs thousands of these per
frame at 30 Hz. Slice I's entire thesis is flat, integer-indexed, zero-allocation
collision (`docs/plans/2026-07-25-modern-runtime-slice-i.md`; I1 measured 0 B/resolve).
D1 optimizes the rare operation at the expense of the hot one and fights the I-series
architecture head-on.
### D1b — Landblock-sliced root (per-prefix immutable slice + small map)
Regroup the root so each landblock's cells / flat cells / EnvCells / buildings / terrain /
outdoor cells / `LandblockPhysics` live in one immutable `LandblockCollisionSlice`, and
the root becomes `Dictionary<prefix, slice>` (~625 entries). Commit = one dictionary
write per prefix.
- **Blast radius:** every keyed read becomes mask + two probes; the seal's removal scans
collapse to "old slice vs new slice". The **shadow registry does not partition**
`ShadowEntityCells`, `ShadowEntityShapes`, `ShadowEntityRegistrations`,
`ShadowOwnerVersions` are owner-keyed and owners legitimately span prefixes (that is
the whole retained-owner problem), so the shadow half needs a separate mechanism.
- **Invariant changes:** the atomic unit becomes the slice; the root swap disappears.
- **Throughput:** O(changed) by construction, and atomic even for a hypothetical
concurrent reader.
- **Verdict:** this is the right answer *if* concurrent readers existed. They do not.
Keep it on the shelf as the migration target should the runtime ever go multi-threaded;
do not pay its refactor cost now.
### D2 — Per-landblock atomic unit: restore the delta apply *(recommended)*
`CommitLandblockReplacement` drains the **already-existing**
`PhysicsEngine.LandblockReplacementApplyCursor` (`PhysicsEngine.cs:568-777`) against the
**active** root inside one synchronous call, instead of `TransferTo`. The staging root
becomes empty-at-admission (target content only); `CollisionStagingBuilder` phases 08 are
deleted.
The delta record already exists and is already tested: `PreparedPhysicsDataCacheLandblock`
(`PhysicsDataCache.cs:1255-1270`) is exactly lists of key/value pairs to install and lists
of ids to remove, all target-scoped. The apply cursor already handles removals, installs,
terrain, the 0x40 synthesized outdoor cells, the landblock itself, and yields the reflood
owner ids to the caller at phase 12 (`PhysicsEngine.cs:702-712`). Today it is used to
rebase a committed peer delta into a *later draft*; pointing its `destination` at the
active engine is a constructor argument, not new machinery.
**What breaks, honestly:**
1. *Readers mid-query* — nothing. Single-threaded, evidenced above. A drained cursor
inside one call is indivisible with respect to every reader that exists.
2. *Re-entrancy* — real, and the audit flagged it: `OwnerMutated` /
`OwnerPrefixMembershipChanged` (`ShadowObjectRegistry.cs:86-87`) can fire mid-delta.
Precedent already exists: the commit brackets itself with
`_suppressCollisionOwnerJournal = true` (`RuntimePhysicsState.cs:2491-2501`). Extend
that bracket to cover the whole apply.
3. *Cross-frame enumerators* — the seal holds live enumerators over the **active** maps
across frames (`PhysicsDataCache.cs:1092, 1136, 1158`). A delta apply now mutates the
maps those enumerators walk. `ConcurrentDictionary` will not throw, but the observed
set is unspecified. **O1 below removes those enumerators entirely**, which is why O1
must land first.
4. *The retirement machinery*`LandblockRetirementCursor` (`PhysicsEngine.cs:348-...`)
currently retires from an off-side draft. Same cursor, destination becomes the active
root, still drained in one call.
5. *The reflood context* — the seal currently computes retained-owner refloods against a
full staging world. With an empty staging root that context is gone, so the reflood
moves to the commit call, against the now-current active world. **That is precisely
retail**: `CObjCell::init_objects` (0x0052B420) → `CPhysicsObj::recalc_cross_cells`
(0x00515A30), already the retail anchor cited on the AD-6 row.
6. *The peer-rebase / journal apparatus* — with no snapshot there is nothing to rebase.
`EnqueueCommittedRebase` (`RuntimePhysicsState.cs:563-578, 2502-2507`) and most of the
journal become dead. Delete them in the same slice; do not leave dead invariants
guarding a deleted mechanism.
- **Throughput:** per publication ≈ target payload (~70200 leaves at the measured
~184 ns/leaf) + the owners touching the target, versus today's ~23 × 20,000. Roughly
**300× less work per publication**, and — decisively — **independent of resident-world
size**, so total ring load goes O(N²) → O(N). At the failing run's numbers that is
~13.7 M leaf copies for a 625-landblock ring down to ~44 K.
### D3 — Adjacency-scoped clone (the tempting middle ground) — **rejected as unsafe**
Copy only leaves in the target's 3×3 landblock neighbourhood. One predicate change in
`CopyOneOutsideTarget` (`PhysicsEngine.cs:949-961`); clone drops ~20,000 → ~630 and
becomes O(1) in world size.
Rejected for a structural reason worth stating plainly: **while commit is a whole-root
transfer, "clone less" means "delete more."** Anything not copied into the draft is absent
from the root that replaces the world. A partial clone is therefore a silent world-erasure
bug, not a perf tuning knob. Only after commit becomes a delta does bounded context become
safe — at which point D2 has already removed the need for it. It also leaves the seal's
O(world) scans untouched, so O(N²) survives regardless.
---
## (c) Recommendation
**Take D2, in three landable slices, with O1 first.**
Rationale in one line: the delta-apply commit path is not a new invention — it is the
mechanism that shipped in `be94bc9b` and was deleted by `6b28ff99` to buy an atomicity
guarantee against concurrent readers that do not exist; restoring it makes the cost
O(changed) by construction and moves the client *toward* retail's `init_objects` shape,
not away from it.
### Invariant-test replacement
Delete from `DenseResidentWorldAdmissionIsConstantAndMaterializesOneLeafPerStep`
(`tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:921-991`) the three
assertions that pin the clone itself — `:983` `Assert.InRange(step.WorkUnits, 0, 1)`,
`:988` `Assert.True(advances > residentLandblocks)`, `:989`
`Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount)`. They assert the exact
mechanism being removed.
Replace with `CollisionPreparationCostIsIndependentOfResidentWorldSize` — a strictly
stronger invariant, because it pins the *property* (bounded, world-size-independent work)
rather than a mechanism:
```
Run the full admission → preparation → seal → commit sequence twice,
at residentLandblocks = 32 and residentLandblocks = 256.
Assert total preparation advances(32) == total preparation advances(256) // O(changed)
Assert total seal WorkUnits(32) == total seal WorkUnits(256) // closes the seal scans
Assert every step.WorkUnits <= K // K = retained per-step bound
Assert admissionAllocation in [1, 128 KiB] // kept from :973
Assert prepared.Engine.LandblockCount == 0 after preparation completes // stronger than :974:
// the draft now holds ONLY the target
```
Add two more:
- `CommitAppliesOneLandblockDeltaInASingleCall` — the engine-mutating
`CommitCollisionGeneration` call drains the apply cursor to `Completed` before it
returns; the active world holds no target-prefix content before it and the complete
target after it, with no observable intermediate.
- `CommitTimeRefloodMatchesPrecomputedReflood` — for a fixed scenario, the owner set and
each owner's resulting cross-cell set after a commit-time reflood are **equal** to what
the pre-change staged reflood produced. This is the proof that D2 is a scheduling
change and not a semantics change, and it is the test that makes the perf framing in
(d) legitimate.
**Keep unchanged:** every `Assert.InRange(seal.WorkUnits, 0, 1)` at `:558, :667, :747,
:848, :1797, :2020, :2318, :2428, :3033` — the seal stays metered; the zero-managed-byte
commit assertions (the delta lists are built during seal, so the apply must still be
allocation-free); and `CommittedPreparationRevokesItsStagingCollisionRoot` (`:1664`) in
spirit — the staging root must still be revoked after commit, it simply no longer becomes
the active root.
### Migration plan
| Slice | Change | Gate |
|---|---|---|
| **O1** | Per-prefix installed-key ledger in `CollisionWorldState`, maintained by the install/remove paths. Rewrite the seal's ten full-map scans (`PhysicsDataCache.cs:1081-1160`, `CellGraph.cs:184, 203`) to enumerate that set. Removes the cross-frame active-map enumerators. **Behaviour-identical; a pure win that lands alone.** | existing suites green + the new seal-independence assertion |
| **O2** | `PhysicsEngine.CommitLandblockReplacement` drains `LandblockReplacementApplyCursor` against the active root instead of `TransferTo`. Extend the `_suppressCollisionOwnerJournal` bracket over the whole apply. Retirement cursor destination → active root. | focused Runtime physics suite + connected lifecycle gate |
| **O3** | Empty staging root: delete `CollisionStagingBuilder` phases 08. Move retained-owner reflood into the commit call (retail `init_objects``recalc_cross_cells`). Delete the now-dead peer-rebase/journal paths and their tests. | full ladder below |
### Gate ladder (O3 closeout)
1. **Focused:** Runtime physics collision-generation suite, App
`LandblockPhysicsPublisherTests`, Headless `HeadlessSessionHostTests`.
2. **Complete Release solution:** baseline to match or beat is **10,808 passed / 0 failed
/ 4 skips** (`-m:1`, `ACDREAM_PAK_PATH`).
3. **Connected lifecycle/reconnect gate:** signature must hold — `Passed=true`,
`Failures=[]`, both sessions `ExitCode=0`, zero render-shadow mismatches, zero pending
deltas, graceful exits.
4. **Nine-stop soak must reach `Passed: true` with `Failures: []`.** The failing run is
`logs/connected-r6-soak-20260802-143157.report.json` (37 failures, `Passed: false`);
the passing baseline is `logs/connected-r6-soak-20260727-004942.report.json`
(`Passed: true`, commit `a9a822f2`). Concrete acceptance, per checkpoint:
| Key | Failing run | Required |
|---|---|---|
| `resources.streamingWork.deferredCompletions` | 92501 at 8/9 stops | `0` at all 9 |
| `resources.streamingWork.farBacklog` | same values | `0` at all 9 |
| `resources.streamingWork.pendingPublications` | `1` at 8/9 | `0` at all 9 |
| `resources.streamingWork.deferredAdoptedCpuBytes` | 1.58.5 MB | `0` at all 9 |
| `resources.streamingWork.oldestDeferredAgeMilliseconds` | 37,76469,728 | `0` |
| `resources.loadedLandblocks` | 124533 | **625** at the eight outdoor stops |
| `reveal.waitCueShown` | `true` at 6/9 | `false` at all 9 — *this is the user-reported "extended/stuck portal space"* |
| `streamingWork.lifetimeFrameOverrunCount` | 1,706 | materially lower |
| `streamingWork.maximumOperationStage` | `"publication-index-physics"` | must no longer name this stage |
**`aerlinthe` (sequence 4) is the control, not a target.** It is the one indoor
destination and the one stop that is already clean in the failing run (374/173 vs the
baseline's 374/176, every streaming counter `0`) — precisely because an indoor
destination streams few landblocks, so O(N²) never bites. It must stay clean; do not
expect it to reach 625.
5. **Frame time must not regress — and must recover.** Route-level `cpuUs` from
`frame-history-summary.json`, microseconds:
| | p50 | p95 | p99 | p999 |
|---|---|---|---|---|
| baseline `20260727` | 9,730 | 41,262 | 44,875 | 63,474 |
| failing `20260802` | 17,001 | 47,434 | 57,061 | 102,876 |
Gate on the sharper per-checkpoint window numbers: **`checkpointWindows[].metrics.cpuUs`
p99 within +10 % of the `20260727` baseline at every stop.** The worst offenders are
`caul-plateau` 101,408 → target ≈ 47,821; `caul-return` 103,309 → ≈ 46,938;
`caul-baseline` 108,148 → ≈ 43,664; `sawato-baseline` 49,748 → ≈ 10,592. Frame count
should recover toward the baseline's 35,492 frames / 498.5 s from the failing run's
25,965 / 583.9 s.
6. **Do NOT gate on these — retest only after convergence.** `trackedGpuBytes` (62 MB
failing vs 474 MB baseline), `meshRenderData` 588 vs 607, `meshEstimatedBytes`
233.8 MB vs 268.6 MB, and the inverted CPU mesh-cache hit ratio
(3,666 hits / 5,689 misses vs 20,825 / 6,845) are all far-ring-never-converged
artifacts of the same mechanism. F4 already reached this conclusion; a leak
investigation before convergence is restored will chase a ghost.
### Register / plan bookkeeping (same commit as the code)
- **`docs/architecture/retail-divergence-register.md:113`** — the retired AD-6 row
describes the deleted mechanism verbatim ("one shared off-side `CollisionWorldState`",
"one zero-managed-byte volatile root transfer", the journal, the peer rebases). A
retired row still documents what shipped; leaving it describing a deleted clone is
exactly the out-of-sync failure the register rules forbid. Rewrite it to the delta-apply
mechanism. The row's retail anchor is already
`CObjCell::init_objects``recalc_cross_cells` (0x0052b420 / 0x00515a30) — the new
mechanism is **closer** to that anchor, so no new deviation row is created.
- **Judgment call for the implementer, do not assume:** the *original* AD-6 deviation was
"Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells`"
(`be94bc9b` register diff). If O3's commit-time reflood is again per-landblock rather
than per-cell, decide explicitly whether AD-6 must be un-retired or a successor row
added, and record the decision. Flagged, not decided here.
- **`docs/research/2026-07-31-atomic-collision-generation.md`** — steps 2, 3, 5, 6, 7, 8
and most of the "Deterministic evidence" list describe the clone / journal / rebase.
Rewrite in the same commit.
- **`memory/project_collision_port.md`** — the 37-line block `6b28ff99` added is now wrong.
- **`claude-memory/project_physics_collision_digest.md`** — add two DO-NOT-RETRY entries:
(1) *"Do not re-introduce a whole-world staging clone. The atomic unit is the landblock
delta applied in one update-thread call; the runtime is single-threaded and the root
swap buys nothing."* (2) *"Batching N leaves per staging step is not a fix — measured
1.8× at N=256, not convergence, and it trips the committed invariant test."*
- **`docs/ISSUES.md`** — F4 established this regression is not in the C3c diff, so it
needs its own issue id (the C3c smoke-test commit `c52ce14a` filed #279 for a different
finding). File it, and reference it from the O1/O2/O3 commit messages.
- **Rollback:** each slice lands as one commit with its own recorded `git revert` SHA,
per the Modern Runtime convention.
---
## (d) Perf-work framing
**This is modern-runtime infrastructure, not retail-scoped behaviour work.** The delta
apply installs the *identical* `PreparedPhysicsDataCacheLandblock` content that
`TransferTo` publishes today — the same cells, flat cells, EnvCell topology, buildings,
terrain, synthesized outdoor cells, landblock, and owner set. Only the path by which that
content reaches the active root changes, and only the amount of work done to get there.
Collision results, contact planes, walkable polygons, membership, and therefore game feel
are bit-identical.
The project's render-perf-not-faithfulness-gated rule
(`claude-memory/feedback_render_perf_not_faithfulness_gated.md`) applies: throughput work
that is pixel- and feel-identical does not need a retail-behaviour gate. But because this
is collision, the acceptance bar is still the connected gates plus the user's visual pass
— green unit tests prove nothing about a streaming convergence bug.
Two guards keep the framing honest:
1. **The direction of travel is toward retail, not away.** Retail hydrates a cell
synchronously in `CObjCell::init_objects` and refloods the objects associated with it
via `recalc_cross_cells`. A per-landblock delta applied in one update-thread call is
the streaming-shaped version of exactly that. The whole-world clone was the adaptation;
removing it retires an adaptation rather than adding one.
2. **The one thing that could change feel is reflood timing** — owners near the target
re-flooding at commit rather than from a pre-computed staged set.
`CommitTimeRefloodMatchesPrecomputedReflood` (above) is the specific test that turns
that from an assumption into evidence. If that test cannot be made to pass, the perf
framing is void and the slice needs a behaviour gate.
**Explicitly not a workaround.** Per the no-workarounds rule, note what this is *not*: no
suppression flag, no grace period, no budget loosening, no early-return guard at the
symptom. The root cause is an algorithm that is quadratic in resident-world size, and the
fix is to make it linear by restoring the per-landblock atomic unit the mechanism had
before `6b28ff99`.
### Measure before and after
A stripped-after probe should count, per publication: (clone leaves, seal leaves, apply
leaves) and wall-clock for each. F4 measured only the clone (median 19,736 / p90 32,135 /
max 38,021 leaves, median 3.64 ms, 1,584 preparations = 8.53 s CPU in one 4-minute capped
session). The seal was never measured and D2 must beat both. Expected after O3: clone
leaves 0, seal leaves ≈ target payload, apply leaves ≈ target payload, total per
publication well under 100 µs and flat as the ring fills.

View file

@ -0,0 +1,118 @@
# Docs-commit drafts — collision publication-throughput fix (O1/O2/O3)
> **HISTORICAL DRAFT — DO NOT APPLY WHOLESALE (2026-08-03).** The O1/O2/O3
> implementation landed in `71604331` and the user-visible stabilization
> fixes continued through `175ad6b0`. This draft predates that work, assigns
> issue number #280 to the collision clone even though #280 now canonically
> tracks incomplete portal-destination prefetch, and names ledger edits that
> must be re-audited against the final production tree. It remains only as
> research evidence. Use `NEXT-AGENT-PROMPT.md`, the campaign plan, and the
> live divergence register for current work.
Drafted per contract; NOT applied to the repo. Apply in the docs commit after
code review. Register judgment executed as pinned: AD-6 stays retired with a
successor note; the residual timing/order compression gets a NEW row (AD-62).
---
## 1. `docs/architecture/retail-divergence-register.md`
### 1a. Append to the retired ~~AD-6~~ row (line 113), at the end of column 2
> **Successor note (2026-08-02, collision publication-throughput fix
> O1/O2/O3):** the whole-world staging clone, the owner-mutation journal, the
> peer-rebase/retirement cursors, and the zero-managed-byte whole-root
> transfer this row describes were deleted. The shipped mechanism is now the
> per-landblock delta commit this row's retail anchor always pointed at:
> admission captures an O(1) empty target-only staging root
> (`PhysicsEngine.CollisionStagingBuilder`), the seal enumerates one prefix's
> installed keys through the `CollisionWorldState` per-prefix ledgers, and
> `PhysicsEngine.CommitLandblockReplacement` drains the sealed delta into the
> ACTIVE root in one synchronous update-thread call, recalculating every
> associated owner's cross-cells against the live world
> (`ShadowObjectRegistry.ApplyCommittedOwnerReplacement` +
> `RefloodPrefixOwnersAfterReplacement`; retail `CObjCell::init_objects`
> 0x0052b420 → `CPhysicsObj::recalc_cross_cells` 0x00515a30). Equivalence is
> pinned by `CommitTimeRefloodMatchesPrecomputedReflood`; world-size
> independence by `CollisionPreparationCostIsIndependentOfResidentWorldSize`.
> Residual timing/order compression vs retail: AD-62.
### 1b. New row AD-62 (residual timing/order compression), adaptation class
| AD-62 | **Adaptation.** Commit-time collision reflood granularity/order: retail runs `CObjCell::init_objects` per CELL at cell hydration and `CPhysicsObj::recalc_cross_cells` per object as each cell loads; acdream runs the equivalent once per LANDBLOCK replacement inside the single synchronous activation call, walking the sealed owner list then the live prefix-owner slots (per-landblock granularity matches the streaming unit, same compression `ShadowObjectRegistry.RefloodLandblock` has always carried). An owner becoming target-associated mid-publication refloods at activation (the prefix-slot sweep) rather than at its own cell's hydration instant; a stationary owner adjacent to the target whose flood would only change through building/EnvCell bridges can carry frame-stale cross-cells between the seal capture and the activation sweep (movers self-heal per `SetPositionInternal`). | `src/AcDream.Core/Physics/PhysicsEngine.cs` (`CommitLandblockReplacement`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`ApplyCommittedOwnerReplacement`, `RefloodPrefixOwnersAfterReplacement`) | Late/stale cross-cell rows for a non-moving seam object for a few frames around a landblock publication — an object collidable through a wall seam or briefly not collidable where new topology landed | Low | `CObjCell::init_objects` 0x0052b420; `CPhysicsObj::recalc_cross_cells` 0x00515a30; `CPhysicsObj::SetPositionInternal` tail 0x00515330 |
---
## 2. `claude-memory/project_physics_collision_digest.md` — DO-NOT-RETRY additions
> - **Do not re-introduce a whole-world staging clone for collision
> generations.** The atomic unit is the landblock delta applied in one
> update-thread call (`PhysicsEngine.CommitLandblockReplacement`); the
> runtime is single-threaded and a root swap buys nothing. The clone made
> ring load O(N²) (the C3c late-monster-pop-in / stuck-portal-space soak
> failure, issue #280). Deleted 2026-08-02.
> - **Batching N staging-clone leaves per step is not a fix** — measured 1.8×
> at N=256, not convergence, and it trips the committed one-work-unit seal
> invariant. The fix was removing the clone, not tuning it.
## 3. `docs/research/2026-07-31-atomic-collision-generation.md` — update
Add a banner at the top:
> **SUPERSEDED IN PART (2026-08-02).** Steps 2 (whole-world staging clone), 3
> (owner-mutation journal write-through), 5 (journal coalescing/compaction), 6
> (peer rebases), 7 (draft retirement cursors), and 8 (whole-root transfer)
> describe machinery deleted by the collision publication-throughput fix
> (O1/O2/O3). The atomicity requirement they served — "the multi-frame build
> must not be observable" — is now met by one synchronous per-landblock delta
> apply under prefix quiescence with commit-time owner refloods against the
> live world (retail init_objects → recalc_cross_cells). The admission
> fairness half (quiescence, prefix mutation permissions, ordered activation)
> is unchanged and still accurate. Deterministic-evidence entries that name
> the journal/rebase/retirement tests refer to tests deleted with the
> machinery; their replacements are
> `CollisionPreparationCostIsIndependentOfResidentWorldSize`,
> `CommitAppliesOneLandblockDeltaInASingleCall`, and
> `CommitTimeRefloodMatchesPrecomputedReflood`.
## 4. `memory/project_collision_port.md`
Remove/replace the 37-line block `6b28ff99` added (the starvation-free clone
description) with a pointer to the new mechanism (same content as 1a).
## 5. `docs/ISSUES.md` — file the regression as its own issue
> - **#280 — OPEN → fixed pending review: collision staging clone made ring
> load O(N²)** (filed 2026-08-02). The per-publication whole-world staging
> clone (be94bc9b synchronous, 6b28ff99 metered) plus the seal's full-map
> scans cost ~2-3× resident world per landblock publication, so an
> N-landblock ring cost O(N²) and the far ring never converged. F4 measured
> median 19,736 leaves / 3.64 ms per publication; C3c made it user-visible
> (late monster pop-in, extended/stuck portal space, portal-exit pop-in,
> nine-stop soak failure 20260802-143157) but did not cause it. Fix: O1
> per-prefix installed-key ledger; O2 per-landblock delta commit
> (restores be94bc9b's O(changed) apply); O3 empty staging root +
> commit-time reflood (retail init_objects → recalc_cross_cells) + journal/
> rebase/retirement machinery deleted. Reference the O1/O2/O3 commits here
> when they land.
## 6. Milestones/roadmap
No phase-table change needed: this is Modern Runtime infrastructure follow-up
inside the active campaign context; the C3c smoke-test findings list in
ISSUES (#278 additions) should get items (d)/(e)/(f)-class re-observed after
the soak gate passes.
## 7. Commit-message notes for the slice commits
- O1: `fix(physics): #280 O1 - per-prefix installed-key ledger; seal scans and
landblock removals become O(prefix keys)` — behavior-identical; new test
CollisionSealWorkIsIndependentOfResidentWorldSize.
- O2: `fix(physics): #280 O2 - restore per-landblock delta commit (be94bc9b
shape) at PhysicsEngine.CommitLandblockReplacement` — notes: staging-slot
owner-list widening (direct-staged owners), staging-root revoke, zero-byte
commit asserts → O(target payload) bounds (commit-time reflood + dictionary
node inserts allocate; world-size independence pinned by the O3 test).
- O3: `fix(physics): #280 O3 - empty staging root; commit-time reflood
(init_objects → recalc_cross_cells); delete journal/rebase/retirement
machinery` — 9 mechanism tests deleted, 2 contract tests added.

View file

@ -0,0 +1,68 @@
# Grep sweep — deleted collision machinery (2026-08-02, against the WIP O1-O3 tree)
Produced by an adversarial-review subagent (completed after the review
round was halted). Verdict summary: the deletions are CLEAN — no
surviving consumer of the journal/peer-rebase/draft-retirement/staging-
clone machinery, and no post-`Revoke()` dereference path found. Two
actionable leftovers and a stale-docs catalog for whoever lands the
collision work.
## Actionable
1. `CollisionWorldStateSlot.TransferTo` (`CollisionWorldState.cs:270-281`)
is fully DEAD — zero callers incl. tests. Delete it (comments at
`PhysicsEngine.cs:311/:379` reference it historically and are
accurate).
2. Two test names are stale terminology with live bodies:
`RuntimePhysicsStateTests.cs:1730`
(`PostCommitOwnerMutationWinsOverQueuedPeerRebase`) and `:2264`
(`PendingOrActivePeerRebaseCannotResurrectRetiredLandblock`) — rename
when touched.
## Confirmed DEAD (zero refs in src/tests/tools/docs)
- `LandblockRetirementCursor`/`Step`/`CreateLandblockRetirementCursor`
- `CollisionStagingBuilder.Advance/.WorkUnits/.Completed/.SuppressLandblock`
- `CopyOneOutsideTarget`
- Owner journal: `_collisionOwnerJournal`, `EnqueueCommittedRebase`,
write-through, draft retirement
## Confirmed LIVE (name overlap, different concept — do not "clean up")
- `InstallLandblockClone` (`PhysicsEngine.cs:576,673`) — the per-landblock
delta-apply installer, not the old clone loop.
- `MirrorOwnerFrom` (`ShadowObjectRegistry.cs:1961,2011,2079`) —
repurposed for the O3 commit-time reflood.
- `OwnerMutated`/`OwnerPrefixMembershipChanged` events — general-purpose,
unrelated to the deleted journal.
- `RetryDeferred` (`RuntimeSetPositionState.cs:4103` et al.) — the
deferred-SetPosition subsystem, unrelated.
- `LandblockReplacementBuilder` `.WorkUnits/.Advance/.Completed` — the
live seal builder, not the deleted staging builder.
## Post-Revoke audit
`Revoke()` has exactly one call site (`PhysicsEngine.cs:381`, end of
`CommitLandblockReplacement`). `MarkCommitted` (`RuntimePhysicsState.cs:
372-380`) + `LandblockPhysicsPublisher.cs:505/:1177-1183` guards mean no
production or test site dereferences a revoked slot. `prepared.Engine`/
`DataCache` remain unguarded by design (ObjectDisposedException is the
intended revoked behavior).
## Stale docs/comments that now describe the DELETED design (rewrite when
landing the collision work)
- `docs/architecture/acdream-architecture.md:504-566` — full section on
journal/write-through/rebase/root-transfer: STALE, needs rewrite to
the per-landblock delta commit.
- `memory/project_collision_port.md:50-88` — same content class, STALE.
- `docs/research/2026-07-31-atomic-collision-generation.md` — whole file
documents the deleted mechanism with no supersession note (the
prepared banner is in docs-drafts.md).
- `src/AcDream.Core/Physics/PhysicsDataCache.cs:126-130` XML doc —
describes the deleted one-leaf-per-step materialization.
- `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:621-625`
comment — "zero-work root transfer" no longer true.
- Correctly archival (no action): `retail-divergence-register.md:113`
(~~AD-6~~ retired entry); the placement-cutover plan + C3c closeout
(they name the clone as the known problem being fixed).

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,95 @@
# P1 — origin-recenter retirement-receipt loop
## Observed failure
`launch-feeltest-oclone.log` contains 243 consecutive failures with this
shape:
```text
streaming: origin-recenter preparation will resume:
InvalidOperationException: Landblock 0xC85AFFFF already has a full
retirement receipt.
```
The stack is `StreamingController.TryAdvanceOriginRecenterPreparation`
`LandblockPresentationPipeline.DetachAllForOriginRecenter`
`LandblockRetirementCoordinator.AdoptDetachedFull`.
## Root cause
An ordinary full retirement detaches every landblock-owned presentation
resource first, then parks surviving live entities in
`GpuWorldState._pendingByLandblock` while the exact cleanup ticket advances
asynchronously (`GpuWorldState.DetachLandblock`, around lines 11881314).
The origin-recenter swap incorrectly treated every pending-only live bucket
as another landblock presentation generation (`GpuWorldState.cs`, former
lines 13521353). It therefore emitted a second full cleanup receipt for the
same already-retired generation. `LandblockRetirementCoordinator` correctly
rejected that duplicate at lines 416425. Because spatial detachment had
already committed, the broad retry catch in
`StreamingController.TryAdvanceOriginRecenterPreparation` then repeated the
detach against the changed state every frame.
The pre-fix regression test
`OriginRecenterAdoption_PendingOnlyLiveProjectionDoesNotCreateSecondFullReceipt`
failed because the recenter returned one receipt for the pending-only bucket.
## Retail and reference boundary
Retail destroys one concrete landblock owner synchronously:
`CLandBlock::destroy_static_objects` (`0x0052FA50`) leaves and deletes the
landblock's static objects; `CLandBlock::Destroy` (`0x0052FAA0`) releases its
buildings and landblock data; `CLandBlock::release_all` (`0x0052FCF0`)
releases the landblock's object and visibility ownership. A live object
parked outside a loaded landblock is not a second `CLandBlock` and therefore
cannot create a second landblock-destruction transaction.
The extracted WorldBuilder reference follows the same ownership boundary:
`ObjectRenderManagerBase` removes an actual `_landblocks` entry before
`UnloadLandblockResources`, and `PortalRenderManager` only unloads a removed
`PortalLandblock`. Neither treats an independently parked object as a new
landblock resource owner.
Acdream retains its approved asynchronous adaptation: the first exact
receipt owns cleanup, while the live projection survives spatial recentering.
## Fix
- `GpuWorldState.DetachAllForOriginRecenter` no longer creates retirement
receipts from `_pendingByLandblock` alone. Pending live identities are
still captured from `_projectionLocations`, cleared atomically, and
re-parked unchanged.
- A landblock that also owns loaded, pending-render, pending-near, tier, or
bounds state still receives its exact full receipt.
- A receipt-ledger invariant thrown after spatial detachment is now surfaced
as a committed `StreamingMutationException`; it is terminal rather than
falsely logged as resumable work.
- The genuine duplicate-receipt guard remains unchanged.
## Deterministic evidence
- The new pending-only regression failed before the source fix and passes
afterward.
- `OriginRecenter_PendingOnlyLiveProjectionKeepsItsExistingRetirementOwner`
drives the production recenter/controller sequence and proves the origin
commits while the first cleanup ticket remains pending.
- `OriginRecenter_CommittedReceiptInvariantFailsFastInsteadOfReplayingDetach`
proves a genuine post-detach ledger violation surfaces once rather than
entering a frame-by-frame retry loop.
- The complete `OriginRecenter` focused group passes 20/20.
## Gate evidence
- Release build: 0 errors (21 pre-existing warnings).
- Complete Release suite: 10,815 passed, 0 failed, 4 skipped.
- Connected lifecycle/reconnect gate:
`logs/connected-world-gate-20260802-203751/report.json``Passed=true`.
- Connected nine-stop soak:
`logs/connected-r6-soak-20260802-204309.report.json``Passed=true`,
`Failures=[]`, graceful exit, all 9 canonical checkpoints present, no wait
cue, no pending landblock retirement, no reveal invariant failure, and no
render-shadow mismatch.
- The soak artifacts contain zero occurrences of
`already has a full retirement receipt`; the captured failing session had
243.

View file

@ -0,0 +1,30 @@
# User feel-test observations — O-slice tree (2026-08-02 ~20:10, uncommitted)
Axioms; they override the gate numbers. Log: launch-feeltest-oclone.log.
1. MONSTERS STILL POP IN while running past — the O-slice did NOT fix the
user-visible symptom despite the soak's publication convergence.
2. MONSTERS SPAWNED MID-AIR far ahead at a newly-entered area.
3. STATICS ("stabs") PLACED INCORRECTLY — visibly wrong static placement.
4. User: "This is not how retail worked. I could see monsters way in
front of me."
5. DOOR APPROACH REGRESSED: using a door no longer walks the character
to it first. NOTE: likely a COMMITTED C3c regression, not O-slice —
prime suspect is PlayerModeController's conditional MoveTo bind
(`if (controller.MoveTo is { } moveTo)` — the flip only binds
approach callbacks IF the Runtime-owned MoveToManager already exists;
the legacy path CREATED it via factory at attach). If Runtime's
MakeMoveToManager runs after player-mode attach (or never for this
flow), MoveToComplete/approach never wires. Triage first in the C3c
fix slice; check whether the 175401 gate probe ever exercised a
door/use-approach (suspect: no coverage).
SMOKING GUN (log): 243x "streaming: origin-recenter preparation will
resume: System.InvalidOperationException: Landblock <id> already has a
full retirement receipt." — continuous catch-retry loop during origin
recenter. Both reviewers redirected with this; the implementer's
"exposed pre-existing" retirement classification is under re-judgment.
The catch-and-resume wrapper is itself suspect as a pre-existing
symptom-swallower (no-silent-catch rule).
STATUS: O-slice commit ON HOLD until every observation is explained.

View file

@ -0,0 +1,29 @@
# User in-game observations — 2026-08-02 (~15:40, during the F4 diagnostic run)
Axioms per the retail-oracle rule. User will do a full test session once
the current work passes; these are the pre-session signals.
1. AIRBORNE-WHILE-STANDING (severe, flip-suspect): repeated
"[System] You can't do that while in the air!" +
"You can't do that. (error 0x042C)" x4 + one "WeenieError 0x001D" when
trying to cast while standing still. Suspect: the conductor placement
path lacks the legacy spawn path's #270 settle sweep (contact from the
compressed first gravity frame) -> outbound contact state says
airborne. Routed to F4 as a lead (unified-hypothesis check); if F4's
stuck item is not the player, this becomes its own slice (F5) BEFORE
the C3c commit — casting is core gameplay and blocks the smoke test.
2. MATERIALIZATION HAZE RE-FIRING while standing still (flip-suspect):
purple haze re-triggers around the character. Plausibly the visible
face of the soak's pendingPublications=1 stuck item if that item is
the local player. Routed to F4.
3. NO SLIDE ALONG IMPASSABLE SLOPES: walking into too-steep terrain does
not glide laterally. Likely pre-existing open issue #269 (Campaign P
slope-slide residual). Verify pre-existence during the review/closeout;
do not fold into C3c unless evidence says the flip changed it.
4. /ls DOES NOT WORK: unclear which command surface (chat slash command?).
Triage at the session; low priority.
Review-focus implication: retail reviewer must verify the flip preserves
the legacy spawn path's contact seeding (#270) semantics; adversarial
reviewer must verify the placement publication for the local player
actually completes and is reaped.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,211 @@
# Runtime initial-placement continuation executor handoff - 2026-08-02
## Purpose and exact stopping point
Behavior commit `5db3de3c7ab2c6350d11af7f34b852464fc1e0f9` implements the
Runtime continuation executor: the missing mechanism that, once an entity's
initial authored placement is acknowledged, adopts that placement exactly
once, applies the retail Create tail, replays deferred missing-parent work,
and drains the admission checkpoint's mixed continuation FIFO in exact
arrival order with retail route decisions made at execution time. The
residence system built by `38fd4b8d` (residence/FIFO) and `30012361`
(admission) is now COMPLETE as a mechanism: an entity can enter the world
through it and every packet accepted while its placement was pending is
applied exactly once, in order, with retail semantics.
This checkpoint deliberately does NOT cut the graphical or headless
production routes over — `RuntimeLiveEntitySessionController.cs` (headless)
and App's `LiveEntityRuntime` still call legacy `RegisterEntity`, and no host
calls `Execute`. It does not begin AP-22 or AD-10 and does not retire
AP-1/AD-1. The executor is exercised by deterministic Runtime tests only, so
no connected visual gate was required.
This file supersedes the executor-boundary portions of
[`2026-08-01-runtime-initial-placement-admission-handoff.md`](2026-08-01-runtime-initial-placement-admission-handoff.md).
## Exact workspace and Git state
- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream`
- Branch: `codex/port-claude-agents`
- Behavior checkpoint: `5db3de3c7ab2c6350d11af7f34b852464fc1e0f9`
- Documentation checkpoint: the commit containing this file
- The same eight unrelated dirty paths as the admission handoff remain
intentionally unstaged; never stage by blanket.
- No push or merge is part of this checkpoint.
## What the executor owns
`RuntimeInitialCreateContinuationExecutor` (constructed inside
`RuntimeEntityObjectLifetime` beside the residence state; internal
`InitialCreateExecution`; generation bound through `BindEventContext`) owns,
per exact `RuntimeEntityKey` + lease:
- the synchronous, retry-idempotent `Execute` transaction:
`Complete` -> `AdoptCompletedPlacement` (consumes the acknowledged initial
placement exactly once, resolving the `HasRetainedCompletion` deadlock so
later authored placements for the key can begin, with `PlacementAdopted`
keeping the completed entry current) -> AfterEnterWorld hook request
(local player, exactly once) -> deferred replay -> strict-sequence FIFO
drain -> `ConsumeExecuted` release (adoption-revision-checked; `Revised`
re-drains only the tail);
- per-continuation applies through gate-less instance seams on
`InboundPhysicsStateController` (`ApplyAccepted*Snapshot`) that read and
write the ONE snapshot store — the legacy fused paths are re-expressed as
gate + the same shared merge bodies, so there is no drift and no second
canonical snapshot;
- execution-time Position routing via
`RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`
with live inputs: the retained wire packet's own `IsGrounded` bit as the
server-asserted contact (never the local body), the data-driven
`(MotionTableId ?? Physics?.MotionTableId)` animation proxy (AP-130), live
distance/`UsePositionFromServer`, and the record's committed cell — driving
authored placements for SetPosition routes through the canonical
`RuntimeSetPositionState` Begin/Watch/resume lifecycle with a retryable
`AwaitingContinuationPlacement` yield (a contention flavor with no pending
token means "retry Execute later");
- atomic `SameIncarnationCreate` envelopes: per-stage index idempotency,
buffered publication flushed in stage order after the final stage (AD-59),
the object-table apply via the accepted-spawn seam (result observed; a
nested replacement abandons), and the three-branch resident-cell
disposition (TS-63);
- missing-parent replay, both flavors, keyed by parent GUID exactly as
retail's `QueueBlobForObject`/`ProcessObjectNetBlobs`: raw child Creates
AND queued accepted parent relations, drained in the initial tail with
whole-bucket atomic detach, per-entry exception containment
(`ReplayFailureCount`/`LastReplayFailure`), typed outcomes
(Registered/ReDeferred/Rejected; ParentApplied/DeferredAwaitingParent/
DiscardedStaleParent), and cancellation-aware restore windows whose tokens
record every cancellation fired while a batch is detached (ABA-safe;
a cleared token restores nothing);
- the field-masked executor baseline: each apply re-syncs ONLY the tracked
fields its own mutations moved, before publication, so external mutations
are detected in every quiet window and publish-callback;
- one shared abandonment routine on every non-retryable exit: forgets any
pending continuation placement (cancellation published), retires the
residence through the lifetime choke point, discards progress, returns a
typed status — the combined ownership ledger (residences, executor
progress, deferred buckets, replay windows, placement watches) converges,
and residence retirement notifies the executor
(`BindRetirementNotification`);
- an ordered immutable execution receipt/trace carrying every fact a cutover
host needs: per-action kind/sequence/stage, Position route facts
(disposition, constrain phase, teleport-hook phase, stop-interpolation,
zero-velocity, preserve-heading, send-position-immediately, unparent),
replay outcomes, and resident-cell dispositions.
## Retail anchors proven this slice
Beyond the admission handoff's eight anchor functions:
- retail's local-ordinary interpolate gate is
`UsePositionFromServer && wire-contact``PositionPack` bit 0x4 →
`has_contact` (pseudo-C 284654) → `UnpackPositionEvent` arg5 (93092) →
the gate at 93044. The earlier research note's "isForce" reading was a
misnomer disproven during review; the shipped classifier was correct.
- `ProcessObjectNetBlobs` detaches the whole per-GUID bucket before
dispatching (93617 → 93649) — mirrored by the detach/restore windows.
- missing-parent relations are QUEUED by parent GUID (standalone parent
handler 0x004535D0: lookup 92312, queue 92326; `QueueBlobForObject`
0x005092D0's GUID-keyed placeholder bucket 271082-271088) — never
discarded; the round-4 discard was overturned on this evidence.
- `HandleReceivedPosition`'s `HasAnims` gate (92992) is animation-queue
presence (`CSequence::has_anims` = non-empty list), anchoring AP-130.
- the same-incarnation tail order and resident-cell cleanup
(93865..93943) are mirrored stage-for-stage, with the claimedCell==0
destruction branch proven structurally unreachable for admitted envelopes
(every envelope carries a WeenieDescription by shape).
## Divergence register
Rows filed in the behavior commit: **AD-59** (envelope buffered live-record
events), **AD-60** (executor canonical cell semantics — wire positions never
directly commit residency), **AP-130** (HasAnims MotionTableId proxy),
**AP-131** (legacy Position merge's unconditional placement-frame/parent-
clear flags — retired by construction at cutover), **AP-132** (parent
incarnation gating vs retail's pointer-only GUID replay), **TS-62** (no live
ConstrainTo binding in the dormant slice — trace-only), **TS-63**
(resident-cell abandonment/delegation split). AP-1 and AD-1 remain open
until the cutover. Issue **#275** tracks the post-cutover unification of the
legacy Position path onto the classifier.
## Validation
- Focused executor/residence/classifier gate: **161/161**.
- Complete Runtime project: **903/903** (829 baseline + 74 slice tests).
- Complete Release solution: **10,696 passed / 4 intentional skips / 0
failed** (`-m:1`, installed `acdream.pak`); Release build 0 errors,
21 pre-existing test-project warnings.
- `git diff --check` clean; the eight unrelated dirty paths untouched.
- Independent reviews (both read-only, both required to PASS): the
retail-conformance reviewer and the architecture/adversarial reviewer each
ran four passes across five implementation rounds. Finding classes fixed
at root cause along the way: wire-vs-body contact source; two-store
snapshot divergence; WeenieDescription wholesale-overwrite; non-converging
abandonment; reentrant mid-drain residence retirement; the
acknowledged-completion leak that would have blocked all future placements
for a key; per-field baseline blessing; replay exception containment and
detached-batch resurrection; and the stale-parent discard overturned in
favor of retail's queue-by-parent-GUID replay. Final verdicts: RETAIL
REVIEW: PASS; ARCHITECTURE REVIEW: PASS (three residual NOTEs, all
defense-in-depth observations, none blocking).
## Production routes intentionally unchanged
Graphical Create still flows through `LiveEntityRuntime.RegisterEntity`;
headless still uses `RuntimeLiveEntitySessionController`'s legacy
`RegisterEntity`; no production code calls
`RegisterEntityWithInitialResidence` or `Execute`. The residence+executor
system is a complete, reviewed, dormant mechanism awaiting the cutover.
## Next implementation boundary — the production cutover
Route graphical AND headless registration through the residence+executor
owner together, then every Create, Position, ForcePosition, Parent, Pickup,
withdrawal, delete, remote-movement, projectile-correction, and dropped-item
edge through the same transaction. Hosts project immutable Runtime results
only; they may not resolve a second placement or create another body. Delete
the legacy duplicate paths only after parity tests pass (this retires AP-131
and closes #275 by construction). Run the exact lifecycle/reconnect and
canonical nine-stop connected routes, two-client observation, and the user
visual matrix. Only then retire AP-1 and AD-1.
Cutover-specific notes from this slice:
- The execution receipt carries every route fact a host must bind — the
constrain phases and stop-interpolation/zero-velocity flags (TS-62), the
teleport-hook phases, and the send-position-immediately echo.
- `AwaitingContinuationPlacement` has two flavors: pending token (host must
prepare/submit/acknowledge the placement, then retry Execute) and
contention (no pending token; retry Execute after the conflicting
operation resolves).
- The dormant placement path's 1,880-bytes/operation allocation budget
(2,048 cap) remains the standing 4B2 activation blocker for
frame-frequency traffic; resolve or budget it before the cutover routes
high-frequency Position traffic through the owner.
After the cutover: **AP-22** (authored collision shapes;
`ShadowShapeBuilder` sole authority), then **AD-10** (remote contact-plane
projection), then the final automated + connected matrix and ledger
synchronization close the campaign; vendor Slice 5 resumes after.
## Rollback
```powershell
git revert 5db3de3c7ab2c6350d11af7f34b852464fc1e0f9
```
The documentation checkpoint containing this file is separate and may be
reverted independently. Do not revert the `38fd4b8d`/`30012361` foundation
beneath it without a separately proven defect.
## Resume checklist
1. Continue in the exact worktree/branch above; confirm `git log` contains
`5db3de3c` and the documentation commit containing this file.
2. Preserve the eight unrelated dirty paths; never `git add -A`.
3. Read this file, the admission handoff, and
`docs/architecture/acdream-architecture.md`.
4. Re-run the focused gate before modifying execution code:
the Residence + Classifier + Executor filter must report 161/161.
5. Begin ONLY the production cutover checkpoint. Do not fold AP-22, AD-10,
or vendor work into it.

View file

@ -0,0 +1,231 @@
# CPhysicsObj::set_description @ 0x00514F40 — three FPU-elided gates recovered
## Verification chain
1. **Binary/PDB pairing**: `py tools/pdb-extract/check_exe_pdb.py "C:/Users/erikn/Downloads/acclient.exe"`
`=== MATCH: this exe pairs with our acclient.pdb ===` (GUID
`9e847e2f-777c-4bd9-886c-22256bb87f32`, linker timestamp
2013-09-06T00:17:56Z). Confirmed before any byte reads.
2. **PE section mapping** (hand-parsed via a one-off script,
`scratchpad/pe_read.py`): image base `0x00400000`;
`.text` VA=0x00401000 RawPtr=0x00001000;
`.rdata` VA=0x00792000 RawPtr=0x00392000 (holds the FP constants below).
VA→file-offset: `file_off = raw_ptr + (VA - image_base - section_virt_addr)`.
3. Raw bytes of the function (`0x00514F40``0x00515153`) were dumped and
hand-disassembled instruction-by-instruction, cross-checked line-by-line
against `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines
283130283251 (function body) so every address in the trace lines up
with a named pseudo-C statement.
4. **Ghidra MCP**: not available this session — no CodeBrowser open on
port 8080/8081 (both probes returned empty). Not needed; binary + ACE
agreement below is already two independent confirmations.
5. **ACE cross-check**: `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs`
(main checkout, not the af5e worktree — ACE isn't vendored there),
`set_description`, lines 35573568. ACE's C# independently reproduces
all three predicates exactly as decoded from the binary below. Binary
is the ground truth per project policy; ACE here is 100% consistent
with it, so no conflict to adjudicate.
Confidence: **byte-certain** for all three. Every constant was read
directly from `.rdata`, every comparison/jump opcode was decoded from
the raw instruction stream, and the result matches ACE's independent
port line-for-line.
---
## Conditional 1 — friction OUTER gate (pseudo-C line 283219, VA 0x0051505a)
### Bytes
```
0051504f: d9 46 68 FLD DWORD PTR [ESI+0x68] ; ST(0) = (double)esi->friction (PhysicsDesc.friction @ +0x68)
00515052: dc 15 10 46 79 00 FCOM QWORD PTR [0x00794610] ; compare ST(0) vs constant, no pop (value reused below)
00515058: df e0 FNSTSW AX
0051505a: f6 c4 05 TEST AH, 0x05 ; mask = C0(bit0) | C2(bit2)
0051505d: 7b 15 JNP 0x00515074 ; jump (skip friction block) iff PF=0
```
### Constant
VA `0x00794610` (.rdata, file offset `0x00394610`), 8 bytes:
`00 00 00 00 00 00 00 00`**`0.0` (double, exact)**.
### Decoding the jump
`TEST AH,0x05` ANDs AH with the C0|C2 status bits, then the parity flag
(PF) reflects the parity of that AND result. Case table for
`FCOM esi->friction, 0.0` (ST0=friction):
| relation | C0 | C2 | C3 | AH&0x05 | popcount | PF |
|---|---|---|---|---|---|---|
| friction > 0.0 | 0 | 0 | 0 | 0x00 | 0 | 1 |
| friction < 0.0 | 1 | 0 | 0 | 0x01 | 1 | **0** |
| friction == 0.0 | 0 | 0 | 1 | 0x00 | 0 | 1 |
| unordered (NaN) | 1 | 1 | 1 | 0x05 | 2 | 1 |
`JNP` (jump on PF=0) only fires for the strict `<` case. So the jump
(which SKIPS the whole friction reassignment block, landing at the
shared cleanup at `0x00515074`) is taken **only when `friction < 0.0`**;
every other case (`>= 0.0`, and — as an accepted compiler-quirk
edge case irrelevant to real game data — unordered/NaN) falls through
into the block.
### Recovered predicate
```c
// outer gate: proceed to the friction-assignment logic only when friction is non-negative
if (esi->friction >= 0.0f) {
// ... inner compare (Conditional 2) ...
}
```
---
## Conditional 2 — friction INNER compare (pseudo-C line 283226, VA 0x0051506a)
### Bytes
```
0051505f: dc 15 c0 28 79 00 FCOM QWORD PTR [0x007928c0] ; compare ST(0)=friction vs constant, no pop
00515065: df e0 FNSTSW AX
00515067: f6 c4 41 TEST AH, 0x41 ; mask = C0(bit0) | C3(bit6)
0051506a: 74 08 JZ 0x00515074 ; jump (skip assignment) iff (AH&0x41)==0
0051506c: d9 9f bc 00 00 00 FSTP DWORD PTR [EDI+0xbc] ; this->friction = friction (field @ +0xbc), pops ST(0)
```
Pseudo-C had already fully rendered the C0/C2/C3 synthetic-byte
construction for this one (only the final `test ah,0x41`→bool
collapse was marked unimplemented), so the byte read is a
confirmation rather than a fresh recovery.
### Constant
VA `0x007928c0` (.rdata, file offset `0x003928c0`), 8 bytes:
`00 00 00 00 00 00 f0 3f`**`1.0` (double, exact; IEEE-754 bit
pattern `0x3FF0000000000000`)**.
### Decoding the jump
Mask `0x41` = C0(below) | C3(equal). `JZ` (jump when the TEST result
is zero, i.e. neither bit set) skips the assignment when friction is
strictly `>` 1.0. Falls through (assigns `this->friction`) when
`friction <= 1.0` (below-or-equal family, exactly as flagged in the
task). This is the canonical `jbe` idiom.
### Recovered predicate
```c
// inner compare: only assign if friction also passes the upper bound
if (esi->friction <= 1.0f)
this->friction = esi->friction;
```
### Combined (conditionals 1+2)
```c
if (esi->friction >= 0.0f && esi->friction <= 1.0f)
this->friction = esi->friction;
```
This is byte-for-byte what ACE's port does at
`PhysicsObj.cs:3557-3558`: `if (desc.Friction >= 0.0f && desc.Friction <= 1.0f) Friction = desc.Friction;`
---
## Conditional 3 — translucency gate (pseudo-C line 283240, VA 0x0051509f)
### Bytes
```
0051508b: d9 44 24 24 FLD DWORD PTR [ESP+0x24] ; ST(0) = (float)translucency (local copy of esi->translucency, field @ esi+0x70)
0051508f: d8 1d 80 6a 7c 00 FCOMP DWORD PTR [0x007c6a80] ; compare ST(0) vs constant, WITH pop (single precision, reg field=3)
00515095: 8b d1 MOV EDX, ECX
00515097: 89 97 b8 00 00 00 MOV [EDI+0xb8], EDX ; this->translucencyOriginal = translucency (unconditional)
0051509d: df e0 FNSTSW AX
0051509f: f6 c4 44 TEST AH, 0x44 ; mask = C2(bit2) | C3(bit6)
005150a2: 7b 15 JNP 0x005150b9 ; jump (skip live-translucency apply) iff PF=0
005150a4: ... ; fallthrough: this->translucency = translucency; PartArray propagation
```
### Constant
VA `0x007c6a80` (.rdata, file offset `0x003c6a80`), 4 bytes:
`00 00 00 00`**`0.0f` (single-precision float, exact)**. Note this
compare is single-precision (`d8`/`FCOMP m32`), unlike the two
friction compares above which are double-precision (`dc`/`FCOM m64`) —
matches the pseudo-C's `((long double)0f)` literal notation (the `f`
suffix is BN flagging a float-typed constant) versus `((long
double)0.0)` for the friction case.
### Decoding the jump
Case table for `FCOMP translucency, 0.0f` (ST0=translucency), mask
`0x44` = C2(unordered) | C3(equal):
| relation | C0 | C2 | C3 | AH&0x44 | popcount | PF |
|---|---|---|---|---|---|---|
| translucency > 0.0 | 0 | 0 | 0 | 0x00 | 0 | 1 |
| translucency < 0.0 | 1 | 0 | 0 | 0x00 | 0 | 1 |
| translucency == 0.0 | 0 | 0 | 1 | 0x40 | 1 | **0** |
| unordered (NaN) | 1 | 1 | 1 | 0x44 | 2 | 1 |
`JNP` (PF=0) fires **only** for the exact-equal-to-zero case. So the
jump — which skips applying live `translucency`/PartArray propagation,
leaving only the unconditional `translucencyOriginal` write — is taken
**only when `translucency == 0.0f`**. Every other case (`>0`, `<0`,
and unordered/NaN as a compiler-quirk edge case) falls through and
applies.
### Recovered predicate
```c
// translucencyOriginal is ALWAYS written (this happens before the gate, unconditionally)
this->translucencyOriginal = translucency;
// live translucency + PartArray propagation only when translucency is non-zero
if (translucency != 0.0f)
{
this->translucency = translucency;
if (this->part_array != 0)
CPartArray::SetTranslucencyInternal(this->part_array, translucency);
}
```
Matches ACE's port at `PhysicsObj.cs:3562-3568` exactly:
```csharp
TranslucencyOriginal = desc.Translucency;
if (desc.Translucency != 0.0f)
{
Translucency = desc.Translucency;
if (PartArray != null)
PartArray.SetTranslucencyInternal(desc.Translucency);
}
```
---
## Summary table
| # | Gate | Predicate (apply-when) | Constant | Cert. |
|---|---|---|---|---|
| 1 | friction outer | `friction >= 0.0f` | `0.0` (double) @ VA 0x00794610 | byte-certain |
| 2 | friction inner | `friction <= 1.0f` | `1.0` (double) @ VA 0x007928c0 | byte-certain |
| 3 | translucency | `translucency != 0.0f` | `0.0f` (float) @ VA 0x007c6a80 | byte-certain |
All three: no unresolved cases. The only caveat on all three is a
decompiler/compiler-codegen edge case around NaN (unordered operands
fall into the "true"/apply bucket rather than IEEE-strict "always
false"), which is a documented quirk of this exact MSVC x87 codegen
pattern and not something the retail struct's `float` fields would
ever hit in practice (friction/translucency are authored data, never
NaN).
## Port note (C3b, `RuntimeRemoteBodyDescription.cs`)
acdream's friction port uses `f >= 0.0f && f <= 1.0f`, which deliberately
SKIPS NaN rather than reproducing the unordered-goes-to-apply codegen quirk
tabled above — the sanctioned modern-boundary deviation this doc
pre-declared. Elasticity's setter port (`!(e >= 0f)` first arm) and
translucency's `!= 0.0f` gate both route NaN exactly as the binary does
(0f and apply respectively); ACE's `set_elasticity` sends NaN to 0.1f and
is divergent from the binary on that edge.

View file

@ -1,5 +1,73 @@
# Collision System Port - Status and Plan # Collision System Port - Status and Plan
## 2026-08-03 production cutover stabilization checkpoint
Production initial Create placement is cut over through the Runtime residence
and continuation-executor path. The O(changed) collision-publication
checkpoint is live, followed by five root-cause fixes:
- `01f4791e`: pending-only live projections no longer create/replay a second
origin-recenter retirement receipt;
- `670f307c`: remote Create placement, local-player hosting, targeting, chase,
and attack share the same world-coordinate frame;
- `1fc529cd`: distant Use materializes the canonical minimal static host before
MoveTo;
- `f24532ad`: effects, projectiles, and static animation bind after canonical
placement and follow the committed cell;
- `175ad6b0`: LoginComplete waits for the local first-placement terminal edge.
The user accepted those connected behaviors. The campaign remains open for
six fixture reconciliations, C4 placement routes 27, #276/#277, destination
prefetch #280, C5's final-binary suite/soak/two-client matrix, AP-22, and
AD-10. Canonical continuation prompt:
`docs/research/2026-08-02-collision-throughput-handoff/NEXT-AGENT-PROMPT.md`.
**Do not use the 2026-04-29 whole-world staging-root description below as a
production blueprint.** It is retained as historical context. O1/O2/O3 at
`71604331` replaced that clone/journal/rebase design with the per-landblock
O(changed) publication mechanism; `01f4791e` then repaired its recenter
receipt lifetime.
## 2026-08-01 initial Create residence checkpoint
> Historical checkpoint; superseded by the 2026-08-03 production cutover
> status above.
The dormant Runtime SetPosition path is now complete through activation,
graphical/no-window placement receipts, collision-prefix quiescence, exact
authoritative route classification, pre-placement App staging, and initial
Create residence. Commit `38fd4b8d` owns the accepted initial Create frame,
cell-less pending lifetime, fresher Position FIFO, exact completion/adoption
revision, and reentrant teardown. Its retail and continuation handoff is:
- `docs/research/2026-08-01-runtime-initial-create-residence-handoff.md`
Production Create registration has not yet been cut over. Next, implement the
synchronous Runtime continuation executor and exact Create tail order
(visual, Parent/Position/Pickup, Movement, State, Vector, WeenieDesc, cleanup),
then cut graphical and no-window hosts over together. AP-1/AD-1 remain open
until that cutover. AP-22 and AD-10 follow as separate behavior slices.
## 2026-07-31 placement checkpoint 2
Runtime now owns the retail collision-report state required by canonical
SetPosition: exact-incarnation object-contact tables, the environment latch,
ordinary/ethereal expiry, force-end ordering, asymmetric object/environment
report eligibility, missile-state clearing, reentrant ordered reports, and the
report-result boolean which distinguishes failed-placement `Collided` from
`NoValidPosition`. Successful dormant SetPosition commits reporting after
contact/ground state and before its one response plus shadow reflood. The
named-retail oracle and next-agent instructions are in:
- `docs/research/2026-07-31-runtime-set-position-collision-reporting.md`
- `docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md`
This checkpoint deliberately does not activate production placement. Next,
4B2 must prepare exact authored movers and land one atomic Runtime body/local-
controller transaction, followed by presentation-only host projection,
collision-prefix quiescence, and an all-route cutover. AP-1 and AD-1 remain
open until that cutover; AP-22 and AD-10 remain later campaign slices.
## Current State (2026-04-29) ## Current State (2026-04-29)
The collision system is no longer a pure placeholder and should not be treated The collision system is no longer a pure placeholder and should not be treated
@ -10,6 +78,45 @@ as "delete everything and start over." A partial retail transition port exists:
- `TransitionTypes` carries the active `SpherePath`, `CollisionInfo`, - `TransitionTypes` carries the active `SpherePath`, `CollisionInfo`,
transition, step, contact, and partial slide logic. transition, step, contact, and partial slide logic.
- `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data. - `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data.
- Landblock streaming never edits the live collision world incrementally.
`PreparedLandblockCollisionGeneration` owns an off-side cache, CellGraph,
engine snapshot, buildings, statics, and versioned retained-owner refloods.
Retained means every non-suspended dynamic or adjacent-root static touching
(or withdrawn from) the target prefix; only authored target-root statics are
superseded. Stable per-prefix owner slots bound capture independently of
unrelated movement. One Runtime-scoped versioned journal records a live
mutation once, coalesces repeated changes by owner, and lets every draft
reconcile only that owner's latest exact state one owner per seal step.
Relevant owners subscribe only after discovery, preserving exact continuous
updates without the former preparations-by-mutations global fanout or a quiet
frame. A changed prefix routes first target entry/departure to the one
matching draft after its journal cursor has passed. During topology build,
visited unrelated owners receive only coalesced dirty notifications; one
later seal unit performs the exact mirror. After the topology seal exists,
observed owners temporarily write through exactly until same-call activation,
guaranteeing that the finite dirty queue drains under multi-owner motion.
Old slots are superseded at the tail rather than reused behind
live cursors, new drafts start at their captured suffix, and obsolete slots
plus empty prefix containers are reclaimed under churn.
Admission captures the active aggregate
root reference in O(1), then stable landblock/owner suffixes materialize the
non-target cache, CellGraph, engine, and shadow leaves one work unit per host
step. All scans and replacement construction run
through bounded preparation/capture/seal cursors. Cache, CellGraph, engine, and shadow
topology share one complete off-side `CollisionWorldState`; final activation
is a single zero-allocation volatile root transfer that preserves borrowed
facade identities and revokes staging. Deterministically ordered concurrent
preparations queue their deltas into later drafts only after commit; each
seal call rebases at most one cache/graph/landblock/owner leaf. Demotion or
withdrawal cancels matching queued/active rebases, suppresses stale source-
clone leaves, and retires one owner/cache/graph/outdoor leaf per seal step
from growable storage; commit rechecks pending retirement after sealing.
The host performs the zero-work root transfer in the same update-thread call
that completes final reconciliation, avoiding a quiet-frame requirement.
Cancellation tears down
only the named staging generation. The old generation remains queryable until
commit. See
`docs/research/2026-07-31-atomic-collision-generation.md`.
- `ShadowObjectRegistry` gives the resolver a broadphase over nearby objects. - `ShadowObjectRegistry` gives the resolver a broadphase over nearby objects.
- `TerrainSurface` uses triangle-aware terrain contact. - `TerrainSurface` uses triangle-aware terrain contact.

View file

@ -2,9 +2,10 @@
## Phase ## Phase
Active phase: **L.2 - Movement & Collision Conformance**. Active phase: **Campaign P — Physics Retail-Feel Parity** (the L.2/R6
foundation is shipped).
Plan: `docs/plans/2026-04-29-movement-collision-conformance.md`. Plan: `docs/plans/2026-07-29-physics-parity-campaign.md`.
Roadmap: `docs/plans/2026-04-11-roadmap.md`. Roadmap: `docs/plans/2026-04-11-roadmap.md`.
@ -78,3 +79,32 @@ InputDispatcher / PlayerMovementController
re-test the adjusted `CheckPos`), not returned to `ValidateTransition`; the re-test the adjusted `CheckPos`), not returned to `ValidateTransition`; the
outer validator treats non-OK as a collision and restores `CurPos`, making outer validator treats non-OK as a collision and restores `CurPos`, making
edges feel like hard stops even when the tangent was computed. edges feel like hard stops even when the tangent was computed.
- 2026-07-31: Campaign P #269 slope-stop parity. A 2,184-quantum live
capture proved the 5% landing reflect and `calc_friction` were correct,
but `ValidateTransition` restored `LastKnownContactPlane` without retail's
preceding `OBJECTINFO::kill_velocity`. Retail consumes that plane only on
a non-OK recovery and overwrites last-known validity from the final contact
plane. Both rules are now ported; focused/full tests and the user's repeated
slope-jump gate pass. See
`docs/research/2026-07-31-269-slope-stop-capture.md`.
- 2026-07-31: #271 stair-side uphill reversal. Never promote ACDream's
retained `LastWalkable` history into retail's current
`SPHEREPATH::walkable` slot inside `edge_slide`. When current walkable is
null, retail `CTransition::edge_slide @ 0x0050B3D0` must back-probe at the
current sphere center and either use that newly discovered polygon or
collide. The stale substitution selected a preceding stair tread, reversed
the uphill tangent, and dropped the player rapidly down the stairs. The
exact live frame is pinned in `Issue185OutdoorStairsSeamReplayTests`; Core
passes 4,108 / 2 skips and the complete Release solution passes 10,062 /
5 skips. The user accepted repeated uphill stair-side runs on 2026-07-31.
See
`docs/research/2026-07-31-271-stair-side-slide-capture.md`.
- 2026-07-31: #272 burden invalidation. Retail `InqLoad @ 0x0058F130`
obtains Strength through `InqAttribute @ 0x00591A00`, so carry capacity
always uses the enchantment-adjusted value. Runtime movement, the indicator
bar, and the inventory meter must all consume
`LocalPlayerState.GetEffectiveAttribute(Strength)` and react to the same
`Spellbook.EnchantmentsChanged` event. That one event covers spell add,
remove, expiration, dispel, and death purge. Never refresh this through a
UI-only workaround or wait for a base-attribute packet. The connected
buff/death gate was user-accepted on 2026-07-31.

View file

@ -585,7 +585,8 @@ internal sealed class FrameRootCompositionPhase
session.LocalPlayerFrame, session.LocalPlayerFrame,
session.LiveSpatialReconciler, session.LiveSpatialReconciler,
live.WorldAvailability, live.WorldAvailability,
live.RenderSceneShadow?.LiveProjections); live.RenderSceneShadow?.LiveProjections,
session.PlacementProjectionRetry);
var cameraFrame = new CameraFrameController( var cameraFrame = new CameraFrameController(
host.CameraController, host.CameraController,
d.InputCapture, d.InputCapture,

View file

@ -614,9 +614,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.Character.Spellbook, d.Character.Spellbook,
d.Inventory.Objects, d.Inventory.Objects,
() => d.PlayerIdentity.ServerGuid, () => d.PlayerIdentity.ServerGuid,
() => d.Character.LocalPlayer.GetAttribute( () => d.Character.LocalPlayer.GetEffectiveAttribute(
LocalPlayerState.AttributeKind.Strength) LocalPlayerState.AttributeKind.Strength),
is { } strength ? (int?)strength.Current : null,
() => late.Session.LinkStatus, () => late.Session.LinkStatus,
d.ClientTime, d.ClientTime,
() => late.Session.CurrentSession?.RequestLinkStatusPing(), () => late.Session.CurrentSession?.RequestLinkStatusPing(),
@ -656,9 +655,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
() => d.PlayerIdentity.ServerGuid, () => d.PlayerIdentity.ServerGuid,
iconComposer.GetIcon, iconComposer.GetIcon,
iconComposer.GetDragIcon, iconComposer.GetDragIcon,
() => d.Character.LocalPlayer.GetAttribute( () => d.Character.LocalPlayer.GetEffectiveAttribute(
LocalPlayerState.AttributeKind.Strength) LocalPlayerState.AttributeKind.Strength),
is { } strength ? (int?)strength.Current : null, d.Character.Spellbook,
guid => late.Session.CurrentSession?.SendUse(guid), guid => late.Session.CurrentSession?.SendUse(guid),
(item, container, placement) => (item, container, placement) =>
late.Session.CurrentSession?.SendPutItemInContainer( late.Session.CurrentSession?.SendPutItemInContainer(

View file

@ -96,6 +96,7 @@ internal sealed record LivePresentationResult(
GpuWorldState WorldState, GpuWorldState WorldState,
RenderSceneShadowRuntime? RenderSceneShadow, RenderSceneShadowRuntime? RenderSceneShadow,
LiveEntityRuntime LiveEntities, LiveEntityRuntime LiveEntities,
RuntimePlacementPresentationSink PlacementProjection,
ProjectileController ProjectileController, ProjectileController ProjectileController,
LiveEntityProjectionWithdrawalController ProjectionWithdrawal, LiveEntityProjectionWithdrawalController ProjectionWithdrawal,
LiveEntityLightController Lights, LiveEntityLightController Lights,
@ -292,6 +293,25 @@ internal sealed class LivePresentationCompositionPhase
d.WorldOrigin, d.WorldOrigin,
d.EffectPoses); d.EffectPoses);
var staticResidency = new LiveStaticAnimationResidency(d.RuntimeSlot); var staticResidency = new LiveStaticAnimationResidency(d.RuntimeSlot);
(LiveEntityAnimationState Animation, PhysicsBody Body)?
ResolveLiveStaticOwner(WorldEntity entity)
{
if (entity.ServerGuid == 0
|| liveEntities?.TryGetRecord(
entity.ServerGuid,
out LiveEntityRecord record) != true
|| !ReferenceEquals(record.WorldEntity, entity)
|| !record.IsSpatiallyProjected
|| !record.IsSpatiallyVisible
|| record.AnimationRuntime
is not LiveEntityAnimationState animation
|| record.PhysicsBody is not { } body)
{
return null;
}
return (animation, body);
}
var staticAnimationScheduler = var staticAnimationScheduler =
new RetailStaticAnimatingObjectScheduler( new RetailStaticAnimatingObjectScheduler(
content.AnimationLoader, content.AnimationLoader,
@ -299,7 +319,8 @@ internal sealed class LivePresentationCompositionPhase
d.EffectPoses.Publish, d.EffectPoses.Publish,
staticResidency.IsResident, staticResidency.IsResident,
(entity, body) => _ = staticRootCommitter.Commit(entity, body), (entity, body) => _ = staticRootCommitter.Commit(entity, body),
staticResidency.ProjectionVersion); staticResidency.ProjectionVersion,
ResolveLiveStaticOwner);
ScriptActivationInfo? ResolveActivation(WorldEntity entity) ScriptActivationInfo? ResolveActivation(WorldEntity entity)
{ {
@ -451,6 +472,44 @@ internal sealed class LivePresentationCompositionPhase
liveEntities, liveEntities,
particleVisibility, particleVisibility,
"particle projection visibility"); "particle projection visibility");
var placementVisibilitySinks = new List<
Action<LiveEntityRecord, bool>>(4)
{
wbVisibility,
};
if (liveRenderProjections is not null)
{
placementVisibilitySinks.Add(
liveRenderProjections.OnProjectionVisibilityChanged);
}
placementVisibilitySinks.Add(particleVisibility);
// Retail enters the CPhysicsObj before ProcessObjectNetBlobs.
// C3c's initial Runtime placement is the equivalent world-entry
// edge, so open/replay the one-shot F754/F755 barrier only after
// mesh poses and particle presentation have both been published.
placementVisibilitySinks.Add((record, visible) =>
{
if (visible)
entityEffects?.OnPresentationBound(record);
});
var placementProjection = new RuntimePlacementPresentationSink(
liveEntities,
worldTransit,
d.WorldGameState,
d.WorldEvents,
d.EffectPoses,
d.LocalPlayerShadow,
() => d.PlayerIdentity.ServerGuid,
guid =>
{
if (d.Selection.SelectedObjectId == guid)
{
d.Selection.Clear(
SelectionChangeSource.System,
SelectionChangeReason.SelectedObjectRemoved);
}
},
placementVisibilitySinks);
Fault(LivePresentationCompositionPoint.ProjectionVisibilityBound); Fault(LivePresentationCompositionPoint.ProjectionVisibilityBound);
var projectileController = new ProjectileController( var projectileController = new ProjectileController(
@ -619,6 +678,7 @@ internal sealed class LivePresentationCompositionPhase
renderSceneShadow, renderSceneShadow,
renderSceneShadowLease, renderSceneShadowLease,
liveEntities, liveEntities,
placementProjection,
projectileController, projectileController,
projectionWithdrawal, projectionWithdrawal,
lightsLease, lightsLease,
@ -668,6 +728,7 @@ internal sealed class LivePresentationCompositionPhase
CompositionAcquisitionScope.CompositionAcquisitionLease< CompositionAcquisitionScope.CompositionAcquisitionLease<
RenderSceneShadowRuntime>? renderSceneShadowLease, RenderSceneShadowRuntime>? renderSceneShadowLease,
LiveEntityRuntime liveEntities, LiveEntityRuntime liveEntities,
RuntimePlacementPresentationSink placementProjection,
ProjectileController projectileController, ProjectileController projectileController,
LiveEntityProjectionWithdrawalController projectionWithdrawal, LiveEntityProjectionWithdrawalController projectionWithdrawal,
CompositionAcquisitionScope.CompositionAcquisitionLease<LiveEntityLightController> lightsLease, CompositionAcquisitionScope.CompositionAcquisitionLease<LiveEntityLightController> lightsLease,
@ -1147,6 +1208,7 @@ internal sealed class LivePresentationCompositionPhase
worldState, worldState,
renderSceneShadow, renderSceneShadow,
liveEntities, liveEntities,
placementProjection,
projectileController, projectileController,
projectionWithdrawal, projectionWithdrawal,
lightsLease.Resource, lightsLease.Resource,

View file

@ -122,6 +122,7 @@ internal sealed record SessionPlayerResult(
PlayerModeAutoEntry PlayerModeAutoEntry, PlayerModeAutoEntry PlayerModeAutoEntry,
LocalPlayerTeleportController LocalTeleport, LocalPlayerTeleportController LocalTeleport,
LiveSessionHost SessionHost, LiveSessionHost SessionHost,
RuntimePlacementProjectionRetrySlot PlacementProjectionRetry,
CurrentGameRuntimeAdapter GameRuntime, CurrentGameRuntimeAdapter GameRuntime,
GameplayInputActionRouter? GameplayActions, GameplayInputActionRouter? GameplayActions,
SessionPlayerRuntimeBindings RuntimeBindings); SessionPlayerRuntimeBindings RuntimeBindings);
@ -499,6 +500,79 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerIdentity, d.PlayerIdentity,
dormantLiveEntities, dormantLiveEntities,
d.Options.DumpLiveSpawns ? d.Log : null); d.Options.DumpLiveSpawns ? d.Log : null);
// C3c: the graphical first-entry drive controller — walks every
// initial-Create residence through its Runtime conductor with the
// production prepared-collision source, the live movement-skill
// options, and the truthful local-player activation preparation
// (authored-cylinder radius/height with the legacy fallbacks, and
// the shadow disposition read from the exact shadow registry the
// activation commit validates against).
IPreparedCollisionSource firstEntryCollision =
content.PreparedAssets as IPreparedCollisionSource
?? throw new NotSupportedException(
"Production prepared assets must expose the matching "
+ "prepared-collision catalog.");
// C3c-R1 review F9: the provider runs on EVERY drive pump while the
// entity's conductor is still yielding, and the Setup cylinder per
// incarnation is immutable (SourceGfxObjOrSetupId and the spawn
// record's ObjScale are fixed at Create) — cache the resolved
// world-entity lookup + GetSetupCylinder per pending incarnation
// (keyed by the incarnation-unique local id; an unresolved/default
// result is NOT cached so late hydration still upgrades it). The
// shadow disposition deliberately stays live: shadow registration
// can land between pumps and the activation commit validates the
// disposition against the exact registry.
uint firstEntryCylinderLocalId = 0u;
float firstEntryCylinderRadius = 0f;
float firstEntryCylinderHeight = 0f;
var firstEntryDrive = new RuntimeFirstEntryDriveController(
d.EntityObjects,
d.Runtime.Clock,
firstEntryCollision,
() => PlayerMovementConstructionOptions.From(
d.Runtime.CharacterOwner.MovementSkills.Snapshot),
record =>
{
float radius = 0.48f;
float height = 1.835f;
uint localId = record.Key?.LocalEntityId ?? 0u;
if (localId != 0u && localId == firstEntryCylinderLocalId)
{
radius = firstEntryCylinderRadius;
height = firstEntryCylinderHeight;
}
else if (live.LiveEntities.TryGetWorldEntity(
record.ServerGuid,
out WorldEntity? playerEntity)
&& playerEntity is not null)
{
(float setupRadius, float setupHeight) =
d.MotionBindings.GetSetupCylinder(
record.ServerGuid,
playerEntity);
if (setupRadius >= 0.05f)
{
radius = setupRadius;
height = setupHeight;
if (localId != 0u)
{
firstEntryCylinderLocalId = localId;
firstEntryCylinderRadius = radius;
firstEntryCylinderHeight = height;
}
}
}
bool hasAuthoredShadow = record.Key is { } key
&& d.PhysicsEngine.ShadowObjects.HasLogicalOwner(
key.LocalEntityId);
return new RuntimeLocalPlayerPhysicsActivationPreparation(
radius,
height,
hasAuthoredShadow
? RuntimeLocalPlayerShadowDisposition
.RegisteredAuthoredPayload
: RuntimeLocalPlayerShadowDisposition.ProvenShapeless);
});
var hydration = new LiveEntityHydrationController( var hydration = new LiveEntityHydrationController(
live.LiveEntities, live.LiveEntities,
d.EntityObjects, d.EntityObjects,
@ -516,7 +590,8 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerIdentity, d.PlayerIdentity,
deletion, deletion,
dormantLiveEntities, dormantLiveEntities,
d.Options.DumpLiveSpawns ? d.Log : null); d.Options.DumpLiveSpawns ? d.Log : null,
firstEntryDrive);
bindings.Adopt( bindings.Adopt(
"landblock-loaded hydration", "landblock-loaded hydration",
live.LandblockLoaded.Bind(hydration)); live.LandblockLoaded.Bind(hydration));
@ -832,6 +907,9 @@ internal sealed class SessionPlayerCompositionPhase
AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? vitals = AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? vitals =
interaction.RetainedUi?.Vitals; interaction.RetainedUi?.Vitals;
var placementProjectionRetry =
new RuntimePlacementProjectionRetrySlot(
() => d.Runtime.Generation);
var sessionRuntimeFactory = new LiveSessionRuntimeFactory( var sessionRuntimeFactory = new LiveSessionRuntimeFactory(
new LiveSessionPlayerRuntime( new LiveSessionPlayerRuntime(
d.PlayerIdentity, d.PlayerIdentity,
@ -878,7 +956,10 @@ internal sealed class SessionPlayerCompositionPhase
content.AnimationHookFrames, content.AnimationHookFrames,
live.Presentation, live.Presentation,
d.RemoteMovementObservations, d.RemoteMovementObservations,
live.RenderSceneShadow), live.RenderSceneShadow,
live.PlacementProjection,
placementProjectionRetry,
firstEntryDrive),
liveSessionCommands, liveSessionCommands,
d.Log); d.Log);
LiveSessionHost sessionHost = sessionRuntimeFactory.Create( LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
@ -1015,6 +1096,7 @@ internal sealed class SessionPlayerCompositionPhase
playerModeAutoEntry, playerModeAutoEntry,
teleportLease.Resource, teleportLease.Resource,
sessionHost, sessionHost,
placementProjectionRetry,
gameRuntime, gameRuntime,
gameplayActionsLease?.Resource, gameplayActionsLease?.Resource,
bindings); bindings);

View file

@ -2,6 +2,7 @@ using System;
using AcDream.App.Net; using AcDream.App.Net;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Runtime.Physics;
namespace AcDream.App.Input; namespace AcDream.App.Input;
@ -83,7 +84,24 @@ internal sealed class LivePlayerModeAutoEntryContext
public bool IsPlayerEntityPresent => public bool IsPlayerEntityPresent =>
_liveEntities.ContainsWorldEntity(_identity.ServerGuid); _liveEntities.ContainsWorldEntity(_identity.ServerGuid);
public bool IsPlayerControllerReady => true; /// <summary>
/// C3c-F2: post-flip the movement controller is Runtime-owned, so this
/// precondition has to report the Runtime first-entry conductor's commit
/// — exactly what <c>PlayerModeController.TryEnter</c> requires. It was
/// the constant <c>true</c>, which was harmless only while entry itself
/// CONSTRUCTED the controller and therefore could not fail on it. After
/// the flip a not-yet-committed conductor made entry return false, and
/// because this guard is a one-shot that disarms before invoking (and
/// <see cref="EnterPlayerMode"/> completes the world reveal
/// unconditionally), a single early attempt permanently sealed the reveal
/// with the player never in world.
/// </summary>
public bool IsPlayerControllerReady =>
_playerMode.Controller is { IsRuntimePublished: true }
&& _liveEntities.TryGetRecord(
_identity.ServerGuid,
out LiveEntityRecord record)
&& record.PhysicsHost is EntityPhysicsHost;
public bool IsWorldReady => public bool IsWorldReady =>
_liveEntities.TryGetSnapshot( _liveEntities.TryGetSnapshot(

View file

@ -164,7 +164,9 @@ internal sealed class PlayerModeController :
try { RetireApproachLifetime(); } try { RetireApproachLifetime(); }
catch (Exception error) { failures.Add(error); } catch (Exception error) { failures.Add(error); }
_mode.IsPlayerMode = false; _mode.IsPlayerMode = false;
_controllerSlot.Controller = null; // C3c: the movement controller is Runtime-owned — player-mode exit
// detaches presentation only; the publication lifecycle (generation
// reset/teardown) owns the controller's retirement.
_hostSlot.Host = null; _hostSlot.Host = null;
_chase.Legacy = null; _chase.Legacy = null;
_chase.Retail = null; _chase.Retail = null;
@ -201,7 +203,9 @@ internal sealed class PlayerModeController :
try { RetireApproachLifetime(); } try { RetireApproachLifetime(); }
catch (Exception error) { failures.Add(error); } catch (Exception error) { failures.Add(error); }
_mode.ResetSession(); _mode.ResetSession();
_controllerSlot.Controller = null; // C3c: the Runtime generation reset retires the controller through
// RuntimeLocalPlayerMovementState.ResetSession; App detaches
// presentation only.
_hostSlot.Host = null; _hostSlot.Host = null;
_chase.Legacy = null; _chase.Legacy = null;
_chase.Retail = null; _chase.Retail = null;
@ -233,6 +237,19 @@ internal sealed class PlayerModeController :
return false; return false;
} }
// C3c: player mode attaches presentation to the Runtime-published
// controller/host; until the first-entry conductor commits them,
// entry simply retries on a later frame.
if (_controllerSlot.Controller is not { } publishedController
|| !publishedController.IsRuntimePublished
|| playerRecord.PhysicsHost is not EntityPhysicsHost)
{
Console.WriteLine(
$"live: {loggingTag} — Runtime first-entry controller for "
+ $"0x{playerGuid:X8} not committed yet");
return false;
}
BuildControllerAndCamera( BuildControllerAndCamera(
loggingTag, loggingTag,
playerGuid, playerGuid,
@ -247,6 +264,32 @@ internal sealed class PlayerModeController :
WorldEntity playerEntity, WorldEntity playerEntity,
LiveEntityRecord playerRecord) LiveEntityRecord playerRecord)
{ {
// C3c route-1 flip: the movement controller, physics body, host, and
// committed placement are Runtime-owned — constructed and activated
// by the first-entry conductor's publication chain before player
// mode can enter. This method attaches only App presentation
// (approach lifetime, animation bindings, camera, shadow, host
// slot). A failure here rolls back camera/shadow ONLY and never
// touches Runtime. C3c-R1 review F8: auto-entry does NOT retry a
// throw from this attach — PlayerModeAutoEntry.TryEnter disarms its
// one-shot BEFORE invoking EnterPlayerMode, so an exception here
// burns the shot; recovery is the manual Tab entry (or a session
// reset re-arming the trigger).
if (_controllerSlot.Controller is not { } controller
|| !controller.IsRuntimePublished)
{
throw new InvalidOperationException(
$"Player mode ({loggingTag}) requires the Runtime-published "
+ "local movement controller; the first-entry conductor has "
+ "not committed it yet.");
}
if (playerRecord.PhysicsHost is not EntityPhysicsHost playerHost)
{
throw new InvalidOperationException(
$"Player mode ({loggingTag}) requires the Runtime-committed "
+ "local physics host.");
}
IPlayerApproachCompletionSink approachLifetime = IPlayerApproachCompletionSink approachLifetime =
_approachCompletions.BeginControllerLifetime(); _approachCompletions.BeginControllerLifetime();
bool lifetimeCommitted = false; bool lifetimeCommitted = false;
@ -256,46 +299,11 @@ internal sealed class PlayerModeController :
LocalPlayerShadowState.Snapshot? priorShadow = _shadow.Capture(); LocalPlayerShadowState.Snapshot? priorShadow = _shadow.Capture();
try try
{ {
var controller = new PlayerMovementController( // Approach-completion presentation rides the Runtime-owned
_physics, // MoveToManager (created by the publication chain's own
playerRecord.ObjectClock, // MakeMoveToManager).
PlayerMovementConstructionOptions.From(_skills.Snapshot)); if (controller.MoveTo is { } moveTo)
controller.ApplyPhysicsState(playerRecord.FinalPhysicsState);
// Retail MovementManager::MakeMoveToManager @ 0x00524000 creates one
// MoveToManager facade over the local CPhysicsObj seams.
PlayerMovementController capturedController = controller;
EntityPhysicsHost playerHost = null!;
controller.Movement.MoveToFactory = () =>
{ {
var moveTo = new MoveToManager(
capturedController.Motion,
stopCompletely: () =>
capturedController.StopCompletelyAtPhysicsObjectBoundary(),
getPosition: () => new Position(
capturedController.CellId,
capturedController.Position,
capturedController.BodyOrientation),
getHeading: () => MoveToMath.HeadingFromYaw(capturedController.Yaw),
setHeading: (heading, _) => capturedController.Yaw =
MoveToMath.YawFromHeading(heading),
getOwnRadius: () => _motionBindings.GetSetupCylinder(
playerGuid,
playerEntity).Radius,
getOwnHeight: () => _motionBindings.GetSetupCylinder(
playerGuid,
playerEntity).Height,
contact: () => capturedController.BodyInContact,
isInterpolating: () => false,
getVelocity: () => capturedController.BodyVelocity,
getSelfId: () => playerGuid,
setTarget: (context, target, radius, quantum) =>
playerHost.SetTarget(context, target, radius, quantum),
clearTarget: playerHost.ClearTarget,
getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(),
setTargetQuantum: playerHost.TargetManager.SetTargetQuantum,
curTime: () => capturedController.SimTimeSeconds);
moveTo.MoveToComplete = error => moveTo.MoveToComplete = error =>
{ {
if (PhysicsDiagnostics.ProbeAutoWalkEnabled) if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
@ -307,75 +315,8 @@ internal sealed class PlayerModeController :
}; };
moveTo.MoveToCancelled = error => moveTo.MoveToCancelled = error =>
approachLifetime.PublishCancellation(error); approachLifetime.PublishCancellation(error);
moveTo.StickTo = (target, radius, height) =>
playerHost.PositionManager.StickTo(target, radius, height);
moveTo.Unstick = () => playerHost.PositionManager.UnStick();
return moveTo;
};
MovementManager exactMovement = controller.Movement;
var configuredHost = new EntityPhysicsHost(
playerGuid,
getPosition: () => new Position(
playerRecord.FullCellId,
playerRecord.WorldEntity?.Position ?? capturedController.Position,
capturedController.BodyOrientation),
getVelocity: () => capturedController.BodyVelocity,
getRadius: () => _motionBindings.GetSetupCylinder(
playerGuid,
playerEntity).Radius,
inContact: () => capturedController.BodyInContact,
minterpMaxSpeed: () => capturedController.Motion.GetAdjustedMaxSpeed(),
curTime: () => capturedController.SimTimeSeconds,
physicsTimerTime: () => capturedController.SimTimeSeconds,
getObjectA: _motionBindings.ResolvePhysicsHost,
handleUpdateTarget: info =>
{
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
Console.WriteLine(
$"[autowalk-target] object=0x{info.ObjectId:X8} "
+ $"status={info.Status} context={info.ContextId} "
+ $"target=({info.TargetPosition.Frame.Origin.X:F2},"
+ $"{info.TargetPosition.Frame.Origin.Y:F2},"
+ $"{info.TargetPosition.Frame.Origin.Z:F2})");
}
exactMovement.HandleUpdateTarget(info);
},
interruptCurrentMovement: () => exactMovement.CancelMoveTo(
WeenieError.ActionCancelled));
playerHost = EntityPhysicsHostComposition.SelectStableHostWithoutRebind(
_liveEntities,
playerRecord,
configuredHost);
exactMovement.MakeMoveToManager();
controller.Motion.UnstickFromObject = () =>
playerHost.PositionManager.UnStick();
controller.PositionManager = playerHost.PositionManager;
controller.Motion.InterruptCurrentMovement = () =>
{
if (PhysicsDiagnostics.ProbeAutoWalkEnabled
&& exactMovement.IsMovingTo())
{
Console.WriteLine("[autowalk-end] reason=interrupt");
}
exactMovement.CancelMoveTo(WeenieError.ActionCancelled);
};
if (RuntimeMovementSkillProjection.ApplyTo(
_skills,
controller))
{
Console.WriteLine(
$"live: {loggingTag} — applied server skills "
+ $"run={_skills.RunSkill} jump={_skills.JumpSkill}");
} }
ApplyStepHeights(controller, playerEntity, playerGuid);
uint initialCellId = ResolveInitialCell(playerGuid, playerEntity);
Action? drainPriorAnimationQueue = null;
if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation) if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation)
&& animation.Sequencer is { } sequencer) && animation.Sequencer is { } sequencer)
{ {
@ -392,51 +333,20 @@ internal sealed class PlayerModeController :
sequencer.Manager.CheckForCompletedMotions; sequencer.Manager.CheckForCompletedMotions;
controller.Motion.DefaultSink = controller.Motion.DefaultSink =
new MotionTableDispatchSink(sequencer); new MotionTableDispatchSink(sequencer);
drainPriorAnimationQueue = sequencer.Manager.HandleEnterWorld; sequencer.Manager.HandleEnterWorld();
// C3c constructs and grounds the Runtime-owned player before
// graphical PartArray presentation attaches. Ground entry can
// therefore enqueue CMotionInterp work while no animation sink
// exists; that work has no matching PartArray completion and
// would permanently sit ahead of later MoveTo/cast motions.
// First let the newly attached PartArray drain every matching
// animation above, then retire only the unmatched pre-attach
// interpreter suffix. In retail both owners exist together,
// so this suffix cannot arise; this is the split-lifetime
// reconciliation at their single attach boundary.
controller.Motion.HandleExitWorld();
} }
// Retail CPhysicsObj owns CMotionInterp and CPartArray throughout
// construction. Our split owners preserve that lifetime with a
// narrow preparation lease: SetPosition's synchronous type-5
// completion reaches this candidate MotionInterpreter, while the
// public controller slot remains unpublished until every other
// player-mode edge has prepared successfully.
using IDisposable motionPreparation =
_controllerSlot.BeginMotionPreparation(
controller,
drainPriorAnimationQueue);
ResolveResult initial = _physics.Resolve(
playerEntity.Position,
initialCellId,
Vector3.Zero,
100f);
var (placementRadius, placementHeight) =
_motionBindings.GetSetupCylinder(playerGuid, playerEntity);
if (placementRadius < 0.05f)
{
placementRadius = 0.48f;
placementHeight = 1.835f;
}
ResolveResult placement = _physics.ResolvePlacement(
initial.Position,
initial.CellId,
placementRadius,
placementHeight,
controller.StepUpHeight,
controller.StepDownHeight,
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
playerEntity.Id);
if (placement.Ok)
initial = placement;
controller.PreparePositionForCommit(
initial.Position,
initial.CellId,
CellLocalForSeed(initial.Position, initial.CellId));
controller.SetBodyOrientation(playerEntity.Rotation);
var legacyCamera = new ChaseCamera { Aspect = _viewport.Aspect }; var legacyCamera = new ChaseCamera { Aspect = _viewport.Aspect };
var retailCamera = new RetailChaseCamera var retailCamera = new RetailChaseCamera
{ {
@ -446,44 +356,15 @@ internal sealed class PlayerModeController :
cameraAttempted = true; cameraAttempted = true;
_camera.EnterChaseMode(legacyCamera, retailCamera); _camera.EnterChaseMode(legacyCamera, retailCamera);
EntityPhysicsHost stableAfterCamera =
EntityPhysicsHostComposition.SelectStableHostWithoutRebind(
_liveEntities,
playerRecord,
configuredHost);
if (!ReferenceEquals(stableAfterCamera, playerHost))
{
throw new InvalidOperationException(
"The local physics host changed during chase-camera activation.");
}
shadowAttempted = true; shadowAttempted = true;
_shadow.SyncPose( _shadow.SyncPose(
playerEntity, playerEntity,
initial.Position, controller.Position,
playerEntity.Rotation, playerEntity.Rotation,
initial.CellId, controller.CellId,
force: true); force: true);
// Publish the incarnation-stable CPhysicsObj delegates only after all _hostSlot.Host = playerHost;
// DAT, placement, shadow, and camera preparation has succeeded. A
// late preparation failure therefore cannot expose an abandoned
// controller through LiveEntityRecord.PhysicsHost.
EntityPhysicsHost publishedHost = EntityPhysicsHostComposition.InstallOrRebind(
_liveEntities,
playerRecord,
configuredHost);
if (!ReferenceEquals(publishedHost, playerHost))
{
throw new InvalidOperationException(
"The local physics host changed between preparation and commit.");
}
playerEntity.SetPosition(initial.Position);
playerEntity.ParentCellId = initial.CellId;
controller.CommitPreparedPosition();
_hostSlot.Host = publishedHost;
_controllerSlot.Controller = controller;
_chase.Legacy = legacyCamera; _chase.Legacy = legacyCamera;
_chase.Retail = retailCamera; _chase.Retail = retailCamera;
_mode.IsPlayerMode = true; _mode.IsPlayerMode = true;
@ -505,8 +386,16 @@ internal sealed class PlayerModeController :
catch (Exception cleanupError) { failures.Add(cleanupError); } catch (Exception cleanupError) { failures.Add(cleanupError); }
} }
// C3c: presentation-only rollback. The Runtime-published
// controller/body/host stay live — retail has no entry-flow
// rollback. C3c-R1 review F8: this rethrow is NOT retried by
// auto-entry — the one-shot trigger disarms before invoking
// (PlayerModeAutoEntry.TryEnter), and the throw also propagates
// out of the auto-entry context before its world-reveal
// Complete() call. After a failed attach the player re-enters
// via the manual Tab path (or a session reset re-arms the
// trigger).
_mode.IsPlayerMode = false; _mode.IsPlayerMode = false;
_controllerSlot.Controller = null;
_hostSlot.Host = null; _hostSlot.Host = null;
_chase.Legacy = null; _chase.Legacy = null;
_chase.Retail = null; _chase.Retail = null;
@ -532,92 +421,4 @@ internal sealed class PlayerModeController :
_approachCompletions.RetireControllerLifetime(lifetime); _approachCompletions.RetireControllerLifetime(lifetime);
} }
private void ApplyStepHeights(
PlayerMovementController controller,
WorldEntity playerEntity,
uint playerGuid)
{
if ((playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
DatReaderWriter.DBObjs.Setup? setup;
lock (_datLock)
setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(
playerEntity.SourceGfxObjOrSetupId);
if (setup is not null)
_collisionAssets.CacheSetup(
playerEntity.SourceGfxObjOrSetupId,
setup);
// TS-46 (2026-07-30): CPartArray::GetStepUpHeight/GetStepDownHeight
// (0x005180d0/0x005180f0) return setup->step_up_height * this->scale
// — apply the same ObjScale multiply the remote/ordinary paths now
// use (LiveEntityMotionRuntimeController.GetSetupMoverShape), for
// parity on a non-1.0-scale player (a rare but real case — e.g. a
// disguise/size-changing effect). Human ObjScale is 1.0 in the
// overwhelming common case, so this is a no-op there.
float scale =
_liveEntities.Snapshots.TryGetValue(playerGuid, out var sp)
&& sp.ObjScale is { } objScale && objScale > 0f
? objScale
: (playerEntity.Scale > 0f ? playerEntity.Scale : 1f);
controller.StepUpHeight = setup is { StepUpHeight: > 0f }
? setup.StepUpHeight * scale
: 0.4f;
controller.StepDownHeight = setup is { StepDownHeight: > 0f }
? setup.StepDownHeight * scale
: 0.4f;
// TS-46 (2026-07-30): the Setup's own ≤2-sphere list, verbatim —
// retail CPhysicsObj::transition (0x00512dc0) seeds the sweep
// from CPartArray::GetSphere, not a (radius, height) capsule
// reconstruction. Empty (no Setup, or a Setup with no sphere
// rows) leaves SphereList at its default empty value, which
// ResolveWithTransition treats as "use the legacy scalar
// reconstruction."
controller.SphereList = setup?.Spheres is { Count: > 0 } spheres
? spheres
.Select(s => new FlatCollisionSphere(s.Origin, s.Radius))
.ToImmutableArray()
: ImmutableArray<FlatCollisionSphere>.Empty;
Console.WriteLine(
$"physics: player step heights — StepUp={controller.StepUpHeight:F3} m "
+ $"(Setup.StepUpHeight={(setup?.StepUpHeight ?? 0f):F3}), "
+ $"StepDown={controller.StepDownHeight:F3} m "
+ $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3}), "
+ $"Spheres={controller.SphereList.Length}");
return;
}
controller.StepUpHeight = 0.4f;
controller.StepDownHeight = 0.4f;
controller.SphereList = ImmutableArray<FlatCollisionSphere>.Empty;
Console.WriteLine(
"physics: player step heights — defaulting to 0.4 m (no setup dat)");
}
private uint ResolveInitialCell(uint playerGuid, WorldEntity playerEntity)
{
if (_liveEntities.Snapshots.TryGetValue(playerGuid, out var spawn)
&& spawn.Position is { LandblockId: not 0u } position)
{
return position.LandblockId;
}
int landblockX = _origin.CenterX
+ (int)MathF.Floor(playerEntity.Position.X / 192f);
int landblockY = _origin.CenterY
+ (int)MathF.Floor(playerEntity.Position.Y / 192f);
return ((uint)landblockX << 24)
| ((uint)landblockY << 16)
| 0x0001u;
}
private Vector3 CellLocalForSeed(Vector3 worldPosition, uint cellId)
{
int landblockX = (int)((cellId >> 24) & 0xFFu);
int landblockY = (int)((cellId >> 16) & 0xFFu);
var origin = new Vector3(
(landblockX - _origin.CenterX) * 192f,
(landblockY - _origin.CenterY) * 192f,
0f);
return worldPosition - origin;
}
} }

View file

@ -0,0 +1,134 @@
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
namespace AcDream.App.Net;
/// <summary>
/// Owns the inbound route, canonical placement observer, and update-thread
/// retry lease for one exact graphical session generation.
/// </summary>
internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
{
private readonly ILiveSessionEventRouting _events;
private readonly Func<RuntimePlacementProjectionSubscription>
_createSubscription;
private readonly Func<RuntimeGenerationToken> _generation;
private readonly RuntimePlacementProjectionRetrySlot _retries;
private readonly RuntimeFirstEntryDriveController? _firstEntry;
private readonly Action<RuntimeEntityRecord>? _localPlayerCompleted;
private RuntimePlacementProjectionSubscription? _subscription;
private IDisposable? _retryLease;
private bool _attachStarted;
private bool _eventsDisposed;
private bool _disposed;
internal GraphicalSessionEventRoute(
ILiveSessionEventRouting events,
GameRuntime runtime,
IRuntimePlacementProjectionSink placements,
RuntimePlacementProjectionRetrySlot retries,
RuntimeFirstEntryDriveController? firstEntry = null,
Action<RuntimeEntityRecord>? localPlayerCompleted = null)
: this(
events,
() => new RuntimePlacementProjectionSubscription(
runtime,
placements,
retryPendingOnSubscribe: false),
() => runtime.Generation,
retries,
firstEntry,
localPlayerCompleted)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(placements);
}
internal GraphicalSessionEventRoute(
ILiveSessionEventRouting events,
Func<RuntimePlacementProjectionSubscription> createSubscription,
Func<RuntimeGenerationToken> generation,
RuntimePlacementProjectionRetrySlot retries,
RuntimeFirstEntryDriveController? firstEntry = null,
Action<RuntimeEntityRecord>? localPlayerCompleted = null)
{
_events = events ?? throw new ArgumentNullException(nameof(events));
_createSubscription = createSubscription
?? throw new ArgumentNullException(nameof(createSubscription));
_generation = generation
?? throw new ArgumentNullException(nameof(generation));
_retries = retries ?? throw new ArgumentNullException(nameof(retries));
_firstEntry = firstEntry;
_localPlayerCompleted = localPlayerCompleted;
}
public void Attach()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_attachStarted)
return;
_attachStarted = true;
// C3c-R1 review F6: assert (not assume) that the prior route
// detached — session reset precedes a new route — before this route
// takes ownership of the shared drive controller's tracked entries.
_firstEntry?.AttachRoute(this, _localPlayerCompleted);
_events.Attach();
RuntimePlacementProjectionSubscription? subscription = null;
IDisposable? retryLease = null;
try
{
subscription = _createSubscription();
RuntimePlacementProjectionSubscription boundSubscription =
subscription;
retryLease = _retries.BindOwned(
_generation(),
// C3c: drive pending first-entry sequences before
// republishing the pending FIFO head — a conductor's own
// Advance is what consumes conductor-owned receipts, and the
// subsequent RetryPending lets the presentation sink apply
// whatever new head the drive surfaced.
() =>
{
_firstEntry?.DriveAll();
return boundSubscription.RetryPending();
});
_subscription = subscription;
_retryLease = retryLease;
_ = subscription.RetryPending();
}
catch
{
retryLease?.Dispose();
subscription?.Dispose();
throw;
}
}
public void Dispose()
{
if (_disposed)
return;
// Unpublish the frame callback before detaching the observer. A frame
// can therefore never retry a retired generation or disposed route.
Interlocked.Exchange(ref _retryLease, null)?.Dispose();
Interlocked.Exchange(ref _subscription, null)?.Dispose();
// C3c: the drive controller's tracked entries die with this exact
// session route; Runtime's own retirement/session-clear fan-out owns
// conductor/residence convergence independently. C3c-R1 review F6:
// route-scoped — a route that never attached cannot clear a live
// route's entries.
_firstEntry?.DetachRoute(this);
if (!_eventsDisposed)
{
_events.Dispose();
_eventsDisposed = true;
}
_disposed = true;
}
}

View file

@ -0,0 +1,86 @@
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Net;
/// <summary>
/// C3c-F1 (2026-08-02): the App half of the movement-stats application
/// seam. Every server stat recompute (skills, burden, stamina, PK status —
/// the character-bindings <c>OnSkillsUpdated</c>/<c>OnMovementStatsUpdated</c>
/// callbacks) routes through
/// <see cref="RuntimeLocalPlayerMovementState.ApplyCharacterMovementStats"/>;
/// App holds no controller reference and performs no direct configuration
/// mutation. A recompute displaced past session teardown (the post-logout
/// inbound-Create ingest chain that crashed the connected lifecycle gate)
/// observes a typed dropped outcome and is logged under the existing
/// player diagnostics instead of faulting the session.
/// </summary>
/// <remarks>
/// Stuck-cast fix (2026-07-30): retail fires
/// <c>CPhysicsObj::report_exhaustion</c> from exactly ONE site —
/// <c>CommandInterpreter::HandleExhaustion</c> (0x006b3c70), a
/// notification-handler vtable slot invoked on the stamina-exhaustion
/// EVENT — not on every vitals refresh. The P1 wiring called
/// <c>ReportExhaustion()</c> on EVERY movement-stats application
/// (every stamina regen/drain tick), and each call re-dispatches the
/// current movement state through the animation sink — truncating any
/// in-flight action animation (cast gestures wedged mid-play; the
/// diagnostic session showed 490 spurious stance re-queues). The
/// re-apply fires only when the exhausted state (stamina == 0)
/// actually TRANSITIONS, matching retail's event semantics. Skill/
/// burden changes still reach <c>PlayerWeenie</c> immediately through
/// the owner seam — the next natural dispatch picks up the new rates,
/// exactly as retail. The edge is observed for dormant applications too
/// (the event fired; a player not yet in world has no movement to
/// re-dispatch, and activation starts movement from the already-current
/// stamina gate), but dispatched only on a live controller.
/// </remarks>
internal sealed class LiveMovementStatsApplier(
RuntimeLocalPlayerMovementState movement,
RuntimeMovementSkillState skills,
Action<string> log)
{
private readonly RuntimeLocalPlayerMovementState _movement = movement
?? throw new ArgumentNullException(nameof(movement));
private readonly RuntimeMovementSkillState _skills = skills
?? throw new ArgumentNullException(nameof(skills));
private readonly Action<string> _log = log
?? throw new ArgumentNullException(nameof(log));
private readonly StaminaExhaustionEdgeTracker _staminaExhaustion = new();
/// <summary>
/// Forgets the retiring character/session exhaustion baseline; the next
/// generation's first sample must not synthesize an edge.
/// </summary>
public void Reset() => _staminaExhaustion.Reset();
public RuntimeMovementStatsApplication Apply(string reason)
{
RuntimeMovementStatsApplication outcome =
_movement.ApplyCharacterMovementStats(_skills);
switch (outcome)
{
case RuntimeMovementStatsApplication.DroppedNoController:
case RuntimeMovementStatsApplication.DroppedIncompleteSnapshot:
// Byte-identical to the pre-F1 ApplyTo=false silent skip.
return outcome;
case RuntimeMovementStatsApplication.DroppedDisplacedController:
_log(
$"player: dropped displaced movement {reason} — the "
+ "Runtime movement controller is terminal");
return outcome;
}
RuntimeMovementSkillSnapshot snapshot = _skills.Snapshot;
if (_staminaExhaustion.Observe(snapshot.CurrentStamina)
&& outcome is RuntimeMovementStatsApplication.AppliedLive)
{
_movement.ReportExhaustion();
}
_log(
$"player: applied server movement {reason} "
+ $"run={snapshot.RunSkill} jump={snapshot.JumpSkill} "
+ $"burden={snapshot.Burden:F2} stamina={snapshot.CurrentStamina}");
return outcome;
}
}

View file

@ -18,6 +18,7 @@ using AcDream.Core.Chat;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Player; using AcDream.Core.Player;
using AcDream.Core.Social; using AcDream.Core.Social;
using AcDream.Core.Spells; using AcDream.Core.Spells;
@ -83,7 +84,10 @@ internal sealed record LiveSessionWorldRuntime(
AnimationHookFrameQueue AnimationHookFrames, AnimationHookFrameQueue AnimationHookFrames,
LiveEntityPresentationController Presentation, LiveEntityPresentationController Presentation,
RemoteMovementObservationTracker RemoteMovementObservations, RemoteMovementObservationTracker RemoteMovementObservations,
RenderSceneShadowRuntime? RenderSceneShadow); RenderSceneShadowRuntime? RenderSceneShadow,
RuntimePlacementPresentationSink PlacementProjection,
RuntimePlacementProjectionRetrySlot PlacementRetries,
RuntimeFirstEntryDriveController FirstEntryDrive);
/// <summary> /// <summary>
/// Builds the exact per-generation route/reset graph for the canonical live /// Builds the exact per-generation route/reset graph for the canonical live
@ -98,6 +102,7 @@ internal sealed class LiveSessionRuntimeFactory
private readonly LiveSessionWorldRuntime _world; private readonly LiveSessionWorldRuntime _world;
private readonly LiveSessionCommandSurface _commands; private readonly LiveSessionCommandSurface _commands;
private readonly Action<string> _log; private readonly Action<string> _log;
private readonly LiveMovementStatsApplier _movementStats;
public LiveSessionRuntimeFactory( public LiveSessionRuntimeFactory(
LiveSessionPlayerRuntime player, LiveSessionPlayerRuntime player,
@ -116,6 +121,12 @@ internal sealed class LiveSessionRuntimeFactory
_world = world ?? throw new ArgumentNullException(nameof(world)); _world = world ?? throw new ArgumentNullException(nameof(world));
_commands = commands ?? throw new ArgumentNullException(nameof(commands)); _commands = commands ?? throw new ArgumentNullException(nameof(commands));
_log = log ?? throw new ArgumentNullException(nameof(log)); _log = log ?? throw new ArgumentNullException(nameof(log));
// C3c-F1: stat recomputes route through the Runtime movement owner's
// typed application seam; App keeps zero direct controller mutations.
_movementStats = new LiveMovementStatsApplier(
_player.Controller,
_domain.Character.MovementSkills,
_log);
} }
public LiveSessionHost Create( public LiveSessionHost Create(
@ -189,6 +200,7 @@ internal sealed class LiveSessionRuntimeFactory
private void ResetPlayerPresentation() private void ResetPlayerPresentation()
{ {
_movementStats.Reset();
_interaction.PlayerMode.ResetSession(); _interaction.PlayerMode.ResetSession();
_world.SpawnClaims.Reset(); _world.SpawnClaims.Reset();
} }
@ -223,7 +235,7 @@ internal sealed class LiveSessionRuntimeFactory
_player.WorldOrigin.Reset(); _player.WorldOrigin.Reset();
} }
private LiveSessionEventRouter CreateEventRouter(WorldSession session) private ILiveSessionEventRouting CreateEventRouter(WorldSession session)
{ {
SkillTable? skillTable = _world.Dats.Get<SkillTable>(0x0E000004u); SkillTable? skillTable = _world.Dats.Get<SkillTable>(0x0E000004u);
if (_ui.CharacterSheet is not null) if (_ui.CharacterSheet is not null)
@ -233,7 +245,7 @@ internal sealed class LiveSessionRuntimeFactory
CharacterSheetProvider.LoadExperienceTable(_world.Dats, _log); CharacterSheetProvider.LoadExperienceTable(_world.Dats, _log);
} }
return new LiveSessionEventRouter( var route = new LiveSessionEventRouter(
session, session,
_world.EntitySession.CreateSink(), _world.EntitySession.CreateSink(),
new LiveEnvironmentSessionSink( new LiveEnvironmentSessionSink(
@ -246,6 +258,13 @@ internal sealed class LiveSessionRuntimeFactory
_domain.Communication.TurbineChat, _domain.Communication.TurbineChat,
_domain.Communication.Friends, _domain.Communication.Friends,
_domain.Communication.Squelch)); _domain.Communication.Squelch));
return new GraphicalSessionEventRoute(
route,
_domain.Runtime,
_world.PlacementProjection,
_world.PlacementRetries,
_world.FirstEntryDrive,
_ => session.SendGameAction(GameActionLoginComplete.Build()));
} }
private LiveInventorySessionBindings CreateInventoryBindings() => new( private LiveInventorySessionBindings CreateInventoryBindings() => new(
@ -275,72 +294,19 @@ internal sealed class LiveSessionRuntimeFactory
_domain.Actions.Combat, _domain.Actions.Combat,
_domain.Character, _domain.Character,
ResolveSkillFormulaBonus: skillCreditResolver.Resolve, ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) => ApplyMovementStats("skills"), OnSkillsUpdated: (runSkill, jumpSkill) =>
_movementStats.Apply("skills"),
OnConfirmationRequest: request => OnConfirmationRequest: request =>
_ui.RetailUi?.HandleConfirmationRequest(request), _ui.RetailUi?.HandleConfirmationRequest(request),
OnConfirmationDone: done => OnConfirmationDone: done =>
_ui.RetailUi?.HandleConfirmationDone(done), _ui.RetailUi?.HandleConfirmationDone(done),
ClientTime: ClientTimerNow, ClientTime: ClientTimerNow,
// Campaign P Slice P1 (2026-07-30): burden/stamina/vitae changes // Campaign P Slice P1 (2026-07-30): burden/stamina/vitae changes
// reactively re-apply to the live controller through the SAME // reactively re-apply through the SAME seam skills already used
// seam skills already used, then wire the previously-dead // (pseudocode doc §8/§9). C3c-F1: that seam is now the Runtime
// ReportExhaustion() R3-W4 seam so movement re-evaluates // movement owner's typed application entry — see
// immediately (pseudocode doc §8/§9). // LiveMovementStatsApplier.
OnMovementStatsUpdated: () => ApplyMovementStats("stats")); OnMovementStatsUpdated: () => _movementStats.Apply("stats"));
}
/// <summary>
/// Tracks the previous "stamina exhausted" state so
/// <see cref="ApplyMovementStats"/> can fire retail's exhaustion
/// notification on the EDGE only. Null = no stamina reading applied yet.
/// </summary>
private bool? _lastStaminaExhausted;
/// <summary>
/// Re-applies the current <see cref="RuntimeMovementSkillState"/>
/// snapshot (skills/burden/stamina) to the live player controller.
/// </summary>
/// <remarks>
/// Stuck-cast fix (2026-07-30): retail fires
/// <c>CPhysicsObj::report_exhaustion</c> from exactly ONE site —
/// <c>CommandInterpreter::HandleExhaustion</c> (0x006b3c70), a
/// notification-handler vtable slot invoked on the stamina-exhaustion
/// EVENT — not on every vitals refresh. The P1 wiring called
/// <c>ReportExhaustion()</c> on EVERY movement-stats application
/// (every stamina regen/drain tick), and each call re-dispatches the
/// current movement state through the animation sink — truncating any
/// in-flight action animation (cast gestures wedged mid-play; the
/// diagnostic session showed 490 spurious stance re-queues). The
/// re-apply now fires only when the exhausted state (stamina == 0)
/// actually TRANSITIONS, matching retail's event semantics. Skill/
/// burden changes still reach <see cref="PlayerWeenie"/> immediately
/// via <see cref="RuntimeMovementSkillProjection.ApplyTo"/> — the next
/// natural dispatch picks up the new rates, exactly as retail.
/// </remarks>
private void ApplyMovementStats(string reason)
{
PlayerMovementController? controller = _player.Controller.Controller;
if (!RuntimeMovementSkillProjection.ApplyTo(
_domain.Character.MovementSkills,
controller))
{
return;
}
RuntimeMovementSkillSnapshot snapshot = _domain.Character.MovementSkills.Snapshot;
bool exhausted = snapshot.CurrentStamina == 0;
if (_lastStaminaExhausted != exhausted)
{
bool isEdge = _lastStaminaExhausted is not null;
_lastStaminaExhausted = exhausted;
if (isEdge)
controller!.Motion.ReportExhaustion();
}
_log(
$"player: applied server movement {reason} "
+ $"run={snapshot.RunSkill} jump={snapshot.JumpSkill} "
+ $"burden={snapshot.Burden:F2} stamina={snapshot.CurrentStamina}");
} }
private LiveSessionCommandBindings CreateCommandBindings( private LiveSessionCommandBindings CreateCommandBindings(

View file

@ -0,0 +1,87 @@
using AcDream.Runtime;
namespace AcDream.App.Net;
internal interface IRuntimePlacementProjectionRetryPhase
{
void RetryPending();
}
/// <summary>
/// Publishes the retry callback owned by the exact active graphical session
/// route. The frame thread may only reach the binding whose Runtime
/// generation is still current; disposing an older lease cannot unbind a
/// replacement route.
/// </summary>
internal sealed class RuntimePlacementProjectionRetrySlot
: IRuntimePlacementProjectionRetryPhase
{
private sealed record Binding(
long Id,
RuntimeGenerationToken Generation,
Func<bool> Retry);
private readonly Func<RuntimeGenerationToken> _currentGeneration;
private Binding? _current;
private long _nextBindingId;
internal RuntimePlacementProjectionRetrySlot(
Func<RuntimeGenerationToken> currentGeneration)
{
_currentGeneration = currentGeneration
?? throw new ArgumentNullException(nameof(currentGeneration));
}
internal int BindingCount => _current is null ? 0 : 1;
internal IDisposable BindOwned(
RuntimeGenerationToken generation,
Func<bool> retry)
{
if (generation.Value == 0UL)
{
throw new ArgumentException(
"A placement retry route requires a live Runtime generation.",
nameof(generation));
}
ArgumentNullException.ThrowIfNull(retry);
if (_current is not null)
{
throw new InvalidOperationException(
"A graphical placement retry route is already bound.");
}
var binding = new Binding(
checked(++_nextBindingId),
generation,
retry);
_current = binding;
return new DelegateDisposable(() => Unbind(binding));
}
public void RetryPending()
{
Binding? binding = _current;
if (binding is null
|| binding.Generation != _currentGeneration())
{
return;
}
_ = binding.Retry();
}
private void Unbind(Binding binding)
{
if (ReferenceEquals(_current, binding))
_current = null;
}
private sealed class DelegateDisposable(Action dispose) : IDisposable
{
private Action? _dispose = dispose
?? throw new ArgumentNullException(nameof(dispose));
public void Dispose() => Interlocked.Exchange(ref _dispose, null)?.Invoke();
}
}

View file

@ -0,0 +1,32 @@
namespace AcDream.App.Net;
/// <summary>
/// Tracks retail's stamina-exhaustion notification edge for one live-session
/// generation.
/// </summary>
internal sealed class StaminaExhaustionEdgeTracker
{
private bool? _wasExhausted;
/// <summary>
/// Observes the current stamina value and returns <see langword="true"/>
/// only when a previously initialized state changes between exhausted and
/// non-exhausted.
/// </summary>
public bool Observe(int currentStamina)
{
bool exhausted = currentStamina == 0;
if (_wasExhausted == exhausted)
return false;
bool isEdge = _wasExhausted.HasValue;
_wasExhausted = exhausted;
return isEdge;
}
/// <summary>
/// Forgets the retiring character/session sample. The first sample in the
/// next generation establishes a baseline and must not synthesize an edge.
/// </summary>
public void Reset() => _wasExhausted = null;
}

View file

@ -417,7 +417,17 @@ internal sealed class LiveEntityMotionRuntimeController
if (update.MotionState.MovementType == 6 if (update.MotionState.MovementType == 6
&& path.TargetGuid is { } tgtGuid && path.TargetGuid is { } tgtGuid
&& _liveEntities is { } liveMoveEntities && _liveEntities is { } liveMoveEntities
&& liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt)) && liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt)
// Retail resolves the CPhysicsObj itself here. Even a static,
// animation-less object (door, chest, corpse, NPC prop) can
// therefore accept AddVoyeur and immediately publish its
// position. C3c moved the local player's GetObjectA seam into
// Runtime, where lookup is intentionally non-constructing;
// eagerly materialize the existing canonical minimal host at
// this App composition boundary before PerformMovement asks
// Runtime for it. Without this, MoveToObject arms but receives
// no target snapshot and times out at (0,0,0).
&& ResolvePhysicsHost(tgtGuid) is not null)
{ {
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject; ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
ms.ObjectId = tgtGuid; ms.ObjectId = tgtGuid;

View file

@ -168,9 +168,6 @@ internal sealed class LiveEntityNetworkUpdateController
System.Numerics.Vector3 worldPos, System.Numerics.Vector3 worldPos,
uint cellId) uint cellId)
{ {
if (cellId == 0)
return;
var (radius, height) = _motionRuntime.GetSetupCylinder(serverGuid, entity); var (radius, height) = _motionRuntime.GetSetupCylinder(serverGuid, entity);
if (radius < 0.05f) if (radius < 0.05f)
{ {
@ -193,33 +190,20 @@ internal sealed class LiveEntityNetworkUpdateController
// retail's first frame would (position snapped onto the floor, // retail's first frame would (position snapped onto the floor,
// contact plane + CONTACT/ON_WALKABLE committed below). A sweep that // contact plane + CONTACT/ON_WALKABLE committed below). A sweep that
// finds no floor (true airborne spawn) leaves the body airborne. // finds no floor (true airborne spawn) leaves the body airborne.
AcDream.Core.Physics.ResolveResult settle = _physicsEngine.ResolveWithTransition( if (!AcDream.Core.Physics.SpawnPlacementSettler.TrySettle(
worldPos, _physicsEngine,
worldPos + new System.Numerics.Vector3(0f, 0f, -0.5f),
cellId,
sphereRadius: radius,
sphereHeight: height,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: false,
body: remote.Body,
moverFlags: moverFlags,
movingEntityId: entity.Id);
if (!settle.Ok || !settle.InContact)
return; // no floor within reach — stays airborne like retail's fall
remote.Body.Position = settle.Position;
AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
remote.Body, remote.Body,
settle.InContact, worldPos,
settle.OnWalkable, cellId,
settle.CollisionNormalValid, radius,
settle.CollisionNormal, height,
previousContact: false, moverFlags,
previousOnWalkable: false, entity.Id,
remote.Movement.HitGround, remote.Movement.HitGround,
remote.Motion.LeaveGround); remote.Motion.LeaveGround))
{
return; // no floor within reach — stays airborne like retail's fall
}
remote.Airborne = !remote.Body.OnWalkable; remote.Airborne = !remote.Body.OnWalkable;
} }
@ -1019,7 +1003,20 @@ internal sealed class LiveEntityNetworkUpdateController
return; return;
} }
if (parsed.Guid == _playerServerGuid) if (parsed.Guid == _playerServerGuid)
_playerController?.ApplyPhysicsState(record.FinalPhysicsState); {
// C3c-F1 (2026-08-02): route through the owner's
// lifecycle-deciding typed entry. The publication lifecycle —
// not this inbound handler — decides whether the push lands:
// a dormant first-entry controller drops it (the activation
// transaction re-reads the same canonical FinalPhysicsState
// itself; the accepted SetState is queued behind the initial
// residence so this value is unchanged), and a terminal
// controller treats it as a displaced push instead of faulting
// the session (the second connected-gate crash chain,
// logs/connected-world-gate-20260802-125907).
_ = _playerController?.ApplyServerPhysicsState(
record.FinalPhysicsState);
}
if (!_liveEntities.TryGetWorldEntity(parsed.Guid, out var entity)) return; if (!_liveEntities.TryGetWorldEntity(parsed.Guid, out var entity)) return;

View file

@ -181,12 +181,14 @@ internal sealed class ProjectileController
// animation workset, classification adopts that same body instead // animation workset, classification adopts that same body instead
// of replacing it or replaying CreateObject vectors. // of replacing it or replaying CreateObject vectors.
body = sharedBody; body = sharedBody;
uint currentCellId = record.FullCellId; // Retail has one CPhysicsObj, whose Position owns both objcell_id
Vector3 currentCellLocal = CellLocalFromWorld( // and the cell-local frame. The graphical record's FullCellId is
body.Position, // only the later projection receipt and is legitimately still
currentCellId, // zero while a residence-managed Create awaits presentation.
liveCenterX, // Validate and adopt the canonical body frame itself; successful
liveCenterY); // classification projects that same cell into the sidecar below.
uint currentCellId = body.CellPosition.ObjCellId;
Vector3 currentCellLocal = body.CellPosition.Frame.Origin;
if (!IsFinite(body.Position) if (!IsFinite(body.Position)
|| !IsFinite(body.Velocity) || !IsFinite(body.Velocity)
|| !IsFinite(body.Omega) || !IsFinite(body.Omega)
@ -268,7 +270,17 @@ internal sealed class ProjectileController
entity.SetPosition(body.Position); entity.SetPosition(body.Position);
entity.Rotation = body.Orientation; entity.Rotation = body.Orientation;
entity.ParentCellId = canonicalCellId; entity.ParentCellId = canonicalCellId;
if (!_liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId) // Classification can run from the projection-visible callback after
// Runtime has already installed this exact cell. Re-entering Rebucket
// from that callback would supersede the outer projection transaction
// merely to write the same bucket again. Only perform a spatial move
// when classification is actually changing residence.
bool alreadyProjectedInCanonicalCell = record.IsSpatiallyProjected
&& record.FullCellId == canonicalCellId;
if ((!alreadyProjectedInCanonicalCell
&& !_liveEntities.RebucketLiveEntity(
record.ServerGuid,
canonicalCellId))
|| !_liveEntities.TryGetRecord(record.ServerGuid, out var currentRecord) || !_liveEntities.TryGetRecord(record.ServerGuid, out var currentRecord)
|| !ReferenceEquals(currentRecord, record) || !ReferenceEquals(currentRecord, record)
|| !ReferenceEquals(currentRecord.WorldEntity, entity) || !ReferenceEquals(currentRecord.WorldEntity, entity)
@ -848,10 +860,43 @@ internal sealed class ProjectileController
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{ {
if (record.ProjectileRuntime is not RuntimeProjectile runtime if (record.WorldEntity is not { } entity
|| record.WorldEntity is not { } entity
|| !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) || !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, record) || !ReferenceEquals(current, record))
{
return;
}
if (visible
&& record.ProjectileRuntime is null
&& record.PhysicsBody is not null
&& (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0)
{
// A residence-managed Create builds its one CPhysicsObj before
// presentation, but Runtime intentionally withholds the committed
// cell frame until enter_world completes. Materialization may
// therefore observe the body while it is still cell-less and its
// eager TryBind correctly refuses that incomplete frame. The
// projection-visible edge is the first point at which both the
// canonical body and its authoritative placement are guaranteed
// to be committed, so retry classification here instead of
// fabricating a CreateObject-frame fallback.
Setup? setup = _setupResolver?.Resolve(
entity.SourceGfxObjOrSetupId);
if (setup is not null)
{
int liveCenterX = _origin?.CenterX ?? 0;
int liveCenterY = _origin?.CenterY ?? 0;
_ = TryBind(
record,
setup,
_lastFiniteGameTime,
liveCenterX,
liveCenterY);
}
}
if (record.ProjectileRuntime is not RuntimeProjectile runtime
|| !ReferenceEquals(current.ProjectileRuntime, runtime)) || !ReferenceEquals(current.ProjectileRuntime, runtime))
{ {
return; return;
@ -933,20 +978,6 @@ internal sealed class ProjectileController
&& float.IsFinite(value.Y) && float.IsFinite(value.Y)
&& float.IsFinite(value.Z); && float.IsFinite(value.Z);
private static Vector3 CellLocalFromWorld(
Vector3 worldPosition,
uint cellId,
int liveCenterX,
int liveCenterY)
{
int landblockX = (int)((cellId >> 24) & 0xFFu);
int landblockY = (int)((cellId >> 16) & 0xFFu);
return worldPosition - new Vector3(
(landblockX - liveCenterX) * 192f,
(landblockY - liveCenterY) * 192f,
0f);
}
private bool TryGetCurrent( private bool TryGetCurrent(
uint serverGuid, uint serverGuid,
out LiveEntityRecord record, out LiveEntityRecord record,

View file

@ -709,6 +709,17 @@ internal sealed class DatLiveEntityProjectionMaterializer
} }
bool createdProjection = false; bool createdProjection = false;
// C3c route-1 flip: a fresh world Create materializes presentation-
// only — the sidecar exists but stays non-spatial until the
// residence-driven Runtime placement's completion receipt (or, for a
// record whose residence already completed before its sidecar could
// exist, the self-projection below). An already-materialized record
// keeps its sticky residence so unflipped same-incarnation
// transitions (an equipped child dropping to world) stay on their
// legacy path by construction.
LiveEntityMaterializationResidence residence =
retainedRecord?.MaterializationResidence
?? LiveEntityMaterializationResidence.AwaitRuntimePlacement;
WorldEntity? entity = _runtime.MaterializeLiveEntity( WorldEntity? entity = _runtime.MaterializeLiveEntity(
expectedCanonical, expectedCanonical,
spawn.Position!.Value.LandblockId, spawn.Position!.Value.LandblockId,
@ -734,7 +745,8 @@ internal sealed class DatLiveEntityProjectionMaterializer
}, },
LiveEntityProjectionKind.World, LiveEntityProjectionKind.World,
initializeProjection: record => record.EffectProfile = profile, initializeProjection: record => record.EffectProfile = profile,
out LiveEntityRecord? expectedRecord); out LiveEntityRecord? expectedRecord,
residence);
if (entity is null if (entity is null
|| expectedRecord is null || expectedRecord is null
|| !_runtime.IsCurrentCreateIntegration( || !_runtime.IsCurrentCreateIntegration(
@ -744,6 +756,27 @@ internal sealed class DatLiveEntityProjectionMaterializer
{ {
return false; return false;
} }
if (residence is LiveEntityMaterializationResidence.AwaitRuntimePlacement
&& expectedCanonical.FullCellId != 0u
&& !_runtime.HasActiveInitialCreateResidence(expectedCanonical))
{
// The residence-driven placement already committed before this
// sidecar existed (a deferred-parent child replayed during its
// parent's drain, or a recovery re-materialization) — its
// completion receipt is gone, so presentation self-projects from
// the committed canonical state through the presentation-only
// bucket path.
if (!_runtime.RebucketLiveEntity(
spawn.Guid,
expectedCanonical.FullCellId)
|| !_runtime.IsCurrentCreateIntegration(
expectedRecord,
expectedCreateIntegrationVersion)
|| !ReferenceEquals(expectedRecord.WorldEntity, entity))
{
return false;
}
}
if (!createdProjection) if (!createdProjection)
{ {
@ -804,12 +837,24 @@ internal sealed class DatLiveEntityProjectionMaterializer
expectedCreateIntegrationVersion) expectedCreateIntegrationVersion)
|| !ReferenceEquals(expectedRecord.WorldEntity, entity)) || !ReferenceEquals(expectedRecord.WorldEntity, entity))
return false; return false;
_projectiles.TryBind( bool initialResidenceActive =
expectedRecord, _runtime.HasActiveInitialCreateResidence(expectedCanonical);
setup, // C3c first entry owns CPhysicsObj construction and SetPosition for a
_gameTime.CurrentScriptTime, // residence-managed Create. Eager projectile classification used to
_origin.CenterX, // acquire that same body here, before the conductor ran; the
_origin.CenterY); // conductor then correctly rejected the unexpected owner and the
// missile remained permanently cell-less. The committed projection
// visibility edge retries TryBind after Runtime has constructed and
// placed the one canonical body.
if (!initialResidenceActive)
{
_projectiles.TryBind(
expectedRecord,
setup,
_gameTime.CurrentScriptTime,
_origin.CenterX,
_origin.CenterY);
}
if (!_runtime.IsCurrentCreateIntegration( if (!_runtime.IsCurrentCreateIntegration(
expectedRecord, expectedRecord,
@ -1000,6 +1045,15 @@ internal sealed class DatLiveEntityProjectionMaterializer
return; return;
} }
if (_runtime.HasActiveInitialCreateResidence(expectedRecord.Canonical))
{
// RuntimeRemoteFirstEntryState owns CPhysicsObj construction and
// SetPosition until the Create residence completes. The static
// scheduler retains this pending animation owner and binds the
// canonical body after projection becomes spatially visible.
return;
}
PhysicsBody body = _runtime.GetOrCreatePhysicsBody( PhysicsBody body = _runtime.GetOrCreatePhysicsBody(
spawn.Guid, spawn.Guid,
incarnation => incarnation =>

View file

@ -524,6 +524,22 @@ public sealed class EquippedChildRenderController : IDisposable
{ {
return false; return false;
} }
// C3c: a world-created (residence-managed) child converting to an
// attached projection exits the residence-managed presentation path
// at this same-incarnation kind transition. Attached children have
// no Runtime placement, and the flip's sticky-residence rule expects
// equipped children to carry LegacyImmediate so a later drop back to
// world stays on the legacy path by construction
// (DatLiveEntityProjectionMaterializer.MaterializeProjection's
// retained-residence comment). C3c-R1 review F1: the conversion is
// the owner's explicit API, which asserts no initial-create
// residence is still active, rather than a direct field write here.
if (retainedChild is not null
&& _liveEntities.IsCurrentRecord(retainedChild))
{
_liveEntities.ConvertMaterializationResidenceToLegacyImmediate(
retainedChild);
}
WorldEntity? entity = _liveEntities.MaterializeLiveEntity( WorldEntity? entity = _liveEntities.MaterializeLiveEntity(
childCanonical, childCanonical,
parentCellId, parentCellId,

View file

@ -53,6 +53,9 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
private readonly Func<WorldEntity, bool> _isResident; private readonly Func<WorldEntity, bool> _isResident;
private readonly Action<WorldEntity, PhysicsBody> _commitLiveRoot; private readonly Action<WorldEntity, PhysicsBody> _commitLiveRoot;
private readonly Func<WorldEntity, ulong> _residencyVersion; private readonly Func<WorldEntity, ulong> _residencyVersion;
private readonly Func<WorldEntity,
(LiveEntityAnimationState Animation, PhysicsBody Body)?>?
_resolveLiveOwner;
private readonly Dictionary<uint, Owner> _owners = new(); private readonly Dictionary<uint, Owner> _owners = new();
private readonly List<Owner> _snapshot = new(); private readonly List<Owner> _snapshot = new();
private readonly List<Owner> _hookSnapshot = new(); private readonly List<Owner> _hookSnapshot = new();
@ -63,7 +66,10 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
Action<WorldEntity, IReadOnlyList<Matrix4x4>, IReadOnlyList<bool>> publishPartPoses, Action<WorldEntity, IReadOnlyList<Matrix4x4>, IReadOnlyList<bool>> publishPartPoses,
Func<WorldEntity, bool>? isResident = null, Func<WorldEntity, bool>? isResident = null,
Action<WorldEntity, PhysicsBody>? commitLiveRoot = null, Action<WorldEntity, PhysicsBody>? commitLiveRoot = null,
Func<WorldEntity, ulong>? residencyVersion = null) Func<WorldEntity, ulong>? residencyVersion = null,
Func<WorldEntity,
(LiveEntityAnimationState Animation, PhysicsBody Body)?>?
resolveLiveOwner = null)
{ {
_animationLoader = animationLoader _animationLoader = animationLoader
?? throw new ArgumentNullException(nameof(animationLoader)); ?? throw new ArgumentNullException(nameof(animationLoader));
@ -74,6 +80,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
_isResident = isResident ?? (_ => true); _isResident = isResident ?? (_ => true);
_commitLiveRoot = commitLiveRoot ?? ((_, _) => { }); _commitLiveRoot = commitLiveRoot ?? ((_, _) => { });
_residencyVersion = residencyVersion ?? (_ => 0UL); _residencyVersion = residencyVersion ?? (_ => 0UL);
_resolveLiveOwner = resolveLiveOwner;
} }
internal int Count => _owners.Count; internal int Count => _owners.Count;
@ -363,12 +370,30 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
foreach (Owner owner in _snapshot) foreach (Owner owner in _snapshot)
{ {
if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current) if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current)
|| !ReferenceEquals(current, owner) || !ReferenceEquals(current, owner))
|| owner.Sequencer is not { } sequencer)
{ {
continue; continue;
} }
// C3c: live Static objects are registered while their visual
// sidecar is hydrated, before Runtime has completed the initial
// CreateObject SetPosition transaction. The first-entry
// conductor owns construction of the canonical CPhysicsObj.
// Resolve and bind that already-placed owner lazily; constructing
// a second/eager body here makes the conductor reject authority
// and leaves portals, doors, and other animated statics cell-less.
if (owner.Sequencer is null
&& owner.Entity.ServerGuid != 0
&& _resolveLiveOwner?.Invoke(owner.Entity) is { } binding)
{
_ = BindLiveOwner(
owner.Entity,
binding.Animation,
binding.Body);
}
if (owner.Sequencer is not { } sequencer)
continue;
owner.ElapsedSinceUpdate += elapsedSeconds; owner.ElapsedSinceUpdate += elapsedSeconds;
if (!_isResident(owner.Entity)) if (!_isResident(owner.Entity))
{ {

View file

@ -36,6 +36,14 @@ public sealed class EntityEffectController : IAnimationHookSink,
private readonly Action<uint, uint?> _ownerSoundTableChanged; private readonly Action<uint, uint?> _ownerSoundTableChanged;
private readonly Dictionary<RuntimeEntityKey, EntityEffectProfile> _liveProfiles = []; private readonly Dictionary<RuntimeEntityKey, EntityEffectProfile> _liveProfiles = [];
private readonly HashSet<RuntimeEntityKey> _readyLiveOwners = []; private readonly HashSet<RuntimeEntityKey> _readyLiveOwners = [];
// C3c constructs the App effect owner before Runtime's initial placement
// receipt binds its world presentation. During that one-time split-lifetime
// window, retail still considers the CPhysicsObj absent: SmartBox queues
// F754/F755 at 0x00452020/0x00452070, enters the object, then drains them
// through ProcessObjectNetBlobs in HandleCreateObject 0x00454C80. Keep the
// exact incarnation behind an equivalent barrier until the graphical
// placement publishes its pose and resource visibility.
private readonly HashSet<RuntimeEntityKey> _initialPresentationBarriers = [];
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new(); private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
private readonly Dictionary<uint, WorldEntity> _staticOwners = new(); private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
private readonly Dictionary<uint, EntityEffectProfile> _staticProfiles = new(); private readonly Dictionary<uint, EntityEffectProfile> _staticProfiles = new();
@ -90,6 +98,8 @@ public sealed class EntityEffectController : IAnimationHookSink,
RefreshLiveAnchor(message.Guid, localId); RefreshLiveAnchor(message.Guid, localId);
if (CanStartOwner(localId)) if (CanStartOwner(localId))
PlayDirect(localId, message.ScriptDid); PlayDirect(localId, message.ScriptDid);
else if (IsWaitingForInitialPresentation(message.Guid))
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
return; return;
} }
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid)); Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
@ -104,6 +114,12 @@ public sealed class EntityEffectController : IAnimationHookSink,
RefreshLiveAnchor(message.Guid, localId); RefreshLiveAnchor(message.Guid, localId);
if (CanStartOwner(localId)) if (CanStartOwner(localId))
PlayTyped(localId, message.RawScriptType, message.Intensity); PlayTyped(localId, message.RawScriptType, message.Intensity);
else if (IsWaitingForInitialPresentation(message.Guid))
{
Enqueue(
message.Guid,
PendingEffect.Typed(message.RawScriptType, message.Intensity));
}
return; return;
} }
Enqueue(message.Guid, PendingEffect.Typed(message.RawScriptType, message.Intensity)); Enqueue(message.Guid, PendingEffect.Typed(message.RawScriptType, message.Intensity));
@ -140,11 +156,45 @@ public sealed class EntityEffectController : IAnimationHookSink,
RuntimeEntityKey key = RequireProjectionKey(record); RuntimeEntityKey key = RequireProjectionKey(record);
_readyLiveOwners.Add(key); _readyLiveOwners.Add(key);
_liveProfiles[key] = profile; _liveProfiles[key] = profile;
if (record.MaterializationResidence is
LiveEntityMaterializationResidence.AwaitRuntimePlacement
&& !record.IsSpatiallyProjected)
{
_initialPresentationBarriers.Add(key);
}
else
{
_initialPresentationBarriers.Remove(key);
}
_runner.SetOwnerAnchor(entity.Id, entity.Position); _runner.SetOwnerAnchor(entity.Id, entity.Position);
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid); _ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
return true; return true;
} }
/// <summary>
/// Opens the C3c-only network-script barrier after the initial placement
/// has published the entity pose and presentation resources, then replays
/// the retained mixed F754/F755 FIFO synchronously in arrival order.
/// </summary>
public bool OnPresentationBound(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
if (record.ProjectionKey is not { } key
|| !_liveEntities.TryGetRecord(key, out LiveEntityRecord current)
|| !ReferenceEquals(current, record))
{
return false;
}
_initialPresentationBarriers.Remove(key);
if (!TryGetReadyLocalId(record.ServerGuid, out uint localId))
return true;
RefreshLiveAnchor(record.ServerGuid, localId);
TryReplayPending(record.ServerGuid, localId);
return true;
}
/// <summary>Replays the mixed F754/F755 FIFO after full object construction.</summary> /// <summary>Replays the mixed F754/F755 FIFO after full object construction.</summary>
public bool ReplayPendingForLiveEntity(uint serverGuid) public bool ReplayPendingForLiveEntity(uint serverGuid)
{ {
@ -219,6 +269,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
{ {
_readyLiveOwners.Remove(key); _readyLiveOwners.Remove(key);
_liveProfiles.Remove(key); _liveProfiles.Remove(key);
_initialPresentationBarriers.Remove(key);
} }
if (record.LocalEntityId is not { } localId) if (record.LocalEntityId is not { } localId)
return; return;
@ -241,6 +292,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
} }
_readyLiveOwners.Clear(); _readyLiveOwners.Clear();
_liveProfiles.Clear(); _liveProfiles.Clear();
_initialPresentationBarriers.Clear();
_pendingByServerGuid.Clear(); _pendingByServerGuid.Clear();
_dirtyLiveOwners.Clear(); _dirtyLiveOwners.Clear();
_dirtyLiveOwnerOrder.Clear(); _dirtyLiveOwnerOrder.Clear();
@ -455,6 +507,11 @@ public sealed class EntityEffectController : IAnimationHookSink,
return false; return false;
} }
private bool IsWaitingForInitialPresentation(uint serverGuid) =>
_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
&& record.ProjectionKey is { } key
&& _initialPresentationBarriers.Contains(key);
private void OnEffectPoseChanged(uint localId) private void OnEffectPoseChanged(uint localId)
{ {
if (_posePublishLocalId == localId) if (_posePublishLocalId == localId)

View file

@ -1349,8 +1349,14 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
if (id != 0u) if (id != 0u)
AddId(id); AddId(id);
} }
foreach (uint id in _pendingByLandblock.Keys) // A pending-only bucket owns live spatial projections, not a loaded
AddId(id); // landblock presentation generation. Those projections are retained
// below through _projectionLocations, but there is no terrain,
// collision, static-script, or renderer owner to retire. In
// particular, DetachLandblock deliberately parks surviving live
// projections here while its existing exact retirement receipt is
// still advancing. Emitting another full receipt during a recenter
// would give the same retired generation two cleanup owners.
foreach (uint id in _pendingRenderIdsByLandblock.Keys) foreach (uint id in _pendingRenderIdsByLandblock.Keys)
AddId(id); AddId(id);
foreach (uint id in _pendingNearTierLandblocks) foreach (uint id in _pendingNearTierLandblocks)

View file

@ -12,7 +12,7 @@ namespace AcDream.App.Streaming;
/// publication. The render publisher commits buildings and EnvCells between /// publication. The render publisher commits buildings and EnvCells between
/// these stages without recomputing the captured origin. /// these stages without recomputing the captured origin.
/// </summary> /// </summary>
public sealed class LandblockPhysicsPublication public sealed class LandblockPhysicsPublication : IDisposable
{ {
internal LandblockPhysicsPublication( internal LandblockPhysicsPublication(
object owner, object owner,
@ -21,7 +21,9 @@ public sealed class LandblockPhysicsPublication
uint currentCellId, uint currentCellId,
BuildingInfo[] buildings, BuildingInfo[] buildings,
uint[] priorStaticOwnerIds, uint[] priorStaticOwnerIds,
RuntimeCollisionAdmission collisionAdmission) RuntimePhysicsState physics,
RuntimeCollisionAdmission collisionAdmission,
PreparedLandblockCollisionGeneration preparedGeneration)
{ {
Owner = owner; Owner = owner;
Build = build; Build = build;
@ -29,7 +31,9 @@ public sealed class LandblockPhysicsPublication
CurrentCellId = currentCellId; CurrentCellId = currentCellId;
Buildings = buildings; Buildings = buildings;
PriorStaticOwnerIds = priorStaticOwnerIds; PriorStaticOwnerIds = priorStaticOwnerIds;
Physics = physics;
CollisionAdmission = collisionAdmission; CollisionAdmission = collisionAdmission;
PreparedGeneration = preparedGeneration;
} }
internal object Owner { get; } internal object Owner { get; }
@ -37,7 +41,11 @@ public sealed class LandblockPhysicsPublication
internal uint CurrentCellId { get; } internal uint CurrentCellId { get; }
internal BuildingInfo[] Buildings { get; } internal BuildingInfo[] Buildings { get; }
internal uint[] PriorStaticOwnerIds { get; } internal uint[] PriorStaticOwnerIds { get; }
internal RuntimePhysicsState Physics { get; }
internal RuntimeCollisionAdmission CollisionAdmission { get; } internal RuntimeCollisionAdmission CollisionAdmission { get; }
internal PreparedLandblockCollisionGeneration PreparedGeneration { get; }
internal PhysicsDataCache StagingCache => PreparedGeneration.DataCache;
internal PhysicsEngine StagingEngine => PreparedGeneration.Engine;
internal SortedSet<uint> GfxObjectIdSet { get; } = new(); internal SortedSet<uint> GfxObjectIdSet { get; } = new();
internal uint[] GfxObjectIds { get; set; } = Array.Empty<uint>(); internal uint[] GfxObjectIds { get; set; } = Array.Empty<uint>();
internal int PreparationCursor { get; set; } internal int PreparationCursor { get; set; }
@ -58,11 +66,51 @@ public sealed class LandblockPhysicsPublication
internal int CylinderOwnerCount { get; set; } internal int CylinderOwnerCount { get; set; }
internal int NoCollisionCount { get; set; } internal int NoCollisionCount { get; set; }
internal int SceneryTried { get; set; } internal int SceneryTried { get; set; }
internal uint[]? RefloodOwnerIds { get; set; } internal IReadOnlyList<uint>? RefloodOwnerIds { get; set; }
internal int RefloodCursor { get; set; } internal int RefloodCursor { get; set; }
internal bool RefloodCommitted { get; set; } internal bool RefloodCommitted { get; set; }
internal bool SealCommitted { get; set; }
internal bool EngineMutationCommitted { get; set; }
/// <summary>
/// The last Runtime mutation poll was nonterminal. This includes exact
/// placement acknowledgements, collision-report/shadow dispatch debt, and
/// Runtime's deliberate first quiescence poll.
/// </summary>
internal bool RuntimeMutationPending { get; set; }
internal bool BeginCommitted { get; set; } internal bool BeginCommitted { get; set; }
internal bool CompletionCommitted { get; set; } internal bool CompletionCommitted { get; set; }
internal bool CancellationRequested { get; private set; }
public void Dispose()
{
if (!TryCancel())
{
throw new InvalidOperationException(
"Collision publication cancellation is waiting for exact placement acknowledgements and must remain retained.");
}
}
internal bool TryCancel()
{
if (CompletionCommitted)
return true;
CancellationRequested = true;
for (int poll = 0; poll < 2; poll++)
{
if (Physics.CancelCollisionGeneration(
CollisionAdmission,
PreparedGeneration))
{
return true;
}
if (Physics.CaptureOwnership()
.PendingCollisionPrefixProjectionCount != 0)
{
return false;
}
}
return false;
}
public uint LandblockId => Build.Landblock.LandblockId; public uint LandblockId => Build.Landblock.LandblockId;
public Vector3 Origin { get; } public Vector3 Origin { get; }
@ -204,20 +252,41 @@ public sealed class LandblockPhysicsPublisher
build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty; build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray() BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray()
?? Array.Empty<BuildingInfo>(); ?? Array.Empty<BuildingInfo>();
var publication = new LandblockPhysicsPublication( RuntimeCollisionAdmission collisionAdmission =
_receiptOwner, _physics.BeginCollisionAdmission(build.Landblock.LandblockId);
build, PreparedLandblockCollisionGeneration? prepared = null;
origin, try
_physicsDataCache.CellGraph.CurrCell?.Id ?? 0u, {
buildings, prepared = _physics.PrepareCollisionGeneration(collisionAdmission);
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock( var publication = new LandblockPhysicsPublication(
build.Landblock.LandblockId), _receiptOwner,
_physics.BeginCollisionAdmission( build,
build.Landblock.LandblockId)); origin,
publication.SetupObjectIds = build.Collisions is { } collisions _physicsDataCache.CellGraph.CurrCell?.Id ?? 0u,
? [.. collisions.SetupIds] buildings,
: datBundle.Setups.Keys.Order().ToArray(); _physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
return publication; build.Landblock.LandblockId),
_physics,
collisionAdmission,
prepared);
publication.SetupObjectIds = build.Collisions is { } collisions
? [.. collisions.SetupIds]
: datBundle.Setups.Keys.Order().ToArray();
publication.PreparedGeneration.SetAssetClosure(
publication.GfxObjectIds,
publication.SetupObjectIds);
return publication;
}
catch (Exception publicationError)
{
if (!_physics.CancelCollisionGeneration(collisionAdmission, prepared))
{
throw new AggregateException(
"Collision preparation failed and its pre-engine cancellation did not converge.",
publicationError);
}
throw;
}
} }
/// <summary> /// <summary>
@ -232,11 +301,21 @@ public sealed class LandblockPhysicsPublisher
if (publication.PreparationCommitted) if (publication.PreparationCommitted)
return true; return true;
if (!_physics.AdvanceCollisionGenerationPreparation(
publication.CollisionAdmission,
publication.PreparedGeneration).Completed)
{
return false;
}
IReadOnlyList<WorldEntity> entities = IReadOnlyList<WorldEntity> entities =
publication.Build.Landblock.Entities; publication.Build.Landblock.Entities;
if (publication.Build.Collisions is { } collisions) if (publication.Build.Collisions is { } collisions)
{ {
publication.GfxObjectIds = [.. collisions.GfxObjIds]; publication.GfxObjectIds = [.. collisions.GfxObjIds];
publication.PreparedGeneration.SetAssetClosure(
publication.GfxObjectIds,
publication.SetupObjectIds);
publication.PreparationCursor = entities.Count; publication.PreparationCursor = entities.Count;
publication.PreparationCommitted = true; publication.PreparationCommitted = true;
return true; return true;
@ -256,6 +335,9 @@ public sealed class LandblockPhysicsPublisher
} }
publication.GfxObjectIds = publication.GfxObjectIdSet.ToArray(); publication.GfxObjectIds = publication.GfxObjectIdSet.ToArray();
publication.PreparedGeneration.SetAssetClosure(
publication.GfxObjectIds,
publication.SetupObjectIds);
publication.PreparationCommitted = true; publication.PreparationCommitted = true;
return true; return true;
} }
@ -299,9 +381,9 @@ public sealed class LandblockPhysicsPublisher
// CacheCellStruct/CacheBuilding use first-wins semantics within one // CacheCellStruct/CacheBuilding use first-wins semantics within one
// publication, so the replacement pass starts with one exact // publication, so the replacement pass starts with one exact
// landblock-scoped withdrawal. // landblock-scoped withdrawal.
_physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId); publication.StagingCache.RemoveCellsForLandblock(landblock.LandblockId);
_physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId); publication.StagingCache.RemoveBuildingsForLandblock(landblock.LandblockId);
_physicsDataCache.CellGraph.RemoveEnvCellsForLandblock( publication.StagingCache.CellGraph.RemoveEnvCellsForLandblock(
landblock.LandblockId); landblock.LandblockId);
publication.PriorCacheRemoved = true; publication.PriorCacheRemoved = true;
} }
@ -330,6 +412,7 @@ public sealed class LandblockPhysicsPublisher
PublishBuilding( PublishBuilding(
landblock, landblock,
datBundle, datBundle,
publication.StagingCache,
publication.TerrainSurface, publication.TerrainSurface,
origin, origin,
publication.Buildings[publication.BuildingCursor]); publication.Buildings[publication.BuildingCursor]);
@ -337,8 +420,9 @@ public sealed class LandblockPhysicsPublisher
} }
else if (!publication.BaseCommitted) else if (!publication.BaseCommitted)
{ {
_physics.AdmitCollisionAssets( _physics.StageCollisionAssets(
publication.CollisionAdmission, publication.CollisionAdmission,
publication.PreparedGeneration,
new RuntimeLandblockCollisionAssets( new RuntimeLandblockCollisionAssets(
landblock.LandblockId, landblock.LandblockId,
publication.TerrainSurface, publication.TerrainSurface,
@ -368,7 +452,7 @@ public sealed class LandblockPhysicsPublisher
/// presentation pipeline supplies the static-presentation owner callback /// presentation pipeline supplies the static-presentation owner callback
/// that preserves per-entity light-before-collision order. /// that preserves per-entity light-before-collision order.
/// </summary> /// </summary>
public void CompletePublication( public bool CompletePublication(
LandblockPhysicsPublication publication, LandblockPhysicsPublication publication,
Action<WorldEntity>? beforeStaticCollision = null) Action<WorldEntity>? beforeStaticCollision = null)
{ {
@ -378,7 +462,29 @@ public sealed class LandblockPhysicsPublisher
"Physics publication cannot complete before its prefix commits."); "Physics publication cannot complete before its prefix commits.");
while (!AdvanceCompleteOne(publication, beforeStaticCollision)) while (!AdvanceCompleteOne(publication, beforeStaticCollision))
{ {
if (publication.RuntimeMutationPending)
{
if (!CanContinueMutationSynchronously())
return false;
// The activation transaction deliberately closes its exact
// quiescence boundary on one poll and transfers the engine on
// the next. This synchronous compatibility API may consume
// that finite internal suffix; the frame-budgeted pipeline
// always yields on the first nonterminal commit below.
publication.RuntimeMutationPending = false;
}
} }
return true;
}
internal bool CanContinueMutationSynchronously()
{
RuntimePhysicsOwnershipSnapshot ownership = _physics.CaptureOwnership();
return ownership.PendingCollisionPrefixProjectionCount == 0
&& ownership.PendingCollisionReportCount == 0
&& ownership.PendingCollisionSetPositionDispatchCount == 0
&& ownership.PendingShadowSetPositionDispatchCount == 0
&& !ownership.IsCollisionReportDispatching;
} }
/// <summary> /// <summary>
@ -390,11 +496,15 @@ public sealed class LandblockPhysicsPublisher
Action<WorldEntity>? beforeStaticCollision = null) Action<WorldEntity>? beforeStaticCollision = null)
{ {
ValidateReceipt(publication); ValidateReceipt(publication);
if (publication.CancellationRequested)
throw new InvalidOperationException(
"A cancelled collision publication cannot resume.");
if (!publication.BeginCommitted) if (!publication.BeginCommitted)
throw new InvalidOperationException( throw new InvalidOperationException(
"Physics publication cannot complete before its prefix commits."); "Physics publication cannot complete before its prefix commits.");
if (publication.CompletionCommitted) if (publication.CompletionCommitted)
return true; return true;
publication.RuntimeMutationPending = false;
long started = Stopwatch.GetTimestamp(); long started = Stopwatch.GetTimestamp();
LoadedLandblock landblock = publication.Build.Landblock; LoadedLandblock landblock = publication.Build.Landblock;
@ -409,7 +519,7 @@ public sealed class LandblockPhysicsPublisher
gfxObjectId, gfxObjectId,
out FlatGfxObjCollisionAsset? prepared) == true) out FlatGfxObjCollisionAsset? prepared) == true)
{ {
_physicsDataCache.CacheGfxObj(gfxObjectId, prepared); publication.StagingCache.CacheGfxObj(gfxObjectId, prepared);
} }
else if (datBundle.GfxObjs.TryGetValue( else if (datBundle.GfxObjs.TryGetValue(
gfxObjectId, gfxObjectId,
@ -417,7 +527,7 @@ public sealed class LandblockPhysicsPublisher
{ {
// Graph-oracle fixture seam. Production near builds always // Graph-oracle fixture seam. Production near builds always
// carry the strict prepared closure. // carry the strict prepared closure.
_physicsDataCache.CacheGfxObj(gfxObjectId, source); publication.StagingCache.CacheGfxObj(gfxObjectId, source);
} }
publication.GfxCursor++; publication.GfxCursor++;
_gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted; _gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted;
@ -430,19 +540,19 @@ public sealed class LandblockPhysicsPublisher
setupId, setupId,
out FlatSetupCollision? prepared) == true) out FlatSetupCollision? prepared) == true)
{ {
_physicsDataCache.CacheSetup(setupId, prepared); publication.StagingCache.CacheSetup(setupId, prepared);
} }
else if (datBundle.Setups.TryGetValue(setupId, out var source)) else if (datBundle.Setups.TryGetValue(setupId, out var source))
{ {
// Graph-oracle fixture seam only. // Graph-oracle fixture seam only.
_physicsDataCache.CacheSetup(setupId, source); publication.StagingCache.CacheSetup(setupId, source);
} }
publication.SetupCursor++; publication.SetupCursor++;
} }
else if (publication.PriorStaticCursor else if (publication.PriorStaticCursor
< publication.PriorStaticOwnerIds.Length) < publication.PriorStaticOwnerIds.Length)
{ {
_physicsEngine.ShadowObjects.DeregisterStaticOwnerForLandblock( publication.StagingEngine.ShadowObjects.DeregisterStaticOwnerForLandblock(
publication.PriorStaticOwnerIds[ publication.PriorStaticOwnerIds[
publication.PriorStaticCursor], publication.PriorStaticCursor],
landblock.LandblockId); landblock.LandblockId);
@ -457,16 +567,23 @@ public sealed class LandblockPhysicsPublisher
} }
else if (publication.RefloodOwnerIds is null) else if (publication.RefloodOwnerIds is null)
{ {
publication.RefloodOwnerIds = RuntimeCollisionOwnerCaptureStep capture =
_physicsEngine.ShadowObjects _physics.AdvanceCollisionRetainedOwnerCapture(
.CaptureRefloodOwnersForLandblock(landblock.LandblockId); publication.CollisionAdmission,
publication.PreparedGeneration);
if (capture.Completed)
{
publication.RefloodOwnerIds =
publication.PreparedGeneration.RetainedOwnerIds;
}
} }
else if (publication.RefloodCursor else if (publication.RefloodCursor
< publication.RefloodOwnerIds.Length) < publication.RefloodOwnerIds.Count)
{ {
_physicsEngine.ShadowObjects.RefloodOwnerForLandblock( _physics.RefreshCollisionRetainedOwner(
publication.RefloodOwnerIds[publication.RefloodCursor], publication.CollisionAdmission,
landblock.LandblockId); publication.PreparedGeneration,
publication.RefloodOwnerIds[publication.RefloodCursor]);
publication.RefloodCursor++; publication.RefloodCursor++;
} }
else if (!publication.RefloodCommitted) else if (!publication.RefloodCommitted)
@ -478,14 +595,58 @@ public sealed class LandblockPhysicsPublisher
$"lb 0x{landblock.LandblockId:X8}: scenery tried={publication.SceneryTried} " + $"lb 0x{landblock.LandblockId:X8}: scenery tried={publication.SceneryTried} " +
$"(outdoorNone={publication.NoCollisionCount})"); $"(outdoorNone={publication.NoCollisionCount})");
} }
LogMissingSceneryBounds(landblock); LogMissingSceneryBounds(landblock, publication.StagingCache);
_refloodCount++;
publication.RefloodCommitted = true; publication.RefloodCommitted = true;
} }
else else if (!publication.SealCommitted)
{ {
_physics.CompleteCollisionAdmission( RuntimeCollisionSealStep seal =
publication.CollisionAdmission); _physics.AdvanceCollisionGenerationSeal(
publication.CollisionAdmission,
publication.PreparedGeneration);
if (seal.WorkUnits > 1)
{
throw new InvalidOperationException(
"Collision seal exceeded its one-unit publication budget.");
}
publication.SealCommitted = seal.Completed;
if (seal.Restarted)
{
publication.RefloodOwnerIds = null;
publication.RefloodCursor = 0;
publication.RefloodCommitted = false;
}
}
// Seal reconciliation and the zero-work root transfer are one host
// update-thread transaction. Deferring this commit to the next frame
// would let continuously moving, unrelated live owners dirty their
// already-visited journal slots forever even though each metered replay
// had just caught up exactly.
if (publication.SealCommitted && !publication.CompletionCommitted)
{
RuntimeCollisionGenerationCommit commit =
_physics.CommitCollisionGeneration(
publication.CollisionAdmission,
publication.PreparedGeneration);
publication.EngineMutationCommitted |= commit.EngineCommitted;
if (!commit.Completed)
{
publication.RuntimeMutationPending = true;
if (!commit.EngineCommitted)
{
// Runtime coalesces post-seal arrivals in its owner
// journal. Resume that seal tail rather than restarting
// the completed generation-wide capture/reflood pass.
publication.SealCommitted = false;
}
// Once EngineCommitted is true the replacement is canonical
// and cannot be resealed or rolled back. Later frames poll
// only the exact resident-restore acknowledgement suffix.
_completePublishTicks += Stopwatch.GetTimestamp() - started;
return false;
}
_refloodCount++;
_staticBspOwnerCount += publication.BspOwnerCount; _staticBspOwnerCount += publication.BspOwnerCount;
_staticCylinderOwnerCount += publication.CylinderOwnerCount; _staticCylinderOwnerCount += publication.CylinderOwnerCount;
publication.CompletionCommitted = true; publication.CompletionCommitted = true;
@ -496,16 +657,54 @@ public sealed class LandblockPhysicsPublisher
return publication.CompletionCommitted; return publication.CompletionCommitted;
} }
public void DemoteToTerrain(uint landblockId) public bool DemoteToTerrain(uint landblockId)
{ {
_physics.DemoteCollisionToTerrain(landblockId); for (int poll = 0; poll < 2; poll++)
_demotionCount++; {
if (AdvanceDemotion(landblockId))
return true;
if (_physics.CaptureOwnership()
.PendingCollisionPrefixProjectionCount != 0)
{
return false;
}
}
return false;
} }
public void RemoveLandblock(uint landblockId) internal bool AdvanceDemotion(uint landblockId)
{ {
_physics.WithdrawCollision(landblockId); RuntimeCollisionMutationResult result =
_physics.DemoteCollisionToTerrain(landblockId);
if (!result.Completed)
return false;
_demotionCount++;
return true;
}
public bool RemoveLandblock(uint landblockId)
{
for (int poll = 0; poll < 2; poll++)
{
if (AdvanceRemoval(landblockId))
return true;
if (_physics.CaptureOwnership()
.PendingCollisionPrefixProjectionCount != 0)
{
return false;
}
}
return false;
}
internal bool AdvanceRemoval(uint landblockId)
{
RuntimeCollisionMutationResult result =
_physics.WithdrawCollision(landblockId);
if (!result.Completed)
return false;
_fullRemovalCount++; _fullRemovalCount++;
return true;
} }
private void PublishCell( private void PublishCell(
@ -545,7 +744,7 @@ public sealed class LandblockPhysicsPublisher
Matrix4x4.CreateFromQuaternion(rotation) Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld); * Matrix4x4.CreateTranslation(cellOriginWorld);
_physicsDataCache.CacheCellStruct( publication.StagingCache.CacheCellStruct(
envCellId, envCellId,
envCell, envCell,
physicsCellTransform, physicsCellTransform,
@ -623,7 +822,7 @@ public sealed class LandblockPhysicsPublisher
Matrix4x4 physicsCellTransform = Matrix4x4 physicsCellTransform =
Matrix4x4.CreateFromQuaternion(rotation) Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld); * Matrix4x4.CreateTranslation(cellOriginWorld);
_physicsDataCache.CacheCellStruct( publication.StagingCache.CacheCellStruct(
envCellId, envCellId,
envCell, envCell,
cellStruct, cellStruct,
@ -687,6 +886,7 @@ public sealed class LandblockPhysicsPublisher
private void PublishBuilding( private void PublishBuilding(
LoadedLandblock landblock, LoadedLandblock landblock,
PhysicsDatBundle datBundle, PhysicsDatBundle datBundle,
PhysicsDataCache cache,
TerrainSurface terrainSurface, TerrainSurface terrainSurface,
Vector3 origin, Vector3 origin,
BuildingInfo building) BuildingInfo building)
@ -720,7 +920,7 @@ public sealed class LandblockPhysicsPublisher
? setup.Parts[0] ? setup.Parts[0]
: 0u; : 0u;
} }
_physicsDataCache.CacheBuilding( cache.CacheBuilding(
landcellId, landcellId,
portals, portals,
buildingTransform, buildingTransform,
@ -752,11 +952,11 @@ public sealed class LandblockPhysicsPublisher
ShadowShapeBuilder.FromLandblockBspParts( ShadowShapeBuilder.FromLandblockBspParts(
entity.MeshRefs, entity.MeshRefs,
entity.IsBuildingShell, entity.IsBuildingShell,
_physicsDataCache.GetGfxObj); publication.StagingCache.GetGfxObj);
entityBspCount = bspShapes.Count; entityBspCount = bspShapes.Count;
if (entityBspCount > 0) if (entityBspCount > 0)
{ {
_physicsEngine.ShadowObjects.RegisterMultiPart( publication.StagingEngine.ShadowObjects.RegisterMultiPart(
entity.Id, entity.Id,
entity.Position, entity.Position,
entity.Rotation, entity.Rotation,
@ -772,9 +972,9 @@ public sealed class LandblockPhysicsPublisher
} }
FlatSetupCollision? setup = FlatSetupCollision? setup =
_physicsDataCache.GetFlatSetup(entity.SourceGfxObjOrSetupId); publication.StagingCache.GetFlatSetup(entity.SourceGfxObjOrSetupId);
if (setup is null if (setup is null
&& _physicsDataCache.GetSetup( && publication.StagingCache.GetSetup(
entity.SourceGfxObjOrSetupId) is { } graphSetup) entity.SourceGfxObjOrSetupId) is { } graphSetup)
{ {
// Graph-oracle fixture seam only. Production Setup publication is // Graph-oracle fixture seam only. Production Setup publication is
@ -858,7 +1058,7 @@ public sealed class LandblockPhysicsPublisher
if (setupShapes.Count > 0) if (setupShapes.Count > 0)
{ {
_physicsEngine.ShadowObjects.RegisterMultiPart( publication.StagingEngine.ShadowObjects.RegisterMultiPart(
entity.Id, entity.Id,
entity.Position, entity.Position,
entity.Rotation, entity.Rotation,
@ -917,7 +1117,9 @@ public sealed class LandblockPhysicsPublisher
} }
} }
private void LogMissingSceneryBounds(LoadedLandblock landblock) private static void LogMissingSceneryBounds(
LoadedLandblock landblock,
PhysicsDataCache cache)
{ {
if (!PhysicsDiagnostics.ProbeBuildingEnabled) if (!PhysicsDiagnostics.ProbeBuildingEnabled)
return; return;
@ -933,7 +1135,7 @@ public sealed class LandblockPhysicsPublisher
foreach (MeshRef meshRef in entity.MeshRefs) foreach (MeshRef meshRef in entity.MeshRefs)
{ {
GfxObjVisualBounds? bounds = GfxObjVisualBounds? bounds =
_physicsDataCache.GetVisualBounds(meshRef.GfxObjId); cache.GetVisualBounds(meshRef.GfxObjId);
if (bounds is not null && bounds.Radius > 0f) if (bounds is not null && bounds.Radius > 0f)
{ {
hasBounds = true; hasBounds = true;
@ -972,5 +1174,12 @@ public sealed class LandblockPhysicsPublisher
"The physics publication receipt belongs to another publisher.", "The physics publication receipt belongs to another publisher.",
nameof(publication)); nameof(publication));
} }
if (!publication.CompletionCommitted
&& !publication.EngineMutationCommitted)
{
ObjectDisposedException.ThrowIf(
publication.PreparedGeneration.IsDisposed,
publication);
}
} }
} }

View file

@ -215,6 +215,38 @@ public sealed class LandblockPresentationPipeline
public IReadOnlyList<LandblockStreamResult> GetPendingPublicationResults() => public IReadOnlyList<LandblockStreamResult> GetPendingPublicationResults() =>
_publications.Keys.ToArray(); _publications.Keys.ToArray();
/// <summary>
/// Cancels retained publication receipts during a generation reset. A
/// pre-engine receipt restores its exact prior generation. A receipt that
/// already transferred the engine generation remains retained until its
/// canonical post-engine placement acknowledgement suffix completes; the
/// committed transfer is never rolled back.
/// </summary>
internal bool CancelPendingPublications()
{
if (_publications.Count == 0)
return true;
LandblockStreamResult[] pending = [.. _publications.Keys];
bool completed = true;
for (int index = 0; index < pending.Length; index++)
{
LandblockStreamResult result = pending[index];
if (!_publications.TryGetValue(
result,
out PublicationTransaction? transaction))
{
continue;
}
bool cancelled = transaction.PhysicsPublication is not { } physics
|| physics.TryCancel();
if (cancelled)
_publications.Remove(result);
else
completed = false;
}
return completed && _publications.Count == 0;
}
public void ResumePublication(LandblockStreamResult result) public void ResumePublication(LandblockStreamResult result)
{ {
ArgumentNullException.ThrowIfNull(result); ArgumentNullException.ThrowIfNull(result);
@ -235,10 +267,15 @@ public sealed class LandblockPresentationPipeline
return Advance(result, transaction, meter, ensureProgress); return Advance(result, transaction, meter, ensureProgress);
} }
public void AdvanceRetirements() => _retirements.Advance(); public void AdvanceRetirements()
{
_retirements.Advance();
}
public void AdvanceRetirements(StreamingWorkMeter meter) => public void AdvanceRetirements(StreamingWorkMeter meter)
{
_retirements.Advance(meter); _retirements.Advance(meter);
}
internal void AdvancePriorityRetirement( internal void AdvancePriorityRetirement(
uint landblockId, uint landblockId,
@ -249,14 +286,26 @@ public sealed class LandblockPresentationPipeline
{ {
_retirements.BeginFull(landblockId); _retirements.BeginFull(landblockId);
if (_retirements.UsesBudgetedSteps) if (_retirements.UsesBudgetedSteps)
{
_retirements.Advance(); _retirements.Advance();
if (_retirements.IsPending(landblockId)
&& (_physicsPublisher?.CanContinueMutationSynchronously()
?? true))
_retirements.Advance();
}
} }
public void BeginNearLayerRetirement(uint landblockId) public void BeginNearLayerRetirement(uint landblockId)
{ {
_retirements.BeginNearLayer(landblockId); _retirements.BeginNearLayer(landblockId);
if (_retirements.UsesBudgetedSteps) if (_retirements.UsesBudgetedSteps)
{
_retirements.Advance(); _retirements.Advance();
if (_retirements.IsPending(landblockId)
&& (_physicsPublisher?.CanContinueMutationSynchronously()
?? true))
_retirements.Advance();
}
} }
internal void EnqueueFullRetirement(uint landblockId) => internal void EnqueueFullRetirement(uint landblockId) =>
@ -273,8 +322,25 @@ public sealed class LandblockPresentationPipeline
{ {
GpuWorldRecenterRetirement detached = GpuWorldRecenterRetirement detached =
_state.DetachAllForOriginRecenter(); _state.DetachAllForOriginRecenter();
Exception? adoptionFailure = Exception? adoptionFailure;
_retirements.AdoptDetachedFull(detached.Landblocks); try
{
adoptionFailure =
_retirements.AdoptDetachedFull(detached.Landblocks);
}
catch (Exception error)
{
// Spatial detachment is already committed. A receipt-ledger
// invariant failure cannot be retried by detaching the same
// generation again; doing so was the 243-frame origin-recenter
// exception loop. Surface the committed edge so the controller
// fails fast instead of pretending the operation is resumable.
throw new StreamingMutationException(
"Origin-recenter retirement receipt adoption failed after " +
"the spatial generation detached.",
mutationCommitted: true,
error);
}
Exception? failure = (detached.ObserverFailure, adoptionFailure) switch Exception? failure = (detached.ObserverFailure, adoptionFailure) switch
{ {
(null, null) => null, (null, null) => null,
@ -719,13 +785,15 @@ public sealed class LandblockPresentationPipeline
< transaction.Build.Landblock.Entities.Count < transaction.Build.Landblock.Entities.Count
? 1 ? 1
: transaction.PhysicsPublication : transaction.PhysicsPublication
.RefloodOwnerIds is not null .RefloodOwnerIds is null
&& transaction.PhysicsPublication || transaction.PhysicsPublication
.RefloodCursor .RefloodCursor
< transaction.PhysicsPublication < transaction.PhysicsPublication
.RefloodOwnerIds.Length .RefloodOwnerIds.Count
? 1 || !transaction.PhysicsPublication
: 0; .SealCommitted
? 1
: 0;
if (!TryRun( if (!TryRun(
new StreamingWorkCost( new StreamingWorkCost(
EntityOperations: entityOperations), EntityOperations: entityOperations),
@ -740,6 +808,17 @@ public sealed class LandblockPresentationPipeline
{ {
return new LandblockPublicationAdvance(false, progressed); return new LandblockPublicationAdvance(false, progressed);
} }
if (transaction.PhysicsPublication.RuntimeMutationPending)
{
if (meter is not null
|| !_physicsPublisher
.CanContinueMutationSynchronously())
{
return new LandblockPublicationAdvance(
false,
progressed);
}
}
} }
while (!transaction.StaticPublication.CompletionCommitted) while (!transaction.StaticPublication.CompletionCommitted)
{ {

View file

@ -73,22 +73,20 @@ public sealed class LandblockPresentationRetirementOwner
static entity => entity.ServerGuid == 0, static entity => entity.ServerGuid == 0,
_staticPresentation.RemovePluginProjection); _staticPresentation.RemovePluginProjection);
if (!ticket.RunOnce(
LandblockRetirementStage.Physics,
() => ticket.Kind == LandblockRetirementKind.Full
? _physics.AdvanceRemoval(ticket.LandblockId)
: _physics.AdvanceDemotion(ticket.LandblockId)))
{
return;
}
if (ticket.Kind == LandblockRetirementKind.Full) if (ticket.Kind == LandblockRetirementKind.Full)
{ {
ticket.RunOnce( ticket.RunOnce(
LandblockRetirementStage.Terrain, LandblockRetirementStage.Terrain,
() => _render.RemoveTerrain(ticket.LandblockId)); () => _render.RemoveTerrain(ticket.LandblockId));
} }
ticket.RunOnce(
LandblockRetirementStage.Physics,
() =>
{
if (ticket.Kind == LandblockRetirementKind.Full)
_physics.RemoveLandblock(ticket.LandblockId);
else
_physics.DemoteToTerrain(ticket.LandblockId);
});
ticket.RunOnce( ticket.RunOnce(
LandblockRetirementStage.CellVisibility, LandblockRetirementStage.CellVisibility,
() => _render.RemoveCellVisibility(ticket.LandblockId)); () => _render.RemoveCellVisibility(ticket.LandblockId));
@ -128,13 +126,9 @@ public sealed class LandblockPresentationRetirementOwner
LandblockRetirementStage.Physics => LandblockRetirementStage.Physics =>
ticket.RunOnceStep( ticket.RunOnceStep(
LandblockRetirementStage.Physics, LandblockRetirementStage.Physics,
() => () => ticket.Kind == LandblockRetirementKind.Full
{ ? _physics.AdvanceRemoval(ticket.LandblockId)
if (ticket.Kind == LandblockRetirementKind.Full) : _physics.AdvanceDemotion(ticket.LandblockId)),
_physics.RemoveLandblock(ticket.LandblockId);
else
_physics.DemoteToTerrain(ticket.LandblockId);
}),
LandblockRetirementStage.CellVisibility => LandblockRetirementStage.CellVisibility =>
ticket.RunOnceStep( ticket.RunOnceStep(
LandblockRetirementStage.CellVisibility, LandblockRetirementStage.CellVisibility,

View file

@ -12,8 +12,8 @@ public enum LandblockRetirementStage : ushort
EntityLighting = 1 << 3, EntityLighting = 1 << 3,
EntityTranslucency = 1 << 4, EntityTranslucency = 1 << 4,
PluginProjection = 1 << 5, PluginProjection = 1 << 5,
Terrain = 1 << 6, Physics = 1 << 6,
Physics = 1 << 7, Terrain = 1 << 7,
CellVisibility = 1 << 8, CellVisibility = 1 << 8,
BuildingRegistry = 1 << 9, BuildingRegistry = 1 << 9,
EnvironmentCells = 1 << 10, EnvironmentCells = 1 << 10,
@ -24,6 +24,7 @@ internal enum LandblockRetirementOperationResult : byte
{ {
NoWork, NoWork,
Progressed, Progressed,
Pending,
Failed, Failed,
} }
@ -77,6 +78,28 @@ public sealed class LandblockRetirementTicket
} }
} }
public bool RunOnce(LandblockRetirementStage stage, Func<bool> operation)
{
ValidateSingleStage(stage);
ArgumentNullException.ThrowIfNull(operation);
if ((CompletedStages & stage) != 0)
return true;
try
{
if (!operation())
return false;
CompletedStages |= stage;
_failures.Remove(stage);
return true;
}
catch (Exception error)
{
_failures[stage] = error;
return false;
}
}
internal LandblockRetirementOperationResult RunOnceStep( internal LandblockRetirementOperationResult RunOnceStep(
LandblockRetirementStage stage, LandblockRetirementStage stage,
Action operation) Action operation)
@ -100,6 +123,31 @@ public sealed class LandblockRetirementTicket
} }
} }
internal LandblockRetirementOperationResult RunOnceStep(
LandblockRetirementStage stage,
Func<bool> operation)
{
ValidateSingleStage(stage);
ArgumentNullException.ThrowIfNull(operation);
if ((CompletedStages & stage) != 0)
return LandblockRetirementOperationResult.NoWork;
try
{
if (!operation())
return LandblockRetirementOperationResult.Pending;
CompletedStages |= stage;
_failures.Remove(stage);
return LandblockRetirementOperationResult.Progressed;
}
catch (Exception error)
{
_failures[stage] = error;
return LandblockRetirementOperationResult.Failed;
}
}
public bool RunForEachEntity( public bool RunForEachEntity(
LandblockRetirementStage stage, LandblockRetirementStage stage,
Func<WorldEntity, bool> predicate, Func<WorldEntity, bool> predicate,
@ -815,6 +863,8 @@ public sealed class LandblockRetirementCoordinator
} }
meter.Complete(); meter.Complete();
if (result == LandblockRetirementOperationResult.Pending)
return BudgetedAdvanceResult.Yielded;
return BudgetedAdvanceResult.Progressed; return BudgetedAdvanceResult.Progressed;
} }

View file

@ -32,6 +32,7 @@ public sealed class StreamingController
public bool GenerationAdvanced; public bool GenerationAdvanced;
public bool PendingLoadsCleared; public bool PendingLoadsCleared;
public bool CompletionQueueCleared; public bool CompletionQueueCleared;
public bool PendingPublicationsCleared;
public bool RegionCleared; public bool RegionCleared;
public bool SpatialGenerationDetached; public bool SpatialGenerationDetached;
public bool PreparationCommitted; public bool PreparationCommitted;
@ -45,6 +46,7 @@ public sealed class StreamingController
public bool GenerationAdvanced; public bool GenerationAdvanced;
public bool PendingLoadsCleared; public bool PendingLoadsCleared;
public bool CompletionQueueCleared; public bool CompletionQueueCleared;
public bool PendingPublicationsCleared;
public bool RegionCleared; public bool RegionCleared;
public List<uint>? ResidentIds; public List<uint>? ResidentIds;
public IEnumerator<uint>? ResidentEnumerator; public IEnumerator<uint>? ResidentEnumerator;
@ -1358,6 +1360,26 @@ public sealed class StreamingController
} }
if (!transaction.RegionCleared) if (!transaction.RegionCleared)
{ {
if (!transaction.PendingPublicationsCleared)
{
bool cancelled = false;
if (!TryRunStreamingWork(
meter,
new StreamingWorkCost(EntityOperations: 1),
"recenter-cancel-publications",
() =>
{
cancelled =
_presentation.CancelPendingPublications();
return true;
}))
{
return false;
}
transaction.PendingPublicationsCleared = cancelled;
if (!cancelled)
return false;
}
if (!TryRunStreamingWork( if (!TryRunStreamingWork(
meter, meter,
new StreamingWorkCost(EntityOperations: 1), new StreamingWorkCost(EntityOperations: 1),
@ -1411,6 +1433,14 @@ public sealed class StreamingController
transaction.PreparationCommitted = true; transaction.PreparationCommitted = true;
return true; return true;
} }
catch (StreamingMutationException error) when (error.MutationCommitted)
{
// The old spatial generation is already gone. Re-entering this
// transaction would replay the detach against a new state, so a
// committed receipt/adoption invariant is terminal and must be
// surfaced to the caller.
throw;
}
catch (Exception error) catch (Exception error)
{ {
Console.WriteLine( Console.WriteLine(
@ -1499,6 +1529,26 @@ public sealed class StreamingController
if (!transaction.RegionCleared) if (!transaction.RegionCleared)
{ {
if (!transaction.PendingPublicationsCleared)
{
bool cancelled = false;
if (!TryRunStreamingWork(
meter,
new StreamingWorkCost(EntityOperations: 1),
"reload-cancel-publications",
() =>
{
cancelled =
_presentation.CancelPendingPublications();
return true;
}))
{
return false;
}
transaction.PendingPublicationsCleared = cancelled;
if (!cancelled)
return false;
}
if (!TryRunStreamingWork( if (!TryRunStreamingWork(
meter, meter,
new StreamingWorkCost(EntityOperations: 1), new StreamingWorkCost(EntityOperations: 1),

View file

@ -86,6 +86,17 @@ public sealed class CharacterSheet
public int ManaCurrent { get; init; } public int ManaCurrent { get; init; }
public int ManaMax { get; init; } public int ManaMax { get; init; }
/// <summary>
/// Unenchanted max Health/Stamina/Mana in that order.
/// </summary>
public int[] VitalBaseMaxValues { get; init; } = Array.Empty<int>();
/// <summary>
/// Isolated vitae contribution to max Health/Stamina/Mana, always
/// non-positive and ordered like <see cref="VitalBaseMaxValues"/>.
/// </summary>
public int[] VitalVitaeModifiers { get; init; } = Array.Empty<int>();
// ── Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ──────────── // ── Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ────────────
// InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self. // InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self.
@ -202,7 +213,8 @@ public sealed record CharacterSkill(
uint IconDid, uint IconDid,
CharacterSkillAdvancementClass AdvancementClass, CharacterSkillAdvancementClass AdvancementClass,
int BaseLevel, int BaseLevel,
// Issue #267: CurrentLevel is now the EFFECTIVE (vitae + buff) level — // BaseLevel is retail's pre-EnchantSkill value, including augmentation
// terms. CurrentLevel is the EFFECTIVE (vitae + buff) level —
// retail CACQualities::EnchantSkill (0x005947b0). Previously an alias of // retail CACQualities::EnchantSkill (0x005947b0). Previously an alias of
// BaseLevel; this activates the existing CharacterStatController. // BaseLevel; this activates the existing CharacterStatController.
// SkillValueColor buffed/debuffed row coloring. // SkillValueColor buffed/debuffed row coloring.

View file

@ -140,6 +140,18 @@ public sealed class CharacterSheetProvider
StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina), StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina),
ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana), ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana),
ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana), ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana),
VitalBaseMaxValues =
[
VitalBaseMax(LocalPlayerState.VitalKind.Health),
VitalBaseMax(LocalPlayerState.VitalKind.Stamina),
VitalBaseMax(LocalPlayerState.VitalKind.Mana),
],
VitalVitaeModifiers =
[
_localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Health),
_localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Stamina),
_localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Mana),
],
// Issue #267: the panel's main attribute values are EFFECTIVE // Issue #267: the panel's main attribute values are EFFECTIVE
// (post-buff) — retail CACQualities::EnchantAttribute. Base values // (post-buff) — retail CACQualities::EnchantAttribute. Base values
@ -176,7 +188,7 @@ public sealed class CharacterSheetProvider
AttrCurrent(LocalPlayerState.AttributeKind.Focus), AttrCurrent(LocalPlayerState.AttributeKind.Focus),
AttrCurrent(LocalPlayerState.AttributeKind.Self), AttrCurrent(LocalPlayerState.AttributeKind.Self),
}, },
Skills = BuildLiveCharacterSkills(), Skills = BuildLiveCharacterSkills(props),
BurdenCurrent = props.GetInt(5u), BurdenCurrent = props.GetInt(5u),
BurdenMax = props.GetInt(96u), BurdenMax = props.GetInt(96u),
EncumbranceAugmentations = props.GetInt(0xE6u), EncumbranceAugmentations = props.GetInt(0xE6u),
@ -344,7 +356,8 @@ public sealed class CharacterSheetProvider
} }
} }
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills() private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills(
PropertyBundle properties)
{ {
var result = new List<CharacterSkill>(); var result = new List<CharacterSkill>();
var skillTable = SkillTable; var skillTable = SkillTable;
@ -374,23 +387,26 @@ public sealed class CharacterSheetProvider
// retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier // retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier
// isolates vitae's own contribution for the footer's separate // isolates vitae's own contribution for the footer's separate
// vitae parenthetical (SkillInfoRegion::GetVitaeModifier 0x004f0fa0). // vitae parenthetical (SkillInfoRegion::GetVitaeModifier 0x004f0fa0).
int effectiveLevel = _localPlayer.GetEffectiveSkill(snapshot.SkillId) PlayerSkillMath.Value values =
?? checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)); _localPlayer.GetSkillValue(snapshot.SkillId, properties)
int vitaeModifier = _localPlayer.GetSkillVitaeModifier(snapshot.SkillId); ?? new PlayerSkillMath.Value(
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
0);
result.Add(new CharacterSkill( result.Add(new CharacterSkill(
snapshot.SkillId, snapshot.SkillId,
name, name,
icon, icon,
advancement, advancement,
checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)), values.UnenchantedLevel,
effectiveLevel, values.EffectiveLevel,
IsUsableUntrained(snapshot.SkillId), IsUsableUntrained(snapshot.SkillId),
trainedCost, trainedCost,
specializedCost, specializedCost,
raiseCost, raiseCost,
raise10Cost, raise10Cost,
vitaeModifier)); values.VitaeModifier));
} }
return result; return result;
@ -467,6 +483,11 @@ public sealed class CharacterSheetProvider
private int VitalMax(LocalPlayerState.VitalKind kind) => private int VitalMax(LocalPlayerState.VitalKind kind) =>
_localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0; _localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0;
private int VitalBaseMax(LocalPlayerState.VitalKind kind) =>
_localPlayer.GetBaseMaxApprox(kind) is { } max
? checked((int)Math.Min(int.MaxValue, max))
: 0;
// ── Raise-request flow ───────────────────────────────────────────────── // ── Raise-request flow ─────────────────────────────────────────────────
/// <summary> /// <summary>

View file

@ -110,7 +110,11 @@ public static class CharacterStatController
/// <summary>Row highlight color — semi-translucent gold, matches retail /// <summary>Row highlight color — semi-translucent gold, matches retail
/// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.</summary> /// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.</summary>
private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f); private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
private static readonly Vector4 BuffedSkillGreen = new(0.55f, 1f, 0.55f, 1f); // LayoutDesc 0x2100002E, FooterTitle 0x1000024E property 0x1B:
// [0]=white, [1]=green, [2]=red, [3]=light blue (#7FFFFF).
private static readonly Vector4 RetailBuffGreen = new(0f, 1f, 0f, 1f);
private static readonly Vector4 RetailDebuffRed = new(1f, 0f, 0f, 1f);
private static readonly Vector4 RetailVitaeBlue = new(127f / 255f, 1f, 1f, 1f);
// ── Row layout constants ───────────────────────────────────────────────── // ── Row layout constants ─────────────────────────────────────────────────
// RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height // RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height
@ -688,7 +692,8 @@ public static class CharacterStatController
_ => 0, _ => 0,
}; };
return v.ToString(); return v.ToString();
}); },
valueColorProvider: () => AttributeValueColor(data(), rowIndex));
row.OnClick = () => row.OnClick = () =>
{ {
@ -719,7 +724,8 @@ public static class CharacterStatController
2 => $"{s.ManaCurrent}/{s.ManaMax}", 2 => $"{s.ManaCurrent}/{s.ManaMax}",
_ => string.Empty, _ => string.Empty,
}; };
}); },
valueColorProvider: () => VitalValueColor(data(), rowIndex));
row.OnClick = () => row.OnClick = () =>
{ {
@ -770,12 +776,14 @@ public static class CharacterStatController
foreach (var skill in skills) foreach (var skill in skills)
{ {
int rowIndex = bindings.Count; int rowIndex = bindings.Count;
CharacterSkill LiveSkill() =>
FindSkill(data(), skill.Id) ?? skill;
var row = AddRow(list, datFont, spriteResolve, var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: SkillRowHeight, left: 0f, top: y, width: listW, height: SkillRowHeight,
iconDid: skill.IconDid, iconDid: skill.IconDid,
nameText: skill.Name, nameText: skill.Name,
valueProvider: () => skill.CurrentLevel.ToString(), valueProvider: () => LiveSkill().CurrentLevel.ToString(),
valueColorProvider: () => SkillValueColor(skill), valueColorProvider: () => SkillValueColor(LiveSkill()),
nameColor: Vector4.One); nameColor: Vector4.One);
row.OnClick = () => row.OnClick = () =>
{ {
@ -851,6 +859,18 @@ public static class CharacterStatController
return result; return result;
} }
private static CharacterSkill? FindSkill(CharacterSheet sheet, uint skillId)
{
IReadOnlyList<CharacterSkill> skills = sheet.Skills;
for (int i = 0; i < skills.Count; i++)
{
CharacterSkill skill = skills[i];
if (skill.Id == skillId)
return skill;
}
return null;
}
private static CharacterSkill? SkillAtDisplayIndex(CharacterSheet sheet, int index) private static CharacterSkill? SkillAtDisplayIndex(CharacterSheet sheet, int index)
{ {
if (index < 0) return null; if (index < 0) return null;
@ -872,10 +892,48 @@ public static class CharacterStatController
return null; return null;
} }
private static Vector4 SkillValueColor(CharacterSkill skill) internal static Vector4 SkillValueColor(CharacterSkill skill)
=> skill.CurrentLevel > skill.BaseLevel ? BuffedSkillGreen {
: skill.CurrentLevel < skill.BaseLevel ? new Vector4(1f, 0.45f, 0.45f, 1f) int withoutVitae = skill.CurrentLevel - skill.VitaeModifier;
return withoutVitae > skill.BaseLevel ? RetailBuffGreen
: withoutVitae < skill.BaseLevel ? RetailDebuffRed
: Vector4.One; : Vector4.One;
}
internal static Vector4 AttributeValueColor(
CharacterSheet sheet,
int rowIndex)
{
int delta = GetAttributeDelta(sheet, rowIndex);
return delta > 0 ? RetailBuffGreen
: delta < 0 ? RetailDebuffRed
: Vector4.One;
}
internal static Vector4 VitalValueColor(
CharacterSheet sheet,
int vitalIndex)
{
if ((uint)vitalIndex >= 3u
|| vitalIndex >= sheet.VitalBaseMaxValues.Length
|| vitalIndex >= sheet.VitalVitaeModifiers.Length)
{
return Vector4.One;
}
int effective = vitalIndex switch
{
0 => sheet.HealthMax,
1 => sheet.StaminaMax,
2 => sheet.ManaMax,
_ => 0,
};
int withoutVitae = effective - sheet.VitalVitaeModifiers[vitalIndex];
int baseline = sheet.VitalBaseMaxValues[vitalIndex];
return withoutVitae > baseline ? RetailBuffGreen
: withoutVitae < baseline ? RetailDebuffRed
: Vector4.One;
}
/// <summary> /// <summary>
/// Handles a row click: toggle (same row → deselect), else select new row. /// Handles a row click: toggle (same row → deselect), else select new row.
@ -1315,6 +1373,64 @@ public static class CharacterStatController
return $"{name}: {value}{delta}"; return $"{name}: {value}{delta}";
} }
private static IReadOnlyList<UiText.TextRun> BuildSelectedTitleRuns(
UiText target,
CharacterStatTab tab,
Func<CharacterSheet> data,
int[] attrSel,
int[] skillSel)
{
Vector4 Color(int index) =>
index >= 0 && index < target.FontColorPalette.Count
? target.FontColorPalette[index]
: index switch
{
1 => RetailBuffGreen,
2 => RetailDebuffRed,
3 => RetailVitaeBlue,
_ => Vector4.One,
};
if (tab == CharacterStatTab.Skills)
{
CharacterSkill? skill = SkillAtDisplayIndex(data(), skillSel[0]);
if (skill is null)
return [new("Select a Skill to Improve", Body)];
if (skill.AdvancementClass < CharacterSkillAdvancementClass.Trained)
return [new(skill.Name, Color(0))];
var runs = new List<UiText.TextRun>
{
new($"{skill.Name}: {skill.CurrentLevel}", Color(0)),
};
if (skill.VitaeModifier < 0)
runs.Add(new(FormatVitaeDelta(skill.VitaeModifier), Color(3)));
int buffDelta = GetSkillBuffOnlyDelta(skill);
if (buffDelta != 0)
runs.Add(new(
FormatBuffDelta(buffDelta),
Color(buffDelta > 0 ? 1 : 2)));
return runs;
}
if (attrSel[0] < 0)
return [new("Select an Attribute to Improve", Body)];
CharacterSheet sheet = data();
var attributeRuns = new List<UiText.TextRun>
{
new(
$"{GetRowName(attrSel[0])}: {GetRowValueString(sheet, attrSel[0])}",
Color(0)),
};
int delta = GetAttributeDelta(sheet, attrSel[0]);
if (delta != 0)
attributeRuns.Add(new(
FormatBuffDelta(delta),
Color(delta > 0 ? 1 : 2)));
return attributeRuns;
}
/// <summary> /// <summary>
/// Add a single attribute/vital row to <paramref name="list"/> as a /// Add a single attribute/vital row to <paramref name="list"/> as a
/// <see cref="UiClickablePanel"/> containing icon + name + value children. /// <see cref="UiClickablePanel"/> containing icon + name + value children.
@ -1512,6 +1628,12 @@ public static class CharacterStatController
// Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here. // Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here.
// RightAligned stays false (BuildText default for a Center element). // RightAligned stays false (BuildText default for a Center element).
titleEl.ClickThrough = true; titleEl.ClickThrough = true;
titleEl.RunsProvider = () => BuildSelectedTitleRuns(
titleEl,
activeTab[0],
data,
attrSel,
skillSel);
titleEl.LinesProvider = () => titleEl.LinesProvider = () =>
{ {
string title = BuildSelectedTitleText(activeTab[0], data, attrSel, skillSel); string title = BuildSelectedTitleText(activeTab[0], data, attrSel, skillSel);

View file

@ -91,7 +91,7 @@ public sealed class IndicatorBarController : IRetainedPanelController
_miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame); _miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame);
_endCharacterSession.OnClick = bindings.RequestEndCharacterSession; _endCharacterSession.OnClick = bindings.RequestEndCharacterSession;
bindings.Spellbook.EnchantmentsChanged += UpdateEnchantments; bindings.Spellbook.EnchantmentsChanged += OnEnchantmentsChanged;
bindings.Objects.ObjectAdded += OnObjectChanged; bindings.Objects.ObjectAdded += OnObjectChanged;
bindings.Objects.ObjectUpdated += OnObjectChanged; bindings.Objects.ObjectUpdated += OnObjectChanged;
bindings.Objects.ObjectRemoved += OnObjectChanged; bindings.Objects.ObjectRemoved += OnObjectChanged;
@ -229,6 +229,11 @@ public sealed class IndicatorBarController : IRetainedPanelController
private void OnObjectMoved(ClientObjectMove _) => UpdateBurden(); private void OnObjectMoved(ClientObjectMove _) => UpdateBurden();
private void OnContainerContentsReplaced(uint _) => UpdateBurden(); private void OnContainerContentsReplaced(uint _) => UpdateBurden();
private void OnObjectsCleared() => UpdateBurden(); private void OnObjectsCleared() => UpdateBurden();
private void OnEnchantmentsChanged()
{
UpdateEnchantments();
UpdateBurden();
}
private void UpdateBurden() private void UpdateBurden()
{ {
@ -253,7 +258,7 @@ public sealed class IndicatorBarController : IRetainedPanelController
{ {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
_bindings.Spellbook.EnchantmentsChanged -= UpdateEnchantments; _bindings.Spellbook.EnchantmentsChanged -= OnEnchantmentsChanged;
_bindings.Objects.ObjectAdded -= OnObjectChanged; _bindings.Objects.ObjectAdded -= OnObjectChanged;
_bindings.Objects.ObjectUpdated -= OnObjectChanged; _bindings.Objects.ObjectUpdated -= OnObjectChanged;
_bindings.Objects.ObjectRemoved -= OnObjectChanged; _bindings.Objects.ObjectRemoved -= OnObjectChanged;

View file

@ -3,6 +3,7 @@ using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -43,6 +44,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds; private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds;
private readonly Func<int?> _strength; private readonly Func<int?> _strength;
private readonly Spellbook? _burdenSpellbook;
private readonly Func<string>? _ownerName; private readonly Func<string>? _ownerName;
private readonly UiItemList? _contentsGrid; private readonly UiItemList? _contentsGrid;
@ -96,13 +98,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint, uint>? notifyMergeAttempt, Action<uint, uint>? notifyMergeAttempt,
ItemInteractionController? itemInteraction, ItemInteractionController? itemInteraction,
Action? onClose, Action? onClose,
StackSplitQuantityState? stackSplitQuantity) StackSplitQuantityState? stackSplitQuantity,
Spellbook? burdenSpellbook)
{ {
_objects = objects; _objects = objects;
_playerGuid = playerGuid; _playerGuid = playerGuid;
_iconIds = iconIds; _iconIds = iconIds;
_dragIconIds = dragIconIds; _dragIconIds = dragIconIds;
_strength = strength; _strength = strength;
_burdenSpellbook = burdenSpellbook;
_ownerName = ownerName; _ownerName = ownerName;
_sendUse = sendUse; _sendUse = sendUse;
_sendPutItemInContainer = sendPutItemInContainer; _sendPutItemInContainer = sendPutItemInContainer;
@ -195,6 +199,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectUpdated += OnObjectChanged; _objects.ObjectUpdated += OnObjectChanged;
_objects.Cleared += OnObjectsCleared; _objects.Cleared += OnObjectsCleared;
_selection.Changed += OnSelectionChanged; _selection.Changed += OnSelectionChanged;
if (_burdenSpellbook is not null)
_burdenSpellbook.EnchantmentsChanged += RefreshBurden;
if (_itemInteraction is not null) if (_itemInteraction is not null)
{ {
_itemInteraction.StateChanged += OnInteractionStateChanged; _itemInteraction.StateChanged += OnInteractionStateChanged;
@ -244,14 +250,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
ItemInteractionController? itemInteraction = null, ItemInteractionController? itemInteraction = null,
Action? onClose = null, Action? onClose = null,
StackSplitQuantityState? stackSplitQuantity = null, StackSplitQuantityState? stackSplitQuantity = null,
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null) Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null,
Spellbook? burdenSpellbook = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection, => new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection,
ownerName, datFont, ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendPutItemInContainer, sendUse, sendPutItemInContainer,
sendStackableSplitToContainer, sendStackableMerge, sendStackableSplitToContainer, sendStackableMerge,
notifyMergeAttempt, itemInteraction, notifyMergeAttempt, itemInteraction,
onClose, stackSplitQuantity); onClose, stackSplitQuantity, burdenSpellbook);
private void OnObjectChanged(ClientObject o) private void OnObjectChanged(ClientObject o)
{ {
@ -944,6 +951,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectUpdated -= OnObjectChanged; _objects.ObjectUpdated -= OnObjectChanged;
_objects.Cleared -= OnObjectsCleared; _objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged; _selection.Changed -= OnSelectionChanged;
if (_burdenSpellbook is not null)
_burdenSpellbook.EnchantmentsChanged -= RefreshBurden;
if (_contentsGrid is not null) if (_contentsGrid is not null)
{ {
_contentsGrid.PrimaryItemPressed = null; _contentsGrid.PrimaryItemPressed = null;

View file

@ -122,6 +122,7 @@ public sealed record InventoryRuntimeBindings(
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon, Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon, Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
Func<int?> Strength, Func<int?> Strength,
Spellbook Spellbook,
Action<uint>? SendUse, Action<uint>? SendUse,
Action<uint, uint, int>? SendPutItemInContainer, Action<uint, uint, int>? SendPutItemInContainer,
Action<uint, uint, uint, uint>? SendStackableSplitToContainer, Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
@ -209,6 +210,7 @@ public sealed class RetailUiRuntime : IDisposable
private UiShortcutDigitGraphics? _shortcutDigitGraphics; private UiShortcutDigitGraphics? _shortcutDigitGraphics;
private ItemCooldownUiController? _itemCooldownController; private ItemCooldownUiController? _itemCooldownController;
private VividTargetIndicatorController? _vividTargetIndicator; private VividTargetIndicatorController? _vividTargetIndicator;
private IDisposable? _characterSheetSubscription;
private ResourceShutdownTransaction? _shutdown; private ResourceShutdownTransaction? _shutdown;
private bool _disposed; private bool _disposed;
@ -1676,9 +1678,12 @@ public sealed class RetailUiRuntime : IDisposable
return; return;
} }
CharacterSheetProvider provider = _bindings.Character.Provider; CharacterSheetProvider provider = _bindings.Character.Provider;
CharacterSheet currentSheet = provider.BuildSheet();
_characterSheetSubscription = provider.SubscribeChanged(
() => currentSheet = provider.BuildSheet());
CharacterStatController.Bind( CharacterStatController.Bind(
layout, layout,
provider.BuildSheet, () => currentSheet,
_bindings.Assets.DefaultFont, _bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont(0x40000001u) ?? _bindings.Assets.DefaultFont, _bindings.Assets.ResolveFont(0x40000001u) ?? _bindings.Assets.DefaultFont,
_bindings.Assets.ResolveSprite, _bindings.Assets.ResolveSprite,
@ -1812,7 +1817,8 @@ public sealed class RetailUiRuntime : IDisposable
notifyMergeAttempt, b.ItemInteraction, notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory), () => CloseWindow(WindowNames.Inventory),
StackSplitQuantity, StackSplitQuantity,
b.ResolveDragIcon); b.ResolveDragIcon,
b.Spellbook);
InventoryPanelController = inventory; InventoryPanelController = inventory;
PaperdollController paperdoll = PaperdollController.Bind( PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.ItemInteraction, layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.ItemInteraction,
@ -1922,7 +1928,11 @@ public sealed class RetailUiRuntime : IDisposable
_shutdown ??= CreateShutdownTransaction( _shutdown ??= CreateShutdownTransaction(
() => _automation?.Dispose(), () => _automation?.Dispose(),
() => _persistence?.Dispose(), () => _persistence?.Dispose(),
() => Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged, () =>
{
_characterSheetSubscription?.Dispose();
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
},
() => _itemConfirmationController?.Dispose(), () => _itemConfirmationController?.Dispose(),
() => _gameplayConfirmationController?.Dispose(), () => _gameplayConfirmationController?.Dispose(),
() => DialogFactory?.Dispose(), () => DialogFactory?.Dispose(),

View file

@ -32,6 +32,11 @@ public sealed class UiText : UiElement, IUiDatStateful
/// <summary>One display line: pre-formatted text + its colour.</summary> /// <summary>One display line: pre-formatted text + its colour.</summary>
public readonly record struct Line(string Text, Vector4 Color); public readonly record struct Line(string Text, Vector4 Color);
/// <summary>
/// One inline fragment in a retail <c>AppendTextWithFont</c> line.
/// </summary>
public readonly record struct TextRun(string Text, Vector4 Color);
/// <summary>A caret position: a line index into the cached line list plus a /// <summary>A caret position: a line index into the cached line list plus a
/// character index (0..line.Text.Length, i.e. a caret slot between glyphs).</summary> /// character index (0..line.Text.Length, i.e. a caret slot between glyphs).</summary>
public readonly record struct Pos(int Line, int Col); public readonly record struct Pos(int Line, int Col);
@ -39,6 +44,13 @@ public sealed class UiText : UiElement, IUiDatStateful
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary> /// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>(); public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
/// <summary>
/// Optional inline fragments for a static one-line element. When present
/// this reproduces retail's per-append font-state colors while preserving
/// the element's authored alignment as one composed line.
/// </summary>
public Func<IReadOnlyList<TextRun>>? RunsProvider { get; set; }
/// <summary>Font for the transcript; falls back to the context default.</summary> /// <summary>Font for the transcript; falls back to the context default.</summary>
public BitmapFont? Font { get; set; } public BitmapFont? Font { get; set; }
@ -381,6 +393,12 @@ public sealed class UiText : UiElement, IUiDatStateful
private void DrawClippedText(UiRenderContext ctx) private void DrawClippedText(UiRenderContext ctx)
{ {
if (OneLine && RunsProvider is { } runsProvider)
{
DrawSingleLineRuns(ctx, runsProvider());
return;
}
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first // Static centered single-line mode (vitals cur/max numbers etc.): draw the first
// line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula // line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
// UIElement_Meter used for its label, then skip the scroll/selection machinery entirely. // UIElement_Meter used for its label, then skip the scroll/selection machinery entirely.
@ -533,6 +551,54 @@ public sealed class UiText : UiElement, IUiDatStateful
} }
} }
private void DrawSingleLineRuns(
UiRenderContext ctx,
IReadOnlyList<TextRun> runs)
{
if (runs.Count == 0) return;
UiDatFont? datFont = DatFont;
BitmapFont? bitmapFont = datFont is null
? Font ?? ctx.DefaultFont
: null;
if (datFont is null && bitmapFont is null) return;
float totalWidth = 0f;
foreach (TextRun run in runs)
{
totalWidth += datFont is not null
? datFont.MeasureWidth(run.Text)
: bitmapFont!.MeasureWidth(run.Text);
}
float x = Centered
? Math.Max(Padding, (Width - totalWidth) * 0.5f)
: RightAligned
? Math.Max(Padding, Width - Padding - totalWidth)
: Padding;
float lineHeight = datFont?.LineHeight ?? bitmapFont!.LineHeight;
float y = VOffset(
Height,
lineHeight,
Padding,
VerticalJustify);
foreach (TextRun run in runs)
{
if (run.Text.Length == 0) continue;
if (datFont is not null)
{
ctx.DrawStringDat(datFont, run.Text, x, y, run.Color);
x += datFont.MeasureWidth(run.Text);
}
else
{
ctx.DrawString(run.Text, x, y, run.Color, bitmapFont);
x += bitmapFont!.MeasureWidth(run.Text);
}
}
}
/// <summary> /// <summary>
/// True when any vertical portion of a line intersects a text viewport. Retail /// True when any vertical portion of a line intersects a text viewport. Retail
/// clips the glyphs at the viewport edge; it does not require the full line box to fit. /// clips the glyphs at the viewport edge; it does not require the full line box to fit.

View file

@ -4,6 +4,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Session;
namespace AcDream.App.World; namespace AcDream.App.World;
@ -180,6 +181,14 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
private readonly LiveEntityDeletionController _deletion; private readonly LiveEntityDeletionController _deletion;
private readonly DormantLiveEntityStore _dormant; private readonly DormantLiveEntityStore _dormant;
private readonly Action<string>? _diagnostic; private readonly Action<string>? _diagnostic;
/// <summary>
/// C3c: the graphical first-entry drive pump — pumped at the end of each
/// Create transaction so a fresh residence drives its conductor
/// synchronously (retail HandleCreateObject runs enter_world inline).
/// Optional so presentation-free hydration tests keep constructing this
/// controller without one.
/// </summary>
private readonly RuntimeFirstEntryDriveController? _firstEntry;
private readonly Dictionary<RuntimeEntityRecord, CanonicalProjectionOperation> private readonly Dictionary<RuntimeEntityRecord, CanonicalProjectionOperation>
_projectionOperations = _projectionOperations =
new(ReferenceEqualityComparer.Instance); new(ReferenceEqualityComparer.Instance);
@ -203,7 +212,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
ILocalPlayerIdentitySource identity, ILocalPlayerIdentitySource identity,
LiveEntityDeletionController deletion, LiveEntityDeletionController deletion,
DormantLiveEntityStore? dormant = null, DormantLiveEntityStore? dormant = null,
Action<string>? diagnostic = null) Action<string>? diagnostic = null,
RuntimeFirstEntryDriveController? firstEntry = null)
{ {
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_entityObjects = entityObjects _entityObjects = entityObjects
@ -219,6 +229,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
_deletion = deletion ?? throw new ArgumentNullException(nameof(deletion)); _deletion = deletion ?? throw new ArgumentNullException(nameof(deletion));
_dormant = dormant ?? new DormantLiveEntityStore(); _dormant = dormant ?? new DormantLiveEntityStore();
_diagnostic = diagnostic; _diagnostic = diagnostic;
_firstEntry = firstEntry;
} }
internal event Action<uint>? AppearanceApplied; internal event Action<uint>? AppearanceApplied;
@ -259,7 +270,9 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
lock (_datLock) lock (_datLock)
{ {
LiveEntityRegistrationResult registration = LiveEntityRegistrationResult registration =
_runtime.RegisterLiveEntity(spawn); _runtime.RegisterLiveEntity(
spawn,
isLocalPlayer: spawn.Guid == _identity.ServerGuid);
InboundCreateResult result = registration.Inbound; InboundCreateResult result = registration.Inbound;
if (result.Disposition is if (result.Disposition is
AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration) AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration)
@ -380,6 +393,16 @@ AppearanceSynchronization:
$"Prior incarnation of live entity 0x{spawn.Guid:X8} failed teardown after its replacement was installed.", $"Prior incarnation of live entity 0x{spawn.Guid:X8} failed teardown after its replacement was installed.",
cleanupFailure); cleanupFailure);
} }
// C3c: pump the first-entry drive after the complete Create
// hydration transaction — the sidecar exists, so this entity's
// conductor can run mover-prep -> placement -> drain and its
// completion receipt can bind presentation synchronously,
// matching retail HandleCreateObject's inline enter_world. Any
// still-yielding sequence (missing prepared Setup, deferred
// destination cell, FIFO ahead of us) is retried by the
// per-frame placement retry phase.
_firstEntry?.DriveAll();
} }
} }

View file

@ -5,6 +5,7 @@ using AcDream.Core.Physics;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
using System.Numerics; using System.Numerics;
using System.Runtime.ExceptionServices; using System.Runtime.ExceptionServices;
@ -29,6 +30,18 @@ public enum LiveEntityProjectionKind
Attached, Attached,
} }
/// <summary>
/// Selects whether App may publish a newly constructed sidecar immediately or
/// must keep it non-spatial until the canonical Runtime SetPosition receipt is
/// projected. The latter is the production CreateObject path; the former is a
/// temporary compatibility seam for callers not yet cut over in Slice 4B2.
/// </summary>
internal enum LiveEntityMaterializationResidence
{
LegacyImmediate,
AwaitRuntimePlacement,
}
/// <summary> /// <summary>
/// Logical-resource seam coordinated by <see cref="LiveEntityRuntime"/>. /// Logical-resource seam coordinated by <see cref="LiveEntityRuntime"/>.
/// Spatial bucketing is deliberately absent: registering or removing meshes, /// Spatial bucketing is deliberately absent: registering or removing meshes,
@ -248,6 +261,8 @@ public sealed class LiveEntityRecord
public IRuntimeProjectile? ProjectileRuntime => Canonical.Projectile; public IRuntimeProjectile? ProjectileRuntime => Canonical.Projectile;
public ILiveEntityEffectProfile? EffectProfile { get; internal set; } public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
public bool ResourcesRegistered { get; internal set; } public bool ResourcesRegistered { get; internal set; }
internal LiveEntityMaterializationResidence? MaterializationResidence
{ get; set; }
public bool IsSpatiallyProjected { get; internal set; } public bool IsSpatiallyProjected { get; internal set; }
public bool IsSpatiallyVisible { get; internal set; } public bool IsSpatiallyVisible { get; internal set; }
internal ulong ProjectionMutationVersion { get; set; } internal ulong ProjectionMutationVersion { get; set; }
@ -386,6 +401,15 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
private readonly Dictionary<RuntimeEntityKey, ILiveEntityAnimationRuntime> _spatialAnimations = new(); private readonly Dictionary<RuntimeEntityKey, ILiveEntityAnimationRuntime> _spatialAnimations = new();
private readonly List<RuntimeEntityRecord> _spatialRootCanonicalScratch = new(); private readonly List<RuntimeEntityRecord> _spatialRootCanonicalScratch = new();
private readonly List<RuntimeEntityRecord> _spatialRemoteCanonicalScratch = new(); private readonly List<RuntimeEntityRecord> _spatialRemoteCanonicalScratch = new();
// Runtime placement receipts are presentation observations of an already
// committed canonical SetPosition transaction. GpuWorldState still emits
// its ordinary visibility callback while that observation rebuckets or
// withdraws a sidecar, so pin the exact incarnation while the callback is
// in flight. A depth (rather than a bool) preserves nested/re-entrant
// projection mutations without ever turning graphical visibility into a
// Runtime physics/workset mutation.
private readonly Dictionary<RuntimeEntityKey, int>
_presentationOnlySpatialMutationDepth = new();
private bool _isClearing; private bool _isClearing;
private bool _sessionClearPendingFinalization; private bool _sessionClearPendingFinalization;
private bool _isRegisteringResources; private bool _isRegisteringResources;
@ -446,6 +470,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_projections.VisibleRecords; _projections.VisibleRecords;
internal IReadOnlyCollection<RuntimeEntityRecord> CanonicalRecords => internal IReadOnlyCollection<RuntimeEntityRecord> CanonicalRecords =>
_directory.ActiveRecords; _directory.ActiveRecords;
internal ulong SessionLifetimeVersion => _directory.SessionLifetimeVersion;
internal RuntimePhysicsState Physics => _physics; internal RuntimePhysicsState Physics => _physics;
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _directory.Snapshots; public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _directory.Snapshots;
internal int AnimationRuntimeCount internal int AnimationRuntimeCount
@ -498,7 +523,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
/// </summary> /// </summary>
public event Action<LiveEntityRecord, bool>? ProjectionVisibilityChanged; public event Action<LiveEntityRecord, bool>? ProjectionVisibilityChanged;
public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming) public LiveEntityRegistrationResult RegisterLiveEntity(
WorldSession.EntitySpawn incoming,
bool isLocalPlayer = false)
{ {
if (_isClearing || _sessionClearPendingFinalization || _isRegisteringResources) if (_isClearing || _sessionClearPendingFinalization || _isRegisteringResources)
{ {
@ -508,9 +535,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
: "A live entity cannot register from inside atomic resource registration."); : "A live entity cannot register from inside atomic resource registration.");
} }
// C3c route-1 flip: every graphical initial Create enters the
// canonical initial-residence lease. The accepted wire frame stays on
// the canonical record with FullCell 0 until the authored Runtime
// SetPosition operation commits; the host's first-entry drive
// controller walks the conductors from the residence-begin
// notification.
RuntimeEntityRegistrationResult registration = RuntimeEntityRegistrationResult registration =
_entityObjects.RegisterEntity( _entityObjects.RegisterEntityWithInitialResidence(
incoming, incoming,
isLocalPlayer,
RetirePriorProjection); RetirePriorProjection);
RuntimeEntityRecord? canonical = registration.Canonical; RuntimeEntityRecord? canonical = registration.Canonical;
LiveEntityRecord? projection = canonical is null LiveEntityRecord? projection = canonical is null
@ -567,10 +601,20 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
Func<uint, WorldEntity> factory, Func<uint, WorldEntity> factory,
LiveEntityProjectionKind projectionKind, LiveEntityProjectionKind projectionKind,
Action<LiveEntityRecord>? initializeProjection, Action<LiveEntityRecord>? initializeProjection,
out LiveEntityRecord? record) out LiveEntityRecord? record,
LiveEntityMaterializationResidence residence =
LiveEntityMaterializationResidence.LegacyImmediate)
{ {
ArgumentNullException.ThrowIfNull(expectedCanonical); ArgumentNullException.ThrowIfNull(expectedCanonical);
ArgumentNullException.ThrowIfNull(factory); ArgumentNullException.ThrowIfNull(factory);
if (residence is LiveEntityMaterializationResidence
.AwaitRuntimePlacement
&& projectionKind is not LiveEntityProjectionKind.World)
{
throw new ArgumentException(
"Only a top-level world projection can await canonical Runtime placement.",
nameof(projectionKind));
}
record = null; record = null;
if (_isClearing if (_isClearing
|| _sessionClearPendingFinalization || _sessionClearPendingFinalization
@ -594,6 +638,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{ {
record = _projections.AddMaterializing(expectedCanonical); record = _projections.AddMaterializing(expectedCanonical);
createdSidecar = true; createdSidecar = true;
record.MaterializationResidence = residence;
initializeProjection?.Invoke(record); initializeProjection?.Invoke(record);
} }
catch catch
@ -607,6 +652,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
throw; throw;
} }
} }
else if (record.MaterializationResidence != residence)
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8}/{expectedCanonical.Incarnation} "
+ $"cannot change its materialization residence from "
+ $"{record.MaterializationResidence} to {residence}.");
}
if (record.WorldEntity is null) if (record.WorldEntity is null)
{ {
@ -642,6 +694,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
} }
record.WorldEntity = entity; record.WorldEntity = entity;
// Runtime-placement residence is sticky for this incarnation and
// must suppress draw before arbitrary resource callbacks observe
// or re-enter the sidecar.
RefreshPresentation(record);
_isRegisteringResources = true; _isRegisteringResources = true;
try try
{ {
@ -715,6 +771,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
} }
RefreshPresentation(record); RefreshPresentation(record);
WorldEntity materialized = record.WorldEntity!; WorldEntity materialized = record.WorldEntity!;
if (residence is LiveEntityMaterializationResidence
.AwaitRuntimePlacement)
{
// Resource construction is a logical App-side ownership edge, not
// world residence. Runtime's immutable Place receipt is the only
// path which may install the accepted frame and spatial bucket.
record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false;
RefreshSpatialPresentationIndexes(record);
RefreshPresentation(record);
return materialized;
}
if (!RebucketLiveEntity(serverGuid, fullCellId) if (!RebucketLiveEntity(serverGuid, fullCellId)
|| !_projections.TryGet(expectedCanonical, out LiveEntityRecord? current) || !_projections.TryGet(expectedCanonical, out LiveEntityRecord? current)
|| !ReferenceEquals(current, record) || !ReferenceEquals(current, record)
@ -735,6 +803,35 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
return false; return false;
if (record.MaterializationResidence is
LiveEntityMaterializationResidence.AwaitRuntimePlacement
&& HasActiveInitialCreateResidence(record.Canonical))
{
// C3c: while the initial-create residence is ACTIVE, Runtime's
// SetPosition owner is the sole canonical position/cell/
// object-clock authority and even the graphical bucket stays
// suppressed: the conductor's completion receipt (which reaches
// presentation through
// TryApplyInitialCreateCompletionPresentation, not this API) is
// the entity's first world-visible moment. Without this gate a
// re-entrant caller (e.g. a resource-registration observer)
// could install a bucket for a suppressed record before its
// placement ever committed. A STALE residence is lazily retired
// by this same query, after which legacy moves flow.
//
// C3c-R1 review R2: the gate is the EXACT-token residence
// activity view, never the sticky MaterializationResidence enum
// alone. Post-residence (the lease completed and was consumed)
// this method falls through to the FULL legacy branch below:
// the unflipped update routes (network position/state, remote
// and local teleports, streaming reprojection, hydration
// recovery) are the position authority again, and retail's
// prepare_to_enter_world (0x00511FA0) clock rebase must run on
// every root-workset membership edge — the earlier
// presentation-only shortcut skipped CommitRebucket and that
// clock edge for the entity's whole post-residence lifetime.
return false;
}
RuntimeEntityKey key = RequireProjectionKey(record); RuntimeEntityKey key = RequireProjectionKey(record);
bool wasProjected = record.IsSpatiallyProjected; bool wasProjected = record.IsSpatiallyProjected;
@ -745,6 +842,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
// between two loaded buckets. Suppress those implementation details // between two loaded buckets. Suppress those implementation details
// and publish only the final logical visibility edge. // and publish only the final logical visibility edge.
record.IsSpatiallyProjected = true; record.IsSpatiallyProjected = true;
bool hasExactDestinationCell = spatialCellOrLandblockId != 0u
&& (spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu;
if (hasExactDestinationCell)
{
// Runtime's physics cell commit and the graphical sidecar are one
// SetPosition result. Retail CPhysicsObj::set_cell changes the
// CObjCell read by ShouldDrawParticles at the same edge; retaining
// the prior sidecar cell makes newly-created spell particles fail
// IsInView as soon as the player crosses an outdoor landcell.
entity.ParentCellId = spatialCellOrLandblockId;
entity.EffectCellId = spatialCellOrLandblockId;
}
Exception? spatialNotificationFailure = null; Exception? spatialNotificationFailure = null;
uint priorRebucketingGuid = _rebucketingGuid; uint priorRebucketingGuid = _rebucketingGuid;
_rebucketingGuid = serverGuid; _rebucketingGuid = serverGuid;
@ -863,6 +972,473 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return true; return true;
} }
/// <summary>
/// C3c: the graphical-bucket-only projection of a conductor-owned
/// initial placement — called ONLY from
/// <see cref="TryApplyInitialCreateCompletionPresentation"/> (the
/// completion receipt at the initial-create residence boundary), never
/// from the public <see cref="RebucketLiveEntity"/> (C3c-R1 review R2:
/// post-residence moves take the full legacy branch there).
/// Deliberately never calls <c>CommitRebucket</c>,
/// <c>SuspendObjectClock</c>, or <c>ResetObjectClockForEnterWorld</c> —
/// Runtime's SetPosition commit already owns all of those for the
/// residence-driven placement this receipt projects. May place into a
/// pending (not-yet-loaded) bucket exactly like the legacy Create path
/// did; the pending drain publishes visibility when the landblock loads.
/// </summary>
private bool RebucketLiveEntityPresentationOnly(
uint serverGuid,
LiveEntityRecord record,
WorldEntity entity,
uint spatialCellOrLandblockId)
{
RuntimeEntityKey key = RequireProjectionKey(record);
bool wasProjected = record.IsSpatiallyProjected;
bool wasVisible = record.IsSpatiallyVisible;
ulong projectionOperation = ++record.ProjectionMutationVersion;
record.IsSpatiallyProjected = true;
Exception? spatialNotificationFailure = null;
uint priorRebucketingGuid = _rebucketingGuid;
_rebucketingGuid = serverGuid;
BeginPresentationOnlySpatialMutation(key);
try
{
try
{
_spatial.RebucketLiveEntity(
key,
entity,
spatialCellOrLandblockId);
}
catch (AggregateException error)
{
spatialNotificationFailure = error;
}
}
finally
{
EndPresentationOnlySpatialMutation(key);
_rebucketingGuid = priorRebucketingGuid;
}
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
{
ThrowAfterCommittedProjectionChange(
serverGuid,
spatialNotificationFailure,
runtimeNotificationFailure: null);
return false;
}
bool visible = _spatial.IsLiveEntityProjectionResident(key);
record.IsSpatiallyVisible = visible;
RefreshSpatialPresentationIndexes(record);
RefreshPresentation(record);
RefreshSpatialRuntimeIndexes(record);
Exception? runtimeNotificationFailure = null;
if (!wasProjected || wasVisible != visible)
{
try
{
PublishProjectionVisibilityChanged(record, visible);
}
catch (Exception error)
{
runtimeNotificationFailure = error;
}
}
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
{
ThrowAfterCommittedProjectionChange(
serverGuid,
spatialNotificationFailure,
runtimeNotificationFailure);
return false;
}
ThrowAfterCommittedProjectionChange(
serverGuid,
spatialNotificationFailure,
runtimeNotificationFailure);
return true;
}
/// <summary>
/// C3c: applies one initial-Create ExecutorCompleted receipt's
/// presentation — the graphical binding point for a residence-driven
/// initial placement. Runtime committed position, cell, body, clocks,
/// and worksets during the conductor's drain; this installs the
/// committed frame on the sidecar and moves its graphical bucket
/// (pending buckets allowed — the legacy Create path's own semantics).
/// Superseded facts (a later legacy-path move already advanced the
/// record past the receipt) are treated as already-projected: the
/// receipt is stale for presentation and must not snap the entity back.
/// </summary>
internal bool TryApplyInitialCreateCompletionPresentation(
in RuntimePlacementProjectionSnapshot projection)
{
RuntimePlacementProjectionToken token = projection.Token;
if (!token.IsValid
|| token.SessionLifetimeVersion != _directory.SessionLifetimeVersion
|| !_projections.TryGet(token.Entity, out LiveEntityRecord? record)
|| !_directory.IsCurrent(record.Canonical)
|| record.Canonical.Key != token.Entity
|| record.WorldEntity is not { } entity)
{
// No sidecar (a deferred-child replay materializes later and
// self-projects from canonical state) or a displaced identity —
// acknowledge-only.
return true;
}
if (record.FullCellId != token.ExactCellId
|| record.Canonical.PlacementCommitVersion
!= token.PlacementCommitVersion)
{
// A newer move superseded this receipt's facts after the drain.
return true;
}
entity.SetPosition(projection.WorldPosition);
entity.Rotation = projection.Orientation;
entity.ParentCellId = token.ExactCellId;
entity.EffectCellId = token.ExactCellId;
return RebucketLiveEntityPresentationOnly(
record.ServerGuid,
record,
entity,
token.ExactCellId);
}
/// <summary>
/// C3c: true while the exact incarnation behind <paramref name="key"/>
/// holds an initial-create residence lease — the discriminator the
/// placement sink uses to leave conductor-owned Place/Withdraw receipts
/// at the FIFO head for the drive controller to consume.
/// </summary>
internal bool HasActiveInitialCreateResidence(RuntimeEntityKey key) =>
_directory.TryGetByLocalId(
key.LocalEntityId,
out RuntimeEntityRecord canonical)
&& _directory.IsCurrent(canonical)
&& canonical.Key == key
&& _entityObjects.TryGetInitialCreateResidence(canonical, out _);
/// <summary>
/// C3c: true when the exact incarnation behind <paramref name="canonical"/>
/// holds an initial-create residence lease. Used by materialization to
/// decide whether presentation must await the conductor's completion
/// receipt or may self-project from already-committed canonical state.
/// </summary>
internal bool HasActiveInitialCreateResidence(
RuntimeEntityRecord canonical) =>
_entityObjects.TryGetInitialCreateResidence(canonical, out _);
/// <summary>
/// C3c-R1 review F1: the ONLY sanctioned mutation of the otherwise
/// sticky <see cref="LiveEntityRecord.MaterializationResidence"/> — a
/// world-created (residence-managed) entity converting to an attached
/// projection at a same-incarnation kind transition (the equipped-child
/// world→attached path). Attached children have no Runtime placement,
/// so the sticky-residence rule expects them to carry
/// <see cref="LiveEntityMaterializationResidence.LegacyImmediate"/>.
/// Owned here so the invariant is asserted at the owner: converting
/// while the initial-create residence is still ACTIVE would let an
/// attached materialization race the conductor's pending placement.
/// </summary>
internal void ConvertMaterializationResidenceToLegacyImmediate(
LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
if (record.MaterializationResidence is not
LiveEntityMaterializationResidence.AwaitRuntimePlacement)
{
return;
}
if (HasActiveInitialCreateResidence(record.Canonical))
{
throw new InvalidOperationException(
$"Live entity 0x{record.ServerGuid:X8}/"
+ $"{record.Canonical.Incarnation} cannot convert to "
+ "LegacyImmediate residence while its initial-create "
+ "residence lease is still active.");
}
record.MaterializationResidence =
LiveEntityMaterializationResidence.LegacyImmediate;
}
/// <summary>
/// Applies one canonical Runtime placement receipt to the graphical
/// sidecar only. Runtime has already committed identity, position,
/// collision residence, object-clock state, and simulation worksets.
/// </summary>
internal bool TryApplyRuntimePlacementProjection(
in RuntimePlacementProjectionSnapshot projection)
{
if (projection.Kind is RuntimePlacementProjectionKind.Discard)
{
// Discard cancels only an unacknowledged observation. If its Place
// was already projected, retail keeps that last committed frame
// visible until a later canonical Place or Withdraw supersedes it.
return true;
}
if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted)
{
// F1: acknowledge-and-ignore, same as Discard. Must NOT fall
// through to the record-lookup/spatial-load gates below - those
// legitimately reject for reasons unrelated to this receipt (no
// sidecar yet, destination backend not loaded), and a false
// return here wedges the whole ordered placement stream at the
// FIFO head (RuntimePlacementProjectionSubscription's contract).
// Provably inert today: PublishExecutorCompletion has zero
// production callers.
return true;
}
RuntimePlacementProjectionToken token = projection.Token;
if (!TryGetRuntimePlacementProjectionRecord(
token,
requirePlacementVersions:
projection.Kind is RuntimePlacementProjectionKind.Place,
out LiveEntityRecord? record)
|| record.WorldEntity is not { } entity)
{
return false;
}
if (projection.Kind is RuntimePlacementProjectionKind.Place
&& !_spatial.IsLoaded(
(token.ExactCellId & 0xFFFF0000u) | 0xFFFFu))
{
// A canonical SetPosition receipt is not permission to create a
// pending graphical bucket. Keep the FIFO head unacknowledged
// until the destination backend exists, otherwise GpuWorldState's
// later pending-drain edge escapes this exact receipt transaction.
return false;
}
return projection.Kind switch
{
RuntimePlacementProjectionKind.Place =>
TryApplyRuntimePlacementPlace(in projection, record, entity),
RuntimePlacementProjectionKind.Withdraw =>
TryApplyRuntimePlacementWithdrawal(token, record, entity),
_ => false,
};
}
private bool TryApplyRuntimePlacementPlace(
in RuntimePlacementProjectionSnapshot projection,
LiveEntityRecord record,
WorldEntity entity)
{
RuntimePlacementProjectionToken token = projection.Token;
RuntimeEntityKey key = token.Entity;
ulong projectionOperation = ++record.ProjectionMutationVersion;
entity.SetPosition(projection.WorldPosition);
entity.Rotation = projection.Orientation;
entity.ParentCellId = token.ExactCellId;
entity.EffectCellId = token.ExactCellId;
record.IsSpatiallyProjected = true;
Exception? spatialNotificationFailure = null;
uint priorRebucketingGuid = _rebucketingGuid;
_rebucketingGuid = record.ServerGuid;
BeginPresentationOnlySpatialMutation(key);
try
{
try
{
_spatial.RebucketLiveEntity(key, entity, token.ExactCellId);
}
catch (AggregateException error)
{
spatialNotificationFailure = error;
}
}
finally
{
EndPresentationOnlySpatialMutation(key);
_rebucketingGuid = priorRebucketingGuid;
}
if (!IsCurrentProjectionOperation(
record.ServerGuid,
record,
projectionOperation)
|| !TryGetRuntimePlacementProjectionRecord(
token,
requirePlacementVersions: true,
out LiveEntityRecord? current)
|| !ReferenceEquals(current, record)
|| !ReferenceEquals(current.WorldEntity, entity))
{
ThrowAfterCommittedProjectionChange(
record.ServerGuid,
spatialNotificationFailure,
runtimeNotificationFailure: null);
return false;
}
bool visible = _spatial.IsLiveEntityProjectionResident(key);
record.IsSpatiallyVisible = visible;
RefreshSpatialPresentationIndexes(record);
RefreshPresentation(record);
if (!IsCurrentProjectionOperation(
record.ServerGuid,
record,
projectionOperation))
{
ThrowAfterCommittedProjectionChange(
record.ServerGuid,
spatialNotificationFailure,
runtimeNotificationFailure: null);
return false;
}
ThrowAfterCommittedProjectionChange(
record.ServerGuid,
spatialNotificationFailure,
runtimeNotificationFailure: null);
return true;
}
private bool TryApplyRuntimePlacementWithdrawal(
in RuntimePlacementProjectionToken token,
LiveEntityRecord record,
WorldEntity entity)
{
RuntimeEntityKey key = token.Entity;
ulong projectionOperation = ++record.ProjectionMutationVersion;
record.IsSpatiallyProjected = false;
Exception? spatialNotificationFailure = null;
uint priorRebucketingGuid = _rebucketingGuid;
_rebucketingGuid = record.ServerGuid;
BeginPresentationOnlySpatialMutation(key);
try
{
try
{
_spatial.RemoveLiveEntityProjection(entity);
}
catch (AggregateException error)
{
spatialNotificationFailure = error;
}
}
finally
{
EndPresentationOnlySpatialMutation(key);
_rebucketingGuid = priorRebucketingGuid;
}
if (!IsCurrentProjectionOperation(
record.ServerGuid,
record,
projectionOperation)
|| !TryGetRuntimePlacementProjectionRecord(
token,
requirePlacementVersions: false,
out LiveEntityRecord? current)
|| !ReferenceEquals(current, record)
|| !ReferenceEquals(current.WorldEntity, entity))
{
ThrowAfterCommittedProjectionChange(
record.ServerGuid,
spatialNotificationFailure,
runtimeNotificationFailure: null);
return false;
}
record.IsSpatiallyVisible = false;
RefreshSpatialPresentationIndexes(record);
RefreshPresentation(record);
if (!IsCurrentProjectionOperation(
record.ServerGuid,
record,
projectionOperation))
{
ThrowAfterCommittedProjectionChange(
record.ServerGuid,
spatialNotificationFailure,
runtimeNotificationFailure: null);
return false;
}
ThrowAfterCommittedProjectionChange(
record.ServerGuid,
spatialNotificationFailure,
runtimeNotificationFailure: null);
return true;
}
private bool TryGetRuntimePlacementProjectionRecord(
in RuntimePlacementProjectionToken token,
bool requirePlacementVersions,
out LiveEntityRecord record)
{
if (!token.IsValid
|| token.SessionLifetimeVersion != _directory.SessionLifetimeVersion
|| !_projections.TryGet(token.Entity, out record!)
|| !_directory.IsCurrent(record.Canonical)
|| record.Canonical.Key != token.Entity
|| RequireProjectionKey(record) != token.Entity
|| !IsValidPortalPlacementAuthority(token))
{
record = null!;
return false;
}
if (requirePlacementVersions
&& (record.Canonical.PositionAuthorityVersion
!= token.PositionAuthorityVersion
|| record.Canonical.SpatialAuthorityVersion
!= token.SpatialAuthorityVersion
|| record.Canonical.PlacementCommitVersion
!= token.PlacementCommitVersion
|| record.Canonical.FullCellId != token.ExactCellId))
{
record = null!;
return false;
}
return true;
}
private static bool IsValidPortalPlacementAuthority(
in RuntimePlacementProjectionToken token)
{
RuntimePortalPlacementAuthority portal = token.Portal;
if (!portal.Present)
return portal.IsEmpty;
return portal.IsValid
&& portal.Projection.DestinationCell == token.ExactCellId;
}
private void BeginPresentationOnlySpatialMutation(RuntimeEntityKey key)
{
_presentationOnlySpatialMutationDepth.TryGetValue(key, out int depth);
_presentationOnlySpatialMutationDepth[key] = checked(depth + 1);
}
private void EndPresentationOnlySpatialMutation(RuntimeEntityKey key)
{
if (!_presentationOnlySpatialMutationDepth.TryGetValue(
key,
out int depth)
|| depth <= 0)
{
throw new InvalidOperationException(
"Presentation-only spatial mutation depth was not balanced.");
}
if (depth == 1)
_presentationOnlySpatialMutationDepth.Remove(key);
else
_presentationOnlySpatialMutationDepth[key] = depth - 1;
}
/// <summary> /// <summary>
/// Removes only the render-bucket reference. The logical record and every /// Removes only the render-bucket reference. The logical record and every
/// create-time resource remain alive for later projection/rebucketing. /// create-time resource remain alive for later projection/rebucketing.
@ -2588,6 +3164,33 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_physics.AcknowledgeSpatialProjection(record.Canonical, spatial); _physics.AcknowledgeSpatialProjection(record.Canonical, spatial);
RefreshSpatialPresentationIndexes(record, current, spatial, key);
}
private void RefreshSpatialPresentationIndexes(LiveEntityRecord record)
{
bool current = IsCurrentRecord(record);
bool spatial = current && HasSpatialRuntimeProjection(record);
if (record.ProjectionKey is not { } key)
{
if (record.WorldEntity is not null)
{
throw new InvalidOperationException(
$"Materialized live entity 0x{record.ServerGuid:X8}/{record.Generation} has no exact projection key.");
}
return;
}
RefreshSpatialPresentationIndexes(record, current, spatial, key);
}
private void RefreshSpatialPresentationIndexes(
LiveEntityRecord record,
bool current,
bool spatial,
RuntimeEntityKey key)
{
if (record.WorldEntity is not null) if (record.WorldEntity is not null)
{ {
if (spatial && record.AnimationRuntime is { } animation) if (spatial && record.AnimationRuntime is { } animation)
@ -2641,6 +3244,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
bool wasVisible = record.IsSpatiallyVisible; bool wasVisible = record.IsSpatiallyVisible;
if (RequireProjectionKey(record) != key) if (RequireProjectionKey(record) != key)
return; return;
if (_presentationOnlySpatialMutationDepth.ContainsKey(key))
{
record.IsSpatiallyVisible = visible;
RefreshSpatialPresentationIndexes(record);
RefreshPresentation(record);
return;
}
bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical); bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
record.IsSpatiallyVisible = visible; record.IsSpatiallyVisible = visible;
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
@ -2723,8 +3333,11 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return; return;
PhysicsStateFlags state = record.FinalPhysicsState; PhysicsStateFlags state = record.FinalPhysicsState;
entity.IsDrawVisible = bool residenceVisible = record.MaterializationResidence is not
(state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0; LiveEntityMaterializationResidence.AwaitRuntimePlacement
|| record.IsSpatiallyProjected;
entity.IsDrawVisible = residenceVisible
&& (state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0;
bool interactionVisible = record.IsSpatiallyVisible bool interactionVisible = record.IsSpatiallyVisible
&& record.ProjectionKind is LiveEntityProjectionKind.World && record.ProjectionKind is LiveEntityProjectionKind.World

View file

@ -27,6 +27,8 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
private readonly ILiveSpatialReconcilePhase _spatialReconciler; private readonly ILiveSpatialReconcilePhase _spatialReconciler;
private readonly IWorldGenerationAvailability _availability; private readonly IWorldGenerationAvailability _availability;
private readonly IRenderProjectionSyncPhase? _renderProjectionSync; private readonly IRenderProjectionSyncPhase? _renderProjectionSync;
private readonly IRuntimePlacementProjectionRetryPhase?
_placementProjectionRetry;
public RetailLiveFrameCoordinator( public RetailLiveFrameCoordinator(
ILiveObjectFramePhase objects, ILiveObjectFramePhase objects,
@ -35,7 +37,8 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
IPostNetworkCommandFramePhase localPlayer, IPostNetworkCommandFramePhase localPlayer,
ILiveSpatialReconcilePhase spatialReconciler, ILiveSpatialReconcilePhase spatialReconciler,
IWorldGenerationAvailability? availability = null, IWorldGenerationAvailability? availability = null,
IRenderProjectionSyncPhase? renderProjectionSync = null) IRenderProjectionSyncPhase? renderProjectionSync = null,
IRuntimePlacementProjectionRetryPhase? placementProjectionRetry = null)
{ {
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
@ -45,6 +48,7 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
?? throw new ArgumentNullException(nameof(spatialReconciler)); ?? throw new ArgumentNullException(nameof(spatialReconciler));
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance; _availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
_renderProjectionSync = renderProjectionSync; _renderProjectionSync = renderProjectionSync;
_placementProjectionRetry = placementProjectionRetry;
} }
public void Tick(float deltaSeconds) public void Tick(float deltaSeconds)
@ -53,7 +57,14 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
if (_availability.IsWorldAvailable) if (_availability.IsWorldAvailable)
_objects.Tick(frameDelta); _objects.Tick(frameDelta);
using (_worldState.BeginMutationBatch()) using (_worldState.BeginMutationBatch())
{
_session.Tick(); _session.Tick();
// Streaming publication precedes this coordinator, while inbound
// network dispatch completes immediately above. A graphical
// receipt that previously lacked its destination backend can now
// retry against both readiness edges on the update thread.
_placementProjectionRetry?.RetryPending();
}
_localPlayer.RunPostNetworkCommandPhase(); _localPlayer.RunPostNetworkCommandPhase();
if (_availability.IsWorldAvailable) if (_availability.IsWorldAvailable)
_spatialReconciler.Reconcile(); _spatialReconciler.Reconcile();

View file

@ -0,0 +1,238 @@
using AcDream.Runtime.Physics;
using AcDream.Runtime.World;
using AcDream.App.Physics;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Plugins;
using AcDream.Core.World;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.World;
/// <summary>
/// Graphical projection sink for canonical Runtime SetPosition receipts. It
/// owns only App-facing world, effect-pose, cached-local-shadow, selection,
/// and renderer/VFX visibility projections. Runtime physics, shadows, body
/// state, clocks, and worksets were committed before this sink is invoked.
///
/// This adapter deliberately owns no subscription. The exact graphical
/// session route owns the shared Runtime observer and its generation-scoped
/// update-thread retry lease.
/// </summary>
internal sealed class RuntimePlacementPresentationSink
: IRuntimePlacementProjectionSink
{
private readonly LiveEntityRuntime _liveEntities;
private readonly RuntimeWorldTransitState _transit;
private readonly WorldGameState _worldState;
private readonly WorldEvents _worldEvents;
private readonly EntityEffectPoseRegistry _effectPoses;
private readonly LocalPlayerShadowState _localPlayerShadow;
private readonly Func<uint> _localPlayerGuid;
private readonly Action<uint> _clearSelectionForUnavailableEntity;
private readonly Action<LiveEntityRecord, bool>[] _visibilitySinks;
public RuntimePlacementPresentationSink(
LiveEntityRuntime liveEntities,
RuntimeWorldTransitState transit,
WorldGameState worldState,
WorldEvents worldEvents,
EntityEffectPoseRegistry effectPoses,
LocalPlayerShadowState localPlayerShadow,
Func<uint> localPlayerGuid,
Action<uint> clearSelectionForUnavailableEntity,
IEnumerable<Action<LiveEntityRecord, bool>>? visibilitySinks = null)
{
_liveEntities = liveEntities
?? throw new ArgumentNullException(nameof(liveEntities));
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
_worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents));
_effectPoses = effectPoses
?? throw new ArgumentNullException(nameof(effectPoses));
_localPlayerShadow = localPlayerShadow
?? throw new ArgumentNullException(nameof(localPlayerShadow));
_localPlayerGuid = localPlayerGuid
?? throw new ArgumentNullException(nameof(localPlayerGuid));
_clearSelectionForUnavailableEntity = clearSelectionForUnavailableEntity
?? throw new ArgumentNullException(
nameof(clearSelectionForUnavailableEntity));
_visibilitySinks = visibilitySinks?.ToArray()
?? Array.Empty<Action<LiveEntityRecord, bool>>();
if (_visibilitySinks.Any(static sink => sink is null))
throw new ArgumentException(
"Presentation visibility sinks cannot contain null.",
nameof(visibilitySinks));
}
public bool TryApply(in RuntimePlacementProjectionSnapshot projection)
{
if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted)
{
// C3c: the initial-Create completion receipt is the graphical
// binding point for a residence-driven placement (the F1
// acknowledge-and-ignore behavior applied only while
// PublishExecutorCompletion had zero production callers).
return TryApplyInitialCreateCompletion(in projection);
}
if (projection.Kind is RuntimePlacementProjectionKind.Place
or RuntimePlacementProjectionKind.Withdraw
&& _liveEntities.HasActiveInitialCreateResidence(
projection.Token.Entity))
{
// C3c: a Place/Withdraw for an entity still holding its
// initial-create residence belongs to the first-entry conductor
// machinery, which acknowledges its own receipts at the exact
// FIFO head. Leave it there — the drive controller's pump
// consumes it; applying or acknowledging here would starve the
// conductor's own acknowledgement stage forever.
return false;
}
if (projection.Kind is RuntimePlacementProjectionKind.Place
&& !_transit.IsCurrentPlacementAuthority(
projection.Token.Portal,
projection.Token.ExactCellId))
{
return false;
}
if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection))
return false;
if (projection.Kind is RuntimePlacementProjectionKind.Discard)
{
// Discard cancels only an unacknowledged observation - no
// world/presentation mutation. Must NOT fall through to the
// record-lookup gate below (that gate legitimately rejects for
// OTHER reasons, and this sink's caller
// (RuntimePlacementProjectionSubscription) treats a false return
// as "leave at the FIFO head" - a rejected Discard would
// permanently wedge the whole ordered stream).
return true;
}
if (!_liveEntities.TryGetRecord(
projection.Token.Entity,
out LiveEntityRecord record)
|| record.WorldEntity is not { } entity)
{
return false;
}
return projection.Kind switch
{
RuntimePlacementProjectionKind.Place =>
TryPublishPlace(record, entity),
RuntimePlacementProjectionKind.Withdraw =>
TryPublishWithdrawal(record, entity),
_ => false,
};
}
/// <summary>
/// C3c: binds one completed initial-Create drain's presentation. A
/// celless completion (a route that performed no SetPosition — a
/// deferred-parent child staying invisible until its parent replay, or a
/// positionless create) and a missing/superseded sidecar are
/// acknowledge-only; the sidecar's own materialization self-projects
/// from canonical state in those cases. Pending (not-yet-loaded)
/// destination buckets are allowed — the legacy Create path's own
/// semantics — so this receipt can never wedge the ordered stream behind
/// an unloaded graphical backend.
/// </summary>
private bool TryApplyInitialCreateCompletion(
in RuntimePlacementProjectionSnapshot projection)
{
if (projection.Token.ExactCellId == 0u)
return true;
if (!_liveEntities.TryApplyInitialCreateCompletionPresentation(
in projection))
{
return false;
}
if (!_liveEntities.TryGetRecord(
projection.Token.Entity,
out LiveEntityRecord record)
|| record.WorldEntity is not { } entity)
{
return true;
}
return TryPublishPlace(record, entity);
}
private bool TryPublishPlace(LiveEntityRecord record, WorldEntity entity)
{
if (!IsCurrent(record, entity))
return false;
WorldEntitySnapshot snapshot = Snapshot(entity);
_worldState.Add(snapshot);
if (!IsCurrent(record, entity))
return false;
_worldEvents.UpsertCurrent(snapshot);
if (!IsCurrent(record, entity))
return false;
_effectPoses.PublishMeshRefs(entity);
if (!IsCurrent(record, entity))
return false;
if (record.ServerGuid == _localPlayerGuid())
{
_localPlayerShadow.Set(
entity.Position,
entity.Rotation,
record.FullCellId);
}
for (int i = 0; i < _visibilitySinks.Length; i++)
{
_visibilitySinks[i](record, true);
if (!IsCurrent(record, entity))
return false;
}
return true;
}
private bool TryPublishWithdrawal(
LiveEntityRecord record,
WorldEntity entity)
{
if (!IsCurrent(record, entity))
return false;
for (int i = 0; i < _visibilitySinks.Length; i++)
{
_visibilitySinks[i](record, false);
if (!IsCurrent(record, entity))
return false;
}
_worldState.RemoveById(entity.Id);
if (!IsCurrent(record, entity))
return false;
_worldEvents.ForgetEntity(entity.Id);
if (!IsCurrent(record, entity))
return false;
_effectPoses.Remove(entity.Id);
if (!IsCurrent(record, entity))
return false;
if (record.ServerGuid == _localPlayerGuid())
_localPlayerShadow.Clear();
if (!IsCurrent(record, entity))
return false;
_clearSelectionForUnavailableEntity(record.ServerGuid);
return IsCurrent(record, entity);
}
private bool IsCurrent(LiveEntityRecord record, WorldEntity entity) =>
_liveEntities.TryGetRecord(
record.ProjectionKey!.Value,
out LiveEntityRecord current)
&& ReferenceEquals(current, record)
&& ReferenceEquals(current.WorldEntity, entity);
private static WorldEntitySnapshot Snapshot(WorldEntity entity) => new(
entity.Id,
entity.SourceGfxObjOrSetupId,
entity.Position,
entity.Rotation);
}

View file

@ -29,8 +29,9 @@ namespace AcDream.Core.Net.Messages;
/// Retail clients send it once the portal-space transition animation finishes. /// Retail clients send it once the portal-space transition animation finishes.
/// acdream's F751 teleport path now does the same through /// acdream's F751 teleport path now does the same through
/// <c>TeleportAnimSequencer.FireLoginComplete</c>. Initial session bootstrap /// <c>TeleportAnimSequencer.FireLoginComplete</c>. Initial session bootstrap
/// still sends from the PlayerCreate handler; that remaining ordering gap is /// sends from the canonical local-player first-placement completion edge, not
/// tracked as TS-28 in the divergence register. /// from raw packet receipt, so ACE's intentional Hidden/pink-bubble state is
/// released only after the client can actually present the player.
/// </para> /// </para>
/// </summary> /// </summary>
public static class GameActionLoginComplete public static class GameActionLoginComplete

View file

@ -555,16 +555,6 @@ public sealed class WorldSession : IDisposable
/// </summary> /// </summary>
public double LastServerTimeTicks { get; private set; } public double LastServerTimeTicks { get; private set; }
/// <summary>
/// Allow re-sending LoginComplete after a portal teleport. The normal
/// _loginCompleteSent latch prevents duplicate sends on the initial spawn
/// path; this method resets it so the teleport completion path can send
/// another LoginComplete to tell the server the client has finished loading
/// the destination cell. Pattern from holtburger's PlayerTeleport handler
/// (client/messages.rs line 434-440: call send_login_complete on teleport).
/// </summary>
public void ResetLoginComplete() => _loginCompleteSent = false;
/// <summary>Raised every time the state machine transitions.</summary> /// <summary>Raised every time the state machine transitions.</summary>
public event Action<State>? StateChanged; public event Action<State>? StateChanged;
@ -771,14 +761,6 @@ public sealed class WorldSession : IDisposable
Buffer.AsMemory(0, Length); Buffer.AsMemory(0, Length);
} }
/// <summary>
/// Phase 4.10 latch — true after we've sent the LoginComplete game
/// action in response to PlayerCreate. Prevents re-sending if the
/// server emits multiple PlayerCreate messages (rare but possible
/// across recall / portal teleports).
/// </summary>
private bool _loginCompleteSent;
/// <summary>L.2g slice 1: one-shot guard so the [setstate-hex] probe /// <summary>L.2g slice 1: one-shot guard so the [setstate-hex] probe
/// emits the first SetState's body bytes only, not 510/sec.</summary> /// emits the first SetState's body bytes only, not 510/sec.</summary>
private bool _setStateHexDumped; private bool _setStateHexDumped;
@ -1061,13 +1043,10 @@ public sealed class WorldSession : IDisposable
// login form. ACE validates this canonical account value. // login form. ACE validates this canonical account value.
SendGameMessage(selection.EnterWorldBody); SendGameMessage(selection.EnterWorldBody);
// NOTE: LoginComplete used to be sent here unconditionally. That was // LoginComplete is emitted by the host only after the accepted local
// wrong — per holtburger's flow (see references/holtburger/.../client/ // Create has completed its canonical first placement. Sending it at
// messages.rs lines 391-422), LoginComplete is sent in response to the // EnterWorld or merely on PlayerCreate races the server's intentional
// server's PlayerCreate (0xF746) game message, NOT immediately after // Hidden/pink-bubble login state.
// EnterWorld. Sending it too early means the player object isn't
// ready and the server ignores it. The actual trigger lives in
// ProcessDatagram.
Transition(State.InWorld); Transition(State.InWorld);
// Phase A.3: start the background receive thread now that the // Phase A.3: start the background receive thread now that the
@ -1705,17 +1684,6 @@ public sealed class WorldSession : IDisposable
// references/holtburger/.../client/messages.rs::DddInterrogation // references/holtburger/.../client/messages.rs::DddInterrogation
SendGameMessage(DddInterrogationResponse.Build()); SendGameMessage(DddInterrogationResponse.Build());
} }
else if (op == 0xF746u && !_loginCompleteSent) // PlayerCreate — server creates our player object
{
// Phase 4.10: PlayerCreate for our character is the cue to
// send LoginComplete. Sending it earlier (right after the
// outbound CharacterEnterWorld) was wrong because the server
// hadn't finished spawning the player yet. Holtburger's
// client/messages.rs (PlayerCreate handler) confirms this is
// the correct trigger. Send once per session.
_loginCompleteSent = true;
SendGameMessage(GameActionLoginComplete.Build());
}
else if (op == CreateObject.Opcode) else if (op == CreateObject.Opcode)
{ {
var parsed = CreateObject.TryParse(body); var parsed = CreateObject.TryParse(body);

View file

@ -20,6 +20,18 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute"> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>AcDream.Core.Tests</_Parameter1> <_Parameter1>AcDream.Core.Tests</_Parameter1>
</AssemblyAttribute> </AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>AcDream.Runtime</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>AcDream.Runtime.Tests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>AcDream.App.Tests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>AcDream.Headless.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" /> <ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" />

View file

@ -179,6 +179,17 @@ public sealed class PropertyBundle
/// </summary> /// </summary>
public sealed class ClientObject public sealed class ClientObject
{ {
private uint? _houseOwnerId;
private uint? _monarchId;
private HouseRestrictionRecord? _restrictions;
/// <summary>
/// Synchronous table-internal notification for the qualities consulted by
/// retail's house-entry restriction gate. Multiple tables may retain the
/// same object during isolated evaluation fixtures; each receives the edge.
/// </summary>
internal event Action<ClientObject>? RestrictionAuthorityChanged;
public uint ObjectId { get; init; } public uint ObjectId { get; init; }
public uint WeenieClassId { get; set; } // "blueprint" public uint WeenieClassId { get; set; } // "blueprint"
public string Name { get; set; } = ""; public string Name { get; set; } = "";
@ -273,7 +284,16 @@ public sealed class ClientObject
/// object (wire <c>WeenieHeaderFlag.Owner</c>, 0x02000000). Zero or a /// object (wire <c>WeenieHeaderFlag.Owner</c>, 0x02000000). Zero or a
/// match against the mover's own id admits regardless of the guest list. /// match against the mover's own id admits regardless of the guest list.
/// </summary> /// </summary>
public uint? HouseOwnerId { get; set; } public uint? HouseOwnerId
{
get => _houseOwnerId;
set
{
if (_houseOwnerId == value) return;
_houseOwnerId = value;
RestrictionAuthorityChanged?.Invoke(this);
}
}
/// <summary> /// <summary>
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc /// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
/// ._monarch_iid</c> (wire <c>WeenieHeaderFlag.Monarch</c>, 0x40) — this /// ._monarch_iid</c> (wire <c>WeenieHeaderFlag.Monarch</c>, 0x40) — this
@ -281,7 +301,16 @@ public sealed class ClientObject
/// objects; a player's own value is what <c>RestrictionDB::IsAllowedIn</c> /// objects; a player's own value is what <c>RestrictionDB::IsAllowedIn</c>
/// compares against a house's <see cref="HouseRestrictionRecord.AllegianceMonarchId"/>. /// compares against a house's <see cref="HouseRestrictionRecord.AllegianceMonarchId"/>.
/// </summary> /// </summary>
public uint? MonarchId { get; set; } public uint? MonarchId
{
get => _monarchId;
set
{
if (_monarchId == value) return;
_monarchId = value;
RestrictionAuthorityChanged?.Invoke(this);
}
}
/// <summary> /// <summary>
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc /// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
/// ._db</c> (<c>RestrictionDB*</c>) — the house's own guest/ban list. /// ._db</c> (<c>RestrictionDB*</c>) — the house's own guest/ban list.
@ -291,7 +320,16 @@ public sealed class ClientObject
/// object — acdream cannot distinguish the two, and both resolve to /// object — acdream cannot distinguish the two, and both resolve to
/// the same retail-faithful "allow" default. /// the same retail-faithful "allow" default.
/// </summary> /// </summary>
public HouseRestrictionRecord? Restrictions { get; set; } public HouseRestrictionRecord? Restrictions
{
get => _restrictions;
set
{
if (ReferenceEquals(_restrictions, value)) return;
_restrictions = value;
RestrictionAuthorityChanged?.Invoke(this);
}
}
public PropertyBundle Properties { get; } = new(); public PropertyBundle Properties { get; } = new();
/// <summary> /// <summary>

View file

@ -116,11 +116,71 @@ public sealed class ClientObjectTable
private readonly ConcurrentDictionary<uint, Container> _containers = new(); private readonly ConcurrentDictionary<uint, Container> _containers = new();
private readonly Dictionary<uint, List<uint>> _containerIndex = new(); private readonly Dictionary<uint, List<uint>> _containerIndex = new();
private readonly Dictionary<uint, List<uint>> _equipmentIndex = new(); private readonly Dictionary<uint, List<uint>> _equipmentIndex = new();
private readonly HashSet<ClientObject> _restrictionObservedObjects =
new(ReferenceEqualityComparer.Instance);
// B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE // B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE
// the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0), // the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0),
// cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo. // cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo.
private readonly Dictionary<uint, (ClientObjectPlacement placement, int outstanding)> _pendingMoves = new(); private readonly Dictionary<uint, (ClientObjectPlacement placement, int outstanding)> _pendingMoves = new();
private ulong _mutationRevision;
public ClientObjectTable()
{
// Keep one conservative authority over every object mutation that can
// affect physics entry restrictions. These handlers are registered
// before any consumer can subscribe, so re-entrant observers see the
// advanced revision before they can evaluate or publish a receipt.
ObjectAdded += _ => AdvanceMutationRevision();
ObjectMoved += _ => AdvanceMutationRevision();
ObjectRemoved += _ => AdvanceMutationRevision();
ObjectUpdated += _ => AdvanceMutationRevision();
Cleared += AdvanceMutationRevision;
}
/// <summary>
/// Monotonic authority for live object-table qualities used by physics,
/// including house ownership/restrictions and the mover's monarch.
/// </summary>
internal ulong MutationRevision => _mutationRevision;
private void AdvanceMutationRevision() =>
_mutationRevision = checked(_mutationRevision + 1UL);
private void RetainObject(ClientObject item)
{
if (_objects.TryGetValue(item.ObjectId, out ClientObject? prior)
&& !ReferenceEquals(prior, item))
{
UnbindRestrictionAuthority(prior);
}
_objects[item.ObjectId] = item;
BindRestrictionAuthority(item);
}
private void BindRestrictionAuthority(ClientObject item)
{
if (!_restrictionObservedObjects.Add(item)) return;
item.RestrictionAuthorityChanged += OnRestrictionAuthorityChanged;
}
private void UnbindRestrictionAuthority(ClientObject item)
{
if (!_restrictionObservedObjects.Remove(item)) return;
item.RestrictionAuthorityChanged -= OnRestrictionAuthorityChanged;
}
private void OnRestrictionAuthorityChanged(ClientObject item)
{
// Unbinding is synchronous, but keep the exact-reference check as a
// defensive lifetime gate against a stale/replaced object callback.
if (_objects.TryGetValue(item.ObjectId, out ClientObject? retained)
&& ReferenceEquals(retained, item))
{
AdvanceMutationRevision();
}
}
/// <summary>Fires when an object is first added to the session.</summary> /// <summary>Fires when an object is first added to the session.</summary>
public event Action<ClientObject>? ObjectAdded; public event Action<ClientObject>? ObjectAdded;
@ -270,7 +330,7 @@ public sealed class ClientObjectTable
ClientObjectPlacement previous = prior is null ClientObjectPlacement previous = prior is null
? default ? default
: ClientObjectPlacement.From(prior); : ClientObjectPlacement.From(prior);
_objects[item.ObjectId] = item; RetainObject(item);
UpdateEquipmentIndex(item.ObjectId, previous, ClientObjectPlacement.From(item)); UpdateEquipmentIndex(item.ObjectId, previous, ClientObjectPlacement.From(item));
if (!existed) ObjectAdded?.Invoke(item); if (!existed) ObjectAdded?.Invoke(item);
else ObjectUpdated?.Invoke(item); else ObjectUpdated?.Invoke(item);
@ -615,6 +675,7 @@ public sealed class ClientObjectTable
bool notifyObjectRemoved) bool notifyObjectRemoved)
{ {
if (!_objects.TryRemove(itemId, out var item)) return false; if (!_objects.TryRemove(itemId, out var item)) return false;
UnbindRestrictionAuthority(item);
List<uint>? changedContainers = RemoveFromOtherContainerIndexes( List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
itemId, itemId,
exceptContainerId: 0u); exceptContainerId: 0u);
@ -690,7 +751,7 @@ public sealed class ClientObjectTable
if (!existed || item is null) if (!existed || item is null)
{ {
item = new ClientObject { ObjectId = guid }; item = new ClientObject { ObjectId = guid };
_objects[guid] = item; RetainObject(item);
} }
foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value; foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value; foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
@ -800,7 +861,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null) // keep: satisfies nullable flow analysis if (!existed || obj is null) // keep: satisfies nullable flow analysis
{ {
obj = new ClientObject { ObjectId = d.Guid }; obj = new ClientObject { ObjectId = d.Guid };
_objects[d.Guid] = obj; RetainObject(obj);
} }
uint oldContainer = obj.ContainerId; uint oldContainer = obj.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(obj); ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
@ -876,7 +937,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null) // keep: satisfies nullable flow analysis if (!existed || obj is null) // keep: satisfies nullable flow analysis
{ {
obj = new ClientObject { ObjectId = guid }; obj = new ClientObject { ObjectId = guid };
_objects[guid] = obj; RetainObject(obj);
} }
uint oldContainer = obj.ContainerId; uint oldContainer = obj.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(obj); ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
@ -955,7 +1016,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null) if (!existed || obj is null)
{ {
obj = new ClientObject { ObjectId = entry.Guid }; obj = new ClientObject { ObjectId = entry.Guid };
_objects[entry.Guid] = obj; RetainObject(obj);
} }
ClientObjectPlacement previous = ClientObjectPlacement.From(obj); ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
@ -1249,7 +1310,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null) if (!existed || obj is null)
{ {
obj = new ClientObject { ObjectId = entry.Guid }; obj = new ClientObject { ObjectId = entry.Guid };
_objects[entry.Guid] = obj; RetainObject(obj);
} }
obj.ContainerTypeHint = entry.ContainerType; obj.ContainerTypeHint = entry.ContainerType;
if (!existed) added.Add(obj); if (!existed) added.Add(obj);
@ -1361,7 +1422,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null) if (!existed || obj is null)
{ {
obj = new ClientObject { ObjectId = entry.Guid }; obj = new ClientObject { ObjectId = entry.Guid };
_objects[entry.Guid] = obj; RetainObject(obj);
} }
ClientObjectPlacement previous = ClientObjectPlacement.From(obj); ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
@ -1426,6 +1487,8 @@ public sealed class ClientObjectTable
/// </summary> /// </summary>
public void Clear() public void Clear()
{ {
foreach (ClientObject item in _restrictionObservedObjects.ToArray())
UnbindRestrictionAuthority(item);
_objects.Clear(); _objects.Clear();
_containers.Clear(); _containers.Clear();
_containerIndex.Clear(); _containerIndex.Clear();

View file

@ -1,3 +1,4 @@
using System.Collections.Frozen;
using System.Collections.Generic; using System.Collections.Generic;
namespace AcDream.Core.Items; namespace AcDream.Core.Items;
@ -22,11 +23,29 @@ namespace AcDream.Core.Items;
/// (0 = dwelling access only, 1 = storage access also). Retail's /// (0 = dwelling access only, 1 = storage access also). Retail's
/// <c>IsAllowedIn</c> only consults key membership for entry; the permission /// <c>IsAllowedIn</c> only consults key membership for entry; the permission
/// value is preserved for wire fidelity but not consulted here.</param> /// value is preserved for wire fidelity but not consulted here.</param>
public sealed record HouseRestrictionRecord( public sealed record HouseRestrictionRecord
bool OpenToPublic,
uint AllegianceMonarchId,
IReadOnlyDictionary<uint, uint> Guests)
{ {
public HouseRestrictionRecord(
bool OpenToPublic,
uint AllegianceMonarchId,
IReadOnlyDictionary<uint, uint> Guests)
{
ArgumentNullException.ThrowIfNull(Guests);
this.OpenToPublic = OpenToPublic;
this.AllegianceMonarchId = AllegianceMonarchId;
this.Guests = Guests.ToFrozenDictionary();
}
public bool OpenToPublic { get; }
public uint AllegianceMonarchId { get; }
/// <summary>
/// Immutable snapshot of the wire permission table. The parser's mutable
/// dictionary must never remain an untracked mutation path into live
/// collision-entry authority.
/// </summary>
public IReadOnlyDictionary<uint, uint> Guests { get; }
/// <summary> /// <summary>
/// Verbatim port of retail <c>RestrictionDB::IsAllowedIn</c> /// Verbatim port of retail <c>RestrictionDB::IsAllowedIn</c>
/// (named-retail pc:444493-444516, 0x005ae8f0): /// (named-retail pc:444493-444516, 0x005ae8f0):

View file

@ -284,28 +284,46 @@ public static class BSPQuery
CollisionSphere sphere, CollisionSphere sphere,
Vector3 up, Vector3 up,
bool small) bool small)
=> CheckWalkableSupport(
poly.Plane,
poly.Vertices,
sphere.Center,
small ? sphere.Radius * 0.5f : sphere.Radius,
up);
/// <summary>
/// Retail <c>CPolygon::check_walkable</c> against an already resolved
/// polygon. The caller supplies the effective support radius; retail's
/// <c>SPHEREPATH::check_walkables</c> halves its saved sphere before
/// entering this routine.
/// </summary>
internal static bool CheckWalkableSupport(
Plane plane,
ReadOnlySpan<Vector3> vertices,
Vector3 center,
float supportRadius,
Vector3 up)
{ {
float angleUp = Vector3.Dot(poly.Plane.Normal, up); float angleUp = Vector3.Dot(plane.Normal, up);
if (angleUp < PhysicsGlobals.EPSILON) return false; if (angleUp < PhysicsGlobals.EPSILON) return false;
float angle = (Vector3.Dot(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp; float angle = (Vector3.Dot(plane.Normal, center) + plane.D) / angleUp;
var center = sphere.Center - up * angle; center -= up * angle;
float radsum = sphere.Radius * sphere.Radius; float radsum = supportRadius * supportRadius;
if (small) radsum *= 0.25f;
int n = poly.Vertices.Length; int n = vertices.Length;
int prevIdx = n - 1; int prevIdx = n - 1;
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++)
{ {
var v = poly.Vertices[i]; var v = vertices[i];
var lv = poly.Vertices[prevIdx]; var lv = vertices[prevIdx];
prevIdx = i; prevIdx = i;
var edge = v - lv; var edge = v - lv;
var disp = center - lv; var disp = center - lv;
var cross = Vector3.Cross(poly.Plane.Normal, edge); var cross = Vector3.Cross(plane.Normal, edge);
float diff = Vector3.Dot(disp, cross); float diff = Vector3.Dot(disp, cross);
if (diff < 0f) if (diff < 0f)
@ -2195,59 +2213,10 @@ public static class BSPQuery
} }
var worldNormal0 = L2W(hitPoly0!.Plane.Normal); var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
// Retail Path 6 always defers a primary/foot-sphere hit to
// L.4 slide-tangent for steep airborne hits (2026-04-30). // the outer Collide handler, regardless of polygon steepness.
// // The BSP layer neither projects a tangent nor writes the
// For polygons too steep to walk on (worldNormal.Z < FloorZ), // persistent sliding normal (0x0053A7B3-0x0053A7DC).
// skip the SetCollide → Path-4 → ContactPlane landing chain.
// That chain commits the body to the steep surface, leading
// to the "stuck in falling animation on the roof" bug — once
// grounded with a steep ContactPlane, our step_up_slide /
// cliff_slide / edge_slide chain can't produce smooth
// descent and the body wedges or "falls a bit at a time"
// when bumped.
//
// Instead: project the move along the steep face (remove
// the into-wall displacement), set CollisionNormal +
// SlidingNormal, return Slid. Same shape as Path 5's
// step-up fallback (line 1545-1547) and CylinderCollision
// (TransitionTypes.cs:1518-1522). Position is updated in-
// place; on the next resolver iteration the sphere is
// outside the poly, FindCollisions returns OK, and
// ValidateTransition commits the new position. Body stays
// airborne, falling animation continues, and gravity's
// tangent component drifts the body downhill until it
// slides off the slope's edge.
//
// This is a deliberate deviation from retail (retail uses
// SetCollide unconditionally and lets find_walkable +
// step_up_slide produce the slide). Validated against
// retail debugger trace 2026-04-30: retail body did not
// wedge; our retail-faithful port DID wedge because we're
// missing implementation details of the step_up_slide /
// cliff_slide chain on grounded-steep movement. The
// slide-tangent here produces user-acceptable behavior
// (slides off naturally) while the deeper chain port is
// researched. Filed as L.5+ followup for retail-strict.
if (worldNormal0.Z < PhysicsGlobals.FloorZ)
{
Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
Vector3 endWorld = path.GlobalSphere[0].Origin;
Vector3 gDelta = endWorld - currWorld;
float diff = Vector3.Dot(worldNormal0, gDelta);
if (diff < 0f)
path.AddOffsetToCheckPos(-worldNormal0 * diff);
collisions.SetCollisionNormal(worldNormal0);
collisions.SetSlidingNormal(worldNormal0);
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
return TransitionState.Slid;
}
// ─── SetCollide response (shallow / walkable) ───────────
// Per retail (acclient_2013_pseudo_c.txt:323783-323821).
path.SetCollide(worldNormal0); path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ; path.WalkableAllowance = PhysicsGlobals.LandingZ;
// L.2d slice 1 (2026-05-13): diagnostic side-channel. // L.2d slice 1 (2026-05-13): diagnostic side-channel.

View file

@ -25,6 +25,14 @@ public sealed class CellArray : ICollection<uint>, IReadOnlyCollection<uint>
private readonly List<uint> _order = new(); private readonly List<uint> _order = new();
private readonly HashSet<uint> _seen = new(); private readonly HashSet<uint> _seen = new();
/// <summary>
/// Optional append-only union target used by one retained SetPosition
/// transaction. Clearing this CELLARRAY must not clear the target: retail
/// can rebuild the working array repeatedly while every probed cell still
/// contributes to the transaction's collision-world authority footprint.
/// </summary>
internal CellArray? UnionTarget { get; set; }
public int Count => _order.Count; public int Count => _order.Count;
public bool IsReadOnly => false; public bool IsReadOnly => false;
@ -34,6 +42,11 @@ public sealed class CellArray : ICollection<uint>, IReadOnlyCollection<uint>
/// <summary>Append <paramref name="id"/> iff not already present (retail add_cell dedup).</summary> /// <summary>Append <paramref name="id"/> iff not already present (retail add_cell dedup).</summary>
public void Add(uint id) public void Add(uint id)
{ {
if (UnionTarget is { } target
&& !ReferenceEquals(target, this))
{
target.Add(id);
}
if (_seen.Add(id)) if (_seen.Add(id))
_order.Add(id); _order.Add(id);
} }

View file

@ -169,6 +169,7 @@ public static class CellTransit
// Retail CEnvCell::find_transit_cells first asks the loaded // Retail CEnvCell::find_transit_cells first asks the loaded
// neighbour cell whether the sphere intersects its CellBSP. // neighbour cell whether the sphere intersects its CellBSP.
// The portal-plane side test is only the unloaded-cell load hint. // The portal-plane side test is only the unloaded-cell load hint.
RecordUnionOnlyProbe(candidates, otherId);
var otherCell = cache.GetCellStruct(otherId); var otherCell = cache.GetCellStruct(otherId);
if (otherCell is not null && if (otherCell is not null &&
CollisionTraversal.HasCellContainment(cache, otherCell)) CollisionTraversal.HasCellContainment(cache, otherCell))
@ -446,6 +447,7 @@ public static class CellTransit
if (portal.OtherPortalId < 0) if (portal.OtherPortalId < 0)
continue; continue;
RecordUnionOnlyProbe(candidates, portal.OtherCellId);
var otherCell = cache.GetCellStruct(portal.OtherCellId); var otherCell = cache.GetCellStruct(portal.OtherCellId);
if (otherCell is null || if (otherCell is null ||
!CollisionTraversal.HasCellContainment(cache, otherCell)) !CollisionTraversal.HasCellContainment(cache, otherCell))
@ -577,10 +579,12 @@ public static class CellTransit
else else
{ {
AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates); AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates);
// Outdoor seeds always walk: retail's null-CLandCell case is // Retail preserves the outside-cell additions above but skips
// "landblock not loaded at all", where our per-cell building // the complete growing-array transit walk when GetVisible cannot
// lookups below come back null anyway (documented adaptation). // resolve the ACTIVE seed CLandCell (0052b50e, 0052b576). A cached
seedLoaded = true; // building alone must not promote an object through an unavailable
// landcell; the normal reflood after terrain publication retries.
seedLoaded = cache.CellGraph.GetVisible(seedCellId) is not null;
} }
if (seedLoaded) if (seedLoaded)
@ -606,6 +610,13 @@ public static class CellTransit
} }
else else
{ {
// CELLARRAY stores GetVisible's result beside every id.
// Retail skips a later candidate whose cell pointer is
// null (0052b588..0052b59f), even when a stale building
// record for that landcell remains cached.
if (cache.CellGraph.GetVisible(cellId) is null)
continue;
// CLandCell::find_transit_cells (0x00533800): // CLandCell::find_transit_cells (0x00533800):
// add_all_outside_cells (added_outside-guarded) then the // add_all_outside_cells (added_outside-guarded) then the
// building bridge for the landcell's building, if any. // building bridge for the landcell's building, if any.
@ -664,16 +675,19 @@ public static class CellTransit
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// acdream adaptation (matches <see cref="FindCellList"/> at line 518): a cell /// A missing or rootless <see cref="CellPhysics"/> record is unavailable
/// with no hydrated <see cref="CellPhysics.CellBSP"/> cannot run /// and skipped. The retail inside base case belongs to a missing positive
/// <c>point_in_cell</c>, so it is treated as NOT containing the point (skipped), /// child below a valid root, not to the root itself.
/// rather than letting <see cref="BSPQuery.PointInsideCellBsp"/>'s null-node
/// "inside" default make it spuriously claim every point.
/// </para> /// </para>
/// </summary> /// </summary>
public static uint FindVisibleChildCell( public static uint FindVisibleChildCell(
PhysicsDataCache cache, uint startCellId, Vector3 worldPoint, bool useStabList) PhysicsDataCache cache,
uint startCellId,
Vector3 worldPoint,
bool useStabList,
ICollection<uint>? probedCells = null)
{ {
probedCells?.Add(startCellId);
var start = cache.GetCellStruct(startCellId); var start = cache.GetCellStruct(startCellId);
if (start is null) return 0u; if (start is null) return 0u;
@ -684,12 +698,17 @@ public static class CellTransit
{ {
// arg3 != 0 → iterate stab_list, GetVisible + point_in_cell (:311444-311465) // arg3 != 0 → iterate stab_list, GetVisible + point_in_cell (:311444-311465)
foreach (uint id in start.VisibleCellIds) foreach (uint id in start.VisibleCellIds)
{
probedCells?.Add(id);
if (PointInCell(cache, cache.GetCellStruct(id), worldPoint)) return id; if (PointInCell(cache, cache.GetCellStruct(id), worldPoint)) return id;
}
} }
else else
{ {
// arg3 == 0 → iterate direct portals, GetOtherCell + point_in_cell (:311411-311434) // arg3 == 0 → iterate direct portals, GetOtherCell + point_in_cell (:311411-311434)
foreach (var portal in start.Portals) foreach (var portal in start.Portals)
{
probedCells?.Add(portal.OtherCellId);
if (PointInCell( if (PointInCell(
cache, cache,
cache.GetCellStruct(portal.OtherCellId), cache.GetCellStruct(portal.OtherCellId),
@ -697,6 +716,7 @@ public static class CellTransit
{ {
return portal.OtherCellId; return portal.OtherCellId;
} }
}
} }
return 0u; return 0u;
@ -705,8 +725,8 @@ public static class CellTransit
/// <summary> /// <summary>
/// <c>CEnvCell::point_in_cell</c> (cell-BSP vtable[0x84]) against a world point: /// <c>CEnvCell::point_in_cell</c> (cell-BSP vtable[0x84]) against a world point:
/// transform to the cell's local frame, then <see cref="BSPQuery.PointInsideCellBsp"/>. /// transform to the cell's local frame, then <see cref="BSPQuery.PointInsideCellBsp"/>.
/// A cell with no hydrated <see cref="CellPhysics.CellBSP"/> returns false (see /// A missing/rootless payload returns false. Retail also returns false
/// <see cref="FindVisibleChildCell"/>'s adaptation note). /// before containment when <c>CEnvCell::portals</c> is null.
/// </summary> /// </summary>
private static bool PointInCell( private static bool PointInCell(
PhysicsDataCache cache, PhysicsDataCache cache,
@ -714,6 +734,7 @@ public static class CellTransit
Vector3 worldPoint) Vector3 worldPoint)
{ {
if (cell is null || if (cell is null ||
cell.Portals.Count == 0 ||
!CollisionTraversal.HasCellContainment(cache, cell)) !CollisionTraversal.HasCellContainment(cache, cell))
{ {
return false; return false;
@ -920,6 +941,11 @@ public static class CellTransit
if ((cellId & 0xFFFFu) < 0x0100u) if ((cellId & 0xFFFFu) < 0x0100u)
{ {
// Match CELLARRAY's stored GetVisible pointer: an adjacent
// landcell id may be present because the sphere overlaps it,
// while that landblock is not loaded yet.
if (cache.CellGraph.GetVisible(cellId) is null)
continue;
// Landcell dispatch — CLandCell::find_transit_cells (0x00533800) // Landcell dispatch — CLandCell::find_transit_cells (0x00533800)
// → CSortCell::find_transit_cells (0x00534060, this->building) // → CSortCell::find_transit_cells (0x00534060, this->building)
// → CBuildingObj::find_building_transit_cells (0x006b5230) // → CBuildingObj::find_building_transit_cells (0x006b5230)
@ -994,17 +1020,13 @@ public static class CellTransit
{ {
// Interior candidate — point_in_cell via the cell BSP (vtable[0x84]). // Interior candidate — point_in_cell via the cell BSP (vtable[0x84]).
var cand = cache.GetCellStruct(candId); var cand = cache.GetCellStruct(candId);
if (cand is null || if (PointInCell(cache, cand, worldSphereCenter))
!CollisionTraversal.HasCellContainment(cache, cand))
{
continue;
}
var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform);
if (CollisionTraversal.PointInsideCell(cache, cand, local))
return candId; // interior-wins, stop (pseudo_c:308819) return candId; // interior-wins, stop (pseudo_c:308819)
} }
else if (outdoorResult == 0u && containingOutdoorId != 0u && outdoorPickAllowed) else if (outdoorResult == 0u &&
containingOutdoorId != 0u &&
outdoorPickAllowed &&
cache.CellGraph.GetVisible(candId) is not null)
{ {
// Outdoor candidate — CLandCell::point_in_cell is the XY-column the // Outdoor candidate — CLandCell::point_in_cell is the XY-column the
// sphere is over (acdream landcells have no BSP point_in_cell; the // sphere is over (acdream landcells have no BSP point_in_cell; the
@ -1059,7 +1081,11 @@ public static class CellTransit
sphereRadius)) sphereRadius))
{ {
uint recovered = FindVisibleChildCell( uint recovered = FindVisibleChildCell(
cache, currentCellId, worldSphereCenter, useStabList: true); cache,
currentCellId,
worldSphereCenter,
useStabList: true,
(candidates as CellArray)?.UnionTarget);
if (recovered != 0u && recovered != currentCellId) if (recovered != 0u && recovered != currentCellId)
return recovered; return recovered;
} }
@ -1069,6 +1095,14 @@ public static class CellTransit
return currentCellId; return currentCellId;
} }
private static void RecordUnionOnlyProbe(
ICollection<uint> candidates,
uint cellId)
{
if (candidates is CellArray { UnionTarget: { } queryFootprint })
queryFootprint.Add(cellId);
}
private static int EffectiveSphereCount(IReadOnlyList<Sphere> worldSpheres, int numSpheres) private static int EffectiveSphereCount(IReadOnlyList<Sphere> worldSpheres, int numSpheres)
{ {
if (numSpheres <= 0 || worldSpheres.Count == 0) return 0; if (numSpheres <= 0 || worldSpheres.Count == 0) return 0;

View file

@ -0,0 +1,297 @@
using System.Collections.Concurrent;
using AcDream.Core.World.Cells;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-landblock installed-key ledger for one collision-world map. Slot lists
/// mirror the shadow registry's prefix-owner-slot idiom: removal tombstones a
/// slot (key 0) so an in-flight metered cursor retains its captured list
/// reference and observes only tombstones, and an emptied container is
/// reclaimed so a future install gets a fresh compact list. Maintained by the
/// <see cref="CollisionWorldState"/> typed install/remove helpers; consumed by
/// the landblock-replacement seal so capturing one prefix's keys never scans
/// the whole resident world (O1 of the 2026-08-02 collision
/// publication-throughput fix).
/// </summary>
internal sealed class PrefixKeyIndex
{
private readonly Dictionary<uint, List<uint>> _slots = new();
private readonly Dictionary<uint, Dictionary<uint, int>> _indices = new();
private readonly Dictionary<uint, Stack<int>> _freeSlots = new();
internal void Add(uint key)
{
uint prefix = key & 0xFFFF0000u;
if (!_slots.TryGetValue(prefix, out List<uint>? slots))
{
slots = new List<uint>();
_slots[prefix] = slots;
_indices[prefix] = new Dictionary<uint, int>();
_freeSlots[prefix] = new Stack<int>();
}
Dictionary<uint, int> indices = _indices[prefix];
if (indices.ContainsKey(key))
return;
if (_freeSlots[prefix].TryPop(out int freeIndex))
{
slots[freeIndex] = key;
indices[key] = freeIndex;
return;
}
indices[key] = slots.Count;
slots.Add(key);
}
internal void Remove(uint key)
{
uint prefix = key & 0xFFFF0000u;
if (!_indices.TryGetValue(prefix, out Dictionary<uint, int>? indices)
|| !indices.Remove(key, out int slotIndex))
{
return;
}
_slots[prefix][slotIndex] = 0u;
_freeSlots[prefix].Push(slotIndex);
if (indices.Count != 0)
return;
// An in-flight seal cursor retains its captured List reference and
// observes only tombstones. A future install gets a fresh container.
_slots.Remove(prefix);
_indices.Remove(prefix);
_freeSlots.Remove(prefix);
}
/// <summary>
/// The live slot list for one landblock prefix, or null when no key is
/// installed. Callers capture the reference plus <c>Count</c> once and
/// iterate by index, skipping tombstone slots (key 0).
/// </summary>
internal List<uint>? SlotsForPrefix(uint prefix) =>
_slots.TryGetValue(prefix & 0xFFFF0000u, out List<uint>? slots)
? slots
: null;
internal int InstalledKeyCountForPrefix(uint prefix) =>
_indices.TryGetValue(prefix & 0xFFFF0000u, out var indices)
? indices.Count
: 0;
}
/// <summary>
/// One exclusive-by-ownership collision-world root. A preparation mutates only
/// its private root; activation transfers the complete root through one volatile
/// reference publication shared by every collision facade.
/// </summary>
internal sealed class CollisionWorldState
{
internal Dictionary<uint, PhysicsEngine.LandblockPhysics> Landblocks { get; } = new();
internal List<uint> LandblockSlots { get; } = new();
internal Dictionary<uint, int> LandblockIndices { get; } = new();
internal Stack<int> LandblockFreeSlots { get; } = new();
internal ConcurrentDictionary<uint, CellPhysics> CellStruct { get; } = new();
internal ConcurrentDictionary<uint, FlatCellStructureCollisionAsset>
FlatCellStruct { get; } = new();
internal ConcurrentDictionary<uint, FlatEnvCellTopology> FlatEnvCell { get; } = new();
internal ConcurrentDictionary<uint, BuildingPhysics> Buildings { get; } = new();
internal ConcurrentDictionary<uint, EnvCell> EnvCells { get; } = new();
internal ConcurrentDictionary<uint, CellGraphTerrain> Terrain { get; } = new();
internal ConcurrentDictionary<uint, ObjCell> OutdoorCells { get; } = new();
internal Dictionary<uint, List<ShadowEntry>> ShadowCells { get; } = new();
internal Dictionary<uint, List<uint>> ShadowEntityCells { get; } = new();
internal HashSet<uint> SuspendedShadowEntities { get; } = new();
internal Dictionary<uint, List<uint>> SuspendedShadowEntityCells { get; } = new();
internal Dictionary<uint, HashSet<uint>> WithdrawnPrefixesByOwner { get; } = new();
internal Dictionary<uint, IReadOnlyList<ShadowShape>> ShadowEntityShapes { get; } = new();
internal Dictionary<uint, ShadowObjectRegistry.RegistrationRecord>
ShadowEntityRegistrations { get; } = new();
internal Dictionary<uint, ulong> ShadowOwnerVersions { get; } = new();
internal Dictionary<uint, HashSet<uint>> ShadowOwnerPrefixes { get; } = new();
internal Dictionary<uint, List<uint>> ShadowPrefixOwnerSlots { get; } = new();
internal Dictionary<uint, Dictionary<uint, int>> ShadowPrefixOwnerIndices { get; } = new();
internal Dictionary<uint, Stack<int>> ShadowPrefixFreeSlots { get; } = new();
internal List<uint> ShadowOwnerSlots { get; } = new();
internal Dictionary<uint, int> ShadowOwnerIndices { get; } = new();
internal Stack<int> ShadowOwnerFreeSlots { get; } = new();
// ── O1 per-prefix installed-key ledgers ────────────────────────────────
// Every mutation of the five landblock-scoped world maps goes through the
// typed helpers below so these ledgers stay exact. The seal's landblock-
// replacement builders enumerate one prefix's keys instead of scanning the
// whole resident map, and the retirement/removal paths retire one prefix
// in O(prefix keys).
internal PrefixKeyIndex CellStructKeys { get; } = new();
internal PrefixKeyIndex FlatCellStructKeys { get; } = new();
internal PrefixKeyIndex FlatEnvCellKeys { get; } = new();
internal PrefixKeyIndex BuildingKeys { get; } = new();
internal PrefixKeyIndex EnvCellKeys { get; } = new();
internal void SetCellStruct(uint id, CellPhysics value)
{
CellStruct[id] = value;
CellStructKeys.Add(id);
}
internal bool TryAddCellStruct(uint id, CellPhysics value)
{
if (!CellStruct.TryAdd(id, value))
return false;
CellStructKeys.Add(id);
return true;
}
internal bool RemoveCellStruct(uint id)
{
if (!CellStruct.TryRemove(id, out _))
return false;
CellStructKeys.Remove(id);
return true;
}
internal void SetFlatCellStruct(uint id, FlatCellStructureCollisionAsset value)
{
FlatCellStruct[id] = value;
FlatCellStructKeys.Add(id);
}
internal bool TryAddFlatCellStruct(uint id, FlatCellStructureCollisionAsset value)
{
if (!FlatCellStruct.TryAdd(id, value))
return false;
FlatCellStructKeys.Add(id);
return true;
}
internal bool RemoveFlatCellStruct(uint id)
{
if (!FlatCellStruct.TryRemove(id, out _))
return false;
FlatCellStructKeys.Remove(id);
return true;
}
internal void SetFlatEnvCell(uint id, FlatEnvCellTopology value)
{
FlatEnvCell[id] = value;
FlatEnvCellKeys.Add(id);
}
internal bool TryAddFlatEnvCell(uint id, FlatEnvCellTopology value)
{
if (!FlatEnvCell.TryAdd(id, value))
return false;
FlatEnvCellKeys.Add(id);
return true;
}
internal bool RemoveFlatEnvCell(uint id)
{
if (!FlatEnvCell.TryRemove(id, out _))
return false;
FlatEnvCellKeys.Remove(id);
return true;
}
internal void SetBuilding(uint id, BuildingPhysics value)
{
Buildings[id] = value;
BuildingKeys.Add(id);
}
internal bool TryAddBuilding(uint id, BuildingPhysics value)
{
if (!Buildings.TryAdd(id, value))
return false;
BuildingKeys.Add(id);
return true;
}
internal bool RemoveBuilding(uint id)
{
if (!Buildings.TryRemove(id, out _))
return false;
BuildingKeys.Remove(id);
return true;
}
internal void SetEnvCell(uint id, EnvCell value)
{
EnvCells[id] = value;
EnvCellKeys.Add(id);
}
internal bool TryAddEnvCell(uint id, EnvCell value)
{
if (!EnvCells.TryAdd(id, value))
return false;
EnvCellKeys.Add(id);
return true;
}
internal bool RemoveEnvCell(uint id)
{
if (!EnvCells.TryRemove(id, out _))
return false;
EnvCellKeys.Remove(id);
return true;
}
}
/// <summary>
/// Stable indirection shared by PhysicsEngine, PhysicsDataCache, CellGraph,
/// and ShadowObjectRegistry. Readers observe either complete root, never a
/// mixture assembled by several facade assignments.
/// </summary>
internal sealed class CollisionWorldStateSlot
{
private CollisionWorldState? _current = new();
private bool _revoked;
internal CollisionWorldStateSlot()
{
}
internal CollisionWorldStateSlot(CollisionWorldState current)
{
_current = current ?? throw new ArgumentNullException(nameof(current));
}
internal CollisionWorldState Current
{
get
{
if (_revoked)
throw new ObjectDisposedException("Transferred collision generation");
return Volatile.Read(ref _current)
?? throw new ObjectDisposedException("Transferred collision generation");
}
}
internal CollisionWorldState TransferTo(CollisionWorldStateSlot destination)
{
ArgumentNullException.ThrowIfNull(destination);
if (_revoked)
throw new ObjectDisposedException("Transferred collision generation");
CollisionWorldState transferred = _current
?? throw new ObjectDisposedException("Transferred collision generation");
_revoked = true;
Volatile.Write(ref destination._current, transferred);
_current = null;
return transferred;
}
/// <summary>
/// O2 (2026-08-02): terminally revokes a consumed staging root. The
/// per-landblock delta commit installs the staged content into the active
/// root instead of swapping roots, so the staging root no longer becomes
/// the active root — but a committed preparation must still lose access to
/// its private world exactly as the old transfer revoked it.
/// </summary>
internal void Revoke()
{
_revoked = true;
_current = null;
}
internal CollisionWorldState Capture() => Current;
}

View file

@ -2036,23 +2036,6 @@ internal static class FlatBspQuery
Vector3 worldNormal0 = LocalToWorld( Vector3 worldNormal0 = LocalToWorld(
tree.PolygonTable.Polygons[defaultHitPolygonIndex0].Plane.Normal); tree.PolygonTable.Polygons[defaultHitPolygonIndex0].Plane.Normal);
if (worldNormal0.Z < PhysicsGlobals.FloorZ)
{
Vector3 currentWorld =
path.GlobalCurrCenter[0].Origin;
Vector3 endWorld =
path.GlobalSphere[0].Origin;
Vector3 globalDelta = endWorld - currentWorld;
float difference = Vector3.Dot(worldNormal0, globalDelta);
if (difference < 0f)
path.AddOffsetToCheckPos(-worldNormal0 * difference);
collisions.SetCollisionNormal(worldNormal0);
collisions.SetSlidingNormal(worldNormal0);
RecordDiagnosticHit(tree, defaultHitPolygonIndex0);
return TransitionState.Slid;
}
path.SetCollide(worldNormal0); path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ; path.WalkableAllowance = PhysicsGlobals.LandingZ;
RecordDiagnosticHit(tree, defaultHitPolygonIndex0); RecordDiagnosticHit(tree, defaultHitPolygonIndex0);
@ -2075,28 +2058,9 @@ internal static class FlatBspQuery
{ {
Vector3 worldNormal1 = LocalToWorld( Vector3 worldNormal1 = LocalToWorld(
tree.PolygonTable.Polygons[defaultHitPolygonIndex1].Plane.Normal); tree.PolygonTable.Polygons[defaultHitPolygonIndex1].Plane.Normal);
if (worldNormal1.Z < PhysicsGlobals.FloorZ) collisions.SetCollisionNormal(worldNormal1);
{
Vector3 currentWorld =
path.GlobalCurrCenter[0].Origin;
Vector3 endWorld =
path.GlobalSphere[0].Origin;
Vector3 globalDelta = endWorld - currentWorld;
float difference =
Vector3.Dot(worldNormal1, globalDelta);
if (difference < 0f)
path.AddOffsetToCheckPos(-worldNormal1 * difference);
collisions.SetCollisionNormal(worldNormal1);
collisions.SetSlidingNormal(worldNormal1);
RecordDiagnosticHit(tree, defaultHitPolygonIndex1);
return TransitionState.Slid;
}
path.SetCollide(worldNormal1);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
RecordDiagnosticHit(tree, defaultHitPolygonIndex1); RecordDiagnosticHit(tree, defaultHitPolygonIndex1);
return TransitionState.Adjusted; return TransitionState.Collided;
} }
} }

View file

@ -199,6 +199,20 @@ public sealed class PhysicsBody
/// point unchanged for an in-range local. /// point unchanged for an in-range local.
/// </summary> /// </summary>
public void SnapToCell(uint cellId, Vector3 worldPos, Vector3 cellLocal) public void SnapToCell(uint cellId, Vector3 worldPos, Vector3 cellLocal)
{
StageDormantCellFrame(cellId, worldPos, cellLocal);
InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell
}
/// <summary>
/// Installs the exact SetPosition cell/frame while the Runtime owner is
/// still dormant. This is the frame half of retail SetPositionInternal;
/// enter_world remains a later explicit publication suffix.
/// </summary>
public void StageDormantCellFrame(
uint cellId,
Vector3 worldPos,
Vector3 cellLocal)
{ {
_position = worldPos; _position = worldPos;
uint cell = cellId; uint cell = cellId;
@ -206,7 +220,6 @@ public sealed class PhysicsBody
if ((cellId & 0xFFFFu) is >= 1u and <= 0x40u) if ((cellId & 0xFFFFu) is >= 1u and <= 0x40u)
LandDefs.AdjustToOutside(ref cell, ref local); LandDefs.AdjustToOutside(ref cell, ref local);
CellPosition = new Position(cell, new CellFrame(local, Orientation)); CellPosition = new Position(cell, new CellFrame(local, Orientation));
InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell
} }
/// <summary> /// <summary>

View file

@ -6,6 +6,7 @@ using DatReaderWriter.Types;
using Plane = System.Numerics.Plane; using Plane = System.Numerics.Plane;
using UcgEnvCell = AcDream.Core.World.Cells.EnvCell; using UcgEnvCell = AcDream.Core.World.Cells.EnvCell;
using UcgCellGraph = AcDream.Core.World.Cells.CellGraph; using UcgCellGraph = AcDream.Core.World.Cells.CellGraph;
using PreparedCellGraphLandblock = AcDream.Core.World.Cells.PreparedCellGraphLandblock;
namespace AcDream.Core.Physics; namespace AcDream.Core.Physics;
@ -21,18 +22,21 @@ namespace AcDream.Core.Physics;
public sealed class PhysicsDataCache public sealed class PhysicsDataCache
{ {
private readonly bool _requirePreparedCollision; private readonly bool _requirePreparedCollision;
private PhysicsDataCache? _readFallback;
private readonly CollisionWorldStateSlot _collisionWorld;
private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new(); private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();
private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new(); private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new();
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new(); private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new(); private ConcurrentDictionary<uint, CellPhysics> _cellStruct =>
_collisionWorld.Current.CellStruct;
private readonly ConcurrentDictionary<uint, FlatGfxObjCollisionAsset> private readonly ConcurrentDictionary<uint, FlatGfxObjCollisionAsset>
_flatGfxObj = new(); _flatGfxObj = new();
private readonly ConcurrentDictionary<uint, FlatSetupCollision> private readonly ConcurrentDictionary<uint, FlatSetupCollision>
_flatSetup = new(); _flatSetup = new();
private readonly ConcurrentDictionary<uint, FlatCellStructureCollisionAsset> private ConcurrentDictionary<uint, FlatCellStructureCollisionAsset>
_flatCellStruct = new(); _flatCellStruct => _collisionWorld.Current.FlatCellStruct;
private readonly ConcurrentDictionary<uint, FlatEnvCellTopology> private ConcurrentDictionary<uint, FlatEnvCellTopology>
_flatEnvCell = new(); _flatEnvCell => _collisionWorld.Current.FlatEnvCell;
public PhysicsDataCache() public PhysicsDataCache()
: this(requirePreparedCollision: false) : this(requirePreparedCollision: false)
@ -40,8 +44,18 @@ public sealed class PhysicsDataCache
} }
private PhysicsDataCache(bool requirePreparedCollision) private PhysicsDataCache(bool requirePreparedCollision)
: this(requirePreparedCollision, new CollisionWorldStateSlot())
{
}
private PhysicsDataCache(
bool requirePreparedCollision,
CollisionWorldStateSlot collisionWorld)
{ {
_requirePreparedCollision = requirePreparedCollision; _requirePreparedCollision = requirePreparedCollision;
_collisionWorld = collisionWorld
?? throw new ArgumentNullException(nameof(collisionWorld));
CellGraph = new UcgCellGraph(_collisionWorld);
if (!requirePreparedCollision if (!requirePreparedCollision
&& PhysicsDiagnostics.CollisionShadowSampleEvery > 0) && PhysicsDiagnostics.CollisionShadowSampleEvery > 0)
{ {
@ -65,6 +79,18 @@ public sealed class PhysicsDataCache
return cache; return cache;
} }
internal static PhysicsDataCache CreateProduction(
CollisionWorldStateSlot collisionWorld)
{
var cache = new PhysicsDataCache(
requirePreparedCollision: true,
collisionWorld)
{
CollisionTraversalMode = CollisionTraversalMode.Flat,
};
return cache;
}
internal CollisionShadowVerifier? CollisionShadow { get; set; } internal CollisionShadowVerifier? CollisionShadow { get; set; }
public CollisionShadowStats CollisionShadowStats => public CollisionShadowStats CollisionShadowStats =>
@ -78,12 +104,13 @@ public sealed class PhysicsDataCache
CollisionTraversalMode.Graph; CollisionTraversalMode.Graph;
// ── Phase 2: building portal cache for outdoor→indoor entry ─────────── // ── Phase 2: building portal cache for outdoor→indoor entry ───────────
private readonly ConcurrentDictionary<uint, BuildingPhysics> _buildings = new(); private ConcurrentDictionary<uint, BuildingPhysics> _buildings =>
_collisionWorld.Current.Buildings;
/// <summary> /// <summary>
/// The unified cell graph (UCG): the active id-&gt;cell resolver and registry. /// The unified cell graph (UCG): the active id-&gt;cell resolver and registry.
/// Populated unconditionally in <see cref="CacheCellStruct"/> — BEFORE the /// Populated by <see cref="CacheCellStruct"/> for cells with valid
/// idempotency + null-BSP guards, so BSP-less cells are registered too — and /// containment (including cells with no physics root), and
/// consumed across the engine: the player render/lighting root /// consumed across the engine: the player render/lighting root
/// (<c>CellGraph.CurrCell</c>, written at the player chokepoint /// (<c>CellGraph.CurrCell</c>, written at the player chokepoint
/// <c>PhysicsEngine.UpdatePlayerCurrCell</c> and read by the renderer), the /// <c>PhysicsEngine.UpdatePlayerCurrCell</c> and read by the renderer), the
@ -92,7 +119,35 @@ public sealed class PhysicsDataCache
/// (<c>TryGetTerrainOrigin</c>, read by <c>CellTransit</c>'s pick + transit /// (<c>TryGetTerrainOrigin</c>, read by <c>CellTransit</c>'s pick + transit
/// paths). No longer inert. /// paths). No longer inert.
/// </summary> /// </summary>
public UcgCellGraph CellGraph { get; } = new(); public UcgCellGraph CellGraph { get; }
internal CollisionWorldStateSlot CollisionWorld => _collisionWorld;
/// <summary>
/// Creates the empty off-side facade used by Runtime's metered root
/// materializer. Global immutable catalogs fall through to this cache;
/// mutable world topology is installed one leaf per host step.
/// </summary>
internal PhysicsDataCache CreateEmptyCollisionStaging(
CollisionWorldStateSlot collisionWorld)
{
return new PhysicsDataCache(_requirePreparedCollision, collisionWorld)
{
CollisionTraversalMode = CollisionTraversalMode,
_readFallback = this,
};
}
internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
PhysicsDataCache staging,
uint landblockId,
uint[] gfxObjectIds,
uint[] setupIds) => new(
this,
staging,
landblockId,
gfxObjectIds,
setupIds);
/// <summary> /// <summary>
/// Extract and cache the physics BSP + polygon data from a GfxObj, /// Extract and cache the physics BSP + polygon data from a GfxObj,
@ -237,7 +292,9 @@ public sealed class PhysicsDataCache
/// Get the cached visual AABB for a GfxObj, or null if not cached. /// Get the cached visual AABB for a GfxObj, or null if not cached.
/// </summary> /// </summary>
public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) => public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) =>
_visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null; _visualBounds.TryGetValue(gfxObjId, out var vb)
? vb
: _readFallback?.GetVisualBounds(gfxObjId);
/// <summary> /// <summary>
/// Compute a tight axis-aligned bounding box over all vertices in the mesh. /// Compute a tight axis-aligned bounding box over all vertices in the mesh.
@ -364,9 +421,10 @@ public sealed class PhysicsDataCache
} }
/// <summary> /// <summary>
/// Extract and cache the physics BSP + polygon data from a CellStruct /// Extract and cache an authored CellStruct payload (indoor room geometry).
/// (indoor room geometry). No-ops if the id is already cached or the /// A missing physics root is valid (the cell can still own containment and
/// CellStruct has no physics BSP. /// portals); a missing containment root is not a loadable CEnvCell and is
/// rejected before either the graph or collision record is published.
/// </summary> /// </summary>
public void CacheCellStruct( public void CacheCellStruct(
uint envCellId, uint envCellId,
@ -396,11 +454,6 @@ public sealed class PhysicsDataCache
!_flatEnvCell.ContainsKey(envCellId)) !_flatEnvCell.ContainsKey(envCellId))
throw MissingPreparedCollision("EnvCell topology", envCellId); throw MissingPreparedCollision("EnvCell topology", envCellId);
if (preparedStructure is not null)
_flatCellStruct.TryAdd(envCellId, preparedStructure);
if (preparedTopology is not null)
_flatEnvCell.TryAdd(envCellId, preparedTopology);
if (_requirePreparedCollision) if (_requirePreparedCollision)
{ {
CachePreparedCellStruct( CachePreparedCellStruct(
@ -414,8 +467,27 @@ public sealed class PhysicsDataCache
return; return;
} }
// UCG Stage 1: register in the unified graph for ALL cells — before the // CCellStruct::point_in_cell dereferences cell_bsp->root_node before
// idempotency + null-BSP guards below, so BSP-less cells are still included. // entering BSPNODE::point_inside_cell_bsp. A null ROOT is therefore
// not the recursive missing-positive-child "inside" sentinel. The
// installed 2013 catalog contains zero such payloads; quarantine one
// rather than publishing a cell that claims the whole world.
if (cellStruct.CellBSP?.Root is null)
return;
// A malformed optional prepared shadow must not attach to an otherwise
// valid raw cell. Production takes the prepared-only overload below.
if (preparedStructure?.ContainmentBsp.RootIndex < 0)
{
preparedStructure = null;
preparedTopology = null;
}
if (preparedStructure is not null)
_collisionWorld.Current.TryAddFlatCellStruct(envCellId, preparedStructure);
if (preparedTopology is not null)
_collisionWorld.Current.TryAddFlatEnvCell(envCellId, preparedTopology);
// UCG Stage 1: register only a loadable authored cell.
if (!CellGraph.Contains(envCellId)) if (!CellGraph.Contains(envCellId))
{ {
CellGraph.Add(UcgEnvCell.FromDat( CellGraph.Add(UcgEnvCell.FromDat(
@ -427,11 +499,12 @@ public sealed class PhysicsDataCache
} }
if (_cellStruct.ContainsKey(envCellId)) return; if (_cellStruct.ContainsKey(envCellId)) return;
if (cellStruct.PhysicsBSP?.Root is null) return;
Matrix4x4.Invert(worldTransform, out var inverseTransform); Matrix4x4.Invert(worldTransform, out var inverseTransform);
var resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray); var resolved = cellStruct.PhysicsPolygons is null
? new Dictionary<ushort, ResolvedPolygon>()
: ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);
// Visible polygons — portals reference these (NOT PhysicsPolygons). // Visible polygons — portals reference these (NOT PhysicsPolygons).
var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray); var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray);
@ -485,7 +558,7 @@ public sealed class PhysicsDataCache
// for every ordinary (non-house-barrier) cell. // for every ordinary (non-house-barrier) cell.
RestrictionObj = envCell.RestrictionObj, RestrictionObj = envCell.RestrictionObj,
}; };
_cellStruct[envCellId] = cellPhysics; _collisionWorld.Current.SetCellStruct(envCellId, cellPhysics);
if (PhysicsDiagnostics.ProbeDumpCellsEnabled if (PhysicsDiagnostics.ProbeDumpCellsEnabled
&& PhysicsDiagnostics.ProbeDumpCellIds.Contains(envCellId)) && PhysicsDiagnostics.ProbeDumpCellIds.Contains(envCellId))
@ -616,8 +689,15 @@ public sealed class PhysicsDataCache
FlatCellStructureCollisionAsset preparedStructure, FlatCellStructureCollisionAsset preparedStructure,
FlatEnvCellTopology preparedTopology) FlatEnvCellTopology preparedTopology)
{ {
_flatCellStruct.TryAdd(envCellId, preparedStructure); // Same invariant as the raw loader. RootIndex -1 is the flattened
_flatEnvCell.TryAdd(envCellId, preparedTopology); // encoding of a missing ROOT, not a recursive positive-child sentinel.
// Reject it atomically so graph, collision, and prepared caches agree
// that this cell is unavailable and a later valid hydration may retry.
if (preparedStructure.ContainmentBsp.RootIndex < 0)
return;
_collisionWorld.Current.TryAddFlatCellStruct(envCellId, preparedStructure);
_collisionWorld.Current.TryAddFlatEnvCell(envCellId, preparedTopology);
if (!CellGraph.Contains(envCellId)) if (!CellGraph.Contains(envCellId))
{ {
@ -628,11 +708,8 @@ public sealed class PhysicsDataCache
preparedTopology)); preparedTopology));
} }
// Preserve CacheCellStruct's existing distinction: BSP-less cells // Physics may be rootless even though the cell's containment and
// participate in the cell graph but do not masquerade as hydrated // topology are valid; preserve that loaded, non-colliding cell.
// collision cells.
if (preparedStructure.PhysicsBsp.RootIndex < 0)
return;
if (_cellStruct.ContainsKey(envCellId)) if (_cellStruct.ContainsKey(envCellId))
return; return;
@ -647,7 +724,7 @@ public sealed class PhysicsDataCache
portal.Flags)); portal.Flags));
} }
_cellStruct.TryAdd(envCellId, new CellPhysics _collisionWorld.Current.TryAddCellStruct(envCellId, new CellPhysics
{ {
SourceId = envCellId, SourceId = envCellId,
WorldTransform = worldTransform, WorldTransform = worldTransform,
@ -736,14 +813,24 @@ public sealed class PhysicsDataCache
$"Production {kind} 0x{sourceId:X8} has no prepared collision asset. " + $"Production {kind} 0x{sourceId:X8} has no prepared collision asset. " +
"Gameplay must not extract or fall back to a parsed DAT graph."); "Gameplay must not extract or fall back to a parsed DAT graph.");
public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null; public GfxObjPhysics? GetGfxObj(uint id) =>
_gfxObj.TryGetValue(id, out var p)
? p
: _readFallback?.GetGfxObj(id);
public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null; public SetupPhysics? GetSetup(uint id) =>
_setup.TryGetValue(id, out var p)
? p
: _readFallback?.GetSetup(id);
public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null; public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null;
public FlatGfxObjCollisionAsset? GetFlatGfxObj(uint id) => public FlatGfxObjCollisionAsset? GetFlatGfxObj(uint id) =>
_flatGfxObj.TryGetValue(id, out var value) ? value : null; _flatGfxObj.TryGetValue(id, out var value)
? value
: _readFallback?.GetFlatGfxObj(id);
public FlatSetupCollision? GetFlatSetup(uint id) => public FlatSetupCollision? GetFlatSetup(uint id) =>
_flatSetup.TryGetValue(id, out var value) ? value : null; _flatSetup.TryGetValue(id, out var value)
? value
: _readFallback?.GetFlatSetup(id);
public FlatCellStructureCollisionAsset? GetFlatCellStruct(uint id) => public FlatCellStructureCollisionAsset? GetFlatCellStruct(uint id) =>
_flatCellStruct.TryGetValue(id, out var value) ? value : null; _flatCellStruct.TryGetValue(id, out var value) ? value : null;
public FlatEnvCellTopology? GetFlatEnvCell(uint id) => public FlatEnvCellTopology? GetFlatEnvCell(uint id) =>
@ -827,7 +914,7 @@ public sealed class PhysicsDataCache
/// dat-driven <see cref="CacheCellStruct"/>. /// dat-driven <see cref="CacheCellStruct"/>.
/// </summary> /// </summary>
public void RegisterCellStructForTest(uint envCellId, CellPhysics physics) public void RegisterCellStructForTest(uint envCellId, CellPhysics physics)
=> _cellStruct[envCellId] = physics; => _collisionWorld.Current.SetCellStruct(envCellId, physics);
/// <summary> /// <summary>
/// Indoor walking Phase 2 (2026-05-19). Cache the building portal list /// Indoor walking Phase 2 (2026-05-19). Cache the building portal list
@ -839,7 +926,7 @@ public sealed class PhysicsDataCache
{ {
if (_buildings.ContainsKey(landcellId)) return; if (_buildings.ContainsKey(landcellId)) return;
Matrix4x4.Invert(worldTransform, out var inverse); Matrix4x4.Invert(worldTransform, out var inverse);
_buildings[landcellId] = new BuildingPhysics _collisionWorld.Current.SetBuilding(landcellId, new BuildingPhysics
{ {
WorldTransform = worldTransform, WorldTransform = worldTransform,
InverseWorldTransform = inverse, InverseWorldTransform = inverse,
@ -848,7 +935,7 @@ public sealed class PhysicsDataCache
// (0x00534030) — and one building per origin landcell mirrors // (0x00534030) — and one building per origin landcell mirrors
// CLandBlock::init_buildings (0x0052fd80). // CLandBlock::init_buildings (0x0052fd80).
ModelId = modelId, ModelId = modelId,
}; });
} }
/// <summary> /// <summary>
@ -867,10 +954,33 @@ public sealed class PhysicsDataCache
/// </summary> /// </summary>
public void RemoveBuildingsForLandblock(uint landblockId) public void RemoveBuildingsForLandblock(uint landblockId)
{ {
uint prefix = landblockId & 0xFFFF0000u; CollisionWorldState world = _collisionWorld.Current;
foreach (var key in _buildings.Keys) RemovePrefixKeys(
if ((key & 0xFFFF0000u) == prefix) world.BuildingKeys,
_buildings.TryRemove(key, out _); landblockId & 0xFFFF0000u,
world.RemoveBuilding);
}
/// <summary>
/// Retires one landblock prefix's installed keys through the O1 ledger:
/// O(prefix keys), never a whole-map scan. Removal tombstones the captured
/// slot list, so index iteration over the captured reference stays exact.
/// </summary>
private static void RemovePrefixKeys(
PrefixKeyIndex ledger,
uint prefix,
Func<uint, bool> remove)
{
List<uint>? slots = ledger.SlotsForPrefix(prefix);
if (slots is null)
return;
int limit = slots.Count;
for (int index = 0; index < limit; index++)
{
uint key = slots[index];
if (key != 0u)
remove(key);
}
} }
/// <summary> /// <summary>
@ -886,15 +996,13 @@ public sealed class PhysicsDataCache
public void RemoveCellsForLandblock(uint landblockId) public void RemoveCellsForLandblock(uint landblockId)
{ {
uint prefix = landblockId & 0xFFFF0000u; uint prefix = landblockId & 0xFFFF0000u;
foreach (var key in _cellStruct.Keys) CollisionWorldState world = _collisionWorld.Current;
if ((key & 0xFFFF0000u) == prefix) RemovePrefixKeys(world.CellStructKeys, prefix, world.RemoveCellStruct);
_cellStruct.TryRemove(key, out _); RemovePrefixKeys(
foreach (var key in _flatCellStruct.Keys) world.FlatCellStructKeys,
if ((key & 0xFFFF0000u) == prefix) prefix,
_flatCellStruct.TryRemove(key, out _); world.RemoveFlatCellStruct);
foreach (var key in _flatEnvCell.Keys) RemovePrefixKeys(world.FlatEnvCellKeys, prefix, world.RemoveFlatEnvCell);
if ((key & 0xFFFF0000u) == prefix)
_flatEnvCell.TryRemove(key, out _);
} }
public BuildingPhysics? GetBuilding(uint landcellId) public BuildingPhysics? GetBuilding(uint landcellId)
@ -903,9 +1011,358 @@ public sealed class PhysicsDataCache
public IReadOnlyCollection<uint> BuildingIds => (IReadOnlyCollection<uint>)_buildings.Keys; public IReadOnlyCollection<uint> BuildingIds => (IReadOnlyCollection<uint>)_buildings.Keys;
/// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary> /// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary>
public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b; public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) =>
_collisionWorld.Current.SetBuilding(landcellId, b);
internal sealed class LandblockReplacementBuilder : IDisposable
{
private readonly PhysicsDataCache _active;
private readonly PhysicsDataCache _staging;
private readonly uint _prefix;
private readonly uint[] _gfxIds;
private readonly uint[] _setupIds;
private readonly List<KeyValuePair<uint, GfxObjPhysics>> _gfx = new();
private readonly List<KeyValuePair<uint, GfxObjVisualBounds>> _bounds = new();
private readonly List<KeyValuePair<uint, FlatGfxObjCollisionAsset>> _flatGfx = new();
private readonly List<KeyValuePair<uint, SetupPhysics>> _setups = new();
private readonly List<KeyValuePair<uint, FlatSetupCollision>> _flatSetups = new();
private readonly List<KeyValuePair<uint, CellPhysics>> _cells = new();
private readonly List<KeyValuePair<uint, FlatCellStructureCollisionAsset>> _flatCells = new();
private readonly List<KeyValuePair<uint, FlatEnvCellTopology>> _flatEnvCells = new();
private readonly List<KeyValuePair<uint, BuildingPhysics>> _buildings = new();
private readonly HashSet<uint> _cellIds = new();
private readonly HashSet<uint> _flatCellIds = new();
private readonly HashSet<uint> _flatEnvCellIds = new();
private readonly HashSet<uint> _buildingIds = new();
private readonly List<uint> _removeCells = new();
private readonly List<uint> _removeFlatCells = new();
private readonly List<uint> _removeFlatEnvCells = new();
private readonly List<uint> _removeBuildings = new();
private readonly UcgCellGraph.LandblockReplacementBuilder _cellGraph;
private List<uint>? _keySlots;
private int _keySlotLimit;
private bool _keySlotsCaptured;
private int _phase;
private int _cursor;
internal LandblockReplacementBuilder(
PhysicsDataCache active,
PhysicsDataCache staging,
uint landblockId,
uint[] gfxIds,
uint[] setupIds)
{
_active = active;
_staging = staging;
_prefix = landblockId & 0xFFFF0000u;
_gfxIds = gfxIds;
_setupIds = setupIds;
_cellGraph = active.CellGraph.CreateLandblockReplacementBuilder(
staging.CellGraph,
_prefix);
}
internal int WorkUnits { get; private set; }
internal PreparedPhysicsDataCacheLandblock? Prepared { get; private set; }
internal bool Advance()
{
switch (_phase)
{
case 0:
if (_cursor < _gfxIds.Length)
{
uint id = _gfxIds[_cursor++];
Preinstall(_staging._gfxObj, _active._gfxObj, id);
Preinstall(_staging._visualBounds, _active._visualBounds, id);
Preinstall(_staging._flatGfxObj, _active._flatGfxObj, id);
Capture(_staging._gfxObj, id, _gfx);
Capture(_staging._visualBounds, id, _bounds);
Capture(_staging._flatGfxObj, id, _flatGfx);
WorkUnits++;
return false;
}
_cursor = 0;
_phase++;
return false;
case 1:
if (_cursor < _setupIds.Length)
{
uint id = _setupIds[_cursor++];
Preinstall(_staging._setup, _active._setup, id);
Preinstall(_staging._flatSetup, _active._flatSetup, id);
Capture(_staging._setup, id, _setups);
Capture(_staging._flatSetup, id, _flatSetups);
WorkUnits++;
return false;
}
_phase++;
return false;
case 2:
{
// O1: enumerate the staging root's installed target-prefix
// keys instead of scanning the whole staging map, one key
// per advance.
if (TryTakeNextPrefixKey(
StagingWorld.CellStructKeys,
out uint id))
{
CaptureInstall(_staging._cellStruct, id, _cells, _cellIds);
WorkUnits++;
return false;
}
_phase++;
return false;
}
case 3:
{
// O1: enumerate the active root's installed target-prefix
// keys for removal capture. This also removes the previous
// cross-frame live enumerator over the active map.
if (TryTakeNextPrefixKey(
ActiveWorld.CellStructKeys,
out uint id))
{
CaptureRemoval(_active._cellStruct, id, _cellIds, _removeCells);
WorkUnits++;
return false;
}
_phase++;
return false;
}
case 4:
{
if (TryTakeNextPrefixKey(
StagingWorld.FlatCellStructKeys,
out uint id))
{
CaptureInstall(
_staging._flatCellStruct,
id,
_flatCells,
_flatCellIds);
WorkUnits++;
return false;
}
_phase++;
return false;
}
case 5:
{
if (TryTakeNextPrefixKey(
ActiveWorld.FlatCellStructKeys,
out uint id))
{
CaptureRemoval(
_active._flatCellStruct,
id,
_flatCellIds,
_removeFlatCells);
WorkUnits++;
return false;
}
_phase++;
return false;
}
case 6:
{
if (TryTakeNextPrefixKey(
StagingWorld.FlatEnvCellKeys,
out uint id))
{
CaptureInstall(
_staging._flatEnvCell,
id,
_flatEnvCells,
_flatEnvCellIds);
WorkUnits++;
return false;
}
_phase++;
return false;
}
case 7:
{
if (TryTakeNextPrefixKey(
ActiveWorld.FlatEnvCellKeys,
out uint id))
{
CaptureRemoval(
_active._flatEnvCell,
id,
_flatEnvCellIds,
_removeFlatEnvCells);
WorkUnits++;
return false;
}
_phase++;
return false;
}
case 8:
{
if (TryTakeNextPrefixKey(
StagingWorld.BuildingKeys,
out uint id))
{
CaptureInstall(
_staging._buildings,
id,
_buildings,
_buildingIds);
WorkUnits++;
return false;
}
_phase++;
return false;
}
case 9:
{
if (TryTakeNextPrefixKey(
ActiveWorld.BuildingKeys,
out uint id))
{
CaptureRemoval(
_active._buildings,
id,
_buildingIds,
_removeBuildings);
WorkUnits++;
return false;
}
_phase++;
return false;
}
case 10:
WorkUnits++;
if (!_cellGraph.Advance())
return false;
Prepared = new PreparedPhysicsDataCacheLandblock(
_prefix,
_gfx,
_bounds,
_flatGfx,
_setups,
_flatSetups,
_removeCells,
_cells,
_removeFlatCells,
_flatCells,
_removeFlatEnvCells,
_flatEnvCells,
_removeBuildings,
_buildings,
_cellGraph.Prepared!);
_phase++;
return true;
default:
return true;
}
}
private static void Capture<T>(
ConcurrentDictionary<uint, T> source,
uint id,
List<KeyValuePair<uint, T>> destination)
{
if (source.TryGetValue(id, out T? value))
destination.Add(new KeyValuePair<uint, T>(id, value));
}
private static void Preinstall<T>(
ConcurrentDictionary<uint, T> source,
ConcurrentDictionary<uint, T> destination,
uint id)
{
if (source.TryGetValue(id, out T? value))
destination.TryAdd(id, value);
}
private CollisionWorldState StagingWorld =>
_staging._collisionWorld.Current;
private CollisionWorldState ActiveWorld =>
_active._collisionWorld.Current;
/// <summary>
/// O1 metered prefix-key cursor. The first call of a phase captures
/// the ledger's live slot-list reference and count; later calls
/// iterate by index, skipping tombstones (key 0). Removal only ever
/// tombstones a slot, so a captured reference stays exact across
/// frames without holding a map enumerator.
/// </summary>
private bool TryTakeNextPrefixKey(PrefixKeyIndex ledger, out uint key)
{
if (!_keySlotsCaptured)
{
_keySlots = ledger.SlotsForPrefix(_prefix);
_keySlotLimit = _keySlots?.Count ?? 0;
_keySlotsCaptured = true;
_cursor = 0;
}
while (_cursor < _keySlotLimit)
{
uint candidate = _keySlots![_cursor++];
if (candidate != 0u)
{
key = candidate;
return true;
}
}
key = 0u;
_keySlots = null;
_keySlotsCaptured = false;
return false;
}
private static void CaptureInstall<T>(
ConcurrentDictionary<uint, T> source,
uint id,
List<KeyValuePair<uint, T>> destination,
HashSet<uint> ids)
{
if (source.TryGetValue(id, out T? value))
{
destination.Add(new KeyValuePair<uint, T>(id, value));
ids.Add(id);
}
}
private static void CaptureRemoval<T>(
ConcurrentDictionary<uint, T> source,
uint id,
HashSet<uint> retained,
List<uint> destination)
{
if (!retained.Contains(id) && source.ContainsKey(id))
destination.Add(id);
}
public void Dispose()
{
_keySlots = null;
_keySlotsCaptured = false;
_cellGraph.Dispose();
}
}
} }
internal sealed record PreparedPhysicsDataCacheLandblock(
uint LandblockPrefix,
IReadOnlyList<KeyValuePair<uint, GfxObjPhysics>> GfxObjects,
IReadOnlyList<KeyValuePair<uint, GfxObjVisualBounds>> VisualBounds,
IReadOnlyList<KeyValuePair<uint, FlatGfxObjCollisionAsset>> FlatGfxObjects,
IReadOnlyList<KeyValuePair<uint, SetupPhysics>> Setups,
IReadOnlyList<KeyValuePair<uint, FlatSetupCollision>> FlatSetups,
IReadOnlyList<uint> CellIdsToRemove,
IReadOnlyList<KeyValuePair<uint, CellPhysics>> Cells,
IReadOnlyList<uint> FlatCellIdsToRemove,
IReadOnlyList<KeyValuePair<uint, FlatCellStructureCollisionAsset>> FlatCells,
IReadOnlyList<uint> FlatEnvCellIdsToRemove,
IReadOnlyList<KeyValuePair<uint, FlatEnvCellTopology>> FlatEnvCells,
IReadOnlyList<uint> BuildingIdsToRemove,
IReadOnlyList<KeyValuePair<uint, BuildingPhysics>> Buildings,
PreparedCellGraphLandblock CellGraph);
/// <summary> /// <summary>
/// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless /// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless
/// of whether it has physics data. Used as a collision fallback shape for /// of whether it has physics data. Used as a collision fallback shape for
@ -1023,8 +1480,9 @@ public sealed class CellPhysics
/// (point-in-cell tests). Separate tree from <see cref="BSP"/> /// (point-in-cell tests). Separate tree from <see cref="BSP"/>
/// (collision) and from the renderer's drawing-BSP. /// (collision) and from the renderer's drawing-BSP.
/// Source: <c>cellStruct.CellBSP</c> at cache time. /// Source: <c>cellStruct.CellBSP</c> at cache time.
/// Nullable: cells without a CellBSP cannot participate in portal /// Root presence is required for a published cell. Missing positive
/// containment and are skipped by <see cref="CellTransit"/>. /// children inside a valid tree are the retail inside base case; a missing
/// root is rejected by <see cref="PhysicsDataCache.CacheCellStruct"/>.
/// </summary> /// </summary>
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; } public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }

File diff suppressed because it is too large Load diff

View file

@ -82,52 +82,17 @@ public static class PhysicsObjUpdate
Func<bool>? isCurrent = null, Func<bool>? isCurrent = null,
Func<bool>? isVelocityCurrent = null) Func<bool>? isVelocityCurrent = null)
{ {
ArgumentNullException.ThrowIfNull(body); if (!CommitSetPositionContactTransition(
body,
// SetPositionInternal replaces Contact first but retains the source inContact,
// OnWalkable bit through its first calc_acceleration call. A deferred onWalkable,
// teleport may have parked the live body with both bits cleared, so previousOnWalkable,
// restore the captured source bit explicitly before reproducing that hitGround,
// ordering. leaveGround,
if (previousOnWalkable) isCurrent))
body.TransientState |= TransientStateFlags.OnWalkable;
else
body.TransientState &= ~TransientStateFlags.OnWalkable;
if (inContact)
body.TransientState |= TransientStateFlags.Contact;
else
body.TransientState &= ~TransientStateFlags.Contact;
body.calc_acceleration();
bool finalOnWalkable = inContact && onWalkable;
if (finalOnWalkable)
body.TransientState |= TransientStateFlags.OnWalkable;
else
body.TransientState &= ~TransientStateFlags.OnWalkable;
// AP-10 (Campaign P Slice P4, 2026-07-30): mirror WATER_CONTACT_TS
// alongside CONTACT_TS/ON_WALKABLE_TS, same as ApplySetPositionContact.
// Callers (e.g. RemoteTeleportPlacement) already set body.ContactPlaneIsWater
// before invoking this commit.
if (body.ContactPlaneIsWater)
body.TransientState |= TransientStateFlags.WaterContact;
else
body.TransientState &= ~TransientStateFlags.WaterContact;
if (!previousOnWalkable && finalOnWalkable)
{ {
hitGround?.Invoke(); return false;
if (isCurrent?.Invoke() == false)
return false;
} }
else if (previousOnWalkable && !finalOnWalkable)
{
leaveGround?.Invoke();
if (isCurrent?.Invoke() == false)
return false;
}
body.calc_acceleration();
// Position, Vector, and Movement are independently timestamped but // Position, Vector, and Movement are independently timestamped but
// can all install m_velocityVector. If a later one arrived from a // can all install m_velocityVector. If a later one arrived from a
@ -142,10 +107,83 @@ public static class PhysicsObjUpdate
collisionNormal, collisionNormal,
previousContact, previousContact,
previousOnWalkable, previousOnWalkable,
finalOnWalkable); body.OnWalkable);
return isCurrent?.Invoke() ?? true; return isCurrent?.Invoke() ?? true;
} }
/// <summary>
/// Commits retail's Contact/OnWalkable and HitGround/LeaveGround prefix,
/// stopping immediately before <c>handle_all_collisions</c>. Runtime uses
/// this seam to run the canonical collision-table reports without adding
/// another per-placement delegate allocation.
/// </summary>
public static bool CommitSetPositionContactTransition(
PhysicsBody body,
bool inContact,
bool onWalkable,
bool previousOnWalkable,
Action? hitGround = null,
Action? leaveGround = null,
Func<bool>? isCurrent = null)
{
ArgumentNullException.ThrowIfNull(body);
bool finalOnWalkable = CommitSetPositionContactPrefix(
body,
inContact,
onWalkable,
previousOnWalkable);
if (!previousOnWalkable && finalOnWalkable)
{
hitGround?.Invoke();
if (isCurrent?.Invoke() == false)
return false;
}
else if (previousOnWalkable && !finalOnWalkable)
{
leaveGround?.Invoke();
if (isCurrent?.Invoke() == false)
return false;
}
CommitSetPositionPostGround(body);
return isCurrent?.Invoke() ?? true;
}
public static bool CommitSetPositionContactPrefix(
PhysicsBody body,
bool inContact,
bool onWalkable,
bool previousOnWalkable)
{
ArgumentNullException.ThrowIfNull(body);
if (previousOnWalkable)
body.TransientState |= TransientStateFlags.OnWalkable;
else
body.TransientState &= ~TransientStateFlags.OnWalkable;
if (inContact)
body.TransientState |= TransientStateFlags.Contact;
else
body.TransientState &= ~TransientStateFlags.Contact;
body.calc_acceleration();
bool finalOnWalkable = inContact && onWalkable;
if (finalOnWalkable)
body.TransientState |= TransientStateFlags.OnWalkable;
else
body.TransientState &= ~TransientStateFlags.OnWalkable;
if (body.ContactPlaneIsWater)
body.TransientState |= TransientStateFlags.WaterContact;
else
body.TransientState &= ~TransientStateFlags.WaterContact;
return finalOnWalkable;
}
public static void CommitSetPositionPostGround(PhysicsBody body)
{
ArgumentNullException.ThrowIfNull(body);
body.calc_acceleration();
}
/// <summary> /// <summary>
/// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's /// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's
/// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on /// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on
@ -179,14 +217,14 @@ public static class PhysicsObjUpdate
// is now owned by the SetPositionInternal-derived contact flags, not a Velocity.Z<=0 // is now owned by the SetPositionInternal-derived contact flags, not a Velocity.Z<=0
// gate). A grounded corridor wall-slide keeps its tangential velocity (should_reflect // gate). A grounded corridor wall-slide keeps its tangential velocity (should_reflect
// false), exactly as retail. // false), exactly as retail.
bool sledding = body.State.HasFlag(PhysicsStateFlags.Sledding); bool sledding = (body.State & PhysicsStateFlags.Sledding) != 0;
bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding); bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding);
if (body.FramesStationaryFall <= 1) if (body.FramesStationaryFall <= 1)
{ {
if (shouldReflect && collisionNormalValid) if (shouldReflect && collisionNormalValid)
{ {
if (body.State.HasFlag(PhysicsStateFlags.Inelastic)) if ((body.State & PhysicsStateFlags.Inelastic) != 0)
{ {
body.Velocity = Vector3.Zero; // pc:282720-282722 body.Velocity = Vector3.Zero; // pc:282720-282722
} }

View file

@ -0,0 +1,156 @@
using System.Collections.Immutable;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail <c>SetPositionError</c> (<c>acclient.h</c>, enum 491). This is
/// deliberately independent of <see cref="PhysicsResidenceDisposition"/>:
/// losing the destination cell is a successful SetPosition operation whose
/// residence is deferred, not a placement error.
/// </summary>
internal enum PhysicsSetPositionError
{
Ok = 0,
GeneralFailure = 1,
NoValidPosition = 2,
NoCell = 3,
Collided = 4,
InvalidArguments = 0x100,
}
internal enum PhysicsResidenceDisposition
{
Committed,
DeferredCell,
Unchanged,
}
/// <summary>
/// Shadow-list operation performed by retail's SetPosition commit tail.
/// Recalculate delegates to the canonical live shadow-shape owner (the
/// PhysicsBSP/bounding-box path cannot be reconstructed from placement
/// spheres); Replace consumes <see cref="PhysicsSetPositionResult.CrossCellIds"/>;
/// Preserve intentionally leaves the existing list untouched.
/// </summary>
internal enum PhysicsShadowCommitAction
{
None,
Recalculate,
Replace,
Preserve,
}
internal readonly record struct PhysicsSetPositionCollisionReport(
bool ContactPlaneValid,
Plane ContactPlane,
uint ContactPlaneCellId,
bool ContactPlaneIsWater,
bool LastKnownContactPlaneValid,
Plane LastKnownContactPlane,
uint LastKnownContactPlaneCellId,
bool LastKnownContactPlaneIsWater,
bool SlidingNormalValid,
Vector3 SlidingNormal,
bool CollisionNormalValid,
Vector3 CollisionNormal,
bool CollidedWithEnvironment,
int FramesStationaryFall,
Vector3 AdjustOffset,
uint? LastCollidedObjectId,
ImmutableArray<uint> CollidedObjectIds);
[Flags]
internal enum PhysicsSetPositionFlags : uint
{
None = 0,
Placement = 0x001,
Teleport = 0x002,
Restore = 0x004,
Slide = 0x010,
DoNotCreateCells = 0x020,
Scatter = 0x100,
RandomScatter = 0x200,
Line = 0x400,
SendPositionEvent = 0x1000,
}
/// <summary>
/// The three retail weenie classifications which bypass placement collision
/// in <c>CPhysicsObj::SetPositionInternal</c> after AdjustPosition succeeds.
/// Runtime derives this from the canonical object record; callers cannot use a
/// free boolean to force ordinary objects through geometry.
/// </summary>
internal enum PhysicsPlacementClass
{
Ordinary,
Hook,
Storage,
Corpse,
}
/// <summary>
/// Complete immutable input to retail <c>CPhysicsObj::SetPosition</c>. World
/// position feeds acdream's flat collision representation; cell-local position
/// is retail's <c>Position.frame.origin</c> and is the only input to
/// <c>LandDefs::adjust_to_outside</c>.
/// </summary>
internal readonly record struct PhysicsSetPositionRequest(
Vector3 Position,
Quaternion Orientation,
uint CellId,
Vector3 CellLocalPosition,
ImmutableArray<FlatCollisionSphere> Spheres,
float Scale,
float StepUpHeight,
float StepDownHeight,
PhysicsStateFlags MoverPhysicsState = PhysicsStateFlags.None,
ObjectInfoState MoverFlags = ObjectInfoState.None,
uint MovingEntityId = 0u,
PhysicsPlacementClass PlacementClass = PhysicsPlacementClass.Ordinary,
PhysicsSetPositionFlags Flags = PhysicsSetPositionFlags.Placement,
Vector3 Line = default,
float ScatterRadiusX = 0f,
float ScatterRadiusY = 0f,
uint ScatterAttempts = 0u,
// Null models retail's distinct `this->cell == nullptr` state. The
// retained Position cell id may equal the destination while the object is
// still in the lost-cell list; that wake must change_cell and reflood.
uint? CurrentCellId = null);
/// <summary>
/// Immutable commit packet produced by the pure placement transaction. Runtime
/// installs this packet atomically into the canonical body/spatial owner before
/// publishing one presentation delta.
/// </summary>
internal readonly record struct PhysicsSetPositionResult(
PhysicsSetPositionError Error,
PhysicsResidenceDisposition Residence,
Vector3 Position,
Quaternion Orientation,
uint CellId,
Vector3 CellLocalPosition,
bool InContact = false,
bool OnWalkable = false,
Plane ContactPlane = default,
uint ContactPlaneCellId = 0u,
bool ContactPlaneIsWater = false,
bool SlidingNormalValid = false,
Vector3 SlidingNormal = default,
bool CollisionNormalValid = false,
Vector3 CollisionNormal = default,
int FramesStationaryFall = 0,
bool CollidedWithEnvironment = false,
bool CollisionHandlerResult = false,
bool CellChanged = false,
PhysicsShadowCommitAction ShadowAction = PhysicsShadowCommitAction.None,
ImmutableArray<uint> CrossCellIds = default,
ImmutableArray<uint> CollidedObjectIds = default,
ImmutableArray<uint> QueriedCellIds = default)
{
internal bool IsSuccessful => Error == PhysicsSetPositionError.Ok;
internal bool IsCommitted =>
IsSuccessful && Residence == PhysicsResidenceDisposition.Committed;
internal bool IsDeferred =>
IsSuccessful && Residence == PhysicsResidenceDisposition.DeferredCell;
}

View file

@ -102,6 +102,25 @@ public sealed class PhysicsTimestampGate
return CreateObjectTimestampDisposition.ExistingGeneration; return CreateObjectTimestampDisposition.ExistingGeneration;
} }
/// <summary>
/// Classifies an incoming CreateObject generation without consuming any
/// channel timestamp. Runtime uses this to preflight only admissions which
/// can create or replace a canonical incarnation; equal/stale packets must
/// still reach the normal per-channel retail gates unchanged.
/// </summary>
public CreateObjectTimestampDisposition PreviewCreateObject(
ushort instance)
{
if (!_seeded)
return CreateObjectTimestampDisposition.InitialGeneration;
ushort currentInstance = _timestamps[Instance];
if (IsNewer(currentInstance, instance))
return CreateObjectTimestampDisposition.NewGeneration;
if (IsNewer(instance, currentInstance))
return CreateObjectTimestampDisposition.StaleGeneration;
return CreateObjectTimestampDisposition.ExistingGeneration;
}
public bool TryAcceptMovementEvent( public bool TryAcceptMovementEvent(
ushort instance, ushort instance,
ushort movement, ushort movement,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,74 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Performs the compressed first-gravity-frame settle used to establish
/// retail Contact/OnWalkable state for a newly placed body.
///
/// Retail gains spawn contact from the FIRST GRAVITY FRAME, not the
/// placement itself: <c>CPhysicsObj::enter_world</c> (0x00516170) runs
/// <c>SetPosition</c> (find_placement validates the spot but records no
/// touch) and every retail CPhysicsObj then simulates, falls the few
/// centimetres onto the floor, and the transition's touch grants the
/// contact plane + CONTACT/ON_WALKABLE. Bodies that do not run that first
/// ordinary frame at placement (stationary remotes — #270 — and the local
/// player's Runtime first-entry activation — C3c-F5) compress the settle
/// here: a short downward sweep from the placed position whose touch
/// handler produces exactly the state retail's first frame would. A sweep
/// that finds no floor (true airborne spawn) leaves the body airborne,
/// exactly like retail's fall.
/// </summary>
public static class SpawnPlacementSettler
{
public const float SettleDistance = 0.5f;
public static bool TrySettle(
PhysicsEngine physicsEngine,
PhysicsBody body,
Vector3 worldPosition,
uint cellId,
float sphereRadius,
float sphereHeight,
ObjectInfoState moverFlags,
uint movingEntityId,
Action hitGround,
Action leaveGround)
{
ArgumentNullException.ThrowIfNull(physicsEngine);
ArgumentNullException.ThrowIfNull(body);
ArgumentNullException.ThrowIfNull(hitGround);
ArgumentNullException.ThrowIfNull(leaveGround);
if (cellId == 0)
return false;
ResolveResult settle = physicsEngine.ResolveWithTransition(
worldPosition,
worldPosition - new Vector3(0f, 0f, SettleDistance),
cellId,
sphereRadius,
sphereHeight,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: false,
body,
moverFlags,
movingEntityId);
if (!settle.Ok || !settle.InContact)
return false;
body.Position = settle.Position;
PhysicsObjUpdate.CommitSetPositionTransition(
body,
settle.InContact,
settle.OnWalkable,
settle.CollisionNormalValid,
settle.CollisionNormal,
previousContact: false,
previousOnWalkable: false,
hitGround,
leaveGround);
return true;
}
}

File diff suppressed because it is too large Load diff

View file

@ -47,12 +47,10 @@ namespace AcDream.Core.Player;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>Enchantment buffs</b> (multiplicative + additive) and the /// <b>Enchantment buffs</b> (multiplicative + additive), vitae, and the
/// 5-min-vital clamp are <b>not yet applied</b> — adding those /// retail five-point minimum are applied through the attached
/// requires the <see cref="AcDream.Core.Spells.Spellbook"/>'s active /// <see cref="AcDream.Core.Spells.Spellbook"/>. Base-value accessors retain
/// enchantment list. The unenchanted max is correct for clean /// the unenchanted values needed by the character-panel comparison logic.
/// characters; buffed players will read percent slightly higher than
/// retail until enchantment integration lands.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class LocalPlayerState public sealed class LocalPlayerState
@ -226,13 +224,37 @@ public sealed class LocalPlayerState
/// skill hasn't arrived yet. /// skill hasn't arrived yet.
/// </summary> /// </summary>
public int? GetEffectiveSkill(uint skillId) public int? GetEffectiveSkill(uint skillId)
=> GetSkillValue(skillId)?.EffectiveLevel;
/// <summary>
/// Full retail <c>CACQualities::InqSkill</c> projection, including the
/// augmentation terms on both sides of <c>EnchantSkill</c>. Callers with a
/// fresher player-object property bundle may supply it; otherwise the
/// PlayerDescription snapshot is used.
/// </summary>
public PlayerSkillMath.Value? GetSkillValue(
uint skillId,
PropertyBundle? properties = null)
{ {
SkillSnapshot? skill = GetSkill(skillId); SkillSnapshot? skill = GetSkill(skillId);
if (skill is null) return null; if (skill is null) return null;
uint baseValue = skill.Value.CurrentLevel;
if (_spellbook is null) return (int)baseValue; PlayerSkillMath.AugmentationBonuses augmentations =
EnchantmentMath.VitalMod mod = _spellbook.GetSkillMod(skillId); PlayerSkillMath.AugmentationBonuses.FromProperties(
return EnchantmentMath.EnchantSkill(mod, baseValue); properties ?? _properties);
EnchantmentMath.VitalMod mod = _spellbook?.GetSkillMod(skillId)
?? EnchantmentMath.VitalMod.Identity;
float vitae = _spellbook is null
? 1f
: EnchantmentMath.GetVitaeMultiplier(
_spellbook.ActiveEnchantments);
return PlayerSkillMath.Calculate(
checked((int)Math.Min(int.MaxValue, skill.Value.CurrentLevel)),
skillId,
skill.Value.Status,
augmentations,
mod,
vitae);
} }
/// <summary> /// <summary>
@ -242,14 +264,7 @@ public sealed class LocalPlayerState
/// is wired, no vitae is active, or the skill hasn't arrived yet. /// is wired, no vitae is active, or the skill hasn't arrived yet.
/// </summary> /// </summary>
public int GetSkillVitaeModifier(uint skillId) public int GetSkillVitaeModifier(uint skillId)
{ => GetSkillValue(skillId)?.VitaeModifier ?? 0;
if (_spellbook is null) return 0;
SkillSnapshot? skill = GetSkill(skillId);
if (skill is null) return 0;
return EnchantmentMath.SkillVitaeModifier(
_spellbook.ActiveEnchantments,
skill.Value.CurrentLevel);
}
/// <summary>Snapshot of the local player's current property bundle.</summary> /// <summary>Snapshot of the local player's current property bundle.</summary>
public PropertyBundle Properties => _properties; public PropertyBundle Properties => _properties;
@ -290,11 +305,8 @@ public sealed class LocalPlayerState
/// </summary> /// </summary>
public uint? GetMaxApprox(VitalKind kind) public uint? GetMaxApprox(VitalKind kind)
{ {
var v = Get(kind); uint? baseValue = GetBaseMaxApprox(kind);
if (v is null) return null; if (baseValue is not uint unbuffed) return null;
uint baseMax = v.Value.Ranks + v.Value.Start;
uint contrib = AttributeContribution(kind);
uint unbuffed = baseMax + contrib;
// Preserve the "no data" sentinel — when the unbuffed max is 0 // Preserve the "no data" sentinel — when the unbuffed max is 0
// we lack the inputs to compute anything reasonable. The retail // we lack the inputs to compute anything reasonable. The retail
// min-vital floor only kicks in once we know the base. // min-vital floor only kicks in once we know the base.
@ -311,6 +323,37 @@ public sealed class LocalPlayerState
return (uint)System.Math.Round(buffed); return (uint)System.Math.Round(buffed);
} }
/// <summary>
/// Unenchanted secondary-attribute maximum used by retail's
/// <c>Attribute2ndInfoRegion::Update @ 0x004F19E0</c> comparison.
/// </summary>
public uint? GetBaseMaxApprox(VitalKind kind)
{
VitalSnapshot? vital = Get(kind);
if (vital is null) return null;
return vital.Value.Ranks
+ vital.Value.Start
+ AttributeContribution(kind);
}
/// <summary>
/// Isolated vitae contribution to a secondary attribute, matching
/// <c>Attribute2ndInfoRegion::GetVitaeModifier @ 0x004F1130</c>.
/// </summary>
public int GetVitalVitaeModifier(VitalKind kind)
{
if (_spellbook is null
|| GetBaseMaxApprox(kind) is not uint baseValue)
{
return 0;
}
return EnchantmentMath.SkillVitaeModifier(
EnchantmentMath.GetVitaeMultiplier(
_spellbook.ActiveEnchantments),
baseValue);
}
private static uint StatKeyForKind(VitalKind kind) => kind switch private static uint StatKeyForKind(VitalKind kind) => kind switch
{ {
VitalKind.Health => EnchantmentMath.StatKey.MaxHealth, VitalKind.Health => EnchantmentMath.StatKey.MaxHealth,

View file

@ -0,0 +1,108 @@
using AcDream.Core.Items;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
namespace AcDream.Core.Player;
/// <summary>
/// Retail <c>CACQualities::InqSkill @ 0x00592660</c> composition.
/// Keeps the augmentation/enchantment ordering in one presentation-independent
/// place so character UI and movement consume the same effective skill.
/// </summary>
public static class PlayerSkillMath
{
public readonly record struct AugmentationBonuses(
int AllSkills,
bool JackOfAllTrades,
int SkilledSpecialized,
bool SkilledMelee,
bool SkilledMissile,
bool SkilledMagic)
{
public static AugmentationBonuses FromProperties(PropertyBundle properties)
{
ArgumentNullException.ThrowIfNull(properties);
return new(
Positive(properties.GetInt((uint)PropertyInt.LumAugAllSkills)),
properties.GetInt((uint)PropertyInt.AugmentationJackOfAllTrades) > 0,
Positive(properties.GetInt((uint)PropertyInt.LumAugSkilledSpec)),
properties.GetInt((uint)PropertyInt.AugmentationSkilledMelee) > 0,
properties.GetInt((uint)PropertyInt.AugmentationSkilledMissile) > 0,
properties.GetInt((uint)PropertyInt.AugmentationSkilledMagic) > 0);
}
public int BeforeEnchantments(uint skillId)
{
int category = skillId switch
{
// Retail switch at pc 0x005926F6. A positive category
// augmentation adds ten once; its stored rank is not multiplied.
0x29u or 0x2Cu or 0x2Du or 0x2Eu or 0x31u when SkilledMelee => 10,
0x2Fu when SkilledMissile => 10,
0x1Fu or 0x20u or 0x21u or 0x22u or 0x2Bu when SkilledMagic => 10,
_ => 0,
};
return SaturatingAdd(AllSkills, category);
}
public int AfterEnchantments(uint advancementClass)
{
int result = JackOfAllTrades ? 5 : 0;
if (advancementClass == 3u)
result = SaturatingAdd(result, SaturatingMultiply(SkilledSpecialized, 2));
return result;
}
}
public readonly record struct Value(
int UnenchantedLevel,
int EffectiveLevel,
int VitaeModifier);
/// <summary>
/// Compose one skill exactly in retail order:
/// intrinsic + LumAugAllSkills/category bonus; EnchantSkill; then
/// Jack of All Trades and the specialized luminance bonus.
/// </summary>
public static Value Calculate(
int intrinsicLevel,
uint skillId,
uint advancementClass,
AugmentationBonuses augmentations,
EnchantmentMath.VitalMod enchantment,
float vitaeMultiplier)
{
int intrinsic = Math.Max(0, intrinsicLevel);
int unenchanted = SaturatingAdd(
intrinsic,
augmentations.BeforeEnchantments(skillId));
int enchanted = EnchantmentMath.EnchantSkill(
enchantment,
(uint)unenchanted);
int effective = SaturatingAdd(
enchanted,
augmentations.AfterEnchantments(advancementClass));
int vitaeModifier = EnchantmentMath.SkillVitaeModifier(
vitaeMultiplier,
(uint)unenchanted);
return new Value(unenchanted, effective, vitaeModifier);
}
private static int Positive(int value) => value > 0 ? value : 0;
private static int SaturatingAdd(int left, int right)
{
long result = (long)left + right;
return result > int.MaxValue
? int.MaxValue
: result < int.MinValue ? int.MinValue : (int)result;
}
private static int SaturatingMultiply(int left, int right)
{
long result = (long)left * right;
return result > int.MaxValue
? int.MaxValue
: result < int.MinValue ? int.MinValue : (int)result;
}
}

View file

@ -28,22 +28,15 @@ namespace AcDream.Core.Spells;
/// <para> /// <para>
/// <b>Vitae</b> (death penalty) is a singleton on /// <b>Vitae</b> (death penalty) is a singleton on
/// <c>CEnchantmentRegistry._vitae</c>, applied multiplicatively after /// <c>CEnchantmentRegistry._vitae</c>, applied multiplicatively after
/// the buff lists. We don't yet wire it through. /// the buff lists.
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>Current implementation status:</b> the aggregator iterates /// <b>Current implementation status:</b> the aggregator consumes the same
/// <see cref="Spellbook.ActiveEnchantments"/> and applies /// complete <c>StatMod (type/key/value)</c> record shape from both the
/// <see cref="SpellTable"/> family-stacking deduplication, but /// PlayerDescription snapshot and live <c>MagicUpdateEnchantment</c>
/// **returns identity (1.0, 0.0) for stat modifiers** because our /// (0x02C2), applies retail family stacking, then evaluates the selected
/// <see cref="ActiveEnchantmentRecord"/> doesn't yet carry the /// attribute, secondary attribute, or skill domain.
/// <c>StatMod (type/key/val)</c> triad — that requires extending
/// <c>ParseMagicUpdateEnchantment</c> to read the full Enchantment
/// payload (60-64 bytes per holtburger
/// <c>messages/magic/types.rs</c>) and storing it on the record.
/// Filed as ISSUES.md #12. Once that lands, the aggregator's
/// `effectiveMult * mod.Val` and `additive + mod.Val` paths fire and
/// the Vitals HUD percent gap closes.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -167,9 +160,8 @@ public static class EnchantmentMath
// Bucket 2 (Additive): additive += ench.StatModValue // Bucket 2 (Additive): additive += ench.StatModValue
// Bucket 4 (Vitae): multiplier *= ench.StatModValue (post-pass) // Bucket 4 (Vitae): multiplier *= ench.StatModValue (post-pass)
// Bucket 8 (Cooldown): skipped (doesn't affect vital max) // Bucket 8 (Cooldown): skipped (doesn't affect vital max)
// Records without StatMod data (StatModKey == null) — e.g. // Records without StatMod data (StatModKey == null) are valid for
// those from older MagicUpdateEnchantment events that don't // non-stat enchantment classes and contribute nothing here.
// yet parse the full payload — contribute nothing.
float multiplier = 1.0f; float multiplier = 1.0f;
float additive = 0.0f; float additive = 0.0f;
float vitae = 1.0f; float vitae = 1.0f;
@ -283,10 +275,17 @@ public static class EnchantmentMath
public static int SkillVitaeModifier( public static int SkillVitaeModifier(
IEnumerable<ActiveEnchantmentRecord> enchantments, IEnumerable<ActiveEnchantmentRecord> enchantments,
uint baseValue) uint baseValue)
=> SkillVitaeModifier(GetVitaeMultiplier(enchantments), baseValue);
/// <summary>
/// Value overload for callers that already captured the registry's vitae
/// singleton. Keeping the truncation here prevents the movement and UI
/// paths from growing subtly different copies of retail's calculation.
/// </summary>
public static int SkillVitaeModifier(float vitaeMultiplier, uint baseValue)
{ {
float vitae = GetVitaeMultiplier(enchantments); if (vitaeMultiplier == 1.0f) return 0;
if (vitae == 1.0f) return 0; return (int)(baseValue * vitaeMultiplier) - (int)baseValue;
return (int)(baseValue * vitae) - (int)baseValue;
} }
/// <summary> /// <summary>

View file

@ -267,11 +267,9 @@ public sealed class Spellbook
} }
/// <summary> /// <summary>
/// Issue #7 / #12 — accept a fully-populated record from /// Accept the canonical fully-populated enchantment record shared by
/// <c>PlayerDescription</c>'s enchantment block (which carries /// <c>PlayerDescription</c> and live <c>MagicUpdateEnchantment</c>
/// the StatMod triad + bucket). Used when the wire-format extension /// (0x02C2), including the StatMod triad and bucket classification.
/// gives us the full per-enchantment payload, rather than the
/// 4-field summary from <c>MagicUpdateEnchantment</c>.
/// </summary> /// </summary>
public void OnEnchantmentAdded(ActiveEnchantmentRecord record) public void OnEnchantmentAdded(ActiveEnchantmentRecord record)
{ {

View file

@ -7,9 +7,9 @@ namespace AcDream.Core.World.Cells;
/// <summary> /// <summary>
/// The unified cell graph: the active, authoritative id-&gt;cell resolver and registry. /// The unified cell graph: the active, authoritative id-&gt;cell resolver and registry.
/// Populated unconditionally from /// Populated from validated
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.CacheCellStruct"/> (before its /// <see cref="AcDream.Core.Physics.PhysicsDataCache.CacheCellStruct"/> payloads
/// idempotency + null-BSP guards, so BSP-less cells are included) and consumed across /// (a physics root is optional; a containment root is required) and consumed across
/// the engine: <see cref="GetVisible"/> resolves any cell id, <see cref="CurrCell"/> is /// the engine: <see cref="GetVisible"/> resolves any cell id, <see cref="CurrCell"/> is
/// the player render/lighting root, <see cref="FindVisibleChildCell"/> resolves the /// the player render/lighting root, <see cref="FindVisibleChildCell"/> resolves the
/// 3rd-person camera cell, and <see cref="TryGetTerrainOrigin"/> supplies the block-local /// 3rd-person camera cell, and <see cref="TryGetTerrainOrigin"/> supplies the block-local
@ -18,8 +18,24 @@ namespace AcDream.Core.World.Cells;
/// </summary> /// </summary>
public sealed class CellGraph public sealed class CellGraph
{ {
private readonly ConcurrentDictionary<uint, EnvCell> _envCells = new(); private readonly CollisionWorldStateSlot _collisionWorld;
private readonly ConcurrentDictionary<uint, (TerrainSurface Terrain, Vector3 Origin)> _terrain = new(); private ConcurrentDictionary<uint, EnvCell> _envCells =>
_collisionWorld.Current.EnvCells;
private ConcurrentDictionary<uint, CellGraphTerrain> _terrain =>
_collisionWorld.Current.Terrain;
private ConcurrentDictionary<uint, ObjCell> _outdoorCells =>
_collisionWorld.Current.OutdoorCells;
public CellGraph()
: this(new CollisionWorldStateSlot())
{
}
internal CellGraph(CollisionWorldStateSlot collisionWorld)
{
_collisionWorld = collisionWorld
?? throw new ArgumentNullException(nameof(collisionWorld));
}
/// <summary>The player's current cell — the render/lighting root. Written ONLY at the /// <summary>The player's current cell — the render/lighting root. Written ONLY at the
/// player chokepoint <see cref="AcDream.Core.Physics.PhysicsEngine.UpdatePlayerCurrCell"/> /// player chokepoint <see cref="AcDream.Core.Physics.PhysicsEngine.UpdatePlayerCurrCell"/>
@ -30,11 +46,26 @@ public sealed class CellGraph
public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId); public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId);
public void Add(EnvCell cell) => _envCells.TryAdd(cell.Id, cell); public void Add(EnvCell cell) =>
_collisionWorld.Current.TryAddEnvCell(cell.Id, cell);
/// <param name="landblockPrefix">Any id in the cell's landblock; masked to (id &amp; 0xFFFF0000).</param> /// <param name="landblockPrefix">Any id in the cell's landblock; masked to (id &amp; 0xFFFF0000).</param>
public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin) public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin)
=> _terrain[landblockPrefix & 0xFFFF0000u] = (terrain, worldOrigin); {
uint prefix = landblockPrefix & 0xFFFF0000u;
_terrain[prefix] = new CellGraphTerrain(terrain, worldOrigin);
for (uint low = 1u; low <= 0x40u; low++)
{
uint id = prefix | low;
int index = (int)(low - 1u);
_outdoorCells[id] = LandCell.Synthesize(
id,
terrain,
worldOrigin,
index / 8,
index % 8);
}
}
/// <summary> /// <summary>
/// World origin (SW corner) of the landblock containing <paramref name="id"/>, /// World origin (SW corner) of the landblock containing <paramref name="id"/>,
@ -64,8 +95,29 @@ public sealed class CellGraph
CurrCell = null; CurrCell = null;
} }
_terrain.TryRemove(lb, out _); _terrain.TryRemove(lb, out _);
foreach (var id in new List<uint>(_envCells.Keys)) for (uint low = 1u; low <= 0x40u; low++)
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _); _outdoorCells.TryRemove(lb | low, out _);
RemoveEnvCellPrefixKeys(lb);
}
/// <summary>
/// O1: retire one prefix's EnvCells through the installed-key ledger —
/// O(prefix keys), never a whole-map scan. Removal tombstones the
/// captured slot list, so index iteration stays exact.
/// </summary>
private void RemoveEnvCellPrefixKeys(uint prefix)
{
CollisionWorldState world = _collisionWorld.Current;
List<uint>? slots = world.EnvCellKeys.SlotsForPrefix(prefix);
if (slots is null)
return;
int limit = slots.Count;
for (int index = 0; index < limit; index++)
{
uint id = slots[index];
if (id != 0u)
world.RemoveEnvCell(id);
}
} }
/// <summary> /// <summary>
@ -81,8 +133,7 @@ public sealed class CellGraph
{ {
CurrCell = null; CurrCell = null;
} }
foreach (var id in new List<uint>(_envCells.Keys)) RemoveEnvCellPrefixKeys(lb);
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
} }
/// <summary>The universal id-&gt;cell resolver (retail CObjCell::GetVisible).</summary> /// <summary>The universal id-&gt;cell resolver (retail CObjCell::GetVisible).</summary>
@ -94,9 +145,9 @@ public sealed class CellGraph
uint low = id & 0xFFFFu; uint low = id & 0xFFFFu;
if (low < 1u || low > 0x40u) return null; if (low < 1u || low > 0x40u) return null;
if (!_terrain.TryGetValue(id & 0xFFFF0000u, out var t)) return null; return _outdoorCells.TryGetValue(id, out ObjCell? cell)
int idx = (int)(low - 1u); ? cell
return LandCell.Synthesize(id, t.Terrain, t.Origin, idx / 8, idx % 8); : null;
} }
/// <summary> /// <summary>
@ -125,4 +176,130 @@ public sealed class CellGraph
return stab; return stab;
return null; return null;
} }
internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
CellGraph staging,
uint landblockId) => new(this, staging, landblockId);
internal sealed class LandblockReplacementBuilder : IDisposable
{
private readonly CellGraph _active;
private readonly CellGraph _staging;
private readonly uint _prefix;
private readonly List<KeyValuePair<uint, EnvCell>> _envCells = new();
private readonly HashSet<uint> _stagingIds = new();
private readonly List<uint> _removeIds = new();
private List<uint>? _keySlots;
private int _keySlotLimit;
private bool _keySlotsCaptured;
private int _cursor;
private int _phase;
internal LandblockReplacementBuilder(
CellGraph active,
CellGraph staging,
uint landblockId)
{
_active = active;
_staging = staging;
_prefix = landblockId & 0xFFFF0000u;
}
internal bool Advance()
{
if (_phase == 0)
{
// O1: enumerate the staging root's installed target-prefix
// EnvCell keys via the ledger instead of scanning the map.
if (TryTakeNextPrefixKey(
_staging._collisionWorld.Current.EnvCellKeys,
out uint stagingId))
{
if ((stagingId & 0xFFFFu) >= 0x0100u
&& _staging._envCells.TryGetValue(
stagingId,
out EnvCell? cell))
{
_envCells.Add(
new KeyValuePair<uint, EnvCell>(stagingId, cell));
_stagingIds.Add(stagingId);
}
return false;
}
_phase = 1;
return false;
}
if (_phase == 1)
{
// O1: active-side removal capture through the ledger — no
// cross-frame live enumerator over the active map.
if (TryTakeNextPrefixKey(
_active._collisionWorld.Current.EnvCellKeys,
out uint activeId))
{
if ((activeId & 0xFFFFu) >= 0x0100u
&& !_stagingIds.Contains(activeId)
&& _active._envCells.ContainsKey(activeId))
{
_removeIds.Add(activeId);
}
return false;
}
bool hasTerrain = _staging._terrain.TryGetValue(
_prefix,
out var terrain);
Prepared = new PreparedCellGraphLandblock(
_prefix,
_removeIds,
_envCells,
hasTerrain,
terrain);
_phase = 2;
}
return true;
}
private bool TryTakeNextPrefixKey(PrefixKeyIndex ledger, out uint key)
{
if (!_keySlotsCaptured)
{
_keySlots = ledger.SlotsForPrefix(_prefix);
_keySlotLimit = _keySlots?.Count ?? 0;
_keySlotsCaptured = true;
_cursor = 0;
}
while (_cursor < _keySlotLimit)
{
uint candidate = _keySlots![_cursor++];
if (candidate != 0u)
{
key = candidate;
return true;
}
}
key = 0u;
_keySlots = null;
_keySlotsCaptured = false;
return false;
}
internal PreparedCellGraphLandblock? Prepared { get; private set; }
public void Dispose()
{
_keySlots = null;
_keySlotsCaptured = false;
}
}
} }
internal sealed record PreparedCellGraphLandblock(
uint LandblockPrefix,
IReadOnlyList<uint> EnvCellIdsToRemove,
IReadOnlyList<KeyValuePair<uint, EnvCell>> EnvCells,
bool HasTerrain,
CellGraphTerrain? Terrain);
internal sealed record CellGraphTerrain(
TerrainSurface Terrain,
Vector3 Origin);

View file

@ -10,7 +10,11 @@ namespace AcDream.Core.World.Cells;
/// <summary>Indoor room cell. Retail anchor: CEnvCell (acclient.h:32072).</summary> /// <summary>Indoor room cell. Retail anchor: CEnvCell (acclient.h:32072).</summary>
public sealed class EnvCell : ObjCell public sealed class EnvCell : ObjCell
{ {
/// <summary>Cell-containment BSP (retail CellStruct.CellBSP). Null =&gt; AABB fallback.</summary> /// <summary>
/// Cell-containment BSP (retail CellStruct.CellBSP). Production publication
/// requires a non-null root; prepared production uses
/// <see cref="FlatContainmentBsp"/> instead.
/// </summary>
public CellBSPTree? ContainmentBsp { get; } public CellBSPTree? ContainmentBsp { get; }
/// <summary> /// <summary>
@ -34,14 +38,18 @@ public sealed class EnvCell : ObjCell
public override bool PointInCell(Vector3 worldPoint) public override bool PointInCell(Vector3 worldPoint)
{ {
// Retail CEnvCell::point_in_cell @ 0x0052C300 returns false before
// touching the CellStruct when this->portals is null. Installed data
// contains real zero-portal cells, so this guard is behavior-bearing.
if (Portals.Count == 0)
return false;
var local = Vector3.Transform(worldPoint, InverseWorldTransform); var local = Vector3.Transform(worldPoint, InverseWorldTransform);
if (FlatContainmentBsp is not null) if (FlatContainmentBsp is { RootIndex: >= 0 })
return FlatBspQuery.PointInsideCellBsp(FlatContainmentBsp, local); return FlatBspQuery.PointInsideCellBsp(FlatContainmentBsp, local);
if (ContainmentBsp?.Root is not null) if (ContainmentBsp?.Root is not null)
return BSPQuery.PointInsideCellBsp(ContainmentBsp.Root, local); // BSPQuery.cs:1034 return BSPQuery.PointInsideCellBsp(ContainmentBsp.Root, local); // BSPQuery.cs:1034
return local.X >= LocalBoundsMin.X && local.X <= LocalBoundsMax.X return false;
&& local.Y >= LocalBoundsMin.Y && local.Y <= LocalBoundsMax.Y
&& local.Z >= LocalBoundsMin.Z && local.Z <= LocalBoundsMax.Z;
} }
/// <summary> /// <summary>

Some files were not shown because too many files have changed in this diff Show more