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 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-10 08:09:27 -05:00
co-authored by Claude Opus 4.8
parent d07dd0bf4e
commit 160b78e38d
5 changed files with 185 additions and 122 deletions
+128 -120
View File
@@ -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<int, HierarchicalDrawComponent*>::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<Entity*, MechRenderTree>::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<int, HierarchicalDrawComponent*>::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<Entity*, MechRenderTree>::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<int, HierarchicalDrawComponent*>::iterator r =
render_tree.segRenderable.find(slot);
if (r == render_tree.segRenderable.end() || r->second == NULL)
continue;
int gstate = 0;
std::map<int, int>::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);
}
+8
View File
@@ -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
+7
View File
@@ -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()
+36 -2
View File
@@ -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)
+6
View File
@@ -357,6 +357,12 @@ class MechDeathHandler
private:
Mech *owner; // @0x14
std::vector<Scalar> 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