bugfix(fx): Destroy slave particle systems with their master instead of orphaning them - #3071
Conversation
…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.
|
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. |
|
I think the fix is good - RE exploring why there's run to run variance, you could:
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.
|
Thanks both — addressed all three. @bobtista on variance: measured it. Three runs of the same replay on the same VC6 binary, fix disabled:
No CRC mismatch in any run, so the simulation is identical and the variance is purely client-side. Three inputs, none of them lockstepped: Correction to something I had implied earlier: 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 Left as draft — that's yours to flip. |
|
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:
So what's happening is here is that if a If the threshold isn't met (e.g. a step value of < 0.01 for "SpectreHotPillarArmFlameSlave"), the particles of the slave 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. |
The issue can be forced with enough explosions. This is retail behavior:gen_zh_particle_system_01.mp4This 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
left a comment
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
transform
AFAIK this is used to indicate a full matrix, whereas particle systems only use a Coord3D position. Just say position imo.
There was a problem hiding this comment.
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.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.
|
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 The correction: that applies to the master's particles, not the slave's. 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 Second, the exposure isn't an INI-settable quantity. A master's effective life is 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. |
What's wrong
~ParticleSystem()clears a slave's master pointer but leaves the slave alive:A slave has no position of its own. Its master merges one in on every burst
(
mergeRelatedParticleSystemstakes the position from the master), so a slave is never attached toanything and never gets a local transform. Orphaning it turns both of those facts into the bug:
m_isIdentitystays true, sogenerateParticleInfoskips the transform andemits at raw local coordinates — (0,0,0).
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 italready 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:
Every logged emission had the same signature — no transform, no owner:
~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::xferalready writes a destroyed system as a null placeholder. The file is in
Core/, so this covers bothGenerals 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
SystemLifetimeruns out.update()returnstruewhilegetParticleCount()is non-zero, so a system's real life is itsSystemLifetimeplushowever 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:
SpectreHotPillarArmFlame(master)SpectreHotPillarArmFlameSlave(slave)Particles also die early via
Particle::isInvisible(), and that is where the randomness enters.The master's
Alpha2 = -2.00 0.00 300means the second alpha key's value is drawn per particleby
GameClientRandomValueReal, so the fade rate lands anywhere in −0.0083…−0.0017 per frame andthe particle crosses the
alpha < 0.01threshold 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, andParticle::updateskips alpha entirely forADDITIVEwhileisInvisible()decideson 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 thesame 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
frame 111 and the simulation is effectively dead by frame ~700, which still presents as a clean
run.
produced exactly one particle template — so headless is blind to anything downstream of the
Drawable.
returns in
createParticle(). That makes it deterministic at the cost of unbounded particlecounts, so it belongs behind a debug flag rather than shipped.