# Retail particle visibility and degradation-distance pseudocode Date: 2026-07-17 Oracle: matching Sept-2013 `acclient.exe` + `acclient.pdb` ## Scope This note records the retail mechanism that prevents distant or non-viewed particle systems from consuming the full simulation and draw cost. It corrects the older performance suggestion in `deepdives/r04-vfx-particles.md`: retail does not merely hide distant particles while continuing to simulate them. ## Named retail functions - `CPhysicsPart::GetMaxDegradeDistance` at `0x0050D510` - `GfxObjDegradeInfo::get_max_degrade_distance` at `0x0051E2D0` - `ParticleEmitter::SetInfo` at `0x0051CE90` - `CPhysicsObj::ShouldDrawParticles` at `0x0050FE60` - `ParticleEmitter::UpdateParticles` at `0x0051D180` - `ParticleManager::UpdateParticles` at `0x0051B800` The two short distance functions and the ambiguous x87 branch in `UpdateParticles` were verified against instructions from the matching retail binary, not inferred from the decompiler's generated types. ## Pseudocode ### `CPhysicsPart::GetMaxDegradeDistance` ```text if part.gfx_object.degrades == null: return 100.0 return part.gfx_object.degrades.get_max_degrade_distance() ``` The unit is the client's world unit (approximately one metre). ### `GfxObjDegradeInfo::get_max_degrade_distance` ```text if num_degrades <= 2: return degrades[0].max_dist return degrades[num_degrades - 2].max_dist ``` `GfxObjInfo` is 20 bytes and `max_dist` is the field at offset 16. The `num_degrades > 2` instruction reads `base + num_degrades * 20 - 24`, which is exactly `degrades[num_degrades - 2].max_dist`. ### `ParticleEmitter::SetInfo` ```text if hw_gfxobj_id cannot be loaded: return false # no software-GfxObj substitution create max_particles PhysicsPart instances from hw_gfxobj_id degrade_distance = first_particle_part.GetMaxDegradeDistance() allocate particle records ``` The distance belongs to the emitter's hardware particle GfxObj, not the owner Setup and not the visual size of a particle. ### `CPhysicsObj::ShouldDrawParticles` ```text if this is the examination object: return true if distance_from_viewer > emitter.degrade_distance: return false if cell == null: return false return cell.IsInView() ``` Equality passes: a particle owner at exactly the authored degradation distance is still eligible. The x87 comparison also admits unordered input: a NaN distance or authored maximum follows the comparison's pass branch. Negative, NaN, and infinite authored `max_dist` values are not sanitized; the 100-unit default applies only when the loaded hardware GfxObj has no degradation table. ### `ParticleEmitter::UpdateParticles` ```text if owner.ShouldDrawParticles(degrade_distance): if degraded_out: degraded_out = false SetNoDraw(false) update every live particle if parent-local: read the parent's current frame emit normally, observing total-particle and total-time limits last_update_time = now return whether emitter remains alive if not degraded_out: SetNoDraw(true) degraded_out = true last_update_time = now if total_particles == 0 and total_seconds == 0: # Infinite emitter. Retail rewrites each particle's birth/update time to # now, freezing its age while degraded, and performs no normal emission. for each live particle: particle.birth_or_last_update_time = now return true # Finite emitter: retire expired particles and advance emission bookkeeping, # but do not create/render a physical particle while degraded. for each live particle: particle.lifetime = now - particle.birth_time if expired: KillParticle(particle) if not stopped: if ShouldEmitParticle(): RecordParticleEmission() # num_particles++ and total_emitted++ StopEmitter() # conditional duration/count test return true return num_particles != 0 ``` `StopEmitter` is a test despite its imperative name. It sets `stopped` only after the authored total duration expires or `total_emitted` reaches the authored total-particle limit. A finite emitter that is merely outside the view therefore remains live and repeats this branch on later updates. There is a counterintuitive retail lifetime edge case here that must not be "repaired": `RecordParticleEmission` increments `num_particles` without allocating a `PhysicsPart`. If that logical emission reaches the authored stop condition while the effect is fully degraded, the already-stopped branch keeps returning `num_particles != 0` even though there is no drawable particle to retire. The emitter consequently remains owned until its physics object tears the effect down. This follows directly from `RecordParticleEmission` (`0x0051C870`), `UpdateParticles` (`0x0051D180`), and the manager's false-return removal contract; the Core conformance test pins the quirk explicitly. The x87 equality branch for `(total_particles == 0 && total_seconds == 0)` was checked from `0x0051D1E1`: `fcomp 0`, `fnstsw ax`, `test ah,0x44`, `jp finite`. For equality the parity jump is not taken, selecting the infinite-emitter freeze path. ## Reference cross-checks - ACE's `Physics/Particles/ParticleEmitter.cs` preserves the two degraded update branches and birth-time reset, but deliberately assigns `DegradeDistance = float.MaxValue`; it is therefore useful for control-flow interpretation but not for the missing client distance rule. - WorldBuilder/ACME's particle simulator confirms the DAT emitter fields and integrators, but does not implement retail's live `CObjCell::IsInView` gate. - The in-tree `GfxObjDegradeResolver` confirms the DatReaderWriter representation of `DIDDegrade`, ordered `GfxObjInfo` entries, and `MaxDist`. `DatCollection` remains the sole runtime DAT reader. Installed-data fixture: Aerlinthe's dominant Swarm emitter `0x32000223` (hardware GfxObj `0x01001358`) resolves to an authored maximum distance of 64 world units. Extended mode therefore raises that effect to 128 units; it does not change landscape, scenery, entity, or fog distance. ## acdream integration mapping - Core resolves the exact authored maximum distance into `EmitterDesc`. - Core owns degraded simulation semantics and the no-per-particle fast path for infinite emitters. - App publishes the previous completed world-visibility product: retail PView interior cells plus the landscape renderer's doorway-clipped outdoor landcell set and camera position. The landscape-only fallback publishes the same product without requiring an interior root. Outdoor dat stabs carry a separate effect cell so their intentionally-null render parent is unchanged. Login and portal-space frames publish an empty world view; explicit examination and dedicated-pass policies are the only bypasses. - The renderer filters an emitter before scanning its particle slots. - The user-requested default Extended mode doubles authored retail distances. Exact Retail remains selectable; the default adaptation is recorded in the divergence register.