From 160b78e38d106a70779bc00e07897e82851adf88 Mon Sep 17 00:00:00 2001 From: arcattack Date: Fri, 10 Jul 2026 08:09:27 -0500 Subject: [PATCH] Respawn: swirl warp, cockpit-not-black on respawn, observer sees peer un-wreck Three respawn-visual bugs the user saw, each grounded in the engine/decomp: 1. Warp = flat blue BLOB, not the demo's swirly blue/light-blue/white shimmer. Ground truth (content/VIDEO/MAT/DAY/BTFX.VMF): tsphere_mtl = a SCROLLING bintA texture (tsphere_scr_tex, SPECIAL SCROLL) + EMISSIVE {0.7,0.5,1} + RAMP "sky" (dark-blue->white). The swirl IS the scrolling texture; the colour is the emissive/ramp. Our draw did COLOROP=SELECTARG1/ARG1=TFACTOR, which REPLACES every texel with one flat colour -> the blob. Fix: MODULATE the (bound, scrolling) texture by a TFACTOR set to the authentic EMISSIVE hue (0xB380FF) so the swirl survives and reads blue-white. DrawMesh's cached SetTexture (L4D3D.cpp:1215) leaves our MODULATE ops standing since textured meshes drew first. Additive glow, all state saved/restored. 2. First-person view BLACK on the dying/respawning mech until V. SetViewInside's body-hide + '_cop' canopy suppression + viewSkeleton update were gated !wrecked, and RebuildMechRenderables (respawn un-wreck) restored the full OUTSIDE torso with no '_cop' rule -> the cockpit eyepoint ended up wrapped in opaque geometry. Fix: factor the per-segment mesh selection into ApplyViewSkeleton(viewpoint, inside) shared by SetViewInside AND RebuildMechRenderables, so respawn re-asserts the inside skeleton + '_cop' hide; record viewSkeleton even while wrecked. Only the mech the local camera views FROM gets the inside treatment (a replicant is always outside). 3. OBSERVER never saw the peer respawn -- the peer's wreck sat forever. The wreck appears incidentally via rising damage-zone replication -> MechDeathHandler::Tick -> BTRemakeMechModel (one-way). The un-wreck+warp ran master-only in Mech::Reset. Fix (reuses the existing damage channel, no stream-framing change): Mech::Reset also ForceUpdate(DamageZoneUpdateModelFlag) so healed zones cross; Tick handles the FALLING edge -- on a ReplicantInstance whose zone heals from destroyed, call BTRebuildMechModel + BTStartWarpEffect once (wasWrecked latch). This is the port analog of the binary's type-0 graphic-state -> ResetPose un-wreck hook (Mech::ReadUpdateRecord case 0); the full Mech::WriteUpdateRecord death record (type 6) is deferred (would touch the netcode framing). Smoke-verified headless (2-node): victim respawns intact + warp; observer logs "replicant un-wrecked + warp" at the peer's spawn point; no crash. Co-Authored-By: Claude Opus 4.8 --- game/reconstructed/btl4vid.cpp | 248 +++++++++++++++++---------------- game/reconstructed/btl4vid.hpp | 8 ++ game/reconstructed/mech4.cpp | 7 + game/reconstructed/mechdmg.cpp | 38 ++++- game/reconstructed/mechdmg.hpp | 6 + 5 files changed, 185 insertions(+), 122 deletions(-) diff --git a/game/reconstructed/btl4vid.cpp b/game/reconstructed/btl4vid.cpp index 13cda26..3571ebe 100644 --- a/game/reconstructed/btl4vid.cpp +++ b/game/reconstructed/btl4vid.cpp @@ -809,57 +809,17 @@ void render_tree.wreckAge = 0.0f; // - // Restore every body segment to its now-intact mesh (mirrors the load in - // RemakeEntityRenderables / MakeMechRenderables, for the healed graphic state). + // Restore every body segment to its now-intact mesh via the shared view-skeleton + // applier, which ALSO re-asserts the inside-view rules (SkeletonType_A + '_cop' + // canopy suppression). Without this, respawning while in cockpit view rebuilt + // the full OUTSIDE torso around the eyepoint -> the eye ended up inside opaque + // geometry -> black viewport until the next V-toggle. Only the mech the local + // camera views FROM (the player's own) gets the inside treatment; a replicant + // (a peer's mech) is always drawn with its outside skeleton. // - JointedMover *jointed_mover = (JointedMover *)entity; - EntitySegment::SkeletonType skeletonType = - (EntitySegment::SkeletonType)render_tree.viewSkeleton; - EntitySegment::SegmentTableIterator segment_iterator(jointed_mover->segmentTable); - EntitySegment *segment; - int restored = 0; - - while ((segment = segment_iterator.ReadAndNext()) != NULL) - { - if (segment->IsSiteSegment() != 0) - continue; - int segment_slot = segment->GetIndex(); - std::map::iterator r = - render_tree.segRenderable.find(segment_slot); - if (r == render_tree.segRenderable.end() || r->second == NULL) - continue; - - Enumeration seg_gstate = 0; // ExistsGraphicState (healed) - int zone_index = segment->GetPrimaryDamageZone(); - if (zone_index >= 0 && zone_index < entity->damageZoneCount - && entity->damageZones[zone_index] != 0) - seg_gstate = entity->damageZones[zone_index]->GetGraphicState(); - - CString *object_name = segment->GetVideoObjectName(skeletonType, seg_gstate); - d3d_OBJECT *new_object = NULL; - if (object_name != NULL) - { - char filename[44]; - strcpy(filename, (const char *)*object_name); - int len = (int)strlen(filename); - if (len >= 4) - filename[len - 4] = '\0'; - strcat(filename, ".bgf"); - new_object = d3d_OBJECT::LoadObject(GetDevice(), filename); - if (new_object != NULL && strstr(filename, "tshd") != NULL) - { - new_object->SetIsShadow(1); - for (int op = 0; op < new_object->GetDrawOpCount(); ++op) - new_object->GetDrawOp(op)->alphaTest = true; - } - } - if (new_object != NULL) - { - r->second->SetDrawObj(new_object); - ++restored; - } - render_tree.segGState[segment_slot] = (int)seg_gstate; - } + Entity *viewpoint = (application != 0) ? application->GetViewpointEntity() : 0; + int inside = (entity == viewpoint) ? mViewInside : 0; + int restored = ApplyViewSkeleton(entity, inside); if (getenv("BT_DEATH_LOG")) DEBUG_STREAM << "[BTrender] respawn: rebuilt intact model (" @@ -2077,6 +2037,86 @@ BTL4VideoRenderer::BTL4VideoRenderer( mViewInside = 0; } +// +// ApplyViewSkeleton -- select + load each body segment's displayed mesh for the +// given view (inside = SkeletonType_A / outside = the mech's own skeletonType), +// honoring the live damage graphic state, the inside-view '_cop' canopy +// suppression, and the shadow (tshd) flagging. Shared by SetViewInside (the live +// V-toggle) and RebuildMechRenderables (the respawn un-wreck) so the cockpit view +// stays self-consistent ACROSS the death/respawn transition -- the respawn path +// used to restore the full OUTSIDE torso around the cockpit eyepoint (no '_cop' +// suppression, and viewSkeleton left stale), which enclosed the eye in opaque +// geometry -> the "black viewport until you press V" bug. Records viewSkeleton so +// a later rebuild restores the RIGHT skeleton. Returns the count of shown meshes. +// +int + BTL4VideoRenderer::ApplyViewSkeleton(Entity *viewpoint, int inside) +{ + std::map::iterator tree_it = + mMechRenderTrees.find(viewpoint); + if (tree_it == mMechRenderTrees.end()) + return 0; + MechRenderTree &render_tree = tree_it->second; + render_tree.viewSkeleton = inside + ? (int)EntitySegment::SkeletonType_A + : render_tree.skeletonType; + + JointedMover *jm = (JointedMover *)viewpoint; + EntitySegment::SegmentTableIterator it(jm->segmentTable); + EntitySegment *segment; + int shown = 0, hidden = 0; + while ((segment = it.ReadAndNext()) != NULL) + { + if (segment->IsSiteSegment() != 0) + continue; + int slot = segment->GetIndex(); + std::map::iterator r = + render_tree.segRenderable.find(slot); + if (r == render_tree.segRenderable.end() || r->second == NULL) + continue; + // Live graphic state from the zone (healed=Exists / damaged / gone) -- read + // fresh so a just-healed respawn shows the intact mesh, not a stale cache. + Enumeration gstate = 0; + int zone_index = segment->GetPrimaryDamageZone(); + if (zone_index >= 0 && zone_index < viewpoint->damageZoneCount + && viewpoint->damageZones[zone_index] != 0) + gstate = viewpoint->damageZones[zone_index]->GetGraphicState(); + CString *nm = segment->GetVideoObjectName( + (EntitySegment::SkeletonType)render_tree.viewSkeleton, gstate); + // The cockpit interior shell (blx_cop -- the canopy frame around the + // eyepoint) currently renders as a black enclosure; keep it hidden until + // its interior rendering is sorted (BT_INSIDE_COCKPIT=1 shows it). + if (inside && nm != NULL && strstr((const char *)*nm, "_cop") != NULL + && !getenv("BT_INSIDE_COCKPIT")) + nm = NULL; + d3d_OBJECT *obj = NULL; + if (nm != NULL) + { + char filename[44]; + strcpy(filename, (const char *)*nm); + int len = (int)strlen(filename); + if (len >= 4) + filename[len - 4] = '\0'; + strcat(filename, ".bgf"); + obj = d3d_OBJECT::LoadObject(GetDevice(), filename); + if (obj != NULL && strstr(filename, "tshd") != NULL) + { + obj->SetIsShadow(1); + for (int op = 0; op < obj->GetDrawOpCount(); ++op) + obj->GetDrawOp(op)->alphaTest = true; + } + } + r->second->SetDrawObj(obj); + render_tree.segGState[slot] = (int)gstate; + if (obj) ++shown; else ++hidden; + } + DEBUG_STREAM << "[view] skeleton " + << (inside ? "A (inside)" : "N (outside)") << ": " + << shown << " segment mesh(es) shown, " << hidden + << " hidden\n" << std::flush; + return shown; +} + // // The V-key view toggle: switch the live camera between the authentic cockpit // eyepoint and the port's external chase camera. A missing eye (e.g. the @@ -2098,68 +2138,21 @@ void // the authentic pod view worked exactly this way), the chase view restores // the full outside set. Damage graphic states are respected per segment. // + // While WRECKED the body is the sinking hulk, so skip the live mesh swap -- + // but still RECORD the chosen skeleton so the respawn rebuild restores the + // right one (RebuildMechRenderables re-applies these same rules on un-wreck). + // Entity *viewpoint = (application != 0) ? application->GetViewpointEntity() : 0; std::map::iterator tree_it = mMechRenderTrees.find(viewpoint); - if (tree_it != mMechRenderTrees.end() && !tree_it->second.wrecked) + if (tree_it != mMechRenderTrees.end()) { - MechRenderTree &render_tree = tree_it->second; - render_tree.viewSkeleton = inside - ? (int)EntitySegment::SkeletonType_A - : render_tree.skeletonType; - - JointedMover *jm = (JointedMover *)viewpoint; - EntitySegment::SegmentTableIterator it(jm->segmentTable); - EntitySegment *segment; - int shown = 0, hidden = 0; - while ((segment = it.ReadAndNext()) != NULL) - { - if (segment->IsSiteSegment() != 0) - continue; - int slot = segment->GetIndex(); - std::map::iterator r = - render_tree.segRenderable.find(slot); - if (r == render_tree.segRenderable.end() || r->second == NULL) - continue; - int gstate = 0; - std::map::iterator g = render_tree.segGState.find(slot); - if (g != render_tree.segGState.end()) - gstate = g->second; - CString *nm = segment->GetVideoObjectName( - (EntitySegment::SkeletonType)render_tree.viewSkeleton, - (Enumeration)gstate); - // The cockpit interior shell (blx_cop -- the canopy frame around the - // eyepoint) currently renders as a black enclosure; keep it hidden - // until its interior rendering is sorted (BT_INSIDE_COCKPIT=1 shows - // it for that investigation). - if (inside && nm != NULL && strstr((const char *)*nm, "_cop") != NULL - && !getenv("BT_INSIDE_COCKPIT")) - nm = NULL; - d3d_OBJECT *obj = NULL; - if (nm != NULL) - { - char filename[44]; - strcpy(filename, (const char *)*nm); - int len = (int)strlen(filename); - if (len >= 4) - filename[len - 4] = '\0'; - strcat(filename, ".bgf"); - obj = d3d_OBJECT::LoadObject(GetDevice(), filename); - } - r->second->SetDrawObj(obj); - if (obj) - { - ++shown; - DEBUG_STREAM << "[view] shown: seg '" - << (const char *)segment->GetName() << "' mesh '" - << (nm ? (const char *)*nm : "?") << "'\n" << std::flush; - } - else ++hidden; - } - DEBUG_STREAM << "[view] skeleton " - << (inside ? "A (inside)" : "N (outside)") << ": " - << shown << " segment mesh(es) shown, " << hidden - << " hidden\n" << std::flush; + if (!tree_it->second.wrecked) + ApplyViewSkeleton(viewpoint, inside); + else + tree_it->second.viewSkeleton = inside + ? (int)EntitySegment::SkeletonType_A + : tree_it->second.skeletonType; } DEBUG_STREAM << "[view] " << (inside ? "COCKPIT eyepoint" : "external chase") @@ -2350,18 +2343,30 @@ void gTLocSphere->SetLocalToWorld(m); - // Draw as a translucent BLUE additive glow -- a shimmering warp, not an - // opaque view-blocking ball. We are inside the alpha pass (ALPHABLENDENABLE - // + ZWRITE off already set, L4VIDEO.cpp:7762), but the blend factors + colour - // are indeterminate here (beams/pfx drew first), so set them explicitly and - // RESTORE every state we touch (a leaked LIGHTING/TFACTOR corrupts the scene). - DWORD sSrc, sDst, sLight, sTFactor, sCOp, sCA1, sAOp, sAA1; + // Draw the AUTHENTIC translocation-sphere material as a shimmering swirl, not + // a flat blob. Ground truth (content/VIDEO/MAT/DAY/BTFX.VMF): tsphere_mtl = + // TEXTURE{tsphere_scr_tex -> MAP bintA, SCROLL 0.0 0.0 0.1 0.5} EMISSIVE{0.7, + // 0.5,1} RAMP{sky:(0,0,0.6)->(.99,.99,.99)}. The swirl IS the scrolling bintA + // texture; the blue/light-blue/white is the EMISSIVE colour (+ the sky ramp). + // The old code did COLOROP=SELECTARG1/ARG1=TFACTOR, which REPLACES every texel + // with one flat colour -> the "opaque blue blob" the user saw. Instead + // MODULATE the (bound, scrolling) texture by a TFACTOR tint set to the authentic + // EMISSIVE hue, so the swirl survives and reads blue-white. DrawMesh binds + // bintA + animates the scroll (SetTexture/SetTextureScrolling, L4D3D.cpp:1093, + // 1220); its SetTexture only rewrites the stage ops on a texturing on/off + // TRANSITION (cached mLastTexturingState, :1215) -- textured meshes drew first + // so the cache is 'on' and our MODULATE ops stand. We are in the alpha pass + // (ALPHABLENDENABLE + ZWRITE off, L4VIDEO.cpp:7762) but the blend/colour are + // indeterminate here (beams/pfx drew first): set them explicitly + RESTORE + // every state (a leaked LIGHTING/TFACTOR/stage-op corrupts the scene). + DWORD sSrc, sDst, sLight, sTFactor, sCOp, sCA1, sCA2, sAOp, sAA1; device->GetRenderState(D3DRS_SRCBLEND, &sSrc); device->GetRenderState(D3DRS_DESTBLEND, &sDst); device->GetRenderState(D3DRS_LIGHTING, &sLight); device->GetRenderState(D3DRS_TEXTUREFACTOR, &sTFactor); device->GetTextureStageState(0, D3DTSS_COLOROP, &sCOp); device->GetTextureStageState(0, D3DTSS_COLORARG1, &sCA1); + device->GetTextureStageState(0, D3DTSS_COLORARG2, &sCA2); device->GetTextureStageState(0, D3DTSS_ALPHAOP, &sAOp); device->GetTextureStageState(0, D3DTSS_ALPHAARG1, &sAA1); @@ -2369,20 +2374,22 @@ void device->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_ONE); // additive glow device->SetRenderState(D3DRS_LIGHTING, FALSE); { - // ARGB blue tint + intensity; BT_WARP_COLOR=AARRGGBB (hex) overrides. + // ARGB tint = the authentic EMISSIVE {0.7,0.5,1} -> R=0xB3 G=0x80 B=0xFF, + // full-intensity alpha; BT_WARP_COLOR=AARRGGBB (hex) overrides. static DWORD s_warpColor = 0; if (s_warpColor == 0) { const char *wc = getenv("BT_WARP_COLOR"); - s_warpColor = wc ? (DWORD)strtoul(wc, 0, 16) : 0x9040A0FF; - if (s_warpColor == 0) s_warpColor = 0x9040A0FF; + s_warpColor = wc ? (DWORD)strtoul(wc, 0, 16) : 0xFFB380FF; + if (s_warpColor == 0) s_warpColor = 0xFFB380FF; } device->SetRenderState(D3DRS_TEXTUREFACTOR, s_warpColor); } - device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1); - device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TFACTOR); + device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE); + device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); // bintA swirl + device->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TFACTOR); // blue tint device->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1); - device->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_TFACTOR); + device->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_TFACTOR); // uniform intensity gTLocSphere->Draw(PASS_ALPHABLEND, view, frame_time); @@ -2392,6 +2399,7 @@ void device->SetRenderState(D3DRS_TEXTUREFACTOR, sTFactor); device->SetTextureStageState(0, D3DTSS_COLOROP, sCOp); device->SetTextureStageState(0, D3DTSS_COLORARG1, sCA1); + device->SetTextureStageState(0, D3DTSS_COLORARG2, sCA2); device->SetTextureStageState(0, D3DTSS_ALPHAOP, sAOp); device->SetTextureStageState(0, D3DTSS_ALPHAARG1, sAA1); } diff --git a/game/reconstructed/btl4vid.hpp b/game/reconstructed/btl4vid.hpp index 7eb7c2c..5754add 100644 --- a/game/reconstructed/btl4vid.hpp +++ b/game/reconstructed/btl4vid.hpp @@ -654,6 +654,14 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device); void RebuildMechRenderables(Entity *entity); + // Select/load each body segment's displayed mesh for the given view + // (inside=SkeletonType_A + '_cop' suppression / outside=full skeleton), + // honoring live damage graphic state. Shared by SetViewInside (V-toggle) + // and RebuildMechRenderables (respawn) so the cockpit view stays consistent + // across death/respawn. Returns the count of shown meshes. + int + ApplyViewSkeleton(Entity *viewpoint, int inside); + protected: // // Per-mech render-tree bookkeeping so RemakeEntityRenderables can find each diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 49b8dd1..f107f26 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -1076,6 +1076,13 @@ void // --- broadcast the reset state to replicants (FUN_004a4c54(this, 0x1f)) --- ForceUpdate(); + // Also re-broadcast the (now-healed) damage zones so a REPLICANT observer sees + // the zone levels FALL from destroyed back to intact -- the falling edge that + // MechDeathHandler::Tick uses to un-wreck + warp the peer on the other screen + // (the master's Reset above un-wrecks + warps LOCALLY; without this the observer + // has no signal that the peer respawned and its wreck sits forever). Same + // channel the damage path uses (mechdmg.cpp:1016). + ForceUpdate(Entity::DamageZoneUpdateModelFlag); if (getenv("BT_DEATH_LOG")) DEBUG_STREAM << "[respawn] Mech::Reset " << GetEntityID() diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index bc27ecb..2fa3ee0 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -949,7 +949,7 @@ extern void BTSpawnDamageEffect(Mech *mech, int effect_resource, int segment_ind extern void BTRemakeMechModel(Entity *entity); MechDeathHandler::MechDeathHandler(Mech *mech) // @0042a984 - : owner(mech) + : owner(mech), wasWrecked(0) { // per-zone last-damage cache, zeroed (binary this[0x10], size mech+0x11c). int count = (mech != 0 && mech->damageZoneCount > 0) ? mech->damageZoneCount : 0; @@ -981,13 +981,47 @@ void Scalar level = zone->GetStructureDamageLevel(); Scalar prev = lastLevel[i]; - if (level <= prev) // no new damage on this zone + + // FALLING edge = the master's Mech::Reset healed + re-broadcast this zone + // (ForceUpdate(DamageZoneUpdateModelFlag)). The rising-damage swap below is + // one-way; on a REPLICANT observer, reverse it here -- drop the destroyed + // segment meshes (BTRebuildMechModel) and play the recovery warp -- so the + // peer's wreck un-wrecks + warps back on the observer's screen. Gated to + // ReplicantInstance: the master already un-wrecks + warps locally in + // Mech::Reset (mech4.cpp), so it must NOT double-fire here. The authentic + // engine drives this off the type-0 graphic-state transition in + // Mech::ReadUpdateRecord (mech.cpp case 0 -> ResetPose); our port has no + // ported RemakeEntity state, so we react to the replicated zone level. + if (level < prev) + { + lastLevel[i] = level; + if (wasWrecked && prev >= 1.0f && level < 1.0f) + { + wasWrecked = 0; // one un-wreck per death->respawn + if (owner->GetInstance() == Entity::ReplicantInstance) + { + extern void BTRebuildMechModel(Entity *entity); + extern void BTStartWarpEffect(float x, float y, float z); + BTRebuildMechModel((Entity *)owner); + BTStartWarpEffect((float)owner->localOrigin.linearPosition.x, + (float)owner->localOrigin.linearPosition.y, + (float)owner->localOrigin.linearPosition.z); + if (getenv("BT_DEATH_LOG")) + DEBUG_STREAM << "[respawn] replicant un-wrecked + warp at (" + << owner->localOrigin.linearPosition.x << "," + << owner->localOrigin.linearPosition.z << ")\n" << std::flush; + } + } + continue; + } + if (level <= prev) // unchanged -- no new damage continue; lastLevel[i] = level; const Mech__DamageZone::DamageDescriptor *d = zone->DescriptorForLevel(level); if (level >= 1.0f && prev < 1.0f) // just destroyed -> the Destroyed descriptor { + wasWrecked = 1; // latch: a wreck the observer must later reverse const Mech__DamageZone::DamageDescriptor *dd = zone->DescriptorForGraphicState(DamageZone::DestroyedGraphicState); // enum 1 if (dd != 0) diff --git a/game/reconstructed/mechdmg.hpp b/game/reconstructed/mechdmg.hpp index 9544c1f..6a94f42 100644 --- a/game/reconstructed/mechdmg.hpp +++ b/game/reconstructed/mechdmg.hpp @@ -357,6 +357,12 @@ class MechDeathHandler private: Mech *owner; // @0x14 std::vector lastLevel; // @0x10 per-zone last damageLevel cache + // PORT (respawn replication): latched when any zone is destroyed, so a + // REPLICANT observer can reverse the one-way wreck swap when the master's + // Mech::Reset heals + re-broadcasts the zones (falling damage edge). Not a + // binary field -- the authentic un-wreck rides the type-0 graphic-state hook + // (Mech::ReadUpdateRecord case 0), which our port drives via the zone levels. + Logical wasWrecked; }; #endif