#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
+5 -1
View File
@@ -48,7 +48,11 @@ spec is `docs/ASSET_PIPELINE.md`. Full list: `docs/PROGRESS_LOG.md §5`.
## Collision / damage / subsystems
- **`.SLD`** collision solids (Block/YCyl/Cone/Ramp/Wedge). Terrain is modeled AS collision volumes;
see [[locomotion]] ground model + [[combat-damage]]. [T1]
- **`.DZM`** = INI damage-zone→material map (mech skin damage states). **`.DMG`/`.TBL`** damage
- **`.DZM`** = INI damage-zone→material map (mech skin damage states) — `[dz_<zone>]` sections each
listing `material=<lib>:<name>_mtl`. One file per SKIN VARIANT (`dzm`/`dzms`/`dzmt`/`dzmo`/`dzma`/
`dzmb`/`dzmc`/`dzmd`). This is the ARMOUR-DARKENING registry: the renderer lerps every listed
material toward 0.1× as that zone's `damageLevel` runs 0→1. Loaded by MUNGA's `DamageZone` stream
ctor into `materialTable` (`GetMaterialList(skl_type)`); full chain in [[rendering]]. [T1] **`.DMG`/`.TBL`** damage
zones/tables, **`.SUB`** subsystems (weapon params — DischargeTime/RechargeRate/WeaponRange),
**`.CTL`** control maps. [T1]
+16
View File
@@ -474,6 +474,8 @@ default-ON (`'0'` disables).
| `BT_POWER_DETACH_TEST=<name\|1>` | 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=<n>` | 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_SELF_DAMAGE_ZONE=<n>` | aim the BT_SELF_DAMAGE harness at an explicit zone (-1/unset = the lottery) -- ramps one zone deterministically past thresholds (#78 bench) |
| `BT_ARMOR_LOG` | mech ARMOUR-DARKENING trace (#87): per-zone `.DZM` list -> draw-op binding counts at tree build, any `unmatched` material name, and every change of a bound zone's level with the resulting brightness |
| `BT_ARMOR_FORCE=<0..1>` | pin EVERY bound armour zone to a fixed damage level, so the same scene can be rendered pristine and fully-damaged and diffed pixel-wise (`scratchpad/night7/armor_ab.sh`) |
| `BT_DRIVE_LOG` | the demand-site drive scale: `[drive] n/drive/mm/dmd/mech` ~1Hz (drive = myomers speedEffect x gimp; mm = the gimp level via BTMechGimpLevel) |
| `BT_GIMP_SPEED=<0..1>` | EXTRA gimp demand multiplier (default 1.0 = authentic since 2026-07-30: the real slowdown is the gimp gait machines' clamp to the wg clip speed — see [[locomotion]] §visible limp; the old 0.5 T3 stand-in is retired) |
| `BT_PICK_LOG` | #73 aimed-pick diagnostics: `[segpick]` the segment→zone map at tree build (index, name, zone, sphere), `[pickwin]` the winning zone/score/t per pick (score ~0 = threading the part core, ~1 = envelope graze) |
@@ -497,6 +499,20 @@ recoil @4bc136-4bc19c (damage>3 → (0,0.6,1.5) × damage/16; `gyroRumbleTime
ex-"clipLoadGuard"). Byte tooling: `scratchpad/dis_4b2980.py` / `dis_range.py` over
`reference/decomp/section_dump.txt`.
**ARMOUR-DARKENING addresses** [T1] (#87; full story: [[rendering]]): `.DZM` res compiler
`FUN_0041e4e0` (part_002.c:7341, reads the `material` key list per `[dz_*]` section); runtime
consumer inside `BTL4VideoRenderer::MakeMechRenderables` **`FUN_004cef28`** (part_014.c:5190-5272);
per-zone node ctor `FUN_00455c7c(...,0.04f)`; the per-material watcher
**`FUN_004573e4(material, &zone->damageLevel, 0.1f)`** (part_007.c:9133, vtable `PTR_LAB_004f1730`,
232 bytes) with Perform **`FUN_00457784`** (lerp constant `@0x4579a4` = 1.0; damaged factor
`0x3dcccccd` = 0.1). Material accessors: `dpl_GetMaterialAmbient` `FUN_0048baf4` /
`Set` `FUN_0048ba98`, `Emissive` `FUN_0048bbb0`/`FUN_0048bb54`, `Diffuse` `FUN_0048bc6c`/
`FUN_0048bc10`, `Specular`(4) `FUN_0048bd2c`/`FUN_0048bccc`, `Opacity` `FUN_0048bdf0` (READ ONLY --
lerped but never pushed), flush `FUN_0048d4d4`; `dpl_SetMaterialRamp` `@0x48bfb0` (writes
`material+0x1c`; ONE caller, load-time only). Material lookup by name `FUN_00497678`. The UNUSED
sibling path: `dpl_Damagize` `FUN_004902b0` (tokens `{0x92,0x05,0x04}`), `SV_SPECIAL DAMAGE`
dispatch @0x4599ae -- **zero shipped BGFs carry a DAMAGE token**.
**PUNCH firmware decode** [T1] (the definitive mechanism, 2026-07-11): cmd 0x20 =
vr_damage_action (VR_PROT.H enum); the i860 firmware content/VREND.MNG (code @0xf0400000)
handler 'damageize' @0xf040f6f8 writes the token triple onto the geogroup's first THREE
+18
View File
@@ -737,3 +737,21 @@ a sibling scalar — read it through the object that owns it, or (if a duplicate
exists) make every gate read BOTH and every writer write BOTH. Sibling of gotcha #1 (shadowed
base field): same failure shape — two cells where the binary has one, and the readers pick the
dead one.
## §23 — Scaling `D3DMATERIAL9` does NOTHING for BGF geometry (the vertex-colour diffuse source)
**(2026-07-31, gitea #87 "mech armour panels don't darken".)** The 1995 armour damage darkens a
mech by scaling its MATERIALS' colour terms (`dpl_SetMaterialAmbient/Emissive/Diffuse/Specular`).
The obvious port translation — scale the draw op's `D3DMATERIAL9` before `SetMaterial` — renders
**byte-identically**: measured **0 changed pixels** at a full 0.1x scale.
**Why:** D3D9 defaults `D3DRS_DIFFUSEMATERIALSOURCE` to **`D3DMCS_COLOR1`** (and `D3DRS_COLORVERTEX`
to TRUE), so when a vertex carries a diffuse colour the material's Diffuse is never consulted.
Every BGF vertex carries a baked colour (that IS the 1995 shading model — no-normal geometry is
unlit and coloured by vertex/ramp), so for essentially all world geometry `SetMaterial` is inert
for colour. The material only matters for lit, normal-bearing meshes.
**Fix pattern:** modulate the FINAL fragment instead — `D3DRS_TEXTUREFACTOR` + a
`MODULATE(CURRENT, TFACTOR)` on texture **stage 1** (unused in `d3d_OBJECT::Draw`), restored to
`D3DTOP_DISABLE` after the op. That darkens the result whatever the diffuse source was, and
equally covers the ramp-baked-texture path and pure-emissive batches.
**Detection smell:** a colour change that logs perfectly at the source and produces zero visible
difference. Diff two runs pixel-wise against a control region before believing a colour path
works — "the log says 0.1x" is not evidence that anything reached the screen.
+51
View File
@@ -321,6 +321,57 @@ Diags: `BT_CULT_LOG` (census/damage/death + `[cultvis]` + `[cultobj]` per-object
flags). MP: replicants transition via `ReadUpdateRecord` → same SetState watcher path (untested
cross-pod).
## Mech ARMOUR DARKENING (the .DZM material-damage system) — RECONSTRUCTED 2026-07-31 (Gitea #87) [T2]
A mech's armour panels **darken as their damage zone takes damage** — a per-zone, per-material
colour ramp-down, NOT a texture swap, a filter, or geometry. Players reported the port showing only
the limb swap ("the actual enemy mech in external view is not showing darkened armor panels").
**The 1995 chain** [T1]:
- **`.DZM`** (`VIDEO\<mech>SKIN.DZM`, 40 shipped) lists, per damage zone, the materials that zone
paints: `[dz_ltorso] material=avaskin:avat2_dz_ltorso_mtl`. One file per SKIN VARIANT — the
`dzm`/`dzms`/`dzmt`/`dzmo`/`dzma`/`dzmb`/`dzmc`/`dzmd` key family (same suffix set as the
`destroyed``destroyedd` video variants); the res compiler is `FUN_0041e4e0`.
- Compiled into BTL4.RES and parsed by **MUNGA's own `DamageZone` stream ctor** into
`materialTable`, keyed by `EntitySegment::SkeletonType` (`engine/MUNGA/DAMAGE.cpp:311-336`,
`GetMaterialList(skl_type)`) — real engine source [T0]. **This data was loaded and unused in the
port all along**; only the consumer was missing.
- **`BTL4VideoRenderer::MakeMechRenderables` (`FUN_004cef28`)** walks the zones and builds, per
(zone, material), a 232-byte watcher **`FUN_004573e4(material, &zone->damageLevel, 0.1f)`**
(a 60-byte per-zone parent first, factor 0.04). Error string: *"BTL4VideoRenderer: Material name
<X> could not be found"* @0x51d6f8.
- The watcher snapshots the material's **16 authored colour floats** — Ambient(3), Emissive(3),
Diffuse(3), Specular+shininess(4), Opacity(3) — precomputes a DAMAGED set = **pristine x 0.1**
(`0x3dcccccd`), and **`FUN_00457784`** re-pushes `lerp(pristine, damaged, damageLevel)` whenever
the level changes (constant `@0x4579a4` = **1.0**). So a zone at full damage renders at **10%**
of authored brightness, continuously interpolated.
- **Faithful quirk:** Opacity is read and lerped but **never written back** — only 13 of the 16
values are pushed (`dpl_GetMaterialOpacity` has no matching Set call in the watcher).
**NOT the mechanism** [T2]: the engine also has `SV_SPECIAL DAMAGE <material>` /
**`dpl_Damagize` `FUN_004902b0`** (punch's sibling — same VPX cmd 0x20, tokens
`f_t_dam(0x92)/sca(0x05)/undamage(0x04)`, dispatch @0x4599ae; error *"SPECIAL GEOGROUP DAMAGE
couldn't find material"*). A scan of **all 879 shipped BGFs finds ZERO `DAMAGE` tokens** — the path
exists in the engine but no shipped asset uses it. Don't chase it.
**The port** (`game/reconstructed/btl4vid.cpp` `BindArmourDamage`/`TickArmourDamage`,
`engine/MUNGA_L4/L4D3D.cpp`): batches now carry their AUTHORED (pre-substitution) material name
(`BgfDrawBatch::matName` -> `L4DRAWOP::dzMatName`); binding resolves the .DZM lists onto real draw
ops once per tree build, and the per-frame tick (driven from `Mech::PerformAndWatch`, EVERY mech)
copies `zone->damageLevel` onto them. Two hard-won details:
- **Key the material list on `viewSkeleton`, not the build `skeletonType`.** Each skeleton variant
is painted from its own skin library (Black Hawk N = `blhskin:`, X = `blxskin:`) and
`ApplyViewSkeleton` reloads every segment mesh; keying on the build skeleton matched a list
belonging to geometry that is not on screen -> **zero bindings, no darkening**.
- **Scaling the `D3DMATERIAL9` is inert** — see gotcha **§23** (`DIFFUSEMATERIALSOURCE` defaults to
vertex colour). The darkening is applied as a `TEXTUREFACTOR` modulate on texture stage 1.
Rebinding runs after every geometry swap (`RemakeEntityRenderables`, `ApplyViewSkeleton`), which
also keeps `segPick` (the #73 aimed pick) off discarded meshes.
Bench: `scratchpad/night7/armor_darken.sh` (self/enemy/ext legs) + `armor_ab.sh` (forced-level
pixel A/B). Gates `BT_ARMOR_LOG` / `BT_ARMOR_FORCE=<0..1>`. Verified: 57 bindings on the enemy
mech, brightness tracking `1 - 0.9*level` exactly, hull 0.35x vs terrain control 0.98x at level 1.
The `damcolor_`/`gen*gry_` arm+gun names go unmatched until the DESTROYED limb variant loads —
correct: those materials live on the `*D*.BGF` damaged meshes.
## Key Relationships
- Geometry/LOD: [[bgf-format]]. Base: [[wintesla-port]] (L4D3D). Shadow/visual-conform: [[locomotion]].
- Renders on: [[pod-hardware]] (main 3D view).
+45
View File
@@ -393,6 +393,17 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName)
object->mDrawOps[i].lodNear = data.batches[i].lodNear;
object->mDrawOps[i].lodFar = data.batches[i].lodFar;
object->mDrawOps[i].lodDepthBias = data.batches[i].lodBias;
// MECH ARMOUR DAMAGE (issue #87): carry the authored material name so the
// mech's .DZM zone->material lists can drive this batch's darkening.
{
const std::string &mn = data.batches[i].matName;
size_t n = mn.size();
if (n >= sizeof(object->mDrawOps[i].dzMatName))
n = sizeof(object->mDrawOps[i].dzMatName) - 1;
memcpy(object->mDrawOps[i].dzMatName, mn.c_str(), n);
object->mDrawOps[i].dzMatName[n] = '\0';
object->mDrawOps[i].dzDamageLevel = 0.0f;
}
const bool useRamp = (s_ramp && data.batches[i].hasRamp);
uint32_t c = data.batches[i].color;
@@ -1198,6 +1209,33 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
mDevice->SetMaterial(&drawOp->material);
// MECH ARMOUR DAMAGE (issue #87): the 1995 renderer kept a per-material
// watcher that lerped the material's colour terms from the authored values
// toward 0.1x as the owning damage zone's damageLevel ran 0->1
// (FUN_004573e4 precomputed pristine*0.1; FUN_00457784 re-pushed
// lerp(pristine, damaged, level) on every change, constant @0x4579a4 = 1.0).
//
// We cannot express that by scaling this op's D3DMATERIAL9: D3D9 defaults
// DIFFUSEMATERIALSOURCE to D3DMCS_COLOR1, and every BGF vertex carries a
// baked colour, so the material's Diffuse is never consulted for this
// geometry -- scaling it renders identically (measured: 0 changed pixels).
// Instead modulate the FINAL colour by k on texture stage 1, which darkens
// the result whatever the diffuse source was, and equally covers the
// ramp-baked-texture path (the mech's own case) and pure-emissive batches.
const bool damageTint = (drawOp->dzDamageLevel > 0.0f);
if (damageTint)
{
float d = drawOp->dzDamageLevel;
if (d > 1.0f) d = 1.0f;
const float k = 1.0f - (1.0f - kDamagedMaterialScale) * d; // lerp(1, 0.1, d)
mDevice->SetRenderState(D3DRS_TEXTUREFACTOR, D3DCOLOR_COLORVALUE(k, k, k, 1.0f));
mDevice->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_MODULATE);
mDevice->SetTextureStageState(1, D3DTSS_COLORARG1, D3DTA_CURRENT);
mDevice->SetTextureStageState(1, D3DTSS_COLORARG2, D3DTA_TFACTOR);
mDevice->SetTextureStageState(1, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1);
mDevice->SetTextureStageState(1, D3DTSS_ALPHAARG1, D3DTA_CURRENT);
}
#ifndef RP3_EMULATE
SetTextureScrolling(&(drawOp->texture), targetRenderFrame);
SetTexture(drawOp->texture.texture);
@@ -1337,6 +1375,13 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, sVAop);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, sVAa2);
}
if (damageTint)
{
// stage 1 is otherwise unused in this draw path -- put it back to
// DISABLE so the next (undamaged) op is not tinted too.
mDevice->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE);
mDevice->SetTextureStageState(1, D3DTSS_ALPHAOP, D3DTOP_DISABLE);
}
if (drawOp->lodDepthBias != 0.0f)
mDevice->SetRenderState(D3DRS_DEPTHBIAS, sDB);
}
+7
View File
@@ -619,6 +619,9 @@ struct Builder {
uint32_t currentColor = 0xFFB0B0B8u;
bool currentHasDiffuse = false; // material carried an explicit diffuse tag
std::string currentTex;
// authored (pre-substitution) "library:material_mtl" of the material in force --
// the .DZM armour-damage lists key on this (issue #87)
std::string currentMatName;
int currentTexChannel = 0; // BSL bit-slice (BMF tag 0x18)
bool currentTexScroll = false; // TEXTURE SPECIAL " SCROLL" (tag 0x2037)
float currentTexScrollU = 0.0f, currentTexScrollV = 0.0f;
@@ -872,6 +875,7 @@ struct Builder {
batch.color = pureEmissive ? currentColor // keep BLACK: L4D3D's
: (useRamp ? rampTint : currentColor); // pure-emissive test keys on it
batch.texPath = currentTex;
batch.matName = currentMatName; // .DZM armour-damage key (issue #87)
batch.texChannel = currentTexChannel;
batch.texScroll = currentTexScroll;
batch.texScrollU = currentTexScrollU;
@@ -1185,6 +1189,9 @@ struct Builder {
size_t maxn = ch.len - 1, n = 0;
while (n < maxn && s[n]) ++n;
std::string full(s, n);
// remember the AUTHORED name (before the paint callback rewrites
// it) -- the .DZM zone material lists are authored against it
currentMatName = full;
// baked ground-shadow material (basev:shadow_mtl etc.)
currentShadowMat = false;
for (size_t si = 0; si + 6 <= full.size(); ++si)
+11
View File
@@ -106,6 +106,17 @@ struct BgfDrawBatch {
// routed through the mech-shadow pipeline (translucent dark, depth-biased,
// no z-write) instead.
bool shadowMat = false;
// AUTHORED MATERIAL NAME ("library:material_mtl", e.g. "avaskin:avat2_dz_ltorso_mtl"),
// recorded PRE-substitution -- the per-pilot paint callback rewrites names, but the
// .DZM damage-zone material lists are authored against these base names.
//
// This is the key the mech ARMOUR-DARKENING system matches on (issue #87): the 1995
// BTL4VideoRenderer built one material-damage watcher per (zone, material) pair
// (FUN_004573e4) that lerped the material's colour terms from pristine toward 0.1x
// as that zone's damageLevel ran 0->1. Our draw path bakes colour into a
// D3DMATERIAL9 at load, so the equivalent modulation is applied per draw op --
// see L4D3D d3d_OBJECT::SetDamageScaleForMaterials.
std::string matName;
};
struct BgfData {
+18
View File
@@ -71,6 +71,12 @@ struct L4RAMP
float r1, g1, b1;
};
// MECH ARMOUR DAMAGE (issue #87): the factor the 1995 material-damage watcher
// (FUN_004573e4) precomputed as the FULLY-DAMAGED colour -- pristine x 0.1, the
// literal 0x3dcccccd passed at every construction site. A zone at damageLevel 1
// renders its panels at 10% of the authored brightness.
const float kDamagedMaterialScale = 0.1f;
struct L4DRAWOP
{
D3DMATERIAL9 material;
@@ -112,6 +118,18 @@ struct L4DRAWOP
// coplanar duplicated surfaces resolve to the detail layer instead of
// venetian-blind z-fighting (the board's submission-order rule, in D3D terms).
float lodDepthBias;
// MECH ARMOUR DAMAGE (issue #87). The 1995 BTL4VideoRenderer bound one
// material-damage watcher per (damage zone, material) pair -- FUN_004573e4 --
// which lerped that material's colour terms from their authored values toward
// 0.1x as the zone's damageLevel ran 0->1 (FUN_00457784 re-pushed them whenever
// the level changed). The zone->material lists come from the per-mech .DZM
// files, already parsed into DamageZone::materialTable (MUNGA DAMAGE.cpp).
//
// Our draw path bakes colour into this D3DMATERIAL9 at load, so the equivalent
// modulation is applied at SetMaterial time from the level cached here.
// Both fields are memset-0 safe: empty name = not zone-mapped, level 0 = pristine.
char dzMatName[64]; // authored "library:material_mtl" for this batch
float dzDamageLevel; // owning zone's damageLevel [0..1] (0 = undamaged)
};
class d3d_OBJECT
+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();
+20
View File
@@ -712,6 +712,14 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
// (per-segment bounding spheres on the live posed skeleton).
struct SegPick { d3d_OBJECT *obj; int zone; };
std::map<int, SegPick> segPick; // segment idx -> pickable
// ARMOUR DARKENING (issue #87): the resolved (object, damage zone)
// bindings for this mech -- one entry per draw object that carries at
// least one material listed in a zone's .DZM material list. Built once
// with the tree (BindArmourDamage), replayed each frame by
// TickArmourDamage. The binary's equivalent is the per-material
// watcher list it builds in MakeMechRenderables (FUN_004573e4).
struct DmgBind { d3d_OBJECT *obj; int op; int zone; };
std::vector<DmgBind> dmgBinds;
std::map<int, int> segGState; // slot -> last applied graphic state
char paintSerno; // the %serno% this mech was BUILT with
// (0 = none) -- ApplyViewSkeleton re-parses
@@ -732,6 +740,18 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
int
TickWreck(Entity *victim, float dt);
//
// ARMOUR DARKENING (issue #87). BindArmourDamage resolves the mech's
// .DZM zone->material lists (DamageZone::GetMaterialList, already parsed
// by MUNGA's DamageZone stream ctor) against the draw ops of the segment
// geometry just loaded, recording one binding per match. TickArmourDamage
// copies each bound zone's live damageLevel onto those ops.
//
void
BindArmourDamage(Entity *entity, MechRenderTree &tree);
void
TickArmourDamage(Entity *entity);
//
// VIEW TOGGLE: the player's mech builds BOTH cameras -- the authentic
// cockpit eyepoint (DPLEyeRenderable at 'siteeyepoint', the pod's only
+11
View File
@@ -2379,6 +2379,17 @@ void
if (deathHandler != 0)
((MechDeathHandler *)deathHandler)->Tick();
// ARMOUR DARKENING (issue #87): push this mech's per-zone damageLevel onto the
// materials that zone paints, so the panels darken toward 0.1x as they take
// damage. The binary hung one watcher per (zone, material) off the mech's
// renderable (FUN_004573e4, built in MakeMechRenderables) and let the renderer
// Perform them; our equivalent update is driven from here for EVERY mech --
// the enemy mech in external view is exactly the case players reported.
{
extern void BTArmourDamageTick(Entity *mech);
BTArmourDamageTick((Entity *)this);
}
// WAVE 7 Phase B: advance/render/impact the flying projectiles once per frame (viewpoint).
if (isPlayerMech && dt > 0.0001f)
BTUpdateProjectiles(dt);
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# #87 PIXEL A/B: render the SAME standing scene twice -- every armour zone pinned
# to level 0 (pristine) and level 1 (fully damaged) via BT_ARMOR_FORCE -- so the
# only difference between the two frame sets is the darkening itself. No damage,
# no smoke, no motion, no death to confound the comparison.
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
sed "s/^map=.*/map=grass/; s/^time=.*/time=day/" MP.EGG > ARMOR.EGG
run () { # run <level> <prefix>
taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 2
rm -f ${2}_*.png
( bt_player_env
unset BT_START_INSIDE # presence-only gate -- external view
export BT_LOG=armor_ab_$2.log BT_AFFINITY=0x03
export BT_ARMOR_FORCE=$1 BT_ARMOR_LOG=1
export BT_SHOT_EVERY=400 BT_SHOT_PREFIX=$2
"$BT_EXE" -egg ARMOR.EGG & )
sleep 45
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
}
run 0.0 abpristine
run 1.0 abdamaged
ls abpristine_*.png abdamaged_*.png 2>/dev/null | head
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# #87 bench: MECH ARMOUR DARKENING.
#
# The 1995 renderer lerped every material a damage zone paints from its authored
# colour toward 0.1x as that zone's damageLevel ran 0->1 (FUN_004573e4 built one
# watcher per (zone, material) from the mech's .DZM list; FUN_00457784 re-pushed
# the blend on every change). Our renderer swapped destroyed LIMB meshes but
# never darkened a panel -- "the actual enemy mech in external view is not
# showing darkened armor panels".
#
# Two legs:
# self -- self-damage one named zone on the player's own mech; the cleanest
# read of the level->brightness chain (deterministic, one zone).
# enemy -- walk up to a spawned target and shoot it: the REPORTED case, an
# enemy hull darkening under fire, with screenshots to pixel-verify.
#
# Verdict lines:
# [armor] zone N '<dzname>' -> M draw op(s) .DZM list resolved onto geometry
# [armor] '<mat>' zone N level a -> b (brightness kx) level reaching the ops
#
# usage: armor_darken.sh [self|enemy] [zone]
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
LEG="${1:-self}"
ZONE="${2:-dz_ltorso}"
LOG=armor_${LEG}.log
rm -f "$LOG" armorshot_*.tga
sed "s/^map=.*/map=grass/; s/^time=.*/time=day/" MP.EGG > ARMOR.EGG
if [ "$LEG" = "enemy" ]; then
# The reported case: an ENEMY hull taking fire. Beeline to the spawned target
# and hold the trigger; BT_SHOT_EVERY dumps backbuffer frames so the panels can
# be compared pixel-wise before/after.
BT_ARMOR_LOG=1 BT_DMG_LOG=1 \
BT_SPAWN_ENEMY=1 BT_GOTO=enemy BT_GOTO_STOP=2 BT_AUTOFIRE=1 \
BT_SHOT_EVERY=300 BT_SHOT_PREFIX=armorshot \
bt_launch "$LOG" ARMOR.EGG 0x03
elif [ "$LEG" = "ext" ]; then
# PIXEL leg. Same single-zone ramp, but in the EXTERNAL chase camera so the
# hull fills the frame and one zone can be compared before/after.
#
# NOTE: this deliberately overrides the shipped player env's BT_START_INSIDE=1
# (bench_common) -- players start in the cockpit and cannot see their own hull.
# This leg exists ONLY to photograph the panels; the `self` and `enemy` legs
# are the player-authentic ones.
( bt_player_env
# presence-only gate (btl4vid.cpp:657) -- "=0" still reads as set, so UNSET it
unset BT_START_INSIDE
export BT_LOG="$LOG" BT_AFFINITY=0x03
export BT_ARMOR_LOG=1 BT_DMG_LOG=1
export BT_SELF_DAMAGE=3 BT_SELF_DAMAGE_ZONE="$ZONE"
export BT_SHOT_EVERY=600 BT_SHOT_PREFIX=armorext
"$BT_EXE" -egg ARMOR.EGG & )
else
# Deterministic single-zone ramp on our own mech. Small dose so the level
# climbs in steps instead of saturating in one tick.
BT_ARMOR_LOG=1 BT_DMG_LOG=1 \
BT_SELF_DAMAGE=3 BT_SELF_DAMAGE_ZONE="$ZONE" \
bt_launch "$LOG" ARMOR.EGG 0x03
fi
sleep 90
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
echo "=== per-entity binding totals ==="
grep -E "^\[armor\] entity" "$LOG" | sort -u
echo "=== level changes ==="
grep -E "^\[armor\] '" "$LOG" | head -30
echo "=== distinct materials darkened ==="
grep -E "^\[armor\] '" "$LOG" | sed "s/.*\['armor'\] //" | awk '{print $2}' | sort -u | head -20
echo "=== shots ==="
ls armorshot_*.tga 2>/dev/null | head
+57
View File
@@ -0,0 +1,57 @@
"""Raw-image xref sweep (gotcha #21: the decomp export has gaps).
Finds every CALL/JMP rel32 in .text whose target is one of the VAs given on the
command line, plus any absolute immediate referencing them.
python xref.py 0x4902b0 0x490308
"""
import struct, sys
PATH = r"C:\git\bt411\content\BTL4OPT.EXE"
data = open(PATH, "rb").read()
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
coff = e_lfanew + 4
num_sec = struct.unpack_from("<H", data, coff + 2)[0]
opt_size = struct.unpack_from("<H", data, coff + 16)[0]
opt = coff + 20
image_base = struct.unpack_from("<I", data, opt + 28)[0]
sec_tbl = opt + opt_size
secs = []
for i in range(num_sec):
off = sec_tbl + i * 40
name = data[off:off + 8].rstrip(b"\0").decode("latin1")
vsize, va, rawsize, rawptr = struct.unpack_from("<IIII", data, off + 8)
secs.append((name, va, vsize, rawptr, rawsize))
def off_to_va(off):
for name, va, vsize, rawptr, rawsize in secs:
if rawptr <= off < rawptr + rawsize:
return image_base + va + (off - rawptr)
return None
targets = set(int(a, 16) for a in sys.argv[1:])
hits = []
for name, va, vsize, rawptr, rawsize in secs:
blob = data[rawptr:rawptr + rawsize]
base_va = image_base + va
# rel32 CALL (E8) / JMP (E9)
for i in range(len(blob) - 5):
op = blob[i]
if op not in (0xE8, 0xE9):
continue
rel = struct.unpack_from("<i", blob, i + 1)[0]
tgt = base_va + i + 5 + rel
if tgt in targets:
hits.append((base_va + i, "call" if op == 0xE8 else "jmp", tgt, name))
# absolute immediates (vtable slots, push offset)
for i in range(len(blob) - 4):
v = struct.unpack_from("<I", blob, i)[0]
if v in targets:
hits.append((base_va + i, "abs32", v, name))
for addr, kind, tgt, sec in sorted(hits):
print("%08x %-6s -> %08x [%s]" % (addr, kind, tgt, sec))
print("\n%d xrefs" % len(hits))