#87: mech armour PANELS now darken with damage -- the .DZM material system was loaded and unused

Players: "the actual enemy mech in external view is not showing darkened armor
panels".  We swapped destroyed LIMB meshes but never darkened a panel.

The 1995 game darkens armour through the MATERIALS.  MakeMechRenderables
(FUN_004cef28) built, per (damage zone, material), a watcher
FUN_004573e4(material, &zone->damageLevel, 0.1f) that snapshotted the materials
This commit is contained in:
Joe DiPrima
2026-07-31 19:52:55 -05:00
parent 5410371b0c
commit 137c151951
14 changed files with 589 additions and 1 deletions
+223
View File
@@ -743,6 +743,11 @@ HierarchicalDrawComponent*
<< (mEyeCockpit ? "" : " (COCKPIT EYE MISSING)") << "\n" << std::flush;
}
// ARMOUR DARKENING (issue #87): the segment geometry is loaded and segPick is
// populated, so the mech's .DZM zone->material lists can now be resolved onto
// real draw ops. The binary does this in the same function.
BindArmourDamage(entity, render_tree);
return this_root;
}
@@ -852,9 +857,30 @@ void
else if ((int)seg_gstate == DamageZone::GoneGraphicState)
r->second->SetDrawObj(NULL);
// The swap replaced the DRAWN object, so the segment's recorded geometry
// is now stale. Re-point it: the armour-damage bindings (issue #87) hold
// draw-op addresses inside these objects and would otherwise keep tinting
// the mesh that was just swapped out. (#73's aimed pick reads the same
// map, so it stops ray-testing a discarded mesh too.)
{
std::map<int, MechRenderTree::SegPick>::iterator sp =
render_tree.segPick.find(segment_slot);
if (sp != render_tree.segPick.end())
{
if (new_object != NULL && new_object->GetIsShadow() == 0)
sp->second.obj = new_object;
else if ((int)seg_gstate == DamageZone::GoneGraphicState)
render_tree.segPick.erase(sp); // blown off: no geometry to hit or tint
}
}
++swapped;
}
// Rebind the .DZM armour materials onto whatever geometry is now drawn.
if (swapped != 0)
BindArmourDamage(entity, render_tree);
if (swapped != 0 || getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[BTrender] RemakeEntity: " << swapped
<< " mesh(es) swapped (" << mapped << " body segs mapped of "
@@ -1319,6 +1345,190 @@ int
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ARMOUR DARKENING -- BindArmourDamage / TickArmourDamage (issue #87)
//
// The 1995 renderer darkened a mech's armour panels as they took damage, and it
// did it through the MATERIALS, not through geometry. In MakeMechRenderables
// (FUN_004cef28) it walked the mech's damage zones and, for every material that
// zone paints, built a watcher (FUN_004573e4) holding {material, &zone->damageLevel,
// 0.1}. The watcher snapshotted the material's sixteen authored colour floats
// (ambient 3, emissive 3, diffuse 3, specular+shininess 4, opacity 3), precomputed
// a DAMAGED set = pristine x 0.1, and on every change of the level (FUN_00457784)
// re-pushed lerp(pristine, damaged, level) -- the constant at 0x4579a4 is 1.0.
// (Faithful quirk: opacity is read and lerped but never written back -- only 13 of
// the 16 values are pushed. We scale colour terms only, which matches.)
//
// The zone->material lists are the per-mech .DZM files (VIDEO\<mech>SKIN.DZM,
// "[dz_ltorso] material=avaskin:avat2_dz_ltorso_mtl"), compiled into BTL4.RES and
// already parsed by MUNGA's own DamageZone stream ctor into materialTable, keyed
// by skeleton type (DAMAGE.cpp:311-336) -- the data has been loaded and unused all
// along. What was missing was purely the consumer.
//
// Our draw path bakes material colour into each draw op's D3DMATERIAL9 at load, so
// instead of holding material pointers we bind (draw object, op index) pairs here
// once, and copy the live level onto them each frame; L4D3D applies the same lerp
// at SetMaterial time. Binding by op index keeps the per-frame cost to a pointer
// write -- no string compare in the frame loop.
//
void
BTL4VideoRenderer::BindArmourDamage(Entity *entity, MechRenderTree &tree)
{
tree.dmgBinds.clear();
if (entity == NULL)
return;
const int log = (getenv("BT_ARMOR_LOG") != NULL);
for (int zone_index = 0; zone_index < entity->damageZoneCount; ++zone_index)
{
DamageZone *zone = entity->damageZones[zone_index];
if (zone == NULL)
continue;
// The .DZM list for the skeleton currently DISPLAYED -- not the one the
// tree was built with. Each skeleton variant is painted from its own
// skin library (the Black Hawk's N set is blhskin:, its X set blxskin:),
// and ApplyViewSkeleton reloads every segment mesh when the view changes,
// so keying on the build skeleton matches a material list belonging to
// geometry that is not on screen -> zero bindings and no darkening.
// A zone with no list for this skeleton simply never darkens, as on the pod.
MaterialList *material_list =
zone->GetMaterialList((Enumeration)tree.viewSkeleton);
if (material_list == NULL)
continue;
int zone_binds = 0;
material_list->First();
CString *material_name;
while ((material_name = material_list->ReadAndNext()) != NULL)
{
const char *want = (const char *)*material_name;
if (want == NULL || want[0] == '\0')
continue;
int name_hits = 0;
// Every non-shadow segment object of this mech is a candidate: one
// zone's materials can span several segments (the torso zones paint
// across the torso and door pieces), and one segment can carry
// several zones' materials.
for (std::map<int, MechRenderTree::SegPick>::iterator si = tree.segPick.begin();
si != tree.segPick.end(); ++si)
{
d3d_OBJECT *obj = si->second.obj;
if (obj == NULL)
continue;
for (int op = 0; op < obj->GetDrawOpCount(); ++op)
{
L4DRAWOP *draw_op = obj->GetDrawOp(op);
if (draw_op->dzMatName[0] == '\0')
continue;
// Both sides are hand-authored; case does not agree on every mech.
if (_stricmp(draw_op->dzMatName, want) != 0)
continue;
MechRenderTree::DmgBind bind;
bind.obj = obj;
bind.op = op;
bind.zone = zone_index;
tree.dmgBinds.push_back(bind);
draw_op->dzDamageLevel = 0.0f;
++zone_binds;
++name_hits;
}
}
// A .DZM name that matches NOTHING on the drawn geometry is the
// failure mode worth naming: it means the material list and the mesh
// disagree (wrong skeleton key, or a mech re-skinned since authoring),
// and that zone silently stops darkening.
if (log && name_hits == 0)
DEBUG_STREAM << "[armor] unmatched '" << want << "' (zone "
<< zone_index << ")\n" << std::flush;
}
if (log)
DEBUG_STREAM << "[armor] zone " << zone_index << " '"
<< (const char *)zone->damageZoneName << "' -> "
<< zone_binds << " draw op(s)\n" << std::flush;
}
if (log)
DEBUG_STREAM << "[armor] entity " << (void *)entity << " skel="
<< tree.skeletonType << ": " << tree.dmgBinds.size()
<< " material binding(s)\n" << std::flush;
}
void
BTL4VideoRenderer::TickArmourDamage(Entity *entity)
{
std::map<Entity*, MechRenderTree>::iterator tree_it =
mMechRenderTrees.find(entity);
if (tree_it == mMechRenderTrees.end())
return;
MechRenderTree &tree = tree_it->second;
if (tree.dmgBinds.empty())
return;
// A wrecked mech has been swapped to the <mech>dbr hulk -- the bound segment
// objects are gone from the draw; leave them alone.
if (tree.wrecked)
return;
const int log = (getenv("BT_ARMOR_LOG") != NULL);
// BENCH (BT_ARMOR_FORCE=<0..1>): pin EVERY bound zone to a fixed level, so the
// same scene can be rendered undamaged and fully-damaged and diffed pixel-wise
// without smoke, motion or death confounding the comparison.
static int s_forceInit = 0;
static int s_forceOn = 0;
static float s_forceLevel = 0.0f;
if (!s_forceInit)
{
s_forceInit = 1;
const char *fv = getenv("BT_ARMOR_FORCE");
if (fv != NULL && *fv != '\0')
{
s_forceOn = 1;
s_forceLevel = (float)atof(fv);
}
}
for (size_t i = 0; i < tree.dmgBinds.size(); ++i)
{
MechRenderTree::DmgBind &bind = tree.dmgBinds[i];
if (bind.zone < 0 || bind.zone >= entity->damageZoneCount)
continue;
DamageZone *zone = entity->damageZones[bind.zone];
if (zone == NULL || bind.obj == NULL)
continue;
L4DRAWOP *draw_op = bind.obj->GetDrawOp(bind.op);
const float level = s_forceOn ? s_forceLevel : (float)zone->damageLevel;
// Change-gated, like the binary's watcher Perform (FUN_00457784 compares the
// cached level against the live one and only then re-pushes the colours).
if (log && fabs(level - draw_op->dzDamageLevel) > 0.001f)
DEBUG_STREAM << "[armor] '" << draw_op->dzMatName << "' zone "
<< bind.zone << " level " << draw_op->dzDamageLevel
<< " -> " << level << " (brightness "
<< (1.0f - (1.0f - kDamagedMaterialScale) * (level > 1.0f ? 1.0f : level))
<< "x)\n" << std::flush;
draw_op->dzDamageLevel = level;
}
}
//
// Sim-side bridge (per-mech, every frame, from Mech::PerformAndWatch).
//
void BTArmourDamageTick(Entity *mech)
{
if (mech == NULL || application == NULL)
return;
BTL4VideoRenderer *renderer =
(BTL4VideoRenderer *)application->GetVideoRenderer();
if (renderer == NULL)
return;
renderer->TickArmourDamage(mech);
}
//
// Sim-side bridge (UpdateDeathState drives the sink each dead frame).
//
@@ -2673,8 +2883,21 @@ int
}
r->second->SetDrawObj(obj);
render_tree.segGState[slot] = (int)gstate;
// Keep the per-segment geometry record in step with what is actually drawn
// (the armour-damage bindings and the #73 pick both key off it).
if (obj != NULL && obj->GetIsShadow() == 0)
{
MechRenderTree::SegPick sp;
sp.obj = obj;
sp.zone = zone_index;
render_tree.segPick[slot] = sp;
}
else
render_tree.segPick.erase(slot);
if (obj) ++shown; else ++hidden;
}
// This reloaded every segment mesh, so every armour-damage binding is stale.
BindArmourDamage(viewpoint, render_tree);
if (reinstall_paint)
{
TearDownMaterialSubstitutionList();