fix(physics): make collision activation starvation-free

This commit is contained in:
Erik 2026-07-31 18:34:46 +02:00
parent d94145e6b8
commit 6b28ff999c
14 changed files with 4637 additions and 474 deletions

View file

@ -494,13 +494,47 @@ What exists and is active:
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 list through one-work-unit seal cursors. The final update-
thread activation performs one mutation-version check and installs the sealed
records without heap allocation before emitting
`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.
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

View file

@ -89,7 +89,7 @@ AD-53..AD-55 (Campaign P response-layer findings).
| 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-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-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~~ | **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 off-side through mutation-stable, one-work-unit capture/seal cursors; rowless owner state/payload updates, movement, spawn, deletion, and seam-static changes reject stale sealing. The complete previous generation remains queryable until one allocation-free update-thread activation. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. The commit installs precomputed cell rows and 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`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/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-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-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 |

View file

@ -20,35 +20,86 @@ generation, and correctness depended on a later optional landblock callback.
The asynchronous unit is now one Runtime-owned collision generation:
1. `BeginCollisionAdmission` issues the exact Runtime/landblock generation.
2. `PrepareCollisionGeneration` clones the bounded resident spatial records
into a private cache, graph, engine, and shadow registry. Global immutable
GfxObj/Setup catalogs are not copied; the accepted build's exact closure is
populated by the existing cursors.
3. App and Headless publish terrain, EnvCells, topology, buildings, prepared
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.
4. A mutation-stable cursor captures 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
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. Each retained owner refresh captures its exact
`ShadowObjectRegistry` mutation version; a rowless withdrawn owner therefore
remains freshness-gated when state or payload changes.
5. Explicit one-work-unit cursors build the complete replacement before the
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. An active-owner mutation restarts capture and
sealing without touching the active world.
6. `CommitCollisionGeneration` performs only the final mutation-version check
and installs the already sealed replacement synchronously on the update
thread. The dense 256-owner gate measures zero managed bytes in this final
activation. Only after the complete replacement does Runtime emit
`CollisionGenerationCommitted` and a ready acknowledgement.
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.
The stable borrowed `PhysicsEngine` and `PhysicsDataCache` object identities do
not change. Presentation and no-window hosts use the same Runtime transaction.
Network workers still enqueue immutable messages and cannot mutate collision or
shadow state.
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
@ -71,17 +122,51 @@ 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;
- movement during staging rejects, refreshes only the dirty owner, and then
installs its latest cell set;
- an authoritative state change on a retained rowless owner rejects a stale
seal and installs the refreshed state on retry;
- 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;
- spawn and deletion during staging both reject stale 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 its final
256-owner activation allocates zero managed bytes;
- 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.