fix(streaming): preserve portal destination ownership

Detach old-world spatial ownership atomically, prioritize destination retirement dependencies, and reveal the viewport at the retail transition edge. Give private paperdoll views independent mesh ownership and retain dormant ACE entities so portal revisits preserve server objects without extending active GPU lifetimes.
This commit is contained in:
Erik 2026-07-25 08:35:12 +02:00
parent 2c848d4167
commit 823936ec31
57 changed files with 2551 additions and 324 deletions

View file

@ -55,6 +55,15 @@ The portal viewport is a replacement for the world viewport. It is not a
full-screen UI overlay: normal retained UI continues to update and draw above
it.
Private retained-UI viewports also keep their own objects. In particular,
`gmPaperDollUI::RedressCreature @ 0x004A3BC0` creates
`m_pInventoryObject` only when it is absent and the SmartBox player is
available; a temporarily unavailable world player does not clear an already
constructed inventory object. `gmPaperDollUI::~gmPaperDollUI @ 0x004A3590`
removes that private object at the UI/session lifetime boundary. Therefore a
portal-space world quiescence may defer a paperdoll redress, but it must not
empty and later reconstruct the visible private paperdoll.
## Begin/end and scene visibility
```text
@ -96,6 +105,36 @@ distance, hides the portal viewport, shows the world viewport, clears the
teleport object's sequence animations, plays `Sound_UI_ExitPortal`, and enters
`WorldFadeIn`. That following state restores the ordinary game view distance.
The viewport swap is also the end of the destination-cell blocking edge, not
the end of the complete teleport protocol:
```text
TunnelFadeOut completes:
destination cells are already available
release SmartBox world blocking
hide portal viewport
show world viewport
enter WorldFadeIn
WorldFadeIn completes one second later:
send LoginComplete
clear teleport-in-progress
enter Off
```
The two edges must remain distinct in an asynchronous client. Holding world
blocking until `LoginComplete` creates a one-second clear-color/void interval
after the tunnel viewport has already been hidden. Conversely, marking the
whole teleport complete at the viewport swap loses retail's final
`WorldFadeIn` protocol state.
The asynchronous renderer may temporarily spend otherwise-idle resource
capacity on the withheld destination, but that policy belongs to the exact
reveal generation. Acdream raises only the count of small mesh/composite
uploads while keeping the ordinary byte, array, buffer, and mipmap ceilings.
The profile ends at the viewport swap above. It never changes the required
destination radius, the readiness predicate, or the resulting pixels.
## Camera motion
The camera rolls around its own AC `+Y` forward axis. It does not yaw around

View file

@ -59,12 +59,35 @@ preview-owned objects. Sources:
- [ACE Player teleport tracking](https://github.com/ACEmulator/ACE/blob/650c5b75ae909957feaf58db320e46be16502653/Source/ACE.Server/WorldObjects/Player_Tracking.cs)
- [Holtburger liveness](https://github.com/merklejerk/holtburger/blob/main/crates/holtburger-world/src/state/liveness.rs)
acdream uses the same compatibility boundary: currently spatially resident
acdream uses the same visibility boundary: currently spatially resident
records are visible; otherwise a 384-unit 3-D global-landblock distance is the
conservative fallback. The distance is an adaptation, not a claimed retail
constant, and remains documented in divergence AP-69 until exact ObjCell/PVS
membership drives the deadline.
There is an additional ACE compatibility boundary. Retail can destroy the
complete client object because its server/client visibility protocol later
recreates that object. ACE may retain the GUID in `KnownObjects` across a
normal teleport and consequently omit the later `CreateObject`. Deleting
acdream's only accepted `EntitySpawn` at the 25-second deadline therefore
made doors, signs, portals, NPCs, and other server objects permanently absent
when a destination was revisited.
The modern client separates those two costs:
- the 25-second deadline destroys every active projection, collision,
animation, physics, script, light, particle, and render owner;
- a cold `EntitySpawn` snapshot remains in a data-only dormant table;
- reloading its authoritative landblock routes that snapshot through the
ordinary generation-safe CreateObject hydration transaction;
- an explicit F747, a newer INSTANCE timestamp, or session reset removes the
dormant snapshot.
This is deliberately not active-object retention: dormant entries execute no
per-frame work and own no GPU resources. It restores the former retained
spawn-table contract required by ACE while preserving the resource reclamation
that fixed the portal-run FPS collapse.
## Port pseudocode
```text
@ -93,14 +116,30 @@ once per second:
for each due candidate:
re-read canonical record
if GUID and generation still match:
route through the normal accepted DeleteObject lifecycle
capture its latest accepted EntitySpawn
run the normal generation-safe active teardown
retain only the cold EntitySpawn
when a landblock becomes resident:
for each dormant spawn in that canonical landblock:
route it through ordinary CreateObject hydration
remove the dormant entry only after the generation is accepted
on explicit DeleteObject:
delete the exact active or dormant generation
remove its ClientObject qualities
on session reset:
clear active runtime and dormant snapshots
```
Routing expiry through `LiveEntityRuntime.UnregisterLiveEntity` is important:
it performs the same generation gate, object-table removal, equipped-child
cleanup, spatial withdrawal, animation/physics/effect teardown, and render
resource release as an authoritative F747. A second teardown path would drift
and recreate the original leak.
it performs the same generation gate, equipped-child cleanup, spatial
withdrawal, animation/physics/effect teardown, and render resource release as
an authoritative F747. Unlike F747, local visibility expiry preserves the
small object-quality record and cold spawn because ACE may never resend them.
An actual F747 removes both. A second active-resource teardown path would
drift and recreate the original leak.
## Reclaimable shared-buffer contract

View file

@ -172,8 +172,11 @@ begin destination generation:
silence old-generation audio
reject old generation from collision, picking, radar, and targeting
at the shared-origin transaction boundary:
atomically swap the complete old spatial generation out of reach
retain one exact teardown receipt per old landblock
over later update/resource-maintenance frames:
detach old landblocks through exact retry receipts
release scripts/effects/lights/plugin projections
release CPU owners
admit renderer retirement; physical GL release remains fence delayed
@ -200,6 +203,48 @@ Release-later never means run-later: an old owner may retain memory until its
cursor runs, but it cannot keep simulating, colliding, rendering, targeting, or
emitting audible work after the generation-quiesce edge.
The shared-origin commit barrier is the completion of **spatial detachment**,
not completion of every deferred resource release:
```text
capture exact old landblock/entity teardown receipts
clear the old loaded/pending/traversal/collision-visible spatial indexes
repark nonpersistent live projections by their canonical landblock
rescue the persistent local-player projection
publish the resulting live visibility edges once
once that single spatial-generation swap commits:
recenter the shared coordinate origin
configure and enqueue the destination window
on later frames:
destination publication uses its reserved work lane
old retirement receipts use remaining cleanup capacity
a publication for landblock X waits only while X has an older receipt
the exact receipt for destination X may advance ahead of unrelated FIFO
ordinary publication continues when no destination result is available
```
The per-landblock publication fence makes key-based terrain, physics,
EnvCell, building, mesh-reference, and static-presentation cleanup safe: a
late old receipt cannot remove a replacement owner with the same landblock
key. Live objects are different. Their logical owner survives spatial
withdrawal and may be reprojected immediately at the destination, so deferred
landblock retirement must never clear a live object's lighting or
translucency by reusable local ID. Live cell-scoped lighting is withdrawn by
the incarnation-aware projection-visibility edge; logical translucency stays
with the live record until its actual teardown. Landblock receipts clean only
DAT-static entity presentation.
This swap is deliberately one **atomic oversized progress operation** in the
modern frame meter. Splitting it into 625 budgeted landblock detach admissions
created a five-second portal stall even though the old generation was already
quiesced and no intermediate subset was observable. The atomic work scans the
small live-projection index, clears the old spatial maps, and transfers their
existing entity lists into receipts; it does not synchronously destroy every
static resource. Expensive script, renderer, physics, light, plugin, and GL
cleanup still advances through the ordinary retryable receipt cursors.
## Port constraints
1. Preserve existing exact retry receipts; successful stages never replay.

View file

@ -0,0 +1,105 @@
# Portal regression closeout — 2026-07-25
## Scope
The first connected retained-scene visual pass reported:
1. portal transit remained in the tunnel too long;
2. a clear/void interval appeared between tunnel exit and destination world;
3. the inventory paperdoll disappeared and rebuilt during transit;
4. doors, signs, portals, NPCs, and other server-spawned objects disappeared
after repeated portal travel.
This note records the root causes and the accepted correction. It does not
reduce world radius, texture quality, particles, readiness, or retail
presentation.
## Retail and protocol anchors
- `gmSmartBoxUI::UseTime @ 0x004D6E30`: the
`TunnelFadeOut -> WorldFadeIn` edge hides the portal viewport, shows the
world viewport, releases cell blocking, and starts the final one-second
WorldFadeIn state.
- The later WorldFadeIn completion sends LoginComplete and ends logical
teleport state. Viewport release and protocol completion are distinct.
- `gmPaperDollUI::RedressCreature @ 0x004A3BC0` creates the private inventory
object only when absent and a SmartBox player is available; temporary player
absence does not delete an existing private object.
- `gmPaperDollUI::~gmPaperDollUI @ 0x004A3590` owns final private-object
destruction.
- `CPhysicsObj::prepare_to_leave_visibility @ 0x00511F40`,
`CObjectMaint::AddObjectToBeDestroyed @ 0x00508F70`, and
`CObjectMaint::UseTime @ 0x005089B0` define retail's delayed active-object
destruction.
- `CPhysicsObj::set_hidden @ 0x00514C60` plays typed UnHide at intensity 1
when materializing. The destination purple/pink effect is DAT-driven retail
behavior, not a generic exit effect to suppress.
## Root causes and corrections
### Shared-origin retirement head-of-line blocking
The 25x25 old spatial window was detached as hundreds of independently metered
operations. No intermediate subset was observable because the complete old
generation was already quiesced, but the shared origin could not change until
all admissions finished.
`GpuWorldState.DetachAllForOriginRecenter` now performs one atomic spatial
generation swap and returns exact per-landblock teardown receipts. Deferred
resource release remains budgeted. A destination publication waits only on an
older receipt for that same landblock; that exact receipt may advance ahead of
unrelated cleanup while the remaining retirement FIFO order stays unchanged.
### Destination scheduling and upload utilization
Near worker jobs, retained publication receipts, mesh uploads, and composite
uploads could each serialize destination work behind unrelated work. The
destination neighborhood is now ordered first at worker and publication
boundaries. When a destination completion actually exists, ordinary
publication is temporarily preempted; when it does not exist or failed,
ordinary Near/Far work continues under the same budget.
`WorldRevealRenderResourceScheduler` owns an exact reveal-generation profile:
- mesh item count: 8 -> 64;
- composite item count: 16 -> 64;
- upload bytes: unchanged at 8 MiB;
- array allocation, mipmap, buffer upload/allocation/copy, and new-resource
ceilings: unchanged.
The profile ends at the portal/world viewport swap. The complete canonical
render/composite/collision readiness predicate remains mandatory.
### Private retained-UI ownership
The paperdoll and creature appraisal used shared mesh data without independent
references, so old-world retirement could evict their meshes. Both now use
`SyntheticEntityMeshReferenceOwner`, with transactional acquisition and
retryable release. `PaperdollFramePresenter` retains the last successful
private object across temporary SmartBox unavailability and equivalent
appearance refreshes; character-session reset is the explicit clear edge.
### ACE KnownObjects revisit
The active 25-second liveness teardown was correct for resource reclamation,
but ACE may keep the GUID in `KnownObjects` and omit CreateObject when the
client revisits that landblock. Deleting acdream's final accepted spawn
snapshot therefore made the object permanently absent.
`DormantLiveEntityStore` retains only the accepted data snapshot after every
active owner is destroyed. Landblock residency re-enters ordinary hydration
with the retained timestamp gate. Exact F747, a newer instance generation, and
session reset remove the dormant snapshot. Dormant objects own no physics,
animation, script, effect, light, audio, render, collision, or per-frame work.
This ACE adaptation remains explicit in divergence AP-69.
## Gates
- Focused portal/retirement/reveal/private-viewport/dormant tests: green.
- Complete App Release suite: 3,813 passed / 3 skipped.
- Release solution build: green with zero warnings.
- Complete Release solution suite: 8,322 passed / 5 skipped.
- User connected visual gate:
- paperdoll remains visible;
- portal transit is materially faster;
- server-spawned objects remain after repeated travel.