Files
BT411/engine/MUNGA_L4/L4PARTICLES.cpp
T
Joe DiPrimaandClaude Fable 5 dca2586aa8 the Owens crash: a device reset that never waited for the device (#35)
Eight byte-identical field stacks from night 6, all one player, all in an
Owens: ParticleEngine::Destroy +0x11, access=0 target=0x0, from the plain
per-frame render path. Nothing in the stack touches weapons or the Owens.
Conn Man's Surface Pro 9 (Iris Xe, 128 MB shared) is simply the only GPU in
the fleet that ever actually LOSES the D3D9 device -- his two-trigger
missile+laser bursts are what provoke the timeout, not what crashes.

What crashed is our device-loss handling, which was wrong three ways at once,
in two inline copies (the scene Present and the wait-screen Present):

  1. On D3DERR_DEVICELOST it called Reset() IMMEDIATELY. Reset on a
     still-lost device ALWAYS fails, and V() only logs. There was no
     TestCooperativeLevel gate at all.
  2. It then ran ParticleEngine::Initialize against the lost device. The
     creates fail there and NULL their out-params -- proven, not assumed:
     the bench repro faults at target=0x0, not at a dangling address.
  3. The next lost frame called ParticleEngine::Destroy again, which
     Release()d those NULLs blind. Read of vtable at 0x0. Dead.

So: lost frame 1 tears down and leaves NULLs, lost frame 2 crashes. Two
frames, every time, deterministic -- which is exactly why all 8 field stacks
are byte-identical.

Reproduced before fixing. BT_DEVICELOST_TEST=<frame>,crashrepro runs the
field sequence on the bench; on the unfixed build it died at Destroy +0x11,
access=0 target=0x0, and symbolized to the same four frames as the field
logs. Same shape, same offsets-modulo-hook. That run also proved the
out-param-nulling assumption the whole diagnosis rested on.

The fix -- one shared DPLRenderer::BTResetLostDevice() replacing both inline
copies:

  - Destroy() is idempotent and null-safe, and nulls after release.
  - Reset() is gated on TestCooperativeLevel() != D3DERR_DEVICELOST; while
    the driver still says lost, skip the frame and retry.
  - The Reset HRESULT is checked; on failure, log and retry next frame
    instead of driving on.
  - On success, re-create via the new CreateDeviceObjects(), NOT
    Initialize(): Initialize memsets the installed-effects table, so every
    reset that DID succeed silently killed all particle effects for the rest
    of the mission. The quieter sibling bug, fixed by the same split.
  - Initialize checks its HRESULTs and defends MAXPARTICLES<=0; the draw
    paths guard the NULL buffer, and ExecuteParticles keeps draining
    particles while the engine is dormant so they cannot pile up.

Verified: the crashrepro shape now logs SURVIVED and play continues; three
forced full loss/reset cycles each log "[render] device reset OK"; a plain
run is assert-free.

Found while verifying, worth its own line: VIDEO\particles.png has NEVER
existed -- not in the tree, not in BTL4.RES, not anywhere in git history.
The texture load has failed on every machine since the engine was written,
and every billboard particle ever rendered was untextured quads via
SetTexture(0, NULL). RenderParticles deliberately does NOT gate on the
texture -- that would disable all particles everywhere; untextured IS the
shipped look. Filed separately; a real particle sheet is a content task.

The field verification that counts is Conn Man flying his exact crash
loadout on this build: instead of a dead process he should see at worst a
brief hitch and "[render] device reset OK" in his log. #35 stays open until
that happens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:21:41 -05:00

606 lines
18 KiB
C++

#include "l4particles.h"
#include "../munga/time.h"
#include "../munga/style.h" // DEBUG_STREAM (#35 diagnostics)
#include <iostream>
LPDIRECT3DDEVICE9 ParticleEngine::mDevice = NULL;
PARTICLE_EFFECT ParticleEngine::mInstalledEffects[MAX_PARTICLE_EFFECTS];
LPDIRECT3DTEXTURE9 ParticleEngine::mParticleTexture = NULL;
bool ParticleEngine::mActiveParticles = false;
LPDIRECT3DVERTEXBUFFER9 ParticleEngine::mVertBuffer = NULL;
Particle *ParticleEngine::mParticlesHead = NULL;
Particle *ParticleEngine::mParticlesTail = NULL;
long ParticleEngine::mTotalParticleCount = 0;
long ParticleEngine::mMaxParticleCount = 8192;
int compare(const Particle *a, const Particle *b, const D3DXMATRIX *view_matrix)
{
D3DXVECTOR3 aVec(a->mX, a->mY, a->mZ);
D3DXVECTOR3 bVec(b->mX, b->mY, b->mZ);
D3DXVec3TransformCoord(&aVec, &aVec, view_matrix);
D3DXVec3TransformCoord(&bVec, &bVec, view_matrix);
float aDist = aVec.x * aVec.x + aVec.y * aVec.y + aVec.z * aVec.z;
float bDist = bVec.x * bVec.x + bVec.y * bVec.y + bVec.z * bVec.z;
// if both particles are within a certain distance from
// each other then we'll sort them by effect instead of
// distance so that they will be grouped together when
// rendering
//if (abs(bDist - aDist) < EFFECT_GROUPING_EPSILON)
// return b->mEffect->id - a->mEffect->id;
//else
return (int)(bDist - aDist);
}
void mergesort(Particle **headPtr, Particle **tailPtr, const D3DXMATRIX *view_matrix)
{
Particle *list = *headPtr;
if (!list)
{
(*tailPtr) = NULL;
return;
}
Particle *p, *q, *e, *tail, *oldhead;
int insize, nmerges, psize, qsize, i;
insize = 1;
while (1)
{
p = list;
oldhead = list;
list = NULL;
tail = NULL;
nmerges = 0; // count number of merges we do in this pass
while (p)
{
nmerges++; // there exists a merge to be done
// step 'insize' places along from p
q = p;
psize = 0;
for (i=0; i<insize; i++)
{
psize++;
q = q->Next();
if (!q)
break;
}
// if q hasn't fallen off end, we have two lists to merge
qsize = insize;
// now we have two lists; merge them
while (psize > 0 || (qsize > 0 && q))
{
// decide whether next element of merge comes from p or q
if (psize == 0)
{
// p is empty; e must come from q
e = q;
q = q->Next();
qsize--;
}
else if (qsize == 0 || !q)
{
// q is empty; e must come from p
e = p;
p = p->Next();
psize--;
}
else if (compare(p, q, view_matrix) <= 0)
{
// first elements of p is lower (or same);
// e must come from p
e = p;
p = p->Next();
psize--;
}
else
{
// first element of q is lower; e must come from q
e = q;
q = q->Next();
qsize--;
}
// add the next element to the merged list
if (tail)
tail->mNextParticle = e;
else
list = e;
e->mPrevParticle = tail;
tail = e;
}
// now p has stepped 'insize' places along, and q has too
p = q;
}
tail->mNextParticle = NULL;
// if we have done only one merge, we're finished
if (nmerges <= 1) // allow for nmerges == 0, the empty list case
{
(*headPtr) = list;
(*tailPtr) = tail;
return;
}
// otherwise repeat, merging lists twice the size
insize *= 2;
}
}
Particle::Particle(PARTICLE_EFFECT *effect)
{
mEffect = effect;
mAge = 0.0f;
mX = 0.0f;
mY = 0.0f;
mZ = 0.0f;
mColor.argb = D3DCOLOR_ARGB(0xFF, 0xFF, 0x00, 0x00);
memset(&mVelocity, 0, sizeof(mVelocity));
memset(&mAcceleration, 0, sizeof(mAcceleration));
}
void Particle::Execute(Scalar dT)
{
if (!mEffect)
return;
if (IsAlive())
{
mX += mVelocity.x * dT;
mY += mVelocity.y * dT;
mZ += mVelocity.z * dT;
mVelocity.x += mAcceleration.x * dT;
mVelocity.y += mAcceleration.y * dT;
mVelocity.z += mAcceleration.z * dT;
mAge += dT;
Scalar percentComplete = mAge / mEffect->fragLifetime;
COLOR_POINT *cp0 = NULL;
COLOR_POINT *cp1 = NULL;
for (COLOR_POINT *cp = mEffect->colors + COLOR_POINT_COUNT - 2; cp >= mEffect->colors; cp--)
{
if (cp->active && percentComplete >= cp->time)
{
cp0 = cp;
cp1 = cp + 1;
break;
}
}
if (!cp0 || !cp1)
{
cp0 = mEffect->colors;
if (COLOR_POINT_COUNT > 1)
{
cp1 = mEffect->colors + 1;
} else
{
cp1 = cp0;
}
}
percentComplete = (percentComplete - cp0->time) / (cp1->time - cp0->time);
mColor.a = (unsigned char)(cp0->color.a + (cp1->color.a - cp0->color.a) * percentComplete);
mColor.r = (unsigned char)(cp0->color.r + (cp1->color.r - cp0->color.r) * percentComplete);
mColor.g = (unsigned char)(cp0->color.g + (cp1->color.g - cp0->color.g) * percentComplete);
mColor.b = (unsigned char)(cp0->color.b + (cp1->color.b - cp0->color.b) * percentComplete);
}
}
ParticleEmitter::ParticleEmitter()
: mEffect(NULL),
mActive(false),
mPosition(0.0f, 0.0f, 0.0f)
{
}
void ParticleEmitter::SetEffect(int effect)
{
mEffect = &ParticleEngine::mInstalledEffects[effect];
}
void ParticleEmitter::Execute()
{
if (mEffect == NULL)
return;
// calculate the number of particles we're going to need
if (mActive)
{
bool fire = false;
if (mEffect->id >= 1000)
{
// this is an independant effect
static Scalar lastEmitted = (Scalar)Now();
Scalar now = (Scalar)Now();
INDIE_EFFECT *indieEffect = (INDIE_EFFECT*)mEffect;
if ((now - lastEmitted) >= indieEffect->releasePeriod)
{
lastEmitted = now;
fire = true;
}
if ((now - mActivated) >= indieEffect->duration)
mActive = false;
}
else
{
fire = true;
mActive = false;
}
if (fire)
{
for (int i = 0; i < mEffect->fragCount; ++i)
ParticleEngine::CreateParticle(mPosition, mEffect);
}
}
}
void ParticleEngine::Destroy()
{
// #35 (the field "Owens crash"). This runs on the DEVICELOST path, and a
// device can stay lost for several frames -- so this must be idempotent
// and null-safe. It used to Release() blind: the first lost frame
// released the buffer, the failed re-create on the still-lost device left
// both statics NULL, and the SECOND lost frame's Release() read the
// vtable at 0x0 (8/8 field stacks byte-identical at Destroy +0x11;
// reproduced on the bench with BT_DEVICELOST_TEST=<n>,crashrepro).
if (mVertBuffer != NULL)
{
mVertBuffer->Release();
mVertBuffer = NULL;
}
if (mParticleTexture != NULL)
{
mParticleTexture->Release();
mParticleTexture = NULL;
}
}
void ParticleEngine::Initialize(LPDIRECT3DDEVICE9 device)
{
// Full STARTUP init only. The device-reset path must use
// CreateDeviceObjects() below -- coming through here would memset the
// installed-effects table and kill every particle effect for the rest of
// the mission (#35's quieter sibling).
memset(mInstalledEffects, 0, sizeof(mInstalledEffects));
const char *max_env = getenv("MAXPARTICLES");
ParticleEngine::mMaxParticleCount = (max_env != NULL) ? atoi(max_env) : 0;
if (ParticleEngine::mMaxParticleCount <= 0)
ParticleEngine::mMaxParticleCount = 8192; // btl4main defaults the env; belt + braces
CreateDeviceObjects(device);
}
void ParticleEngine::CreateDeviceObjects(LPDIRECT3DDEVICE9 device)
{
// (Re)create the D3D resources. #35: CHECK the HRESULTs -- on a lost
// device (or an out-of-video-memory iGPU, the field machine is a 128 MB
// Iris Xe) these FAIL and null their out params; the old code drove
// straight on, and the NULLs then killed the next Destroy(). On failure
// we log and leave the engine dormant -- Execute/Render guard on the
// NULLs, particles drain but don't draw -- and the next successful reset
// brings it back.
mDevice = device;
mVertBuffer = NULL;
mParticleTexture = NULL;
// create the vertex buffer that will store the six vertices we need for the billboards
HRESULT hr = mDevice->CreateVertexBuffer(
ParticleEngine::mMaxParticleCount * 6 * sizeof(L4BASICVERTEX),
D3DUSAGE_DYNAMIC,
L4BASICVERTEX_FVF,
D3DPOOL_DEFAULT,
&mVertBuffer,
NULL);
if (FAILED(hr))
{
mVertBuffer = NULL; // belt + braces vs runtime behavior
DEBUG_STREAM << "[particles] CreateVertexBuffer FAILED hr=0x"
<< std::hex << (unsigned long)hr << std::dec
<< " (max=" << ParticleEngine::mMaxParticleCount
<< ") -- particles dormant until the next successful reset"
<< std::endl << std::flush;
}
// NB: VIDEO\particles.png has never shipped (verified 2026-07-29: absent
// from the tree, the RES, and all of git history), so this load has
// failed on every machine since the engine was written and particles
// draw untextured. The failure is expected + logged once; rendering
// proceeds without the texture (SetTexture(0, NULL) = the shipped look).
hr = D3DXCreateTextureFromFile(mDevice, L"VIDEO\\particles.png", &mParticleTexture);
if (FAILED(hr))
mParticleTexture = NULL;
if (mVertBuffer != NULL)
{
DEBUG_STREAM << "[particles] device objects created (max="
<< ParticleEngine::mMaxParticleCount
<< ", texture=" << (mParticleTexture != NULL ? "loaded" : "MISSING (untextured quads -- the shipped look)")
<< ")" << std::endl << std::flush;
}
}
void ParticleEngine::InstallEffect(int effectNumber, PARTICLE_EFFECT effect)
{
mInstalledEffects[effectNumber] = effect;
mInstalledEffects[effectNumber].id = effectNumber;
}
void ParticleEngine::CreateParticle(D3DXVECTOR3 position, PARTICLE_EFFECT *effect)
{
if (effect == NULL || effect->fragCount <= 0)
return;
Particle *p = new Particle(effect);
// check to make sure we have room for this new particle
if (mTotalParticleCount + 1 >= ParticleEngine::mMaxParticleCount)
{
mTotalParticleCount--;
// there isn't room for this particle so we'll delete the oldest particle
if (mParticlesHead == mParticlesTail)
{
delete mParticlesHead;
mParticlesHead = NULL;
mParticlesTail = NULL;
}
else
{
Particle *temp = mParticlesTail;
mParticlesTail = mParticlesTail->mPrevParticle;
mParticlesTail->mNextParticle = NULL;
delete temp;
}
}
// insert the new particle into the linked list
if (mParticlesHead)
mParticlesHead->mPrevParticle = p;
p->mPrevParticle = NULL;
p->mNextParticle = mParticlesHead;
mParticlesHead = p;
if (!mParticlesTail)
mParticlesTail = p;
mTotalParticleCount++;
// now initialize all the particle data based on the effect
p->mX = position.x + ((float)rand() * 2.0f / RAND_MAX - 1.0f) * effect->varianceX;
p->mY = position.y + ((float)rand() * 2.0f / RAND_MAX - 1.0f) * effect->varianceY;
p->mZ = position.z + ((float)rand() * 2.0f / RAND_MAX - 1.0f) * effect->varianceZ;
D3DXVECTOR3 vec(
(float)rand() * 2.0f / RAND_MAX - 1.0f,
(float)rand() * 2.0f / RAND_MAX - 1.0f,
(float)rand() * 2.0f / RAND_MAX - 1.0f);
D3DXVec3Normalize(&vec, &vec);
p->mVelocity.x = vec.x * effect->velocity;
p->mVelocity.y = vec.y * effect->velocity;
p->mVelocity.z = vec.z * effect->velocity;
p->mAcceleration.x = 0.0f;
p->mAcceleration.y = effect->gravity;
p->mAcceleration.z = 0.0f;
// generate a random rotation amount in radians
float centerX = effect->textureBounds.left + (effect->textureBounds.right - effect->textureBounds.left) / 2.0f;
float centerY = effect->textureBounds.top + (effect->textureBounds.bottom - effect->textureBounds.top) / 2.0f;
D3DXVECTOR2 rotCenter(centerX, centerY);
D3DXMatrixTransformation2D(&p->mTextureTransform, NULL, 1, NULL, &rotCenter, ((float)rand() / RAND_MAX) * 2.0f * D3DX_PI, NULL);
p->mColor.argb = (effect->colors[0].active ? effect->colors[0].color.argb : 0);
}
int ParticleEngine::BuildParticleVertices(const Particle *p, L4BASICVERTEX *verts, D3DXMATRIX *inverse_view_matrix)
{
D3DXMATRIX rotation;
PARTICLE_EFFECT *effect = p->mEffect;
inverse_view_matrix->_41 = p->mX;
inverse_view_matrix->_42 = p->mY;
inverse_view_matrix->_43 = p->mZ;
float fragSize = effect->fragSize;
D3DXVECTOR3 vector3s[] =
{
D3DXVECTOR3(-fragSize, fragSize, 0.0f), // upper left
D3DXVECTOR3( fragSize, -fragSize, 0.0f), // lower right
D3DXVECTOR3(-fragSize, -fragSize, 0.0f), // lower left
D3DXVECTOR3(-fragSize, fragSize, 0.0f), // upper left
D3DXVECTOR3( fragSize, fragSize, 0.0f), // upper right
D3DXVECTOR3( fragSize, -fragSize, 0.0f) // lower right
};
D3DXVECTOR2 vector2s[] =
{
D3DXVECTOR2(effect->textureBounds.left, effect->textureBounds.top), // upper left
D3DXVECTOR2(effect->textureBounds.right, effect->textureBounds.bottom), // lower right
D3DXVECTOR2(effect->textureBounds.left, effect->textureBounds.bottom), // lower left
D3DXVECTOR2(effect->textureBounds.left, effect->textureBounds.top), // upper left
D3DXVECTOR2(effect->textureBounds.right, effect->textureBounds.top), // upper right
D3DXVECTOR2(effect->textureBounds.right, effect->textureBounds.bottom) // lower right
};
D3DXVECTOR3 normal, inNormal(0.0f, 0.0f, 1.0f);
D3DXVec3TransformCoord(&normal, &inNormal, inverse_view_matrix);
int count = (sizeof(vector3s) / sizeof(D3DXVECTOR3));
for (int i=0; i<count; i++)
{
D3DXVECTOR3 outVec3;
D3DXVECTOR2 outVec2;
D3DXVec3TransformCoord(&outVec3, &vector3s[i], inverse_view_matrix);
verts[i].x = outVec3.x;
verts[i].y = outVec3.y;
verts[i].z = outVec3.z;
verts[i].nx = normal.x;
verts[i].ny = normal.y;
verts[i].nz = normal.z;
if (effect->rotate)
D3DXVec2TransformCoord(&outVec2, &vector2s[i], &p->mTextureTransform);
else
outVec2 = vector2s[i];
verts[i].u = outVec2.x;
verts[i].v = outVec2.y;
verts[i].color = p->mColor.argb;
}
return count;
}
void ParticleEngine::ExecuteParticles(const D3DXMATRIX *view_matrix, Scalar timeSlice)
{
// if we don't have a device or we have no particles, early exit
if (!mDevice || mTotalParticleCount <= 0)
return; // we can't run without having been initialized with a device
// first we need to execute all the particles to update their positions, color, etc.
for (Particle *p = mParticlesHead; p;)
{
if (p->IsAlive())
{
p->Execute(timeSlice);
p = p->mNextParticle;
}
else
{
// remove from the list
if (p->mPrevParticle)
p->mPrevParticle->mNextParticle = p->mNextParticle;
if (p->mNextParticle)
p->mNextParticle->mPrevParticle = p->mPrevParticle;
// time to kill the particle
if (p == mParticlesHead)
{
mParticlesHead = p->mNextParticle;
}
if (p == mParticlesTail)
{
mParticlesTail = p->mPrevParticle;
}
Particle *temp = p;
p = p->mNextParticle;
mTotalParticleCount--;
delete temp;
}
}
// #35: with the device objects torn down (device lost mid-reset) the
// update/expire loop above must still run -- particles keep draining --
// but there is no buffer to build into.
if (mVertBuffer == NULL)
return;
// now we're going to sort the particle list based on distance to the camera
mergesort(&mParticlesHead, &mParticlesTail, view_matrix);
//Scalar beforeBuild = (Scalar)Now();
D3DXMATRIX inverse_view_matrix;
D3DXMatrixInverse(&inverse_view_matrix, NULL, view_matrix);
// and finally, we'll build the vertex list to send to Direct3D
L4BASICVERTEX *vertBuffer = NULL;
mVertBuffer->Lock(0, mTotalParticleCount * sizeof(L4BASICVERTEX), (void**)&vertBuffer, D3DLOCK_DISCARD);
int actualParticles = 0;
for (Particle *p = mParticlesHead; p; p = p->mNextParticle)
{
vertBuffer += BuildParticleVertices(p, vertBuffer, &inverse_view_matrix);
actualParticles++;
}
mVertBuffer->Unlock();
//Scalar afterBuild = (Scalar)Now();
//afterBuild = afterBuild;
}
void ParticleEngine::RenderParticles(const D3DXMATRIX *view_matrix, Scalar timeSlice)
{
// we always have to execute otherwise our timing
// variables get all screwed up when we don't run for a while
ExecuteParticles(view_matrix, timeSlice);
// (#35: the buffer is legitimately NULL while the device is lost. The
// TEXTURE is deliberately NOT part of this gate: VIDEO\particles.png has
// never existed -- not in the tree, the RES, or git history -- so
// mParticleTexture has been NULL on every machine since the engine was
// written, and SetTexture(0, NULL) drawing untextured quads IS the
// shipped look. Gating on it would silently disable all billboard
// particles everywhere.)
if (!mDevice || mVertBuffer == NULL || mTotalParticleCount <= 0)
return;
// setup stages for particle's texture
mDevice->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE);
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_MODULATE);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_TEXTURE);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_DIFFUSE);
mDevice->SetRenderState(D3DRS_DIFFUSEMATERIALSOURCE, D3DMCS_COLOR1);
mDevice->SetRenderState(D3DRS_EMISSIVEMATERIALSOURCE, D3DMCS_COLOR1);
mDevice->SetTexture(0, mParticleTexture);
mDevice->SetTextureStageState(0, D3DTSS_TEXTURETRANSFORMFLAGS, D3DTTFF_DISABLE);
mDevice->SetFVF(L4BASICVERTEX_FVF);
mDevice->SetStreamSource(0, mVertBuffer, 0, sizeof(L4BASICVERTEX));
//DWORD wrap_u, wrap_v;
//mDevice->GetSamplerState(0, D3DSAMP_ADDRESSU, &wrap_u);
//mDevice->GetSamplerState(0, D3DSAMP_ADDRESSV, &wrap_v);
//mDevice->SetSamplerState(0, D3DSAMP_ADDRESSU, D3DTADDRESS_WRAP);
//mDevice->SetSamplerState(0, D3DSAMP_ADDRESSV, D3DTADDRESS_WRAP);
D3DXMATRIX world_matrix;
D3DXMatrixIdentity(&world_matrix);
mDevice->SetTransform(D3DTS_WORLD, &world_matrix);
mDevice->DrawPrimitive(D3DPT_TRIANGLELIST, 0, mTotalParticleCount * 2);
mDevice->SetRenderState(D3DRS_EMISSIVEMATERIALSOURCE, D3DMCS_MATERIAL);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_TFACTOR);
//mDevice->SetSamplerState(0, D3DSAMP_ADDRESSU, wrap_u);
//mDevice->SetSamplerState(0, D3DSAMP_ADDRESSV, wrap_v);
}