Add a canonical documentation map, modernize the public README, and align milestone, roadmap, architecture, issue, divergence, and session guidance with the July 20 baseline. Correct the far-teleport residual to issue #153, close visually accepted indicator and terrain-tiling work, record the remaining detail-overlay and build-warning debt, and deprecate the duplicate legacy bug ledger. Co-authored-by: OpenAI Codex <codex@openai.com>
50 KiB
acdream — Comprehensive Architecture Plan
Vision
A modern C# .NET 10 Asheron's Call client that:
- Behaves identically to the retail client — same physics, same animations, same terrain, same collision, same network protocol
- Looks identical to the retail client — same meshes, same textures, same lighting, same blending, rendered via modern Silk.NET OpenGL
- Adds a plugin API the retail client never had — native C# plugins
- Lua macro scripting for player automation
- Is NOT a 1:1 C++ port — uses modern C# patterns (composition over inheritance, interfaces, dependency injection) while matching retail behavior exactly
Current program status is intentionally not duplicated in this architecture
document. Start at ../README.md, then use the milestone and
roadmap linked there. This file governs ownership and dependency shape even
when the active gameplay milestone changes.
Guiding Principle
The code is modern. The behavior is retail.
Every AC-specific algorithm is ported faithfully from the named retail
decomp at docs/research/named-retail/ — Sept 2013 EoR build PDB
(18,366 named functions, 5,371 named struct types) + Binary Ninja
pseudo-C with 99.6% function-name recovery + verbatim retail header
struct definitions. The older Ghidra FUN_xxx chunks at
docs/research/decompiled/ (688K lines) remain a fallback for the
obfuscated/packed minority. The code AROUND those algorithms is modern
C# with clean architecture. The plugin API exposes game state through
well-defined interfaces that the retail client never had.
Layer Architecture
┌──────────────────────────────────────────────────────────────┐
│ LAYER 5: Plugin API │
│ IGameState, IEvents, IActions, IPacketPipeline, IOverlay │
│ Plugin host (ALC), Lua macro engine (MoonSharp) │
│ ► acdream-unique — not in retail client │
├──────────────────────────────────────────────────────────────┤
│ LAYER 4: Game Objects │
│ GameEntity (one per world object) │
│ ├── PhysicsBody (ported from decompiled) │
│ ├── AnimSequencer (ported from decompiled) │
│ ├── CellTracker (ported from decompiled) │
│ ├── AppearanceState (ObjDesc: palettes, textures, parts)│
│ └── MotionState (ported from decompiled) │
│ ► behavior matches retail, code is modern C# composition │
├──────────────────────────────────────────────────────────────┤
│ LAYER 3: World Systems │
│ TerrainSystem (heightmap, blending, scenery) │
│ CellSystem (LandCells, EnvCells, portals, BSP) │
│ StreamingSystem (background loading, LOD, frustum cull) │
│ ► behavior matches retail, streaming is acdream-unique │
├──────────────────────────────────────────────────────────────┤
│ LAYER 2: Network │
│ WorldSession (ISAAC, fragments, game messages) │
│ MessageRouter (opcode dispatch, sequence tracking) │
│ ► wire-format identical to retail │
├──────────────────────────────────────────────────────────────┤
│ LAYER 1: Renderer │
│ Silk.NET OpenGL 4.3 core profile │
│ TerrainModernRenderer, WbDrawDispatcher, EnvCellRenderer │
│ Shaders (terrain blending, mesh lighting, translucency) │
│ ► completely different from retail (D3D7), same visual │
│ output │
├──────────────────────────────────────────────────────────────┤
│ LAYER 0: Platform │
│ .NET 10, Silk.NET window/input, DatReaderWriter │
│ ► acdream-unique infrastructure │
└──────────────────────────────────────────────────────────────┘
UI Architecture (two coexisting presentation stacks)
The 2026-04-24 design began with a swappable renderer abstraction. D.2b proved
that retail fidelity needs a retained LayoutDesc/DAT tree, while ImGui remains
valuable as permanent devtools. The current architecture therefore has two
presentation stacks over shared state, ViewModels, events, and commands. Full
history and the corrected contract live in
docs/plans/2026-04-24-ui-framework.md.
┌─────────────────────────────────────────────────────────────┐
│ DEVELOPER UI │
│ IPanel/IPanelRenderer → permanent ImGui devtools │
│ GAMEPLAY UI │
│ LayoutDesc/DAT → UiRoot retained widgets + controllers │
├─────────────────────────────────────────────────────────────┤
│ SHARED CONTRACTS │
│ ViewModels, commands, input actions, state/event services │
│ ► one model and mutation path, two presentation projections│
├─────────────────────────────────────────────────────────────┤
│ Game state + events (unchanged) │
│ IGameState / IEvents / WorldSession — UI only reads │
└─────────────────────────────────────────────────────────────┘
AcDream.UI.Abstractions owns backend-neutral ViewModels, commands, input,
and the IPanel/IPanelRenderer devtools contract. AcDream.App/UI owns the
retained gameplay tree, LayoutDesc importer, window runtime, and panel
controllers. Neither presentation stack owns independent game-state truth.
Chat submission follows the same rule: both presentation stacks enter the
shared ChatCommandRouter, which emits distinct backend-neutral intents for a
retail client command (ExecuteClientCommandCmd), an ACE-owned command
(SendServerCommandCmd), or ordinary chat (SendChatCmd). App-layer
handlers and controllers translate those intents to WorldSession; panels
never inspect or construct wire messages.
Plugins register retained gameplay markup through the BCL-only
AcDream.Plugin.Abstractions.IUiRegistry; they do not import App or ImGui
assemblies. Core SelectionState is the sole selected-object owner for world,
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
IPluginHost.Selection exposes that same state and retail-style old/new callback.
Temporary pointer modes are separate App orchestration in InteractionState and
must never become a competing selection owner.
External-container state follows the same ownership rule. Core
ExternalContainerState owns the expected/current ClientUISystem::groundObject
identity, Core.Net applies authoritative ViewContents and
CloseGroundContainer events, and App ExternalContainerLifecycleController
owns the replacement-only NoLongerViewingContents side effect. The retained
ExternalContainerController is a projection over that state; mounting or
disabling the gameplay UI cannot change the session lifetime.
Every retained gameplay window has a typed RetailWindowHandle and at most one
IRetainedPanelController lifecycle owner. Multi-controller windows use
RetainedPanelControllerGroup; the manager disposes controllers exactly once in
reverse ownership order. UiHost removes Silk device subscriptions first, then
disposes the manager/controllers, then its GL renderer. GameWindow.OnClosing
tears this runtime down before session and game-state sources.
RetailUiRuntime is the production composition boundary. GameWindow creates
the GL/DAT resolvers and supplies focused state/action binding records in one
mount call; the runtime owns all LayoutDesc imports, controller construction,
window registration, plugin mounts, cursor feedback, layout persistence, and the
retained tick/draw/restore/dispose paths. Panel-specific construction must not
move back into GameWindow.OnLoad.
Magic follows the same boundary. Core Spellbook is the one learned/favorite/
desired/enchantment state projection; Core.Net owns exact manifest and live
message parsing; App SpellCastingController owns validated cast intent; and
the retained spell bar, spell/component book, and effects controllers only
project that state and invoke supplied actions. ACE remains authoritative after
the targeted/untargeted request for turning, animation, mana/components,
fizzle, impact, damage, and completion.
The shared LayoutDesc importer resolves inherited controller trees with retail
LayoutDesc::InqFullDesc/ElementDesc::Incorporate child identity semantics;
panel controllers never reconstruct missing inherited widgets by hand.
RetailPanelUiController is the single retained owner of retail
gmPanelUI::RecvNotice_SetPanelVisibility semantics: one active main-panel
child, optional DAT-property-driven deferred restoration, and visibility
reconciliation for persistence. Toolbar buttons and the authored Helpful/
Harmful effects indicators both send panel IDs into this owner; neither owns a
parallel window-lifecycle map.
Project Structure (current + target)
src/
AcDream.Core/ Layer 2-4: no GL, no Silk.NET, pure logic
Physics/
PhysicsBody.cs -> body state / integration foundation (done)
CollisionPrimitives.cs -> retail primitive helpers (partial, active)
MotionInterpreter.cs -> motion state machine (done, still L.1 polish)
AnimationSequencer.cs -> animation playback + root-motion data (done, L.1 active)
TerrainSurface.cs -> triangle-aware terrain contact (done)
BSPQuery.cs -> partial retail BSP dispatcher (active in L.2)
TransitionTypes.cs -> SpherePath / CollisionInfo / transition helpers (active in L.2)
PhysicsDataCache.cs -> GfxObj / Setup / CellStruct collision data (done, active)
ShadowObjectRegistry.cs -> broadphase for nearby physics objects (active)
PhysicsEngine.cs -> ResolveWithTransition active player path
ProjectilePhysicsStepper.cs -> pure retail projectile integration + sweep
RetailFrameMath.cs -> retail full-3D vector-heading frame construction
CellBsp.cs -> not a first-class runtime owner yet (L.2e)
World/
GameEntity.cs -> target unified entity, not current reality
WorldState.cs -> target entity owner
CellTracker.cs -> target per-entity cell management
SceneryGenerator.cs -> verified against decompiled (done)
LandblockLoader.cs -> done
Terrain/
LandblockMesh.cs -> verified against ACME (done)
TerrainBlending.cs -> verified against ACME (done)
Meshing/
GfxObjMesh.cs -> cross-checked against ACME (done)
SetupMesh.cs -> cross-checked (done)
Textures/
SurfaceDecoder.cs -> done
Dat/
MotionResolver.cs -> done (target move from Meshing/)
AcDream.Core.Net/ Layer 2: networking
WorldSession.cs -> done (wire-compatible with ACE)
NetClient.cs -> done
Messages/ -> done (CreateObject, MoveToState, etc.)
AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces
IAcDreamPlugin.cs -> done
IPluginHost.cs -> done
IGameState.cs -> done
IEvents.cs -> done
ISelectionService.cs -> done
AcDream.App/ Layer 1 + Layer 4 wiring
Physics/
ProjectileController.cs -> live-record projectile orchestration/corrections
RemotePhysicsUpdater.cs -> ordinary/Hidden remote narrow-tick integration
LiveEntityOrdinaryPhysicsUpdater.cs -> manager-less body Transition commits
RemoteInboundMotionDispatcher.cs -> animation-optional retail UM funnel
RemoteTeleportController.cs -> incarnation-scoped loaded/pending placement owner
RemoteTeleportHook.cs -> ordered retail teleport teardown seam
RemoteTeleportPlacement.cs -> collision-seated SetPosition transition commit
World/
LiveEntityRuntime.cs -> canonical identity/state/body/projectile/spatial ownership
RetailInboundEventDispatcher.cs -> update-thread packet/frame FIFO barrier
LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation
LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain
RetailLiveFrameCoordinator.cs -> object/physics-before-network frame barrier
Rendering/
GameWindow.cs -> still owns too much runtime wiring
LiveEntityAnimationScheduler.cs -> canonical ordinary live-object workset
RetailStaticAnimatingObjectScheduler.cs -> retail static-animation workset
StaticLiveRootCommitter.cs -> static root pose/collision commit boundary
TerrainModernRenderer.cs -> mandatory bindless+MDI terrain path
Wb/WbDrawDispatcher.cs -> ordinary live/static entity draw dispatch
Wb/EnvCellRenderer.cs -> indoor cell-shell draw path
ParticleRenderer.cs -> DAT particle billboard/full-mesh dispatch
RetailAlphaQueue.cs -> shared delayed world alpha ordering/flush owner
TextureCache.cs -> done
ChaseCamera.cs -> done
FlyCamera.cs -> done
Streaming/
StreamingController.cs -> done
GpuWorldState.cs -> done
Input/
PlayerMovementController.cs -> active movement driver
Plugins/
AppPluginHost.cs -> done
Movement And Collision Architecture
Phase L.2 is the current organizing program for physics, collision,
boundaries, buildings, sliding, cell ownership, movement packets, and server
authority. Detailed plan: docs/plans/2026-04-29-movement-collision-conformance.md.
The active player movement spine is:
InputDispatcher / PlayerMovementController
-> MotionInterpreter + local body prediction
-> PhysicsEngine.ResolveWithTransition
-> TransitionTypes + BSPQuery + ShadowObjectRegistry
-> ResolveResult contact/cell state
-> MoveToState / AutonomousPosition outbound messages
-> WorldSession server echo or correction handling
What exists and is active:
PhysicsEngine.ResolveWithTransitionis the path used for local player collision resolution and the shared continuous object sweep.ProjectilePhysicsStepperis the pure Core driver for retail projectile clock quanta, final-state acceleration, velocity/angular integration, AlignPath, Setup-sphere transition sweeps, and collision response. It owns no live entity, network, rendering, or App state; the App controller supplies those boundaries.ProjectileControlleris that App boundary. It installs one body on the canonicalLiveEntityRecord, advances it on the update/render thread, commits throughWorldEntity.SetPosition, publishes the effect root, synchronizes the existing shadow, and callsRebucketLiveEntityonly when the full cell changes. Pending/leave-world residence suspends the shadow's cell rows while retaining its registration for exact re-entry. Timestamp- gated State/Vector/Position/Movement packets correct or stop that same body; every production MovementManager delegates full-cell reads/writes to the incarnation-scoped live record, and projectile acquisition adopts its body without replaying vectors already installed when that body was created, regardless of component creation order. Accepted SetState flags reach that canonical body before optional projectile acquisition, so absent/unsupported DAT shape data cannot leave collision state stale; a non-finite local receipt clock uses the controller's last finite game-clock value and cannot consume first-time Missile classification. Shared-body acquisition validates the adopted body's current frame/vectors, not obsolete CreateObject inputs. Initial and late Setup resolution share GameWindow's_datLock, preserving the single-readerDatCollectioninvariant while streaming is active. Generic remote translation is suppressed while projectile integration owns the frame; delete, generation replacement, pickup/parent leave-world, and session reset useLiveEntityRuntime's normal lifecycle and never create a second GUID map.LiveEntityAnimationScheduleris the one update-thread walk over spatial live roots. It admits each incarnation'sRetailObjectQuantumClock, advances PartArray state, then selects exactly one movement owner: remote motion, the retained projectile body, orLiveEntityOrdinaryPhysicsUpdaterfor a manager-less canonical body. Every hook boundary revalidates record, component, and object-clock epoch before transition, manager-tail, pose, or rebucket commits.RetailStaticAnimatingObjectSchedulerremains a separate workset, matchingCPhysics::UseTime: Setup DefaultAnimation is installed at PartArray construction, while DAT and live PhysicsState-Static owners advance whole elapsed intervals throughanimate_static_objectsemantics. A live static owner shares the record's canonical PartArray and PhysicsBody; raw static omega commits the root, effect pose, and collision shadow together, while zero omega does not reflood collision and Hidden never restores it. The typed animation view copies the runtime's concrete spatial dictionary through reusable storage, so update and render classification allocate nothing and scale with resident owners rather than retained KnownObjects.RemoteInboundMotionDispatcheris the single remoteMovementManager::unpack_movementowner. Animated and animation-less live objects differ only by the presence of a PartArray dispatch sink; packet head interrupt/style routing, MoveTo cases, case-0 wholesale state, sticky, and long-jump ordering cannot drift between twoGameWindowbranches.- Ordinary remote/manager-less Transition commits publish the resolved body,
WorldEntityroot, and full cell before invoking the canonical rebucket writer. That writer is a re-entrant lifetime boundary: the caller immediately revalidates the exact record/runtime and publishes no collision shadow or manager tail if the destination became pending or the GUID was replaced.LiveEntityPresentationControllerowns the symmetric non-projectile collision-residency edge: loaded-to-pending suspends the retained shadow, hydration restores it immediately, andOnLiveEntityReadyreconciles an object that materialized pending before its collision registration existed. Direct local-player and authoritative remote publication use the same ordering: commit root, rebucket, then prove exact-record spatial residency before touching collision. Remote shadows are pose/cell-gated, including complete sign-invariant orientation for offset/multipart Setup shapes and a forced refresh on cell-only transitions. Projectile residency remains exclusively inProjectileController. RetailInboundEventDispatcherwraps every live-object session callback and each complete live-object frame phase. Synchronous App observers can enqueue another packet, but that packet cannot interleave halfway through the older packet's collision, rebucket, shadow, pose, hook, or manager tail. The live record retains independent Position, State, Vector, and Movement authority versions plus a narrow shared velocity version for the three packet families that can install velocity. This mirrors retail's single update-thread FIFO without incorrectly making an accepted State cancel an accepted Position. Its direct frame/packet path accepts state plus a cached static callback and performs zero allocation; only real nested reentrancy materializes a queued heterogeneous operation.- Projectile prediction is a two-phase transaction around retail's
process_hooksslot.BeginQuantumholds a candidate while leaving the canonical body at its begin frame;CompleteQuantumcommits only if the exact record/runtime and prediction version still match. Position, Vector, and State corrections therefore win if accepted between the two halves. Teleport placement uses the same exact-record and per-channel authority checks, so a newer Position supersedes an older resolver while an independent State is preserved alongside the completed placement. EntityEffectPoseRegistryassigns a monotonic lifetime to every local-ID incarnation.AnimationHookFrameQueuesnapshots it before semanticAnimationDonecallbacks and revalidates it before every semantic completion and every routed hook. A delete/local-ID reuse during capture or during an earlier hook can never advance the displaced sequencer or send the old owner's remaining sound, particle, or light hooks to its replacement.RemoteTeleportControllerowns the placement half of a fresh remote teleport afterRemoteTeleportHookhas torn down movement/target state. It collision-seats loaded destinations throughRemoteTeleportPlacement; an unloaded destination retains one generation- and PositionSequence-scoped pending placement and resolves the latest accepted frame when that same projection becomes visible. It neither owns GUID identity nor reconstructs an entity. A placement failure after hydration restores the captured source frame/cell/contact rather than leaving a visible collisionless projection; source-resident shadows restore immediately, while an unloaded source delegates one incarnation-scoped restore toLiveEntityPresentationController, shared with Hidden/UnHide. A newer placement transfers that marker into an explicit active-placement generation before rebucketing even while Hidden. That generation suppresses every intervening Hidden/UnHide and projection restore until the controller reaches a stable result, then it either restores after collision seating, re-defers its rollback source, or hands a Hidden result back for UnHide. The typedILiveEntityRemotePlacementRuntimeseam permits a same-incarnation wrapper rebind only around the canonical body; pending placement adopts that wrapper before hydration and cannot silently lose ownership. Clearing a motion or projectile component retains the incarnation's body/contract identity until logical teardown. The production wrapper exposes an immutable body, while hydration defensively validates arbitrary implementations against the record and rolls the retained body back on mismatch. Runtime binding snapshots the interface Body getter once for validation, assignment, and state mutation.GpuWorldStaterebuckets atomically and commits spatial visibility before draining its transition FIFO;LiveEntityRuntimerejects delayed duplicate edges. A rollback/rebucket inside a visibility observer therefore cannot expose a remove/add pulse, leave stale final visibility, or reorder the final presentation edge. Observer failures are reported only after both spatial and canonical runtime cell/projection state have committed. Every projection operation also carries a per-record mutation token; if a synchronous observer replaces the GUID or reprojects that same record, the displaced outer operation cannot overwrite the newer cell or presentation indices.BSPQuerycontains the partial retail-style BSP collision dispatcher used by the transition path.TransitionTypescarriesSpherePath,CollisionInfo,ObjectInfo, transition validation, step-up/down, contact-plane handling, and partial slide behavior.PhysicsDataCacheloads GfxObj, Setup, and CellStruct physics data from DATs.ShadowObjectRegistrygives movement a broadphase over nearby objects and buildings.TerrainSurfaceuses triangle-aware terrain contact; older "bilinear terrain Z" descriptions are historical B.3 language, not current architecture.
What remains incomplete:
CELLARRAY,CObjCell::find_cell_list, adjacent-cell checks, and low outdoor cell id updates across 24m seams.cell_bsp/CellBSPas the authoritative runtime owner for indoor and building collision.- Building portal transit and normal walking through building entry/exit boundaries.
- Full retail
edge_slide,cliff_slide,precipice_slide, andNegPolyHitdispatch behavior. - Exact
CSphere/CCylSphereobject-shape parity, especially for live entities that currently collapse to a simplified cylinder fallback. - Routine local/server correction diagnostics. ACE accepting a position is a compatibility signal, not proof of fine retail collision parity.
Ownership by phase:
- B.3 is shipped MVP history: first resolver foundation and tests.
- L.1 owns animation/motion parity, including root-motion coupling.
- L.2 owns the movement/collision conformance stack listed above.
- G.3 owns dungeon streaming and portal-space delivery after L.2e gives it trustworthy cell/building boundaries.
GameEntity: The Unified Entity (target refactor)
LiveEntityRuntime is now the shipped bridge to this target. It owns one
LiveEntityRecord per accepted server-object incarnation, ServerGuid-to-local-id
translation, accepted snapshots/timestamp gates, the canonical PhysicsBody,
animation/remote-motion/projectile components, parent-event state,
effect-profile defaults, and exact logical
teardown. Live PhysicsDesc effect fields replace Setup defaults on that same
record; rebucketing never recreates them. GpuWorldState
owns spatial buckets only: register/rebucket/unregister are separate operations,
logical teardown removes the exact WorldEntity incarnation rather than every
projection sharing its server GUID, and per-GUID/session mutation epochs prevent
a callback from resurrecting an outer CreateObject after delete/reset. Per-GUID
epoch tombstones remain until session clear, so delete-then-create cannot repeat
an outer operation's epoch (the ABA problem). Resource registration is an atomic
boundary, superseded cleanup failures surface at the runtime boundary, and
visibility observers are failure-isolated without interrupting canonical commits.
Landblock reloads reuse the same WorldEntity without replaying renderer or
script creation. Its canonical materialized view remains stable across pending
landblocks, while a separate visible-only view feeds radar, picking, status, and
targeting. Raw server PhysicsState and the final state produced by retail's
ordered Lighting/NoDraw/Hidden side effects are stored separately.
LiveEntityPresentationController drains those accepted transitions only after
the renderer/effect owner is ready: Hidden suppresses the retained root's mesh,
collision, interaction, radar, and target eligibility while preserving the
logical record, scripts, particles, lights, full cell, and local identity.
Direct attached children receive retail's NoDraw mutation, and typed
Hidden/UnHide effects resolve through the live PhysicsScriptTable.
Pickup/parent leave-world clears cell membership and pauses root
movement/animation without destroying retained owners. GameWindow retains
storage-free typed views while its large feature loops are extracted.
DAT decoding for runtime VFX crosses one narrow AcDream.Content.Vfx seam.
RetailAnimationLoader and RetailPhysicsScriptLoader still read through the
single shared DatCollection, delegate ordinary animation-hook schemas to
Chorizite.DatReaderWriter, and replace only its incomplete
CreateBlockingParticleHook model with retail's inherited CreateParticle
payload. Core consumes the loaders through delegates/interfaces and therefore
does not depend on Content or a database implementation.
Runtime effect delivery is owned by App-layer EntityEffectController. It
retains pre-materialization F754/F755 packets in one mixed FIFO per server GUID,
resolves the canonical local ID through LiveEntityRuntime only after
renderer/resources/profile are ready, and resolves typed/default/part-default
hooks through the live owner's current EntityEffectProfile. It does not own a
second GUID map. The same profile publishes Setup/static or network/live
SoundTable ownership at readiness and on every same-generation PhysicsDesc
replacement, including present-zero clearing. Core PhysicsScriptRunner mirrors retail's one
serial FIFO per owner: duplicate plays append, owners progress independently,
CallPES uses an injected uniform delay, and every hook traverses the shared
AnimationHookRouter. Existing queues on cell-less or Frozen live roots do
not advance; a new ordinary F754/F755/default play for an already-created
cell-less object is dropped as in retail. Hidden/UnHide is the retail exception:
CPhysicsObj::set_hidden resolves the typed script and calls
play_script_internal, queuing it until owner eligibility returns. Attached
children advance through their eligible parent. Every effect
resource uses the canonical, globally unique WorldEntity.Id; static
allocators fail before their namespace can wrap. EntityEffectPoseRegistry
publishes the final root and indexed rigid animated-part transforms after animation
and equipped-child composition. Animation hooks are captured during sequence
advance and drained only after those poses are current; PhysicsScripts then run,
attached emitters and object lights refresh, and particle simulation advances.
World-released particles retain their birth positions and parent-local particles
follow their current owner while in-world. Pending spatial projections skip
particle updates and drawing exactly like retail's cell-less object gate without
ending emitter state or identity; absolute creation timestamps remain unchanged,
so elapsed particles/durations expire on re-entry without a backlog burst. Their
anchors may still accept authoritative pose correction before that update.
Missing emitter DAT records fail diagnostically
without a synthesized effect. LiveEntityLightController keeps live light
projection/re-entry outside GameWindow; LightingHookSink remains the Core
hook and per-frame pose consumer. It also owns retail's Lighting-bit/
SetLightHook latch so spatial withdrawal preserves logical state while a
true state transition creates or destroys the Setup lights. Stable indexed
poses exclude Setup visual scale and remain separate from drawable MeshRefs.
Light registration follows final runtime visibility edges; equipped-child
updates are parent-before-child with retained per-child buffers and cascade
withdrawal when an ancestor pose disappears. A later pose publication drains
the waiting attachment graph transitively, so A→B→C recovers B before C in the
same parent-first pass. Recovery is edge-triggered by object/appearance/pose
publication; permanent missing DAT or holding parts are never polled per frame.
The remaining aggregation is primarily _playerController's player-specific
movement plus the separate WorldEntity/animation/physics component types.
Those should become ONE class:
public sealed class GameEntity
{
// Identity
public uint ServerGuid { get; }
public uint SetupId { get; }
public string? Name { get; }
// Spatial (ported from CPhysicsObj)
public PhysicsBody Physics { get; } // position, velocity, gravity
public CellTracker Cell { get; } // which cell we're in
// Appearance (ported from CPartArray)
public AnimationSequencer Animation { get; } // frame playback
public AppearanceState Appearance { get; } // ObjDesc overrides
// Motion (ported from CMotionInterp)
public MotionInterpreter Motion { get; } // walk/run/turn state
// Render output (consumed by WbDrawDispatcher)
public IReadOnlyList<MeshRef> MeshRefs { get; }
// Per-frame update (matches retail update_object)
public void Update(float dt)
{
Motion.ApplyCurrentMovement(); // set velocity from motion state
Physics.UpdateObject(dt); // integrate position
PhysicsEngine.ResolveWithTransition(); // current L.2 collision spine
Cell.UpdateCell(Physics.Position); // target: retail cell ownership
Animation.Advance(dt); // advance animation frames
RebuildMeshRefs(); // compute per-part transforms
}
}
Target state: every entity in the world — player, NPC, monster, lifestone,
door, chest — becomes a GameEntity. The renderer iterates them and draws.
The plugin API exposes them as WorldEntitySnapshot. GameWindow becomes thin.
Lifecycle invariant in both the current split model and the target GameEntity:
an ObjDescEvent changes appearance in place. It may replace resolved meshes,
palette ranges, part overrides, and visual bounds, but it must preserve entity
identity plus animation, motion, physics, collision, selection, and
dead-reckoning owners. Only a real delete/despawn tears those owners down. This
matches retail's CPhysicsObj::DoObjDescChangesFromDefault behavior.
Runtime resource ownership and bounded residency
Logical lifetime, spatial residency, and physical GPU lifetime are separate contracts. A live entity or landblock owns stable logical references; moving it between buckets does not reacquire resources. Appearance replacement acquires the complete new mesh/texture set before publication, then releases the old set. Despawn and landblock demotion withdraw every public render reference before their physical resources become reclaimable. All of these transactions are retryable and generation-scoped, so a failed release cannot silently strand a half-retired owner or affect a reused server GUID.
Runtime content residency is deliberately bounded rather than proportional to every region visited:
RuntimeDatCollectionFactorykeeps DAT indexes on demand but usesFileCachingStrategy.Never;DatCollectionremains the sole raw reader.- Each typed DAT facade has a 256-entry / 64 MiB estimated LRU. Unknown object graphs are conservatively charged at least 128 KiB. Canonical decoded texture pixels use a separate 128-entry / 64 MiB cache.
- Standalone bindless textures retain at most 256 unowned entries / 32 MiB and retire at most one per frame. Owner-scoped composite textures use a 64 MiB unowned budget and 128 MiB physical budget, admitting at most 16 uploads or 8 MiB per frame.
ObjectMeshManagermay retain at most 32 empty texture atlases / 64 MiB.GlobalMeshBufferowns reclaimable vertex/index ranges capped at 384 MiB and 128 MiB respectively, with an 896 MiB physical ceiling that includes an in-progress migration and its retired predecessor.
OpenGL deletion and range/slot reuse are not synonymous with logical release.
GpuFrameFlightController fences three frames in flight. Mesh-buffer stores,
texture handles, atlas layers, terrain slots, and landblock render records enter
retirement only after they are no longer publishable, and their physical ids are
recycled only after the corresponding fence signals. Shutdown follows the same
dependency order and remains retryable: UI/controllers and render registrations
withdraw first, then owner leases and caches, then GL backing stores. This keeps
drivers from reading freed memory without adding a portal-specific purge or a
visual-distance reduction.
Per-Frame Update Order (current runtime)
1. Network tick
└── Drain inbound queue → process CreateObject, UpdateMotion,
UpdatePosition, PlayerTeleport → create/update GameEntities
2. Streaming tick
└── Compute observer position → load/unload landblocks →
create terrain + scenery GameEntities
3. Input tick (player mode only)
└── InputDispatcher scopes → PlayerMovementController →
MotionInterpreter/body prediction → ResolveWithTransition →
send MoveToState/AutonomousPosition to server
4. Entity / animation tick
└── Current code still has scattered world/entity state. L.1 owns
animation parity; L.2 owns movement/collision conformance.
5. Render tick
└── Read current entity mesh refs, draw
TerrainModernRenderer + WbDrawDispatcher + EnvCellRenderer
(frustum cull, translucency pass, portal visibility, etc.)
World Wb translucents + Scene particles submit to RetailAlphaQueue;
RetailPViewRenderer flushes the landscape scope before the conditional
depth clear and GameWindow flushes the final scope before private
viewports/UI. Sky/private viewport particles remain immediate.
Projectiles remain ordinary live-entity draws; there is no global or
projectile-specific render pass.
6. Plugin tick
└── Fire IEvents, drain IActions queue
6a. UI tick
IPanelHost.Draw → iterate registered IPanel instances, build
ViewModels from IGameState, dispatch user Commands via ICommandBus.
Backend-agnostic — ImGui or custom retail-look draws here depending
on which is compiled in. See docs/plans/2026-04-24-ui-framework.md.
Render Pipeline (SSOT — current state + unified-PView target)
The modern path (Phase N.5, mandatory) is
WbDrawDispatcher(entities) +EnvCellRenderer(indoor cell shells) +TerrainModernRenderer(terrain), fed by the portal-visibility stack. This section is the authoritative description of how indoor/outdoor rendering is supposed to work and where the code currently diverges. Canonical reset handoff:docs/research/2026-05-31-render-architecture-reset-handoff.md.
The principle (retail PView). acdream must render the world the way retail does —
through one portal-visibility traversal whose output gates every geometry type
uniformly. From the player's cell, walk the portal graph; each visible cell carries a
screen-space clip region (its portal opening, recursively intersected); the outside
(terrain + outdoor scenery) is reached only through exit portals and is clipped to
those openings. Interior cell shells, interior statics, and the outside are all
clipped to their PView region. This is why retail is seamless by construction. Decomp
anchors: PView::ConstructView (:433750), InitCell (:432896), DrawCells
(:432715), CEnvCell::find_visible_child_cell (:311397), SmartBox::update_viewer
(:92761). Reference port acdream owns but never invokes: WB RenderInsideOut /
VisibilityManager.
The one rule: compute visibility once; enforce it once, for all geometry. Indoors, you see the outside only through portal openings (clipped); an empty outside-view (windowless interior) draws no outdoor geometry. Outdoors, the gate is "everything."
Current divergence (the patchwork — what the reset must fix). acdream computes the
visibility correctly (CellVisibility.ComputeVisibility → PortalVisibilityBuilder.Build,
a ConstructView port → ClipFrameAssembler) but then enforces it three different,
inconsistent ways:
TerrainModernRenderer— gated byTerrainClipMode {Skip|Scissor|Planes}(the Scissor fallback over-includes).EnvCellRenderer— gated by the per-cell clip slot (≈correct; the shells DO render — proven by the[shell]probe,ACDREAM_PROBE_SHELL).WbDrawDispatcher— gated byParentCellId ∈ visibleCellIds, but outdoor stabs (ParentCellId==null) bypass the gate → outdoor scenery/terrain shows from inside (issue #78).
Three gates that must agree but don't → structural seams (transparent walls,
terrain-through-floor, grey enclosure). The reset consolidates them into the single
PView gate (outside content clipped to the OutsideView region; no ParentCellId==null
bypass; no Scissor over-include). This is a consolidation of existing machinery
(PortalVisibilityBuilder + ClipFrame), not a rewrite. Do NOT add a fourth special-case
gate to mask a seam — that anti-pattern produced the patchwork.
Streaming publication ownership
The streaming worker may read dats and build CPU payloads, but it must not mutate
live render-frame registries. A near-tier job produces one LandblockBuild whose
optional EnvCellLandblockBuild owns the complete portal-cell and drawable-shell
sets for that landblock. The exact build object crosses the LandblockStreamer
completion channel. On the render thread, StreamingController applies that one
completion to CellVisibility, cell physics state, EnvCellRenderer, and finally
GpuWorldState during the same update-frame drain.
This is the publication boundary: per-job builders are private and single-use;
completed payloads are immutable snapshots; live landblock stores are replaced as
complete units. Process-wide pending bags or renderer-owned pending lists are not
valid streaming seams because a duplicate load or a second landblock can drain or
replace state produced by another job. CPU mesh extraction may still be scheduled
onto ObjectMeshManager's thread-safe work queue, but the worker never publishes
partially hydrated cell membership or shell placement to the renderer.
Teleport cell identity at the streaming boundary
The network and physics layers carry retail's complete Position: an
authoritative objcell_id plus a frame. Any streaming decision that asks
whether a teleport crossed a landblock compares the source and destination cell
IDs. It must never reconstruct either landblock by flooring render/world XYZ.
Dungeon EnvCells have arbitrary frame origins, including valid negative local
coordinates, so XYZ-to-grid inference can evict the landblock the player is
still standing in.
TeleportLandblockTransition owns this App-layer classification. When the
source cell is unavailable before first placement, it uses the typed current
streaming-center landblock as the fallback identity. The existing async
recenter/hydration path runs only for a real cell-ID landblock change; a
same-landblock teleport keeps the already-published render and physics unit.
This is the asynchronous adapter for retail's direct full-Position placement
(SmartBox::TeleportPlayer @ 0x00453910).
Portal-space presentation boundary
TeleportAnimSequencer is the pure Core owner of retail's seven-state
teleport lifecycle, exact 1/2/5-second thresholds, frame-aligned exit window,
and 100-sample UIGlobals::GetAnimLevel view-plane curve. App-layer
PortalTunnelPresentation owns the synthetic DAT Setup (0x02000306), its
40-frame/s animation (0x030005AC), CSequence, SmartBox-FOV camera, distant light, mesh
references, and animation-hook delivery. It renders through the existing
modern WbDrawDispatcher as a replacement 3-D viewport, after world rendering
and before retained UI. The shared dispatcher is reset to no world-cell clips
and no world point lights for this pass. Chat, gameplay windows, toolbar,
cursor, and input continue normally above it. The synthetic object never
enters LiveEntityRuntime, collision, picking, radar, or server GUID state.
TeleportViewPlaneController is the App-layer projection adapter for retail
SmartBox::SetOverrideFovDistance / Render::set_vdst. It captures the active
game projection at teleport begin, then applies the same table-eased
view-plane distance and near plane to the portal and world viewports through
the four retail FADE states. Those states do not drive a black alpha layer:
the portal and world 3-D viewports switch directly at the transition view
distance, while retained UI remains independently composed. Portal camera
direction is a roll around its local AC
+Y forward axis, so the animated scene remains a passage rather than yawing
away from it. Destination placement also calls the retail chase camera's
set_viewer(player, reset_sought=1) equivalent; the normal damped/swept camera
path then re-extends from the arriving player.
One reset seam ends a logical transit on successful completion, replacement
by a newer teleport sequence, or session teardown. It clears the destination,
streaming priority, sequencer edges, projection override, and portal scene
together, preventing transition state from crossing destination or session
lifetimes. TeleportTransitCoordinator correlates each fresh F751 sequence
with exactly one accepted UpdatePosition. Because the Position can arrive on
either side of F751, a pre-notification packet is buffered only when it advances
retail's TELEPORT_TS; after notification, the first accepted matching packet
is consumed even if that timestamp already advanced. Duplicate/older F751
notifications are rejected with wrap-safe ordering. Canonical physics remains
independent, and streaming recentering compares destination identity with the
actual current streaming center rather than the player's still-unplaced source
cell. A live teleport received while acdream's developer fly/orbit camera is
active re-enters the existing player-mode lifecycle first; retail has no
detached camera mode, and destination placement therefore always retains the
canonical local physics controller and chase-camera handoff.
The destination residency gate supplies retail's EndTeleportAnimation edge
once asynchronous streaming is ready. Player placement remains authoritative
and separate from presentation; arrival resets camera viewer state but does
not reset the character animation sequence. RetailLiveFrameCoordinator
matches SmartBox::UseTime @ 0x00455410: it advances local and remote object/
projectile runtime, composes final animated/equipped poses, drains animation
hooks, then advances particles before owner scripts and inbound network dispatch.
RetailLocalPlayerFrameController owns the player's exactly-once tick and sends
input-originated movement/jump output on the object side of that barrier. After
inbound dispatch, the retail command-interpreter phase evaluates periodic
AutonomousPosition from current authoritative state, then a non-advancing spatial
reconciliation updates equipped children and live effect/light anchors. Consequently a
teleport Hidden transition cannot freeze the last fraction of recall and replay
it after UnHide, and ForcePosition/F751 cannot combine old one-shot input with a new cell.
The draw pass consumes that completed update snapshot and never advances live
animation independently. See
docs/research/2026-07-15-retail-portal-space-pseudocode.md.
Roadmap Model
The old R1-R8 architecture sequence was a useful early refactor sketch, but it
is no longer the execution plan. The strategic source of truth is now
docs/plans/2026-04-11-roadmap.md, with per-phase details in docs/plans/
and docs/superpowers/specs/.
Current movement/collision ownership:
- B.3 is shipped MVP history: first collision resolver foundation.
- L.1/R6 own animation/motion parity and the complete retail object-frame
update order over canonical
LiveEntityRuntimerecords. - L.2 owns the remaining movement and collision conformance:
docs/plans/2026-04-29-movement-collision-conformance.md. - G.3 is shipped: dungeon streaming, portal-space presentation, collision, lighting, doors, and the full round-trip gate landed. The separate far- teleport unstreamed-edge residual is issue #153, not unfinished G.3.
- M3 owns current connected magic/portal acceptance; Track LH is the future presentation-independent Linux/headless extraction.
The GameEntity / thin GameWindow refactor remains a valid target architecture, but it is not a prerequisite for L.2. Do not resurrect old R1-R8 phase numbers for new work; add or update roadmap phases instead.
Development Workflow (mandatory for ALL work)
For every AC-specific behavior:
0. GREP NAMED → Search docs/research/named-retail/ by class::method
1. FALLBACK → Use older docs/research/decompiled/ chunks only if needed
2. CROSS-CHECK → Verify against ACE + ACME + holtburger where relevant
3. PSEUDOCODE → Translate to readable pseudocode
4. PORT → Faithful C# translation
5. TEST → Conformance test against retail/decomp golden values
6. INTEGRATE → Surgical wiring into the existing system
7. VERIFY → Visual + functional test
For acdream-specific code (renderer, plugin API, streaming):
- Design for clean interfaces
- Test independently
- No AC-specific magic — those live in the ported layer
Reference Hierarchy
| Domain | Primary Oracle | Secondary |
|---|---|---|
| Physics/collision | docs/research/named-retail/ |
ACE Physics/ + older decompiled chunks |
| Animation | docs/research/named-retail/ + ACE Animation/ |
— |
| Terrain | ACME ClientReference.cs | named retail / older decompiled chunks |
| Rendering | WorldBuilder (Silk.NET) | ACViewer |
| Protocol | holtburger | AC2D |
| Server behavior | ACE | — |
Success Criteria
The client is "done" when:
- You can log in to an ACE server
- Walk around the entire world (streaming loads new areas)
- Enter and exit buildings through doorways
- See all NPCs, monsters, and players animated correctly
- Open doors, talk to NPCs, pick up items
- Send and receive chat
- A Lua macro can automate gameplay
- Side-by-side with the retail client, the world looks the same