Skip to content

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

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

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

Conversation

@wh1ter0se69

@wh1ter0se69 wh1ter0se69 commented Aug 7, 2026

Copy link
Copy Markdown

What's wrong

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

m_slaveSystem->setMaster( nullptr );   // orphaned, not destroyed
setSlave( nullptr );

A slave has no position of its own. Its master merges one in on every burst
(mergeRelatedParticleSystems takes the position from the master), so a slave is never attached to
anything and never gets a local transform. Orphaning it turns both of those facts into the bug:

  1. With no master, m_isIdentity stays true, so generateParticleInfo skips the transform and
    emits at raw local coordinates — (0,0,0).
  2. The only thing stopping a slave from emitting on its own is the master check in update()
    (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:
"If we don't it will leak forever. We are solely responsible for it." The destructor did not.

The fix

One line — destroy the slave with its master, matching destroy().

destroy() sets a flag, it does not delete anything. The slave stops emitting and the particles it
already put in the air finish normally in place, so nothing that currently renders correctly is
lost. Only the burst at the origin goes away.

Evidence

VC6 Release build (RTS_BUILD_OPTION_DEBUG=OFF), windowed, playing back !Golden Replay #1.rep,
with a temporary probe logging any particle system emitting 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 had the same signature — no transform, no owner:

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

~550,000 particle systems were created over the run, so the probe was not starved.

Retail compatibility. Client-side FX only; no logic random values are consumed and the replay
stays CRC-clean before and after. The save format is unchanged — ParticleSystemManager::xfer
already writes a destroyed system as a null placeholder. The file is in Core/, so this covers both
Generals and Zero Hour.

Why a master can die while its slave is still running (thanks @Caball009 — this is his finding, verified, with one correction)

A particle system is not deleted the moment its SystemLifetime runs out. update() returns
true while getParticleCount() is non-zero, so a system's real life is its SystemLifetime plus
however long its last particle survives. Orphaning happens when the master's last particle dies
before the slave's does.

For the pair caught in the replay:

Shader SystemLifetime particle Lifetime
SpectreHotPillarArmFlame (master) ALPHA 10 300
SpectreHotPillarArmFlameSlave (slave) ADDITIVE 120 120

Particles also die early via Particle::isInvisible(), and that is where the randomness enters.
The master's Alpha2 = -2.00 0.00 300 means the second alpha key's value is drawn per particle
by GameClientRandomValueReal, so the fade rate lands anywhere in −0.0083…−0.0017 per frame and
the particle crosses the alpha < 0.01 threshold somewhere between ~59 and ~295 frames after birth.
When the whole batch happens to draw fast fades, the master is gone well before the slave's 120
frames are up.

The correction: this applies to the master's particles, not the slave's. The slave is
ADDITIVE, and Particle::update skips alpha entirely for ADDITIVE while isInvisible() decides
on colour instead — so the alpha threshold never executes for the slave at all. Its particles die on
Lifetime = 120.

Two more inputs shorten the master's tail the same way, which is why the count varies run to run:
the dynamic-LOD early return in createParticle() and the global particle cap. Three runs of the
same replay on the same binary with the fix disabled gave 18, 101 and 171 origin emissions, all
CRC-clean — identical simulation, purely client-side variance.

Reproduction notes
  • Must be a VC6 Release build. Other toolchains diverge 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.
  • An easier repro than a 99-minute replay: pin the client seed and disable the LOD and cap early
    returns in createParticle(). That makes it deterministic at the cost of unbounded particle
    counts, so it belongs behind a debug flag rather than shipped.

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

@Caball009

Copy link
Copy Markdown

I think the fix is correct if we accept that a slave particle system is never expected to outlive its master system. The fact that it happens at all with the default INI files seems like a data issue.

Here are my findings:

  1. Particles are created on the basis of ParticleInfo, which includes data on how the colors (RGB Alpha) are supposed to change over time.
  2. ParticleInfo is generated in ParticleSystem::generateParticleInfo.
    The crucial part here is that a random value is used here to set the alpha step; this is non-deterministic by means of GameClientRandomValueReal.
  3. Particles that are nearly invisible are removed if Particle::isInvisible returns true.
    This happens when the alpha value is below 0.01.

So what's happening is here is that if a ParticleSystem has a slave system whose life time exceeds its master system (presumably determined by INI data), it comes down to the random alpha step value if the particles are removed or not.

If the threshold isn't met (e.g. a step value of < 0.01 for "SpectreHotPillarArmFlameSlave"), the particles of the slave ParticleSystem become visible. As far as I'm aware a slave ParticleSystem never has a location of its own, just (0 0 0), so if it outlives its master system, the particles show at 0, 0, 0.

The randomness comes from here. The randomness is gone if that's set to a fixed value. If set to 0.0f, the particles are never removed early.

@Caball009

Caball009 commented Aug 9, 2026

Copy link
Copy Markdown

The issue can be forced with enough explosions. This is retail behavior:

gen_zh_particle_system_01.mp4

This is what the particles look like when given the correct location (notice the additional explosion for some units):

gen_zh_particle_system_02.mp4

@Caball009 Caball009 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update the PR description in a format that's easy to digest for humans and not a wall of text. Feel free to use my findings for it. You could verify them if you wish.

Comment thread Core/GameEngine/Source/GameClient/System/ParticleSys.cpp Outdated
Comment on lines +1239 to +1240
// TheSuperHackers @bugfix Destroy the slave instead of orphaning it alive. A masterless
// slave has no transform of its own and starts self-emitting at the world origin.

@Caball009 Caball009 Aug 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transform

AFAIK this is used to indicate a full matrix, whereas particle systems only use a Coord3D position. Just say position imo.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to position in b489125 — it is the accurate word here anyway, since what an orphaned slave is missing is a position source (its master merges one in per burst), not a matrix.

The comment now reads:

// TheSuperHackers @bugfix wh1ter0se69 10/08/2026 Destroy the slave instead of orphaning it
// alive. A slave has no position of its own - its master merges one in on every burst - and
// nothing stops a masterless slave from emitting, so it starts bursting at the world origin.

@Caball009
Caball009 marked this pull request as ready for review August 9, 2026 23:36
The slave has no position of its own, not no transform - particle systems
carry a Coord3D, and the wording was confusing on that point. Also adds the
author/date fields the annotation convention asks for.
@wh1ter0se69

Copy link
Copy Markdown
Author

Description rewritten, and both inline notes are addressed in b489125.

On your findings — verified, with one correction.

The mechanism holds. A system is not deleted when its SystemLifetime runs out: update() returns true while getParticleCount() is non-zero, so a system's real life is its SystemLifetime plus however long its last particle survives. The random alpha value therefore decides when the master dies, and the master dying is the trigger. Alpha2 = -2.00 0.00 300 means the second key's value is drawn per particle, putting the fade rate somewhere in −0.0083…−0.0017 per frame, so a particle crosses alpha < 0.01 anywhere between ~59 and ~295 frames after birth. When the batch happens to draw fast fades, the master is gone well before the slave's 120 frames are up.

The correction: that applies to the master's particles, not the slave's. SpectreHotPillarArmFlameSlave is Shader = ADDITIVE, and Particle::update skips alpha integration entirely for ADDITIVE (if (m_system->getShaderType() != ParticleSystemInfo::ADDITIVE)), while isInvisible() takes the colour branch. The alpha < 0.01 test never executes for that slave — its particles die on Lifetime = 120. The master, SpectreHotPillarArmFlame, is Shader = ALPHA, and that is where the draw you pointed at lands.

On "it seems like a data issue". I don't think this one can be fixed in INI, for two reasons.

First, "slave outlives master" is not a state the data can make valid. A slave's particles are positioned only by mergeRelatedParticleSystems, which takes the position from the master. With no master there is no position source at all — not a wrong one, none. That is why destroy() already destroys the slave, with the comment "If we don't it will leak forever. We are solely responsible for it." The destructor was the odd one out.

Second, the exposure isn't an INI-settable quantity. A master's effective life is SystemLifetime plus its last particle's survival, and that depends on a per-particle random draw plus LOD and global-cap pressure. There is no value an author can set that guarantees the master outlives the slave. 33 of the 60 SlaveSystem declarations in retail ParticleSystem.ini have a finite-lifetime master, so the shape isn't unique to this pair. SpectreHotPillarArmFlameSlave is in fact a copy of MammothTankSubExplosionFlameSlave differing only in name, Priority, one DriftVelocity component and BurstCount (0.00 → 1.00) — and it's the BurstCount that makes this the one you can see.

On the second video. Giving the orphan the correct location is a different change: it makes the slave emit effects the retail game never displayed, which is where the extra explosions come from. This PR deliberately doesn't do that. destroy() only stops emission — the slave's particles already in the air are in world space and finish where they are — so the only thing removed is the burst at the origin, and nothing that currently renders in the right place changes. If those extra explosions are wanted as an intentional improvement, that reads like a separate change and a design call rather than part of this bugfix.

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