Skip to content

bugfix(fx): Destroy slave particle systems with their master instead of orphaning them - #3071

Draft
wh1ter0se69 wants to merge 2 commits into
TheSuperHackers:mainfrom
wh1ter0se69:fix/particle-slave-origin-emission
Draft

bugfix(fx): Destroy slave particle systems with their master instead of orphaning them#3071
wh1ter0se69 wants to merge 2 commits into
TheSuperHackers:mainfrom
wh1ter0se69:fix/particle-slave-origin-emission

Conversation

@wh1ter0se69

@wh1ter0se69 wh1ter0se69 commented Aug 7, 2026

Copy link
Copy Markdown

What happens

~ParticleSystem() clears a slave system's master pointer but leaves the slave alive:

if( m_slaveSystem )
{
    DEBUG_ASSERTCRASH( m_slaveSystem->getMaster() == this, (...) );
    m_slaveSystem->setMaster( nullptr );   // orphaned, not destroyed
    setSlave( nullptr );
}

That one line turns a well-behaved slave into a system that emits at the world origin, for two
reasons that only combine once the master is gone:

  1. A slave is never positioned or attached by anything. Its master merges positions into it on
    every burst (mergeRelatedParticleSystems), so the slave has no attachment and no local
    transform. With no master, m_isIdentity is true and generateParticleInfo places its
    particles at raw local coordinates - (0,0,0).
  2. A slave is stopped from emitting on its own only by the master check in update():
    if (!isShrouded && m_isStopped == false && m_masterSystem == nullptr). Clearing the master is
    exactly what opens that gate.

destroy() already handles this correctly and propagates to the slave, with a comment saying why.
The destructor did not.

Why this is not a one-off entry

The exposure exists whenever a slave has a longer lifetime than its master. For the pair observed
in Golden Replay 1:

SystemLifetime
SpectreHotPillarArmFlame (master) 10
SpectreHotPillarArmFlameSlave (slave) 120

The slave is configured to outlive its master by 110 frames, and that window is the exposure.
Any master/slave pair with the same shape has it - worth grepping ParticleSystem.ini for others
rather than treating this as a single bad entry.

Both are CRITICAL priority, so LOD/priority culling is not what removes the master here.

Evidence

Measured on a VC6 Release build (RTS_BUILD_OPTION_DEBUG=OFF) playing back
!Golden Replay #1.rep, with a temporary probe logging any particle system whose resolved emitter
transform lands within 250 world units of the origin.

frames reached emissions at origin CRC mismatches
before 173,416 59 0
after 175,331 0 0

Every logged emission before the fix was identical in shape:

[GXORIGINFX] frame=56644 tmpl=SpectreHotPillarArmFlameSlave pos=(0.0,0.0,0.0) burst=1 identity=1 attachObj=0 attachDraw=0

identity=1 with both attachment ids zero is the signature described above: no transform, no
owner. ~550,000 particle systems were created over the run, so the probe was not starved.

The replay stays CRC-clean before and after, so retail compatibility is unaffected - expected,
since this is client-side FX state that consumes no logic random values.

The file lives in Core/, so the fix covers both Generals and Zero Hour.

Run-to-run variance

The issue notes the explosions are not reproducible at fixed timestamps. That is measurable.
Three runs of the same replay on the same binary, fix disabled:

run emissions at origin first frame last frame
1 18 21,550 146,699
2 101 19,109 172,962
3 171 19,120 156,162

Nearly a 10x spread in count, and the first event moves by ~2,400 frames. The simulation is
identical in all three (no CRC mismatch), so this is entirely client-side. Three inputs, none of
them lockstepped:

  • GameClientRandomValue drives burst counts, emission positions and particle lifetimes, and
    is deliberately decoupled from the logic seed.
  • Dynamic LOD. createParticle() early-returns on
    priority < TheGameLODManager->getMinDynamicParticlePriority(), documented in the header as
    "priority at which particles will still render at current FPS", plus isParticleSkipped().
  • The global particle cap (m_maxParticleCount / removeOldestParticles), which fires based
    on how many particles happen to be alive at that instant.

The last two decide whether a particle exists, which decides how long a system keeps particles
alive, which decides when the master is finally deleted - and that deletion is the trigger. So
whether a given master is deleted while its slave still has lifetime is a client-side race.

For the record, ParticleSystemManager::update() is throttled to once per logic frame, so the
stepping itself is not framerate-dependent; the LOD gate is.

This also suggests an easier reproduction than waiting on a long replay: pinning the client seed
and disabling the LOD/cap early-returns should make it deterministic, at the cost of unbounded
particle counts - worth doing behind a debug flag rather than shipping.

Reproduction notes

  • Must be a VC6 Release build. Any other toolchain diverges from this retail-recorded replay
    around frame 111 and the simulation is effectively dead by frame ~700, which still presents as a
    clean run.
  • Must be windowed. A headless run creates almost no client-side FX - a full headless replay
    produced exactly one particle template - so headless is blind to anything downstream of the
    Drawable.

…of orphaning them

~ParticleSystem() cleared the slave's master pointer but left the slave alive. A slave
is never positioned or attached itself - the master merges positions into it on every
burst - and it is kept from emitting on its own only by the m_masterSystem == NULL check
in update(). Orphaning it therefore produces a live system with no transform at all,
which emits its particles at raw local coordinates, i.e. the world origin.

destroy() already propagates to the slave for this reason; the destructor did not.

Verified on a VC6 release build against Golden Replay 1: emissions at the world origin
drop from 59 to 0 over a full playthrough, with no CRC mismatch before or after.
@Caball009
Caball009 marked this pull request as draft August 7, 2026 15:43
@Caball009

Copy link
Copy Markdown

I think the fix works, but would like to have a better way to verify.

I think it'd be good to find out and report why there's run-to-run variance, and use that for an easier reproduction.

@bobtista

bobtista commented Aug 7, 2026

Copy link
Copy Markdown

I think the fix is good - RE exploring why there's run to run variance, you could:

  • Run the VC6 replay a few times and report whether the orphaning/emission frames themselves vary.
  • If they vary, log the master/slave creation, destruction, shroud, and particle-budget state at the first differing event.

Maybe Reword “the slave is meant to outlive the master” to “the slave is configured with a longer lifetime.” The former implies design intent that isn’t established.

Nit: The large source comment could be shortened, most of that explanation belongs in the PR description.

Per review: the long rationale belongs in the pull request description, not inline.
@wh1ter0se69

Copy link
Copy Markdown
Author

Thanks both — addressed all three.

@bobtista on variance: measured it. Three runs of the same replay on the same VC6 binary, fix disabled:

run emissions at origin first frame last frame
1 18 21,550 146,699
2 101 19,109 172,962
3 171 19,120 156,162

No CRC mismatch in any run, so the simulation is identical and the variance is purely client-side. Three inputs, none of them lockstepped: GameClientRandomValue (burst counts, emission positions, lifetimes), the dynamic LOD gate in createParticle() — the header documents it as "priority at which particles will still render at current FPS" — and the global particle cap. The last two decide whether a particle exists, which decides how long a system keeps particles alive, which decides when the master is finally deleted, and that deletion is the trigger condition.

Correction to something I had implied earlier: ParticleSystemManager::update() is throttled to once per logic frame, so the stepping itself is not framerate-dependent. The LOD gate is.

Also took the wording fix — "configured to outlive its master" rather than "meant to", since the design intent isn't established — and moved the long rationale out of the source comment into the description.

@Caball009 on an easier reproduction: the variance analysis points at one. Pinning the GameClientRandomValue seed and disabling the LOD/cap early-returns in createParticle() should make it deterministic. It buys that with unbounded particle counts, though, so I'd keep it behind a debug flag rather than ship it.

Left as draft — that's yours to flip.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explosions in map corner around coordinates 0, 0, 0

3 participants