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

177 lines
6.9 KiB
Markdown

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