Honor the requested non-dev-UI behavior by making the 2x DAT-authored particle cutoff the application default while keeping Retail as the exact opt-down setting. Update migration tests and the divergence/roadmap documentation accordingly. Release build succeeds and all 5,857 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
6.9 KiB
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::GetMaxDegradeDistanceat0x0050D510GfxObjDegradeInfo::get_max_degrade_distanceat0x0051E2D0ParticleEmitter::SetInfoat0x0051CE90CPhysicsObj::ShouldDrawParticlesat0x0050FE60ParticleEmitter::UpdateParticlesat0x0051D180ParticleManager::UpdateParticlesat0x0051B800
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
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
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
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
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
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.cspreserves the two degraded update branches and birth-time reset, but deliberately assignsDegradeDistance = 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::IsInViewgate. - The in-tree
GfxObjDegradeResolverconfirms the DatReaderWriter representation ofDIDDegrade, orderedGfxObjInfoentries, andMaxDist.DatCollectionremains 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.