diff --git a/context/decomp-reference.md b/context/decomp-reference.md index fa1e0cb..3cdd759 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -473,6 +473,7 @@ default-ON (`'0'` disables). | `BT_SELF_DAMAGE=` | dispatch an unaimed `TakeDamage` at your OWN mech once a second, through the real `Entity::Dispatch` path, so the whole RESPAWN family is bench-testable solo (nothing else can kill the local pilot: `BT_MP_FORCE_DMG` only targets replicants). **Latches off at first death** so everything after the respawn is the respawn's doing, not the harness still shooting you | | `BT_POWER_DETACH_TEST=` | drop a subsystem's voltage link + force Auto, so the auto-hunt must recover it. `1` = first powered subsystem to tick; a NAME (`PPC_1`, `Myomers`) targets one, which is what proves FAILOVER to a different generator rather than a same-generator re-attach | | `BT_AUDIO_SOURCES=` | request `n` OpenAL mono sources instead of the driver default (~256). **Opt-in on purpose** — the cap doubles as a governor, and with EFX reverb live a higher ceiling means more simultaneous voices mixing during heavy combat. Measure frame time. See [[wintesla-port]] | +| `BT_DEVICELOST_TEST=[,crashrepro]` | #35 bench hook. `` forces the D3D9 DEVICELOST branch at that render frame (+600/+1200 = 3 cycles), driving the REAL `BTResetLostDevice` recovery. `,crashrepro` runs the field null-teardown shape (double `ParticleEngine::Destroy`) — pre-fix this reproduced the field crash byte-for-byte (`Destroy +0x11`, `target=0x0`); post-fix it must log `SURVIVED`. See [[wintesla-port]] §Device-loss | Full render/locomotion gates (BT_RAMP, BT_MATPRI, BT_CULL, BT_SHADOW_*, BT_LODSEL, BT_ADDLOD, BT_PUNCH, …) are catalogued in [[rendering]]. Warp visuals: [[translocation-warp]]. diff --git a/context/multiplayer.md b/context/multiplayer.md index 892d770..76e5393 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -676,7 +676,9 @@ Only the relay host needs port forwarding. Verified: scripted protocol suite in 2-node mission launched + stopped purely over the socket. ## The interest-teardown audio crash (2026-07-24) [T2 stack-captured + fixed] -THE layout-shifting release-only crash (weeks of field flakes, #35's prime suspect) finally +THE layout-shifting release-only crash (weeks of field flakes; was #35's prime suspect until +2026-07-29 CLEARED it — #35 turned out to be the ParticleEngine device-reset null-deref, root-caused +from 8 symbolized field stacks and fixed; see [[wintesla-port]] §Device-loss) finally hit a crash-filter build: `InterestManager::OrphanInterestOrigin → RemoveUninterestingEntity → DestroyEntityAudioObjects → {Static,Dynamic}3DPatchSource::IsAudioSourceClipped` — AV reading NULL+0x38: the authentic burning-mech death-silence check dereferences diff --git a/context/wintesla-port.md b/context/wintesla-port.md index c538ae3..c8c3ba5 100644 --- a/context/wintesla-port.md +++ b/context/wintesla-port.md @@ -16,6 +16,30 @@ on top of it. Full detail: `docs/PROGRESS_LOG.md §5b, §8`. ## What WinTesla already did (do NOT rebuild) - **Renderer bypass: DONE** — `MUNGA_L4/L4D3D.cpp` + `DXUtils` replace libDPL / the IG board with Direct3D9. No `libdpl.lib` in the tree. (The early from-scratch D3D9 viewer was retired and removed.) [T1] +- **Device-loss: the #35 "Owens crash" — ROOT-CAUSED + FIXED 2026-07-29** [T2, bench-reproduced]. + 8 byte-identical field stacks (one player, Surface Pro 9 / **Iris Xe 128 MB** — the only GPU in + the fleet that ever actually loses the device): `ParticleEngine::Destroy +0x11`, `access=0 + target=0x0`, from the per-frame render path. **Not a weapons bug** — the Owens two-trigger + missile+laser combo is merely what provokes the GPU timeout on that iGPU. The broken protocol + (both Present sites, inline copies): on `D3DERR_DEVICELOST` they called `Reset()` IMMEDIATELY — + which **always fails while still lost** (`V()` only logged) — then `ParticleEngine::Initialize()` + ran against the lost device, its creates failed and **NULLed the out-params** (verified: the bench + repro faulted at `target=0x0`, not a dangling address), and the NEXT lost frame's blind + `mVertBuffer->Release()` read a vtable at 0x0. FIX (`L4VIDEO.cpp` + `L4PARTICLES.cpp`): one shared + `DPLRenderer::BTResetLostDevice()` — release POOL_DEFAULT resources every lost frame (idempotent, + null-safe `Destroy`), **gate `Reset()` on `TestCooperativeLevel() != D3DERR_DEVICELOST`**, check + the Reset HRESULT, and on success re-create via **`CreateDeviceObjects()` — NOT `Initialize()`**, + whose `memset(mInstalledEffects)` silently killed every particle effect for the rest of the + mission on each reset that DID succeed (the quieter sibling bug). Draw paths guard the NULL buffer + (`ExecuteParticles` keeps draining particles while dormant). Bench: `BT_DEVICELOST_TEST` repro'd + the field crash byte-for-byte pre-fix, and post-fix the same shape logs `SURVIVED` + 3 forced + loss/reset cycles recover (`[render] device reset OK`). + ⚠ **Discovery from the verify: `VIDEO\particles.png` has NEVER existed** — absent from the tree, + the RES, and all of git history — so `mParticleTexture` has been NULL on every machine since the + engine was written and **all billboard particles draw as untextured quads** (`SetTexture(0,NULL)` + = the shipped look). Deliberately NOT gated on in `RenderParticles` — that would disable all + particles everywhere. Filed on the tracker; a real texture (or the original particle sheet) is a + content task. Possibly related to the field "missile artifact looks wrong" observation. - **Audio SOURCE BUDGET — the dropout complaint was fixed 2026-07-23; the leftover is NOT the same bug** [T2, 2026-07-28]. **The reported bug is closed**: `a999e5c` (per-source delete instead of the spec-atomic bulk `alDeleteSources`) stopped the pool leaking, and there have been **no field diff --git a/engine/MUNGA_L4/L4PARTICLES.cpp b/engine/MUNGA_L4/L4PARTICLES.cpp index 599cdc4..3cdd4fc 100644 --- a/engine/MUNGA_L4/L4PARTICLES.cpp +++ b/engine/MUNGA_L4/L4PARTICLES.cpp @@ -1,5 +1,7 @@ #include "l4particles.h" #include "../munga/time.h" +#include "../munga/style.h" // DEBUG_STREAM (#35 diagnostics) +#include LPDIRECT3DDEVICE9 ParticleEngine::mDevice = NULL; PARTICLE_EFFECT ParticleEngine::mInstalledEffects[MAX_PARTICLE_EFFECTS]; @@ -251,26 +253,89 @@ void ParticleEmitter::Execute() void ParticleEngine::Destroy() { - mVertBuffer->Release(); - mParticleTexture->Release(); + // #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=,crashrepro). + if (mVertBuffer != NULL) + { + mVertBuffer->Release(); + mVertBuffer = NULL; + } + if (mParticleTexture != NULL) + { + mParticleTexture->Release(); + mParticleTexture = NULL; + } } void ParticleEngine::Initialize(LPDIRECT3DDEVICE9 device) { - mDevice = 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)); - ParticleEngine::mMaxParticleCount = atoi(getenv("MAXPARTICLES")); - - // create the vertex buffer that will store the four vertices we need for the billboards - mDevice->CreateVertexBuffer(ParticleEngine::mMaxParticleCount * 6 * sizeof(L4BASICVERTEX), + 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; + } - D3DXCreateTextureFromFile(mDevice, L"VIDEO\\particles.png", &mParticleTexture); + // 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) @@ -453,6 +518,12 @@ void ParticleEngine::ExecuteParticles(const D3DXMATRIX *view_matrix, Scalar time } } + // #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); @@ -484,7 +555,14 @@ void ParticleEngine::RenderParticles(const D3DXMATRIX *view_matrix, Scalar timeS // variables get all screwed up when we don't run for a while ExecuteParticles(view_matrix, timeSlice); - if (!mDevice || mTotalParticleCount <= 0) + // (#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 diff --git a/engine/MUNGA_L4/L4PARTICLES.h b/engine/MUNGA_L4/L4PARTICLES.h index 49fe90c..27f4e3a 100644 --- a/engine/MUNGA_L4/L4PARTICLES.h +++ b/engine/MUNGA_L4/L4PARTICLES.h @@ -136,6 +136,11 @@ class ParticleEngine public: static void Destroy(); static void Initialize(LPDIRECT3DDEVICE9 device); + // #35: the device-RESET path re-creates ONLY the D3D objects. Initialize() + // is full startup init -- it also wipes the installed-effects table, which + // a mid-mission reset must never do (it silently killed every particle + // effect for the rest of the mission). + static void CreateDeviceObjects(LPDIRECT3DDEVICE9 device); static void InstallEffect(int effectNumber, PARTICLE_EFFECT effect); static void RenderParticles(const D3DXMATRIX *view_matrix, Scalar timeSlice); diff --git a/engine/MUNGA_L4/L4VIDEO.cpp b/engine/MUNGA_L4/L4VIDEO.cpp index 01a8657..c0acade 100644 --- a/engine/MUNGA_L4/L4VIDEO.cpp +++ b/engine/MUNGA_L4/L4VIDEO.cpp @@ -8197,6 +8197,75 @@ static void BTVerifyCockpitCanvasAfterReset(LPDIRECT3DDEVICE9 device) //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Execute Method, performs the rendering of one frame // +// +//############################################################################# +// BTResetLostDevice -- the one true D3D9 device-loss recovery (#35). +// +// Both Present sites (the scene frame and the wait-screen idle frame) used to +// carry an inline copy of this, and both copies were wrong the same way: +// on DEVICELOST they called Reset() IMMEDIATELY. Reset() on a still-lost +// device always fails; V() only logged it; ParticleEngine::Initialize then ran +// against the lost device, its creates failed and NULLed the statics, and the +// NEXT lost frame's ParticleEngine::Destroy() read a vtable at 0x0 -- the +// field crash (8/8 byte-identical stacks at Destroy +0x11 on the one machine +// whose GPU ever actually loses the device, a 128 MB Iris Xe). +// +// Correct protocol: release the POOL_DEFAULT resources every lost frame +// (Destroy is idempotent now), but Reset ONLY once TestCooperativeLevel stops +// answering D3DERR_DEVICELOST; until then skip and retry next frame. On a +// successful Reset re-create ONLY the device objects -- NOT Initialize(), +// which memsets the installed-effects table and killed every particle effect +// for the rest of the mission on every reset that did succeed. +//############################################################################# +// +void DPLRenderer::BTResetLostDevice() +{ + if (mWaitOverlaySurface != NULL) + { + mWaitOverlaySurface->Release(); // pre-Reset, like every non-MANAGED resource + mWaitOverlaySurface = NULL; + } + ParticleEngine::Destroy(); // idempotent + null-safe (#35) + + HRESULT coop = mDevice->TestCooperativeLevel(); + if (coop == D3DERR_DEVICELOST) + { + static unsigned long s_lostLogAt = 0; + unsigned long now_ms = GetTickCount(); + if (now_ms - s_lostLogAt > 2000) + { + s_lostLogAt = now_ms; + DEBUG_STREAM << "[render] device LOST -- waiting for the driver " + "before Reset" << std::endl << std::flush; + } + return; // cannot reset yet; retry next frame + } + + int bbCount = mPresentParams.BackBufferCount; + int bbWidth = mPresentParams.BackBufferWidth; + int bbHeight = mPresentParams.BackBufferHeight; + + HRESULT reset_hr = mDevice->Reset(&mPresentParams); + + mPresentParams.BackBufferCount = bbCount; // Reset writes actuals back; + mPresentParams.BackBufferWidth = bbWidth; // keep the app's own intent + mPresentParams.BackBufferHeight = bbHeight; + + if (FAILED(reset_hr)) + { + DEBUG_STREAM << "[render] device Reset FAILED hr=0x" + << std::hex << (unsigned long)reset_hr << std::dec + << " -- will retry next frame" << std::endl << std::flush; + return; + } + + ParticleEngine::CreateDeviceObjects(mDevice); // NOT Initialize (effects table!) + this->SetCoreRenderStates(); + BTVerifyCockpitCanvasAfterReset(mDevice); // Gitea #56 guard + DEBUG_STREAM << "[render] device reset OK (lost -> restored)" + << std::endl << std::flush; +} + void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::InterestingEntityIterator* all_iterator) { Component *component; @@ -8913,27 +8982,71 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte sAcc = 0.0; sFrames = 0; sMaxD = 0.0; sMaxP = 0.0; } } + // #35 BENCH HOOK (BT_DEVICELOST_TEST=[,crashrepro], off by default). + // A real device loss needs a GPU that actually hangs (Conn Man's Iris Xe); + // no bench machine here can produce one on demand. Two modes: + // force the DEVICELOST branch below at that render + // frame and twice more at +600/+1200 -- drives the + // REAL recovery code, not a copy of it. + // ,crashrepro run the exact field sequence at that frame: + // teardown, a re-Initialize forced to fail + // (MAXPARTICLES=0 -> CreateVertexBuffer(0) fails -> + // NULL out-params), then a second teardown -- the + // two-lost-frames-in-a-row shape from the field logs. + { + static long s_dltFrame = -2; // -2 unparsed, -1 off + static long s_dltTick = 0; + static int s_dltRepro = 0; + if (s_dltFrame == -2) + { + const char *e = getenv("BT_DEVICELOST_TEST"); + s_dltFrame = -1; + if (e != NULL && *e != '\0') + { + s_dltFrame = strtol(e, NULL, 10); + if (s_dltFrame <= 0) s_dltFrame = -1; + if (strstr(e, "crashrepro") != NULL) s_dltRepro = 1; + } + } + if (s_dltFrame > 0) + { + ++s_dltTick; + if (s_dltRepro) + { + if (s_dltTick == s_dltFrame) + { + // The field null-teardown shape: Destroy releases + nulls, + // the second Destroy runs against the NULL statics -- the + // exact state the field crash died in. (The PRE-fix repro + // of 2026-07-29 used Destroy / MAXPARTICLES=0-forced- + // failed-Initialize / Destroy, and faulted at Destroy + // +0x11 with target=0x0, matching all 8 field stacks; the + // hardened Initialize defends against that injection now, + // so the double-Destroy is the surviving regression form.) + DEBUG_STREAM << "[dltest] crashrepro: Destroy / Destroy on NULL statics" + << std::endl << std::flush; + ParticleEngine::Destroy(); + ParticleEngine::Destroy(); // field crash site + DEBUG_STREAM << "[dltest] crashrepro SURVIVED; restoring device objects" + << std::endl << std::flush; + ParticleEngine::CreateDeviceObjects(mDevice); + } + } + else if (s_dltTick >= s_dltFrame + && ((s_dltTick - s_dltFrame) % 600) == 0 + && (s_dltTick - s_dltFrame) <= 1200) + { + DEBUG_STREAM << "[dltest] forcing DEVICELOST branch (cycle " + << ((s_dltTick - s_dltFrame) / 600 + 1) << "/3)" + << std::endl << std::flush; + hr = D3DERR_DEVICELOST; + } + } + } + if (hr == D3DERR_DEVICELOST) { - if (mWaitOverlaySurface != NULL) - { - mWaitOverlaySurface->Release(); // D3DPOOL_DEFAULT: pre-Reset - mWaitOverlaySurface = NULL; - } - int bbCount = mPresentParams.BackBufferCount; - int bbWidth = mPresentParams.BackBufferWidth; - int bbHeight = mPresentParams.BackBufferHeight; - - ParticleEngine::Destroy(); - V(mDevice->Reset(&mPresentParams)); - ParticleEngine::Initialize(mDevice); - this->SetCoreRenderStates(); - - mPresentParams.BackBufferCount = bbCount; - mPresentParams.BackBufferWidth = bbWidth; - mPresentParams.BackBufferHeight = bbHeight; - - BTVerifyCockpitCanvasAfterReset(mDevice); // Gitea #56 guard + BTResetLostDevice(); // #35: the guarded recovery } ticks = HiResNowTicks(); @@ -9199,27 +9312,9 @@ void DPLRenderer::ExecuteIdle() BTWaitScreenPaint(wait_line1, wait_line2); } if (present_hr == D3DERR_DEVICELOST) - { - if (mWaitOverlaySurface != NULL) { - mWaitOverlaySurface->Release(); // D3DPOOL_DEFAULT: must go pre-Reset - mWaitOverlaySurface = NULL; + BTResetLostDevice(); // #35: the guarded recovery } - int bbCount = mPresentParams.BackBufferCount; - int bbWidth = mPresentParams.BackBufferWidth; - int bbHeight = mPresentParams.BackBufferHeight; - - ParticleEngine::Destroy(); - V(mDevice->Reset(&mPresentParams)); - ParticleEngine::Initialize(mDevice); - this->SetCoreRenderStates(); - - mPresentParams.BackBufferCount = bbCount; - mPresentParams.BackBufferWidth = bbWidth; - mPresentParams.BackBufferHeight = bbHeight; - - BTVerifyCockpitCanvasAfterReset(mDevice); // Gitea #56 guard - } } } // diff --git a/engine/MUNGA_L4/L4VIDEO.h b/engine/MUNGA_L4/L4VIDEO.h index 6ed5999..98d0363 100644 --- a/engine/MUNGA_L4/L4VIDEO.h +++ b/engine/MUNGA_L4/L4VIDEO.h @@ -306,6 +306,12 @@ public: // dpl_ZONE* MakeNewZone(); + // #35: the one true device-loss recovery -- both Present sites route here. + // Correct D3D9 protocol: release POOL_DEFAULT resources, then Reset ONLY + // once TestCooperativeLevel says the device is ready; a Reset while still + // lost always fails, and the old inline copies drove on past that failure. + void BTResetLostDevice(); + void MarkDCSHiearchy(dpl_DCS *root_DCS, Entity *entity); void FlushBitSliceTexture(unsigned int *local_storage); void LoadBitSliceTexture(BitMap *bitmap_to_load, LPDIRECT3DTEXTURE9 local_storage);