merge: placement and collision stabilization checkpoint
This commit is contained in:
commit
c7d5fc14b9
242 changed files with 81829 additions and 2433 deletions
310
docs/ISSUES.md
310
docs/ISSUES.md
|
|
@ -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 1–3, including the complete assessment surface, are user-accepted.
|
Slices 1–4, including equipped-child picking, are user-accepted. Vendor
|
||||||
Equipped-child picking and vendor browse/transactions remain Slices 4–6.
|
browsing and authoritative transactions remain Slices 5–6.
|
||||||
- **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 (`0x0050AAED–0x0050AB42`). 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:273191–273307 |
|
||||||
| 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:273001–273090 |
|
||||||
| 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:272811–272856; `CTransition::step_down` 0x0050B2A0 pc:272946–272998; `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 ~80–350 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 ~80–350 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 @@ N0–N6 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 2–7, 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).
|
||||||
|
|
|
||||||
|
|
@ -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 1–3 are user-accepted; resume at Slice 4
|
broaden the feature surface. Slices 1–4 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 2–7, 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 A–D: corrected measurement, prepared-package bake/dedup, package-only
|
Slices A–D: corrected measurement, prepared-package bake/dedup, package-only
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 3–5 and the automated
|
||||||
|
20-login scenario 11, the Campaign P matrix is closed with #273 and #274 as
|
||||||
|
explicit carried follow-ups.
|
||||||
|
|
|
||||||
267
docs/plans/2026-08-02-placement-cutover.md
Normal file
267
docs/plans/2026-08-02-placement-cutover.md
Normal 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 2–7 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.
|
||||||
|
|
@ -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:272946–272998) 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:272811–272856) 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
|
||||||
|
|
@ -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, ¤tStamina) // vital-buff adjusts the LOCAL COPY only
|
EnchantAttribute2nd(this, 4, ¤tStamina) // 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.
|
||||||
|
|
|
||||||
104
docs/research/2026-07-31-269-slope-stop-capture.md
Normal file
104
docs/research/2026-07-31-269-slope-stop-capture.md
Normal 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)` |
|
||||||
|
| 1742–1758 | 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
|
||||||
|
`0x0050AAED–0x0050AB42` 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.
|
||||||
108
docs/research/2026-07-31-271-stair-side-slide-capture.md
Normal file
108
docs/research/2026-07-31-271-stair-side-slide-capture.md
Normal 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 479–482, 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 `0x0050B458–0x0050B50F`
|
||||||
|
- `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.
|
||||||
174
docs/research/2026-07-31-atomic-collision-generation.md
Normal file
174
docs/research/2026-07-31-atomic-collision-generation.md
Normal 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.
|
||||||
333
docs/research/2026-07-31-canonical-set-position.md
Normal file
333
docs/research/2026-07-31-canonical-set-position.md
Normal 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 (`0x005153E5–0x005154FE`). 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.
|
||||||
92
docs/research/2026-07-31-cell-availability-semantics.md
Normal file
92
docs/research/2026-07-31-cell-availability-semantics.md
Normal 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**.
|
||||||
95
docs/research/2026-07-31-issue273-tight-gap-support.md
Normal file
95
docs/research/2026-07-31-issue273-tight-gap-support.md
Normal 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.
|
||||||
497
docs/research/2026-07-31-remaining-physics-campaign-handoff.md
Normal file
497
docs/research/2026-07-31-remaining-physics-campaign-handoff.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
104
docs/research/2026-08-02-c3c-cutover-closeout.md
Normal file
104
docs/research/2026-08-02-c3c-cutover-closeout.md
Normal 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.
|
||||||
681
docs/research/2026-08-02-canonical-body-writer-map.md
Normal file
681
docs/research/2026-08-02-canonical-body-writer-map.md
Normal 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.
|
||||||
|
|
@ -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 2–7 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 1–3:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
@ -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 **2–3×
|
||||||
|
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 0–8 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 (~70–200 leaves at the measured
|
||||||
|
~184 ns/leaf) + the owners touching the target, versus today's ~2–3 × 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 0–8. 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` | 92–501 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.5–8.5 MB | `0` at all 9 |
|
||||||
|
| `resources.streamingWork.oldestDeferredAgeMilliseconds` | 37,764–69,728 | `0` |
|
||||||
|
| `resources.loadedLandblocks` | 124–533 | **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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
|
@ -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 1188–1314).
|
||||||
|
|
||||||
|
The origin-recenter swap incorrectly treated every pending-only live bucket
|
||||||
|
as another landblock presentation generation (`GpuWorldState.cs`, former
|
||||||
|
lines 1352–1353). It therefore emitted a second full cleanup receipt for the
|
||||||
|
same already-retired generation. `LandblockRetirementCoordinator` correctly
|
||||||
|
rejected that duplicate at lines 416–425. 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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
1042
docs/research/2026-08-02-cutover-route-inventory.md
Normal file
1042
docs/research/2026-08-02-cutover-route-inventory.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||||
231
docs/research/2026-08-02-set-description-float-gates.md
Normal file
231
docs/research/2026-08-02-set-description-float-gates.md
Normal 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
|
||||||
|
283130–283251 (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 3557–3568. 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.
|
||||||
|
|
@ -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 2–7, #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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
134
src/AcDream.App/Net/GraphicalSessionEventRoute.cs
Normal file
134
src/AcDream.App/Net/GraphicalSessionEventRoute.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/AcDream.App/Net/LiveMovementStatsApplier.cs
Normal file
86
src/AcDream.App/Net/LiveMovementStatsApplier.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
87
src/AcDream.App/Net/RuntimePlacementProjectionRetrySlot.cs
Normal file
87
src/AcDream.App/Net/RuntimePlacementProjectionRetrySlot.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/AcDream.App/Net/StaminaExhaustionEdgeTracker.cs
Normal file
32
src/AcDream.App/Net/StaminaExhaustionEdgeTracker.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
bool initialResidenceActive =
|
||||||
|
_runtime.HasActiveInitialCreateResidence(expectedCanonical);
|
||||||
|
// C3c first entry owns CPhysicsObj construction and SetPosition for a
|
||||||
|
// residence-managed Create. Eager projectile classification used to
|
||||||
|
// acquire that same body here, before the conductor ran; the
|
||||||
|
// 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(
|
_projectiles.TryBind(
|
||||||
expectedRecord,
|
expectedRecord,
|
||||||
setup,
|
setup,
|
||||||
_gameTime.CurrentScriptTime,
|
_gameTime.CurrentScriptTime,
|
||||||
_origin.CenterX,
|
_origin.CenterX,
|
||||||
_origin.CenterY);
|
_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 =>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,6 +252,12 @@ 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>();
|
||||||
|
RuntimeCollisionAdmission collisionAdmission =
|
||||||
|
_physics.BeginCollisionAdmission(build.Landblock.LandblockId);
|
||||||
|
PreparedLandblockCollisionGeneration? prepared = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
prepared = _physics.PrepareCollisionGeneration(collisionAdmission);
|
||||||
var publication = new LandblockPhysicsPublication(
|
var publication = new LandblockPhysicsPublication(
|
||||||
_receiptOwner,
|
_receiptOwner,
|
||||||
build,
|
build,
|
||||||
|
|
@ -212,13 +266,28 @@ public sealed class LandblockPhysicsPublisher
|
||||||
buildings,
|
buildings,
|
||||||
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
|
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
|
||||||
build.Landblock.LandblockId),
|
build.Landblock.LandblockId),
|
||||||
_physics.BeginCollisionAdmission(
|
_physics,
|
||||||
build.Landblock.LandblockId));
|
collisionAdmission,
|
||||||
|
prepared);
|
||||||
publication.SetupObjectIds = build.Collisions is { } collisions
|
publication.SetupObjectIds = build.Collisions is { } collisions
|
||||||
? [.. collisions.SetupIds]
|
? [.. collisions.SetupIds]
|
||||||
: datBundle.Setups.Keys.Order().ToArray();
|
: datBundle.Setups.Keys.Order().ToArray();
|
||||||
|
publication.PreparedGeneration.SetAssetClosure(
|
||||||
|
publication.GfxObjectIds,
|
||||||
|
publication.SetupObjectIds);
|
||||||
return publication;
|
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>
|
||||||
/// Advances one entity's stable GfxObj-index preparation. No external
|
/// Advances one entity's stable GfxObj-index preparation. No external
|
||||||
|
|
@ -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,8 +462,30 @@ 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>
|
||||||
/// Advances one exact physics-suffix operation: one GfxObj cache entry,
|
/// Advances one exact physics-suffix operation: one GfxObj cache entry,
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -456,17 +566,24 @@ public sealed class LandblockPhysicsPublisher
|
||||||
publication.StaticCursor++;
|
publication.StaticCursor++;
|
||||||
}
|
}
|
||||||
else if (publication.RefloodOwnerIds is null)
|
else if (publication.RefloodOwnerIds is null)
|
||||||
|
{
|
||||||
|
RuntimeCollisionOwnerCaptureStep capture =
|
||||||
|
_physics.AdvanceCollisionRetainedOwnerCapture(
|
||||||
|
publication.CollisionAdmission,
|
||||||
|
publication.PreparedGeneration);
|
||||||
|
if (capture.Completed)
|
||||||
{
|
{
|
||||||
publication.RefloodOwnerIds =
|
publication.RefloodOwnerIds =
|
||||||
_physicsEngine.ShadowObjects
|
publication.PreparedGeneration.RetainedOwnerIds;
|
||||||
.CaptureRefloodOwnersForLandblock(landblock.LandblockId);
|
}
|
||||||
}
|
}
|
||||||
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)
|
||||||
{
|
{
|
||||||
|
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);
|
_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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
adoptionFailure =
|
||||||
_retirements.AdoptDetachedFull(detached.Landblocks);
|
_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,11 +785,13 @@ 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
|
||||||
|
|| !transaction.PhysicsPublication
|
||||||
|
.SealCommitted
|
||||||
? 1
|
? 1
|
||||||
: 0;
|
: 0;
|
||||||
if (!TryRun(
|
if (!TryRun(
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
238
src/AcDream.App/World/RuntimePlacementPresentationSink.cs
Normal file
238
src/AcDream.App/World/RuntimePlacementPresentationSink.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 5–10/sec.</summary>
|
/// emits the first SetState's body bytes only, not 5–10/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);
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
{
|
||||||
|
public HouseRestrictionRecord(
|
||||||
bool OpenToPublic,
|
bool OpenToPublic,
|
||||||
uint AllegianceMonarchId,
|
uint AllegianceMonarchId,
|
||||||
IReadOnlyDictionary<uint, uint> Guests)
|
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):
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
@ -698,6 +717,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;
|
||||||
|
|
|
||||||
297
src/AcDream.Core/Physics/CollisionWorldState.cs
Normal file
297
src/AcDream.Core/Physics/CollisionWorldState.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
{
|
|
||||||
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.SetCollisionNormal(worldNormal1);
|
||||||
collisions.SetSlidingNormal(worldNormal1);
|
|
||||||
RecordDiagnosticHit(tree, defaultHitPolygonIndex1);
|
RecordDiagnosticHit(tree, defaultHitPolygonIndex1);
|
||||||
return TransitionState.Slid;
|
return TransitionState.Collided;
|
||||||
}
|
|
||||||
|
|
||||||
path.SetCollide(worldNormal1);
|
|
||||||
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
|
||||||
RecordDiagnosticHit(tree, defaultHitPolygonIndex1);
|
|
||||||
return TransitionState.Adjusted;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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->cell resolver and registry.
|
/// The unified cell graph (UCG): the active id->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
|
|
@ -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();
|
|
||||||
if (isCurrent?.Invoke() == false)
|
|
||||||
return 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
156
src/AcDream.Core/Physics/PhysicsSetPosition.cs
Normal file
156
src/AcDream.Core/Physics/PhysicsSetPosition.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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
74
src/AcDream.Core/Physics/SpawnPlacementSettler.cs
Normal file
74
src/AcDream.Core/Physics/SpawnPlacementSettler.cs
Normal 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
|
|
@ -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,
|
||||||
|
|
|
||||||
108
src/AcDream.Core/Player/PlayerSkillMath.cs
Normal file
108
src/AcDream.Core/Player/PlayerSkillMath.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ namespace AcDream.Core.World.Cells;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The unified cell graph: the active, authoritative id->cell resolver and registry.
|
/// The unified cell graph: the active, authoritative id->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 & 0xFFFF0000).</param>
|
/// <param name="landblockPrefix">Any id in the cell's landblock; masked to (id & 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->cell resolver (retail CObjCell::GetVisible).</summary>
|
/// <summary>The universal id->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);
|
||||||
|
|
|
||||||
|
|
@ -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 => 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
Loading…
Add table
Add a link
Reference in a new issue