acdream/docs/research/2026-07-17-retail-particle-visibility-pseudocode.md
Erik 48a118db91 config(vfx): default to extended particle range
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>
2026-07-17 15:32:57 +02:00

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::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

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.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.