From 137c15195136e0439c3dab2e0c79b4044cd90a52 Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Fri, 31 Jul 2026 19:52:17 -0500 Subject: [PATCH] #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 --- context/asset-formats.md | 6 +- context/decomp-reference.md | 16 +++ context/reconstruction-gotchas.md | 18 +++ context/rendering.md | 51 +++++++ engine/MUNGA_L4/L4D3D.cpp | 45 ++++++ engine/MUNGA_L4/bgfload.cpp | 7 + engine/MUNGA_L4/bgfload.h | 11 ++ engine/MUNGA_L4/l4d3d.h | 18 +++ game/reconstructed/btl4vid.cpp | 223 ++++++++++++++++++++++++++++++ game/reconstructed/btl4vid.hpp | 20 +++ game/reconstructed/mech4.cpp | 11 ++ scratchpad/night7/armor_ab.sh | 29 ++++ scratchpad/night7/armor_darken.sh | 78 +++++++++++ scratchpad/night7/xref.py | 57 ++++++++ 14 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 scratchpad/night7/armor_ab.sh create mode 100644 scratchpad/night7/armor_darken.sh create mode 100644 scratchpad/night7/xref.py diff --git a/context/asset-formats.md b/context/asset-formats.md index b9327ba..59aba33 100644 --- a/context/asset-formats.md +++ b/context/asset-formats.md @@ -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_]` sections each + listing `material=:_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] diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 3ef2be5..9c52bf5 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -474,6 +474,8 @@ default-ON (`'0'` disables). | `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_SELF_DAMAGE_ZONE=` | 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 diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index a23806b..fddcf08 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -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. diff --git a/context/rendering.md b/context/rendering.md index def0d84..92b2dc6 100644 --- a/context/rendering.md +++ b/context/rendering.md @@ -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\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 + 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 ` / +**`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). diff --git a/engine/MUNGA_L4/L4D3D.cpp b/engine/MUNGA_L4/L4D3D.cpp index 7dbc039..42a7faf 100644 --- a/engine/MUNGA_L4/L4D3D.cpp +++ b/engine/MUNGA_L4/L4D3D.cpp @@ -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); } diff --git a/engine/MUNGA_L4/bgfload.cpp b/engine/MUNGA_L4/bgfload.cpp index 197bc6e..2bb2ca3 100644 --- a/engine/MUNGA_L4/bgfload.cpp +++ b/engine/MUNGA_L4/bgfload.cpp @@ -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) diff --git a/engine/MUNGA_L4/bgfload.h b/engine/MUNGA_L4/bgfload.h index 32e2d8e..a06b275 100644 --- a/engine/MUNGA_L4/bgfload.h +++ b/engine/MUNGA_L4/bgfload.h @@ -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 { diff --git a/engine/MUNGA_L4/l4d3d.h b/engine/MUNGA_L4/l4d3d.h index 55d6f9e..5ea36ce 100644 --- a/engine/MUNGA_L4/l4d3d.h +++ b/engine/MUNGA_L4/l4d3d.h @@ -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 diff --git a/game/reconstructed/btl4vid.cpp b/game/reconstructed/btl4vid.cpp index bbbb34f..65634dd 100644 --- a/game/reconstructed/btl4vid.cpp +++ b/game/reconstructed/btl4vid.cpp @@ -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::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\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::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::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 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(); diff --git a/game/reconstructed/btl4vid.hpp b/game/reconstructed/btl4vid.hpp index 195a3cd..c9ad5ec 100644 --- a/game/reconstructed/btl4vid.hpp +++ b/game/reconstructed/btl4vid.hpp @@ -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 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 dmgBinds; std::map 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 diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index c9f9f4d..0429eb5 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -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); diff --git a/scratchpad/night7/armor_ab.sh b/scratchpad/night7/armor_ab.sh new file mode 100644 index 0000000..6818d53 --- /dev/null +++ b/scratchpad/night7/armor_ab.sh @@ -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 + 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 diff --git a/scratchpad/night7/armor_darken.sh b/scratchpad/night7/armor_darken.sh new file mode 100644 index 0000000..a93980b --- /dev/null +++ b/scratchpad/night7/armor_darken.sh @@ -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 '' -> M draw op(s) .DZM list resolved onto geometry +# [armor] '' 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 diff --git a/scratchpad/night7/xref.py b/scratchpad/night7/xref.py new file mode 100644 index 0000000..974bf30 --- /dev/null +++ b/scratchpad/night7/xref.py @@ -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(" %08x [%s]" % (addr, kind, tgt, sec)) +print("\n%d xrefs" % len(hits))