Oracle: "no range finder on this drop" + a screenshot -- tick marks present,
moving caret absent, one drop, only tester affected.
Not the host, not the chassis, not his destroyed HUD. From the four field
logs: he WAS hosting (`[lobby] host:` appears only in his log) but range
computed fine on his node (1806 nonzero samples) and the reticle built on all
6 drops; a second tester flew a Thor the same night without hosting and saw
nothing, and the ladder is shared HudSimulation/BTReticleRenderable, not
per-chassis content; his HUD was destroyed twice but for 11s and 26s only, and
a destroyed HUD costs the LOCK (_DAT_004b7ec4 = 0.75), not the caret.
THE DEFECT. sShownRange -- what the caret binds to -- is a function-level
static in mech4's targeting step: one cell for the whole process, shared by
every mech, carried across drops, never re-seeded. NaN is ABSORBING in
step = trueRange - sShownRange;
if (step > maxStep) step = maxStep; // false for NaN
if (step < -maxStep) step = -maxStep; // false for NaN
sShownRange += step;
so one poisoned frame makes it NaN for the life of the process. The consumer
repeats the mistake -- BTReticleRenderable::Draw clamps with the same two
comparisons -- so NaN reaches AddPoint/ConcatMatrix and the caret + its bar
become degenerate geometry that STOPS RENDERING, while every static reticle
element including the tick marks still draws. That is the reported symptom
exactly, and it is sticky until relaunch.
WHY NO LOG COULD SETTLE IT. The caret's actual input had NO diagnostic
anywhere: BT_RANGE_LOG instruments the PICK (#4), and [target]'s `range=` is a
SEPARATE locally-recomputed Sqrt in the weapon-range check -- neither is
sShownRange or gBTHudRangeStorage. Grepping the field logs for NaN returns
nothing because the poisoned variable was never printed. Absence of the
signal was not evidence of absence.
FIX (4 parts):
1. re-seed sShownRange when the viewpoint mech CHANGES, so a new drop starts
at the binary's 1200 default. Deliberately NOT on respawn -- that reuses
the entity, and the binary does not reset the readout on respawn either.
2. producer NaN trap -> re-seed to 1200 instead of propagating.
3. NaN-safe consumer clamp (test x == x first) -> fall back to the authentic
no-target peg rather than rendering nothing.
4. BT_RANGE_LOG now prints the caret's real input at 1 Hz plus a
"[range] NaN TRAPPED" receipt, so the next field log CAN settle it.
STATUS [T3 on the field link]. The defect and the symptom match exactly and
the fix stands on its own merits -- a process-lifetime static feeding unguarded
float geometry is a bug regardless. But the causal link to Oracle's report is
INFERENCE: the NaN source is unidentified and this has not been reproduced.
Field-verify with BT_RANGE_LOG=1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
4245 lines
169 KiB
C++
4245 lines
169 KiB
C++
//===========================================================================//
|
|
// File: btl4vid.cpp //
|
|
// Project: BattleTech Brick: Video Renderer Manager //
|
|
// Contents: BTL4VideoRenderer -- the BattleTech L4 out-the-window 3D WORLD //
|
|
// renderer. Builds the main-view scene each time an interesting //
|
|
// entity becomes visible: the player mech (jointed-mover segment //
|
|
// hierarchy + per-subsystem weapon/effect renderables + targeting //
|
|
// reticle), terrain, and other movers. //
|
|
//---------------------------------------------------------------------------//
|
|
// Date Who Modification //
|
|
// -------- --- ---------------------------------------------------------- //
|
|
// 02/13/95 CPB Initial coding. //
|
|
//---------------------------------------------------------------------------//
|
|
// Copyright (C) 1994-1996, Virtual World Entertainment, Inc. //
|
|
// PROPRIETARY and CONFIDENTIAL //
|
|
//===========================================================================//
|
|
//
|
|
// RECONSTRUCTED from the shipped binary (BTL4OPT.EXE). Behaviour follows the
|
|
// Ghidra pseudo-C in the bt_l4 cluster (recovered/all/part_014.c, addresses
|
|
// @004cc40c..@004d2bbc). Class/member/method names come from the embedded
|
|
// assert path "d:\tesla\bt\bt_l4\BTL4VID.CPP", the embedded class-name string
|
|
// "BTL4VideoRenderer::Material name ... could not be found" (@0051d6f8), and
|
|
// the direct Red Planet analogue RP_L4/RPL4VID.cpp. Each method cites its
|
|
// originating @ADDR.
|
|
//
|
|
// The recovered code targets the 1996 (pre-DPL) renderable API: joint
|
|
// renderables parent on a dpl_DCS*, geometry is loaded as a video object, and
|
|
// the tree is built against an opaque Scene root. Engine-object accesses have
|
|
// been translated to the surviving public accessors (EntitySegment::GetParent /
|
|
// GetParentIndex / GetBaseOffset / GetVideoObjectName, Joint::GetJointType /
|
|
// GetHinge / GetEulerAngles / GetTranslation, JointedMover::segmentTable /
|
|
// segmentCount / GetJointSubsystem / GetSegment, Subsystem::GetSegmentIndex).
|
|
// The BT-specific renderables (declared in btl4vid.hpp) keep the recovered
|
|
// construction signatures.
|
|
//
|
|
|
|
#include <bt.hpp>
|
|
#include <matchlog.hpp> // [paint] entity ids
|
|
#if !defined(EMITTER_HPP)
|
|
# include <emitter.hpp> // Emitter/PPC (the reticle's per-weapon pip wiring)
|
|
#endif
|
|
#pragma hdrstop
|
|
|
|
#if !defined(BTL4VID_HPP)
|
|
# include <btl4vid.hpp>
|
|
#endif
|
|
#if !defined(MECH_HPP)
|
|
# include <mech.hpp> // Mech / JointedMover segment table + subsystems
|
|
#endif
|
|
#if !defined(MECHWEAP_HPP)
|
|
# include <mechweap.hpp> // MechWeapon::GetClassDerivations (reticle pip)
|
|
#endif
|
|
#if !defined(MECHDMG_HPP)
|
|
# include <mechdmg.hpp> // Mech__DamageZone::segmentIndex (the @004d097c dispatcher)
|
|
#endif
|
|
#if !defined(NOTATION_HPP)
|
|
# include <notation.hpp>
|
|
#endif
|
|
#if !defined(NAMELIST_HPP)
|
|
# include <namelist.hpp>
|
|
#endif
|
|
#if !defined(APP_HPP)
|
|
# include <app.hpp>
|
|
#endif
|
|
#if !defined(TERRAIN_HPP)
|
|
# include <terrain.hpp> // Terrain::GetClassDerivations (world-pick target, task #41)
|
|
#endif
|
|
|
|
#include <string.h>
|
|
#include <math.h>
|
|
#include <time.h> // clock() -- the threat-trail ages (task #37)
|
|
|
|
// WORLD-PICK TARGET (task #41): a live Terrain entity mech4 cites in
|
|
// mech+0x388 when the boresight pick lands on the ground (the binary's target
|
|
// slot holds non-mech world entities). Captured in MakeEntityRenderables.
|
|
Entity *gBTTerrainEntity = 0;
|
|
|
|
|
|
//
|
|
// Material-name substitution placeholders. Mirrors RPL4VideoRenderer's
|
|
// color_parameter/badge_parameter, plus BT's patch/serno.
|
|
//
|
|
static const char * const colorParameter = "%color%"; // @0051d188
|
|
static const char * const badgeParameter = "%badge%"; // @0051d18c
|
|
static const char * const patchParameter = "%patch%"; // @0051d190
|
|
static const char * const sernoParameter = "%serno%"; // @0051d194
|
|
|
|
//
|
|
// Radial spacing between adjacent weapon pips along the reticle (_DAT_004cdce8).
|
|
//
|
|
static const float PIP_SPACING = 0.03f; // _DAT_004cdce8 (a DOUBLE: 0.03 --
|
|
// verified from the exe; the 0.01 guess
|
|
// overlapped the 0.028-wide pips)
|
|
|
|
//
|
|
// One-character serial number stamped into %serno% material names; advances
|
|
// '0'..'9' then 'A' each mech loaded. (DAT @0051d1b5.)
|
|
//
|
|
static char gSerno = '0';
|
|
|
|
//
|
|
// BattleTech entity / subsystem ClassIDs touched by the dispatch switches that
|
|
// were not recoverable from the surviving headers (the rest -- MechClassID,
|
|
// BTPlayerClassID, ReservoirClassID, EmitterClassID, PPCClassID -- resolve via
|
|
// the BT registration headers). Values from CLASSMAP.md / the recovered enum.
|
|
//
|
|
enum
|
|
{
|
|
MechMarkerClassID = 0xBBA, // timestamp / beacon marker
|
|
MechWeaponClassID = 0xBCD, // projectile-weapon tracer
|
|
SearchLightClassID = 0xBD8 // searchlight subsystem
|
|
};
|
|
|
|
extern NameList
|
|
*materialSubstitutionList; // DAT_004f1aac
|
|
|
|
extern Entity
|
|
*Entity_Being_Created; // DAT_004f1aa8
|
|
|
|
|
|
//
|
|
//#############################################################################
|
|
// MakeEntityRenderables
|
|
//#############################################################################
|
|
//
|
|
// @004d0774
|
|
//
|
|
// The ClassID dispatch (analogue of RPL4VideoRenderer::MakeEntityRenderables).
|
|
//
|
|
void
|
|
BTL4VideoRenderer::MakeEntityRenderables(
|
|
Entity *entity,
|
|
ResourceDescription *model_resource,
|
|
ViewFrom view_type)
|
|
{
|
|
Entity_Being_Created = entity; // DAT_004f1aa8
|
|
|
|
HierarchicalDrawComponent *mech_root = NULL;
|
|
|
|
switch (entity->GetClassID()) // entity[0x04]
|
|
{
|
|
case MechClassID: // 0xBB9
|
|
{
|
|
//
|
|
// Fog for the mech's main view, then load the colour/badge/patch
|
|
// material substitutions, build the whole mech, and tear the
|
|
// substitution list back down.
|
|
//
|
|
SetFogStyle(updateFogSetting); // FUN_0045d3cc(this,0x68)
|
|
// REMEMBER the serial this mech's paint is stamped with: gSerno is
|
|
// what SetupMaterialSubstitutionList stamps into %serno% and it
|
|
// ADVANCES per call, so a later geometry RE-parse (ApplyViewSkeleton,
|
|
// which the 1995 engine never did -- it had all skeleton variants
|
|
// resident) must re-install the list with THIS serial, not the next
|
|
// one. Without it the reload resolves unpainted material names and
|
|
// the mech turns grey (Gitea #38).
|
|
const char built_serno = gSerno;
|
|
SetupMaterialSubstitutionList(entity); // FUN_004d0cc0
|
|
mech_root = MakeMechRenderables( // FUN_004cef28
|
|
entity, model_resource, view_type);
|
|
TearDownMaterialSubstitutionList(); // FUN_004d11e8
|
|
{
|
|
std::map<Entity*, MechRenderTree>::iterator ti =
|
|
mMechRenderTrees.find(entity);
|
|
if (ti != mMechRenderTrees.end())
|
|
ti->second.paintSerno = built_serno;
|
|
}
|
|
// NB: the RootRenderable built by MakeMechRenderables registers
|
|
// itself with the renderer (AddRenderable) and hooks to the entity's
|
|
// localToWorld in its ctor -- no explicit AddDynamicRenderable here
|
|
// (unlike the 1996 VideoComponent path).
|
|
(void)mech_root;
|
|
|
|
//
|
|
// SEARCHLIGHT pass (2026-08-05) -- the roster walk's `case 0xbd8`
|
|
// (@004cef28, raw pseudocode part_014). The binary, per searchlight
|
|
// subsystem: resolve the "LightOn" attribute (error print
|
|
// BTL4VID.CPP:0x941 when absent); COCKPIT build (view 1) -> stash up
|
|
// to TWO attrs for the fog-swap watcher (@00456778); EXTERNAL build
|
|
// -> load "spot.bgf" (warn if missing), hang it as a child of the
|
|
// mount joint and bind visibility to the attribute (@0045612c). Our
|
|
// tree builder does not walk the roster, so the probe bridge
|
|
// (searchlight.cpp) supplies {attr, mount segment} and the two
|
|
// watcher Execute bodies live in TickSearchlight.
|
|
//
|
|
{
|
|
extern int BTMechSearchlightProbe(void *, int **, int *);
|
|
int *attrs[2];
|
|
int segs[2];
|
|
int count = BTMechSearchlightProbe((void *)entity, attrs, segs);
|
|
std::map<Entity*, MechRenderTree>::iterator ti =
|
|
mMechRenderTrees.find(entity);
|
|
if (count > 0 && ti != mMechRenderTrees.end())
|
|
{
|
|
MechRenderTree &tree = ti->second;
|
|
tree.searchLightCount = 0;
|
|
tree.searchIsCockpit = (view_type == 1);
|
|
for (int sl = 0; sl < count; ++sl)
|
|
{
|
|
MechRenderTree::SearchLight &light =
|
|
tree.searchLight[tree.searchLightCount];
|
|
light.cone = NULL;
|
|
light.coneObj = NULL;
|
|
light.lightOn = attrs[sl];
|
|
light.shown = 0;
|
|
light.mountSeg = segs[sl];
|
|
// the binary's inverted seed (@00456778: [9]/[10] =
|
|
// (*attr == 0)) -- guarantees the FIRST tick "changes"
|
|
// and normalizes the fog to the real lamp state, which
|
|
// is how a night mission starts on nosearchlightfog.
|
|
tree.searchFogCache[tree.searchLightCount] =
|
|
(*attrs[sl] == 0);
|
|
// BT_SPOT_SELF=1 (bench-only): build the cone on the
|
|
// OWN-cockpit tree too, so the V-chase camera can
|
|
// eyeball it solo. Authentic builds never cone the
|
|
// cockpit tree (the 1995 view-1 branch stashes attrs
|
|
// only) -- keep this env off outside look benches.
|
|
if (!tree.searchIsCockpit || getenv("BT_SPOT_SELF"))
|
|
{
|
|
// NB: the renderer-level LoadObject wrapper (@00498448) is
|
|
// still a btstubs no-op; load through the d3d route the
|
|
// tree builder itself uses (extension REQUIRED there).
|
|
d3d_OBJECT *spot = d3d_OBJECT::LoadObject(
|
|
GetDevice(), (char *)"spot.bgf"); // @0051d6c0
|
|
if (spot == NULL)
|
|
{
|
|
DEBUG_STREAM << "[spot] Couldn't locate spot.bgf "
|
|
"for mech\n" << std::flush; // @0051d6c9
|
|
}
|
|
else
|
|
{
|
|
std::map<int, HierarchicalDrawComponent*>::iterator si =
|
|
tree.segRenderable.find(segs[sl]);
|
|
HierarchicalDrawComponent *parent =
|
|
(si != tree.segRenderable.end()) ? si->second : NULL;
|
|
if (parent == NULL)
|
|
{
|
|
// slot-space mismatch would land here -- keep the
|
|
// receipt loud (see the bench).
|
|
DEBUG_STREAM << "[spot] mount segment " << segs[sl]
|
|
<< " has no joint renderable -- cone skipped; slots:";
|
|
for (std::map<int, HierarchicalDrawComponent*>::iterator di =
|
|
tree.segRenderable.begin();
|
|
di != tree.segRenderable.end(); ++di)
|
|
DEBUG_STREAM << " " << di->first;
|
|
DEBUG_STREAM << "\n" << std::flush;
|
|
}
|
|
else
|
|
{
|
|
LinearMatrix identity(True);
|
|
dpl_ISECT_MODE gSpotIsectMode; // stub type (binary: mode 1)
|
|
light.cone = new DPLStaticChildRenderable(
|
|
entity, false /* main zone */, spot,
|
|
gSpotIsectMode, 0 /* mask 0: never pickable */,
|
|
identity, parent);
|
|
light.cone->SetDrawObj(NULL); // hidden until LightOn
|
|
light.coneObj = spot;
|
|
}
|
|
}
|
|
}
|
|
++tree.searchLightCount;
|
|
}
|
|
DEBUG_STREAM << "[spot] searchlight pass: " << count
|
|
<< " light(s), view=" << (int)view_type
|
|
<< " mountSeg=" << segs[0]
|
|
<< (tree.searchIsCockpit ? " (cockpit: fog watcher)"
|
|
: " (external: spot cone)")
|
|
<< "\n" << std::flush;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case MechMarkerClassID: // 0xBBA (timestamp/marker beacon)
|
|
{
|
|
d3d_OBJECT *marker = LoadObject("tmst_c"); // FUN_00498448
|
|
BTRootRenderable *root = // FUN_00453578, alloc 100
|
|
new BTRootRenderable(
|
|
entity, VideoRenderable::Dynamic, marker,
|
|
GetScene(), 1, 0);
|
|
// watcher that keeps the marker oriented (FUN_00458c58, alloc 0x120)
|
|
new BTMarkerWatcherRenderable(
|
|
entity, 0, GetMainView() /* this[0x2cc] */, root->GetDCS());
|
|
break;
|
|
}
|
|
|
|
case BTPlayerClassID: // 0xBDA
|
|
{
|
|
StateIndicator *sim_state =
|
|
(StateIndicator *)entity->GetAttributePointer(1 /* SimulationState */);
|
|
if ((entity->GetInstance() & 0xC) == 4) // ReplicantInstance
|
|
{
|
|
//
|
|
// Third-party view: drop-zone translocation effect.
|
|
//
|
|
Point3D *drop_zone =
|
|
(Point3D *)entity->GetAttributePointer("DropZoneLocation"); // @0051d73a
|
|
if (sim_state && drop_zone)
|
|
{
|
|
new BTTranslocationRenderable( // FUN_00458d2c, alloc 0x40
|
|
entity, VideoRenderable::Watcher, GetMainView(),
|
|
sim_state, drop_zone, 1);
|
|
}
|
|
}
|
|
else if (sim_state)
|
|
{
|
|
//
|
|
// Our own POV start/end (mission fade in/out) using the fog
|
|
// colour + near/far planes stored on the renderer.
|
|
//
|
|
new BTPOVStartEndRenderable( // FUN_00454394, alloc 0x50
|
|
entity, VideoRenderable::Watcher, GetMainView(),
|
|
dplMainZone, dplDeathZone, sim_state,
|
|
fogRed, fogGreen, fogBlue, fogNear, fogFar,
|
|
3 /* MissionStartingState */, 4 /* MissionEndingState */);
|
|
}
|
|
break;
|
|
}
|
|
|
|
default:
|
|
{
|
|
//
|
|
// Unknown / non-mech entities (terrain, cavern world geometry, props,
|
|
// landmarks, doorframes, eyecandy, ...) route to the DPL per-entity
|
|
// builder -- exactly as RP's default does (RPL4VID.cpp:1436). That
|
|
// builder loads each entity's video object(s) (.bgf via
|
|
// d3d_OBJECT::LoadObject -> LoadObjectBGF) and hangs them on a
|
|
// Root/Static/DCS-instance renderable, which is how the cavern world
|
|
// gets onto the screen. (Previously this deferred to the no-op
|
|
// VideoRenderer grandparent -> world drew nothing.) The uninitialised
|
|
// `this_instance` in the CulturalIcon/Landmark arm has been fixed in
|
|
// L4VIDEO.cpp.
|
|
//
|
|
// WORLD-PICK TARGET support (task #41): the boresight pick can hit
|
|
// TERRAIN (the binary's target slot holds non-mech world entities --
|
|
// HudSimulation part_013.c:5620 explicitly handles a target without
|
|
// damage zones). Capture a live Terrain entity for mech4's ground
|
|
// pick to cite in mech+0x388 (the pick POINT carries the geometry;
|
|
// which specific instance matters only to damage, which never routes
|
|
// to terrain).
|
|
{
|
|
extern Entity *gBTTerrainEntity;
|
|
// The boresight ground/structure pick needs a non-null world Entity to
|
|
// cite in mech+0x388 (the geometry rides in the pick POINT; the entity is
|
|
// only the non-mech designation -- the decomp's HudSimulation handles a
|
|
// target with no damage-zone table, part_013.c:5620). Previously this
|
|
// required a TERRAIN-derived entity, but maps whose world geometry is
|
|
// buildings/cultural/props (class-42, NOT Terrain-derived) captured
|
|
// nothing -> gBTTerrainEntity stayed 0 -> the ground pick was skipped
|
|
// (mech4.cpp:4372) -> only mechs were targetable/firable (task #50
|
|
// regression: LAST.EGG rewrite lost its Terrain entity). This `default`
|
|
// case is world-geometry ONLY (mechs route elsewhere), so capture the
|
|
// first entity reaching it -- preferring a real Terrain, else any world
|
|
// entity (building/prop/landmark) as the non-mech pick sentinel.
|
|
if (gBTTerrainEntity == 0
|
|
|| !gBTTerrainEntity->IsDerivedFrom(*Terrain::GetClassDerivations()))
|
|
{
|
|
if (entity->IsDerivedFrom(*Terrain::GetClassDerivations()))
|
|
gBTTerrainEntity = entity; // prefer a true Terrain entity
|
|
else if (gBTTerrainEntity == 0)
|
|
gBTTerrainEntity = entity; // else the first world entity
|
|
}
|
|
// CENSUS: what world-geometry entities does the RENDER tree walk?
|
|
// (the garages/structures may be here even if not in the map's class-42
|
|
// instance stream that BuildTables reads.)
|
|
if (getenv("BT_GROUND_LOG"))
|
|
{
|
|
Derivation *dv = entity->GetClassDerivations();
|
|
const int isTer = entity->IsDerivedFrom(*Terrain::GetClassDerivations())?1:0;
|
|
const Point3D &ep = entity->localOrigin.linearPosition;
|
|
// tag NON-terrain entities distinctly -- these are the structures
|
|
// (garages/walls/props); log their world XZ so we can spawn beside
|
|
// one and confirm it is a collision solid the boresight now hits.
|
|
DEBUG_STREAM << (isTer ? "[rendent]" : "[rendent-STRUCT]")
|
|
<< " class='" << (dv && dv->className ? dv->className : "?")
|
|
<< "' terrain=" << isTer
|
|
<< " pos=(" << ep.x << "," << ep.y << "," << ep.z << ")"
|
|
<< "\n" << std::flush;
|
|
}
|
|
}
|
|
DPLRenderer::MakeEntityRenderables(
|
|
entity, model_resource, view_type);
|
|
break;
|
|
}
|
|
}
|
|
|
|
Entity_Being_Created = NULL;
|
|
}
|
|
|
|
|
|
//
|
|
//#############################################################################
|
|
// MakeMechRenderables
|
|
//#############################################################################
|
|
//
|
|
// @004cef28 (6157 bytes -- the main world-view builder)
|
|
//
|
|
// Build the renderable tree for one mech and submit it to the scene. This is
|
|
// the BattleTech analogue of RPL4VideoRenderer::MakeJointedMoverRenderables.
|
|
//
|
|
HierarchicalDrawComponent*
|
|
BTL4VideoRenderer::MakeMechRenderables(
|
|
Entity *entity,
|
|
ResourceDescription *model_resource, // (unused; tree built from segment table)
|
|
ViewFrom type)
|
|
{
|
|
//
|
|
// RECONSTRUCTION NOTE (WinTesla port):
|
|
// The shipped 1996 BattleTech built this tree from a bespoke pre-DPL
|
|
// renderable hierarchy (BTRootRenderable / BTHingeRenderable / ...) that
|
|
// parented on raw dpl_DCS* handles and drove the Division IG board. That
|
|
// hierarchy was never ported to WinTesla -- the engine here replaced it with
|
|
// the D3D-backed VideoRenderable family (RootRenderable / HingeRenderable /
|
|
// BallJointRenderable / BallTranslateJointRenderable / DPLStaticChildRenderable
|
|
// / DPLEyeRenderable, see MUNGA_L4/L4VIDRND). Those renderables self-register
|
|
// with the renderer, build their own DCS, and parent on the PARENT RENDERABLE
|
|
// (a HierarchicalDrawComponent*), not a dpl_DCS*. So this body is rebuilt the
|
|
// RP way (mirrors RPL4VideoRenderer::MakeJointedMoverRenderables, the
|
|
// segment-table variant) using the engine renderables, which is what actually
|
|
// gets mech geometry onto the screen. The BT-specific 2D reticle + weapon/
|
|
// effect renderables (BTReticleRenderable, beams, tracers, searchlight) still
|
|
// depend on the unported dpl2d_ layer and are deferred -- TODO(bring-up).
|
|
//
|
|
JointedMover *jointed_mover = (JointedMover *)entity;
|
|
|
|
//
|
|
//~~~~~~~~~~~~~~~~~~~~~~~
|
|
// Inside or Outside view: pick skeleton variant + intersect mode/mask.
|
|
//~~~~~~~~~~~~~~~~~~~~~~~
|
|
//
|
|
bool inDeathZone;
|
|
dpl_ISECT_MODE intersect_mode; // stub type (empty); kept for parity
|
|
uint32 intersect_mask;
|
|
EntitySegment::SkeletonType skeletonType;
|
|
|
|
//
|
|
// DEBUG(bring-up): external chase camera. The player POV mech is normally
|
|
// built with the INSIDE skeleton -- the camera sits AT the cockpit eyepoint
|
|
// with no world geometry ahead, so the frame is black. To make the mech
|
|
// BODY visible, treat the player's own mech as an OUTSIDE build (which loads
|
|
// the full body geometry) and, after the renderable tree is built, install a
|
|
// fixed external camera a few mech-heights in FRONT looking back at the mech.
|
|
// Other (already-outside) mechs keep their normal no-camera body build.
|
|
// TODO(bring-up): replace with a real spectator/chase view-mode toggle wired
|
|
// through BTL4Application; see RECONCILE.md.
|
|
//
|
|
const bool buildDebugChaseCamera = (type == insideEntity);
|
|
if (buildDebugChaseCamera)
|
|
type = outsideEntity;
|
|
|
|
if (type == insideEntity)
|
|
{
|
|
inDeathZone = true;
|
|
intersect_mask = 0;
|
|
skeletonType = EntitySegment::SkeletonType_A; // 4
|
|
}
|
|
else
|
|
{
|
|
inDeathZone = false;
|
|
intersect_mask = INTERSECT_ALL; // 0xffffffff
|
|
skeletonType = EntitySegment::SkeletonType_N; // 0
|
|
}
|
|
|
|
//
|
|
// Root renderable for this entity. Its ctor calls AddRenderable(this) and
|
|
// binds to entity->localToWorld, so the whole tree is driven from the entity
|
|
// position every frame.
|
|
//
|
|
RootRenderable *this_root =
|
|
new RootRenderable(
|
|
entity, VideoRenderable::Dynamic, NULL,
|
|
inDeathZone, intersect_mode, intersect_mask);
|
|
|
|
//
|
|
// Start (or reset) this mech's RemakeEntity bookkeeping: record the skeleton
|
|
// variant now; the per-segment renderables + initial graphic states are filled
|
|
// in as the tree is built below (see RemakeEntityRenderables).
|
|
//
|
|
MechRenderTree &render_tree = mMechRenderTrees[entity];
|
|
render_tree = MechRenderTree();
|
|
render_tree.skeletonType = (int)skeletonType;
|
|
render_tree.viewSkeleton = (int)skeletonType;
|
|
render_tree.rootRenderable = this_root;
|
|
render_tree.wrecked = 0;
|
|
if (getenv("BT_DEATH_LOG"))
|
|
DEBUG_STREAM << "[BTrender] tracking mech tree for entity " << (void*)entity
|
|
<< " classID=" << entity->GetClassID() << " ("
|
|
<< mMechRenderTrees.size() << " tracked)\n" << std::flush;
|
|
|
|
//
|
|
// Per-segment renderable array (the parent for each segment's children).
|
|
//
|
|
int segment_count = jointed_mover->segmentCount; // [0x318]
|
|
HierarchicalDrawComponent **dcs_array =
|
|
new HierarchicalDrawComponent*[segment_count];
|
|
for (int i = 0; i < segment_count; ++i)
|
|
dcs_array[i] = NULL;
|
|
|
|
// bring-up diagnostics (counts geometry actually loaded vs. requested)
|
|
int dbg_obj_requested = 0, dbg_obj_loaded = 0, dbg_eye = 0;
|
|
DEBUG_STREAM << "[BTrender] MakeMechRenderables: " << segment_count
|
|
<< " segments, view=" << (int)type
|
|
<< " entity=" << entity->GetEntityID() << "\n" << std::flush;
|
|
|
|
JointSubsystem *joint_subsystem = jointed_mover->GetJointSubsystem(); // [0x31c]
|
|
|
|
//
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// Walk the EntitySegment table. For each segment: build its offset matrix,
|
|
// find its parent renderable, choose its joint renderable, load and hang its
|
|
// geometry. Site segments (eyepoint, gun ports) are handled specially.
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
//
|
|
EntitySegment::SegmentTableIterator segment_iterator(jointed_mover->segmentTable /* [0x300] */);
|
|
EntitySegment *segment;
|
|
|
|
while ((segment = segment_iterator.ReadAndNext()) != NULL) // vtbl+0x28
|
|
{
|
|
LinearMatrix offset_matrix;
|
|
offset_matrix = segment->GetBaseOffset(); // [0x74]
|
|
|
|
// task #58 diagnostics (BT_SEG_DUMP): the draw-tree topology -- which
|
|
// segment carries which joint, and who parents whom.
|
|
if (getenv("BT_SEG_DUMP"))
|
|
{
|
|
int ji = segment->GetJointIndex();
|
|
DEBUG_STREAM << "[seg] " << segment->GetIndex()
|
|
<< " '" << (const char *)segment->GetName()
|
|
<< "' parent=" << (segment->GetParent()
|
|
? segment->GetParentIndex() : -1)
|
|
<< " joint=" << ji
|
|
<< " jtype=" << (ji == -1 ? -1
|
|
: (int)joint_subsystem->GetJoint(ji)->GetJointType())
|
|
<< " site=" << (int)(segment->IsSiteSegment() != 0)
|
|
<< "\n" << std::flush;
|
|
}
|
|
|
|
//
|
|
// Parent renderable: root if the segment has no parent, else the
|
|
// renderable already built for its parent segment.
|
|
//
|
|
HierarchicalDrawComponent *parent_DCS;
|
|
if (!segment->GetParent() /* [0xc4] */)
|
|
{
|
|
parent_DCS = this_root;
|
|
}
|
|
else
|
|
{
|
|
parent_DCS = dcs_array[segment->GetParentIndex() /* [0xc8] */];
|
|
}
|
|
|
|
//
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// Site segment? The eyepoint site builds the camera (DPLEyeRenderable)
|
|
// for the inside view; other sites carry no body geometry here (their
|
|
// subsystem effects are deferred -- TODO(bring-up)).
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
//
|
|
if (segment->IsSiteSegment() /* [0x10] */ != 0)
|
|
{
|
|
if (getenv("BT_TREE_LOG"))
|
|
DEBUG_STREAM << "[tree] SITE seg=" << segment->GetIndex()
|
|
<< " name=" << (segment->GetName() ? (const char *)segment->GetName() : "?")
|
|
<< " parent=" << (segment->GetParent() ? segment->GetParentIndex() : -1)
|
|
<< "\n" << std::flush;
|
|
// The authentic COCKPIT EYEPOINT. Built for the true inside view
|
|
// AND for the player's chase build (buildDebugChaseCamera), so the
|
|
// V-key toggle can switch to it -- the pod's only view was this
|
|
// eyepoint; the chase camera is the port's usability addition.
|
|
if ((type == insideEntity || buildDebugChaseCamera) &&
|
|
strcmp((const char *)segment->GetName() /* [0x11c] */, "siteeyepoint") == 0) // @0051d290
|
|
{
|
|
EulerAngles *eye_rot =
|
|
(EulerAngles *)entity->GetAttributePointer("EyepointRotation"); // @0051d29d
|
|
//
|
|
// AUTHENTIC eye (decomp FUN_004579a8 @part_007.c:9274-9325; caller part_014.c:5525-5566):
|
|
// the eye offset matrix is the siteeyepoint segment's LOCAL rest transform (GetBaseOffset,
|
|
// segment+0x74 -- already in offset_matrix above), and the eye is parented on its PARENT
|
|
// segment's draw component (parent_DCS) -- NOT the root, NOT the full GetSegmentToEntity,
|
|
// NOT an upright hack. World orientation + all live motion (torso twist, gait) come from
|
|
// the parent-chain composition, exactly as the decomp's dpl_AddDCSToDCS hierarchy does;
|
|
// EyepointRotation is combined in Execute as baseOffset * R, and VIEW = inverse(eyeWorld)
|
|
// (FUN_004c22c4 @part_013.c:11788) -- so the look/up axes fall out of the eye's own basis
|
|
// with no per-mech forward-axis assumption.
|
|
//
|
|
mEyeCockpit = new DPLEyeRenderable(
|
|
entity, offset_matrix, parent_DCS, eye_rot);
|
|
// issue #16 (boresight parallax): the VIEWPOINT mech's cockpit
|
|
// eyepoint is the BORESIGHT eye -- it owns the aim-camera
|
|
// (pick/sight ray) publish in BOTH views, from its own eye
|
|
// origin, so chase and cockpit ballistics are identical (see
|
|
// DPLEyeRenderable::Execute).
|
|
mEyeCockpit->SetBoresightEye(true);
|
|
if (type == insideEntity) // true inside build: it IS the camera
|
|
mCamera = mEyeCockpit;
|
|
dbg_eye = 1;
|
|
}
|
|
//
|
|
// SEARCHLIGHT (2026-08-05): sites are ATTACHMENT joints, and the
|
|
// 1995 render graph gave every one a DCS node -- that node is what
|
|
// the subsystem-visual walk parents to (the spot cone hangs on the
|
|
// searchlight site, @004cef28 case 0xbd8). Build the geometry-less
|
|
// child so site slots are posed + parentable; nothing draws for the
|
|
// site itself.
|
|
//
|
|
{
|
|
dpl_ISECT_MODE site_isect;
|
|
HierarchicalDrawComponent *site_child = new DPLStaticChildRenderable(
|
|
entity, inDeathZone, NULL /* no geometry */,
|
|
site_isect, 0, offset_matrix, parent_DCS);
|
|
dcs_array[segment->GetIndex()] = site_child;
|
|
render_tree.segRenderable[segment->GetIndex()] = site_child;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
//
|
|
// Load this segment's geometry (skeleton-variant .bgf), if any.
|
|
//
|
|
d3d_OBJECT *this_object = NULL;
|
|
// Select the segment's model VARIANT by its damage zone's graphic state.
|
|
// The engine keys video-object names by {skeleton, damage_graphic_state}
|
|
// (SEGMENT.h:172): a Destroyed zone (GetGraphicState()==1) returns the
|
|
// destroyed/damaged model, so a wrecked segment visibly comes apart. The
|
|
// recon previously passed ONLY the skeleton type, leaving the state at its
|
|
// default 0 (Exists) -> always the intact model = no visible damage.
|
|
Enumeration seg_gstate = 0; // ExistsGraphicState
|
|
{
|
|
int zone_index = segment->GetPrimaryDamageZone(); // SEGMENT.h:107 (a zone INDEX)
|
|
if (zone_index >= 0 && zone_index < entity->damageZoneCount
|
|
&& entity->damageZones[zone_index] != 0)
|
|
seg_gstate = entity->damageZones[zone_index]->GetGraphicState(); // DAMAGE.h:196
|
|
}
|
|
CString *object_name = segment->GetVideoObjectName(skeletonType, seg_gstate); // FUN_00424084
|
|
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'; // strip ".bgf"
|
|
strcat(filename, ".bgf"); // d3d_OBJECT::LoadObject wants the extension
|
|
this_object = d3d_OBJECT::LoadObject(GetDevice(), filename);
|
|
++dbg_obj_requested;
|
|
if (this_object != NULL) ++dbg_obj_loaded;
|
|
else DEBUG_STREAM << "[BTrender] no mesh for '" << filename
|
|
<< "' (expects VIDEO\\*.x)\n" << std::flush;
|
|
// SHADOW PROXY (task #20): the binary's shadow is the flat *_tshd.bgf
|
|
// silhouette posed by jointshadow/jointtshadow (model-record
|
|
// ShadowJointName @0xB4, part_012.c:10285). Tag it so d3d_OBJECT
|
|
// draws it translucent in the blend pass instead of opaque black;
|
|
// alphaTest=true routes it there (HierarchicalDrawComponent::Execute,
|
|
// L4VIDRND.cpp:149, schedules the pass per-drawOp on alphaTest).
|
|
if (this_object != NULL && strstr(filename, "tshd") != NULL)
|
|
{
|
|
this_object->SetIsShadow(1);
|
|
for (int op = 0; op < this_object->GetDrawOpCount(); ++op)
|
|
this_object->GetDrawOp(op)->alphaTest = true;
|
|
}
|
|
|
|
// #73: record the pickable segment geometry + its zone for the
|
|
// aimed per-part pick (MechSegmentPick). The shadow proxy is not
|
|
// a target; a rebuild resets the map with the tree.
|
|
if (this_object != NULL && this_object->GetIsShadow() == 0)
|
|
{
|
|
MechRenderTree::SegPick sp;
|
|
sp.obj = this_object;
|
|
sp.zone = segment->GetPrimaryDamageZone(); // SEGMENT.h:107
|
|
render_tree.segPick[segment->GetIndex()] = sp;
|
|
if (getenv("BT_PICK_LOG"))
|
|
DEBUG_STREAM << "[segpick] seg=" << segment->GetIndex()
|
|
<< " '" << (const char *)segment->GetName()
|
|
<< "' zone=" << sp.zone
|
|
<< " r=" << this_object->mCullRadius
|
|
<< " c=(" << this_object->mCullCenter.x << ","
|
|
<< this_object->mCullCenter.y << ","
|
|
<< this_object->mCullCenter.z << ")\n" << std::flush;
|
|
}
|
|
}
|
|
|
|
//
|
|
// Determine joint type: static (-1 -> Static) or look up the joint in
|
|
// the JointSubsystem's joint table.
|
|
//
|
|
int segment_slot = segment->GetIndex() /* [0xcc] */;
|
|
// DIAG (BT_TREE_LOG): the built joint topology -- segment slot, name,
|
|
// joint index/type, parent -- the crouch root-drop chain hunt.
|
|
if (getenv("BT_TREE_LOG"))
|
|
DEBUG_STREAM << "[tree] seg=" << segment_slot
|
|
<< " name=" << (segment->GetName() ? (const char *)segment->GetName() : "?")
|
|
<< " joint=" << segment->GetJointIndex()
|
|
<< " parent=" << (segment->GetParent() ? segment->GetParentIndex() : -1)
|
|
<< "\n" << std::flush;
|
|
Joint *this_joint = NULL;
|
|
Joint::JointType joint_type;
|
|
if (segment->GetJointIndex() /* [0xc0] */ == -1)
|
|
{
|
|
joint_type = Joint::StaticJointType; // 3
|
|
}
|
|
else
|
|
{
|
|
this_joint = joint_subsystem->GetJoint(segment->GetJointIndex()); // FUN_0041d3b3
|
|
joint_type = this_joint->GetJointType() /* [0x10] */;
|
|
}
|
|
|
|
//
|
|
// Build the appropriate engine joint renderable, recording it in the
|
|
// per-segment array so children can parent to it.
|
|
//
|
|
HierarchicalDrawComponent *child;
|
|
switch (joint_type)
|
|
{
|
|
case Joint::BallJointType: // 4
|
|
{
|
|
child = new BallJointRenderable(
|
|
entity, VideoRenderable::Dynamic, this_object,
|
|
inDeathZone, intersect_mode, intersect_mask,
|
|
parent_DCS, &offset_matrix, &this_joint->GetEulerAngles());
|
|
break;
|
|
}
|
|
case Joint::BallTranslationJointType: // 5
|
|
{
|
|
child = new BallTranslateJointRenderable(
|
|
entity, VideoRenderable::Dynamic, this_object,
|
|
inDeathZone, intersect_mode, intersect_mask,
|
|
parent_DCS, &offset_matrix,
|
|
&this_joint->GetEulerAngles(), &this_joint->GetTranslation());
|
|
break;
|
|
}
|
|
case Joint::StaticJointType: // 3
|
|
{
|
|
child = new DPLStaticChildRenderable(
|
|
entity, inDeathZone, this_object,
|
|
intersect_mode, intersect_mask, offset_matrix, parent_DCS);
|
|
break;
|
|
}
|
|
default: // 0,1,2 HingeX/Y/Z
|
|
{
|
|
child = new HingeRenderable(
|
|
entity, VideoRenderable::Dynamic, this_object,
|
|
inDeathZone, intersect_mode, intersect_mask,
|
|
parent_DCS, &offset_matrix, &this_joint->GetHinge() /* [0xc] */);
|
|
break;
|
|
}
|
|
}
|
|
dcs_array[segment_slot] = child;
|
|
|
|
// Record this segment's renderable + the graphic state it was built with,
|
|
// so a later damage-state change can swap its mesh in place (RemakeEntity).
|
|
render_tree.segRenderable[segment_slot] = child;
|
|
render_tree.segGState[segment_slot] = (int)seg_gstate;
|
|
}
|
|
|
|
delete [] dcs_array;
|
|
|
|
DEBUG_STREAM << "[BTrender] mech tree built: meshes " << dbg_obj_loaded
|
|
<< "/" << dbg_obj_requested << " loaded, eye=" << dbg_eye << "\n" << std::flush;
|
|
|
|
//
|
|
// If this mech DIED before its tree was built (a fast kill during mission
|
|
// creation), apply the remembered wreck swap now.
|
|
//
|
|
{
|
|
extern int BTTakePendingWreck(Entity *entity);
|
|
if (BTTakePendingWreck(entity))
|
|
SwapToWreck(entity);
|
|
}
|
|
|
|
//
|
|
// The TARGETING RETICLE (the main-view HUD): built for the player's mech,
|
|
// per the 1996 inside-view path (@part_014.c:5127-5158 constructs the
|
|
// 0x358 BTReticleRenderable, then :5390-5436 registers one pip per weapon
|
|
// from its TargetWithinRange / WeaponRange / PipPosition / PipColor /
|
|
// PipExtendedRange / SimulationState attributes). Drawn by BTDrawReticle
|
|
// in the cockpit view only.
|
|
//
|
|
if (buildDebugChaseCamera)
|
|
{
|
|
extern BTReticleRenderable *BTBuildReticle(Entity *mech);
|
|
BTBuildReticle(entity);
|
|
}
|
|
|
|
// DEV: BT_START_INSIDE=1 begins in the cockpit view (also exercises the
|
|
// inside-skeleton swap headlessly).
|
|
if (buildDebugChaseCamera && getenv("BT_START_INSIDE"))
|
|
SetViewInside(1);
|
|
|
|
//
|
|
// TODO(bring-up): inside-view targeting reticle (BTReticleRenderable +
|
|
// AddWeapon pips) and the per-subsystem weapon/effect renderables (PPC/
|
|
// emitter beams, projectile tracers, coolant, searchlight) are NOT built
|
|
// here yet -- they depend on the dpl2d_ 2D display-list layer (stubbed) and
|
|
// the BT effect renderables (stubbed). Adding them is the HUD / weapons
|
|
// render bring-up step; they are not required to get the mech body drawn.
|
|
//
|
|
|
|
//
|
|
// DEBUG(bring-up): install the fixed external chase camera for the player's
|
|
// own mech. The eye renderable parents on this_root, so when the render
|
|
// tree executes (RootRenderable::Execute pushes entity->localToWorld onto the
|
|
// matrix stack) the camera's offset is composed with the mech's world matrix
|
|
// -- i.e. the camera tracks the mech. DPLEyeRenderable looks down its local
|
|
// +Z axis (row 2) with local +Y as up (row 1) from its translation (row 3);
|
|
// D3DXMatrixLookAtRH re-derives the basis from pos/at/up.
|
|
//
|
|
// Mech-local frame (from the .skl): +Y up, the mech FACES -Z (gun ports /
|
|
// eyepoint are at -Z), and the Avatar is ~10-12 units tall (hip at y~5.3,
|
|
// eyepoint ~y9). Place the camera in FRONT (-Z) and above, looking back
|
|
// toward the mech centre.
|
|
//
|
|
if (buildDebugChaseCamera)
|
|
{
|
|
// CHASE view (task #15 usability): the mech faces -Z, so the original
|
|
// debug placement (camera at -Z, "in front, looking back at its face")
|
|
// made W walk the mech TOWARD the viewer -- hopelessly disorienting to
|
|
// drive. Default is now BEHIND (+Z) and above, looking forward over the
|
|
// shoulder: press forward, the mech walks away from you; turns read
|
|
// correctly. env BT_CAM=face restores the old face-on animation view.
|
|
const char *camMode = getenv("BT_CAM");
|
|
const bool faceView = (camMode != 0 && camMode[0] == 'f');
|
|
float camPx = 0.0f, camPy = faceView ? 9.0f : 11.0f;
|
|
float camPz = faceView ? -28.0f : 28.0f; // -Z front / +Z behind
|
|
const float tgtX = 0.0f, tgtY = 6.0f;
|
|
const float tgtZ = faceView ? 0.0f : -6.0f; // chase: look ahead of the mech
|
|
// DEBUG(bring-up): BT_CAM_Y / BT_CAM_Z override the fixed chase-camera offset --
|
|
// raising it clears mound-shoulder OCCLUSION, but NOT genuine geometry clipping
|
|
// where the mech is stopped on a steep slope and the terrain rises through its
|
|
// legs (that is a collision-vs-visual issue, not a camera one).
|
|
if (const char *cy = getenv("BT_CAM_Y")) camPy = (float)atof(cy);
|
|
if (const char *cz = getenv("BT_CAM_Z")) camPz = (float)atof(cz);
|
|
|
|
// look direction (local +Z of the camera) = normalize(target - pos)
|
|
float zx = tgtX - camPx, zy = tgtY - camPy, zz = tgtZ - camPz;
|
|
float zl = (float)sqrt(zx*zx + zy*zy + zz*zz);
|
|
if (zl < 1e-6f) zl = 1.0f;
|
|
zx /= zl; zy /= zl; zz /= zl;
|
|
|
|
// world up
|
|
const float ux = 0.0f, uy = 1.0f, uz = 0.0f;
|
|
|
|
// right (local +X) = up x forward
|
|
float xx = uy*zz - uz*zy, xy = uz*zx - ux*zz, xz = ux*zy - uy*zx;
|
|
float xl = (float)sqrt(xx*xx + xy*xy + xz*xz);
|
|
if (xl < 1e-6f) xl = 1.0f;
|
|
xx /= xl; xy /= xl; xz /= xl;
|
|
|
|
// recomputed up (local +Y) = forward x right
|
|
float yx = zy*xz - zz*xy, yy = zz*xx - zx*xz, yz = zx*xy - zy*xx;
|
|
|
|
// CAMERA-BASIS CONVENTION (task #56 follow-through): the view is now the
|
|
// authentic VIEW = inverse(eyeWorld) (DPLEyeRenderable::Execute), under
|
|
// which the camera looks along -Z of its own basis -- so the basis rows
|
|
// are right/up/BACK, not right/up/look. This chase matrix was authored
|
|
// for the old LookAt (row2 = look); convert by a 180-degree turn about
|
|
// local Y (negate the X and Z rows -- determinant stays +1).
|
|
LinearMatrix debugOffset; // identity
|
|
debugOffset(0,0) = -xx; debugOffset(0,1) = -xy; debugOffset(0,2) = -xz; // X row (right, flipped)
|
|
debugOffset(1,0) = yx; debugOffset(1,1) = yy; debugOffset(1,2) = yz; // Y row (up)
|
|
debugOffset(2,0) = -zx; debugOffset(2,1) = -zy; debugOffset(2,2) = -zz; // Z row (BACK = -look)
|
|
debugOffset(3,0) = camPx; debugOffset(3,1) = camPy; debugOffset(3,2) = camPz; // W row (pos)
|
|
|
|
mEyeChase = new DPLEyeRenderable(entity, debugOffset, this_root, NULL);
|
|
// Respect the pilot's CHOSEN view across renderable rebuilds: this build
|
|
// used to stomp mCamera back to chase on every remake (damage swaps,
|
|
// start-inside), silently flipping the active eye out from under the V
|
|
// toggle (and the aim-ray camera feed with it).
|
|
mCamera = (mViewInside && mEyeCockpit != 0) ? mEyeCockpit : mEyeChase;
|
|
DEBUG_STREAM << "[BTrender] external debug chase camera installed at ("
|
|
<< camPx << "," << camPy << "," << camPz << ") looking at ("
|
|
<< tgtX << "," << tgtY << "," << tgtZ << ") -- V toggles the cockpit eyepoint"
|
|
<< (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;
|
|
}
|
|
|
|
|
|
//
|
|
//#############################################################################
|
|
// RemakeEntityRenderables (the render "RemakeEntity" state -- damage swap)
|
|
//#############################################################################
|
|
//
|
|
// A damage zone's graphic state changed (a segment became Destroyed or Gone).
|
|
// Walk this mech's segments and, for any whose graphic state now differs from
|
|
// what its renderable was built with, re-pick the video-object variant by the
|
|
// new graphic state and swap it onto the joint renderable IN PLACE. Execute()
|
|
// re-reads graphicalObject each frame, so the wrecked mesh shows next frame.
|
|
// No teardown: the component dtor does not cascade to children (L4VIDRND.cpp:104),
|
|
// so a rebuild would leak -- the authentic behaviour is an in-place mesh swap.
|
|
//
|
|
void
|
|
BTL4VideoRenderer::RemakeEntityRenderables(Entity *entity)
|
|
{
|
|
std::map<Entity*, MechRenderTree>::iterator tree_it =
|
|
mMechRenderTrees.find(entity);
|
|
if (tree_it == mMechRenderTrees.end())
|
|
{
|
|
if (getenv("BT_DEATH_LOG"))
|
|
DEBUG_STREAM << "[BTrender] RemakeEntity: no render tree for entity "
|
|
<< (void*)entity << " (" << mMechRenderTrees.size()
|
|
<< " tracked)\n" << std::flush;
|
|
return; // tree not built yet -- Make will read the state
|
|
}
|
|
MechRenderTree &render_tree = tree_it->second;
|
|
if (render_tree.wrecked)
|
|
return; // already the dbr hulk -- nothing left to swap
|
|
|
|
JointedMover *jointed_mover = (JointedMover *)entity;
|
|
EntitySegment::SkeletonType skeletonType =
|
|
(EntitySegment::SkeletonType)render_tree.viewSkeleton; // the DISPLAYED set
|
|
|
|
EntitySegment::SegmentTableIterator segment_iterator(jointed_mover->segmentTable);
|
|
EntitySegment *segment;
|
|
int swapped = 0, checked = 0, mapped = 0;
|
|
|
|
while ((segment = segment_iterator.ReadAndNext()) != NULL)
|
|
{
|
|
if (segment->IsSiteSegment() != 0)
|
|
continue;
|
|
++checked;
|
|
|
|
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;
|
|
++mapped;
|
|
|
|
//
|
|
// Current graphic state for this segment (from its damage zone).
|
|
//
|
|
Enumeration seg_gstate = 0; // ExistsGraphicState
|
|
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();
|
|
|
|
if ((int)seg_gstate == render_tree.segGState[segment_slot])
|
|
continue; // unchanged -- nothing to swap
|
|
render_tree.segGState[segment_slot] = (int)seg_gstate;
|
|
|
|
//
|
|
// Re-pick + load the segment's video-object variant for the new graphic
|
|
// state (same construction as the initial build in MakeMechRenderables).
|
|
//
|
|
CString *object_name = segment->GetVideoObjectName(skeletonType, seg_gstate);
|
|
if (getenv("BT_DEATH_LOG"))
|
|
DEBUG_STREAM << "[BTrender] seg '" << (const char *)segment->GetName()
|
|
<< "' slot " << segment_slot << " -> gstate " << (int)seg_gstate
|
|
<< " variant=" << (object_name ? (const char *)*object_name : "(none)")
|
|
<< "\n" << std::flush;
|
|
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'; // strip ".bgf"
|
|
strcat(filename, ".bgf");
|
|
new_object = d3d_OBJECT::LoadObject(GetDevice(), filename);
|
|
if (new_object == NULL && getenv("BT_DEATH_LOG"))
|
|
DEBUG_STREAM << "[BTrender] damaged variant '" << filename
|
|
<< "' FAILED to load (expects VIDEO\\*.x)\n" << std::flush;
|
|
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;
|
|
}
|
|
}
|
|
|
|
//
|
|
// GoneGraphicState (blown off): no mesh -> hide the segment. Destroyed/
|
|
// Exists: swap to the variant if it loaded; otherwise keep the current
|
|
// mesh (don't blank a segment merely because a damaged .bgf is missing).
|
|
//
|
|
if (new_object != NULL)
|
|
r->second->SetDrawObj(new_object);
|
|
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 "
|
|
<< checked << " checked) entity="
|
|
<< entity->GetEntityID() << "\n" << std::flush;
|
|
}
|
|
|
|
|
|
//
|
|
//#############################################################################
|
|
// RebuildMechRenderables (the HEAL direction of RemakeEntity -- respawn)
|
|
//#############################################################################
|
|
//
|
|
// SwapToWreck hides every body segment and hangs a sinking dbr hulk on the root,
|
|
// latching render_tree.wrecked (one-way). On respawn Mech::Reset heals the sim
|
|
// state, but the render stays the sunk hulk unless we reverse the swap: drop the
|
|
// hulk/debris and restore every segment to its now-intact mesh (the zones are
|
|
// healed, so GetGraphicState() == Exists). This is the same in-place mesh swap
|
|
// as RemakeEntityRenderables, forced (RemakeEntity early-returns while wrecked).
|
|
//
|
|
int BTTakePendingWreck(Entity *entity); // defined below (SwapToWreck section)
|
|
|
|
void
|
|
BTL4VideoRenderer::RebuildMechRenderables(Entity *entity)
|
|
{
|
|
std::map<Entity*, MechRenderTree>::iterator tree_it =
|
|
mMechRenderTrees.find(entity);
|
|
if (tree_it == mMechRenderTrees.end())
|
|
{
|
|
BTTakePendingWreck(entity); // clear a queued (never-applied) wreck
|
|
return;
|
|
}
|
|
MechRenderTree &render_tree = tree_it->second;
|
|
|
|
//
|
|
// Drop the wreck hulk + strewn debris (the renderables leak -- the component
|
|
// dtor does not cascade, same as the wreck swap -- but hiding them removes
|
|
// them from the draw and stops TickWreck from sinking a nulled tree).
|
|
//
|
|
if (render_tree.wreckHulk != NULL) render_tree.wreckHulk->SetDrawObj(NULL);
|
|
if (render_tree.wreckDebris != NULL) render_tree.wreckDebris->SetDrawObj(NULL);
|
|
if (render_tree.wreckFlames != NULL) render_tree.wreckFlames->SetDrawObj(NULL);
|
|
render_tree.wreckHulk = NULL;
|
|
render_tree.wreckDebris = NULL;
|
|
render_tree.wreckFlames = NULL;
|
|
render_tree.wrecked = 0;
|
|
render_tree.wreckAge = 0.0f;
|
|
render_tree.wreckRevealed = 0;
|
|
render_tree.wreckHulkObj = NULL;
|
|
render_tree.wreckDebrisObj = NULL;
|
|
render_tree.wreckFlamesObj = NULL;
|
|
|
|
//
|
|
// 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.
|
|
//
|
|
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 ("
|
|
<< restored << " segs restored, hulk dropped)\n" << std::flush;
|
|
}
|
|
|
|
|
|
//
|
|
//#############################################################################
|
|
// BTRemakeMechModel (sim-side bridge -- see btl4vid.hpp)
|
|
//#############################################################################
|
|
//
|
|
// Reaches the live renderer and refreshes a mech's visible model after its
|
|
// damage graphic state changed. Called from MechDeathHandler (sim TU). The
|
|
// frame loop is single-threaded (sim + render share the main thread; only the
|
|
// network RX socket runs on its own thread), so loading geometry here is safe.
|
|
//
|
|
void BTRemakeMechModel(Entity *entity)
|
|
{
|
|
if (entity == NULL || application == NULL)
|
|
return;
|
|
BTL4VideoRenderer *renderer =
|
|
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
|
if (renderer != NULL)
|
|
renderer->RemakeEntityRenderables(entity);
|
|
}
|
|
|
|
// Sim-side bridge for the respawn render un-wreck (Mech::Reset calls this to
|
|
// restore the intact model after healing).
|
|
void BTRebuildMechModel(Entity *entity)
|
|
{
|
|
if (entity == NULL || application == NULL)
|
|
return;
|
|
BTL4VideoRenderer *renderer =
|
|
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
|
if (renderer != NULL)
|
|
renderer->RebuildMechRenderables(entity);
|
|
}
|
|
|
|
|
|
//
|
|
//#############################################################################
|
|
// StartEntityEffectImplementation @004d097c (coverage-audit reconstruction)
|
|
//#############################################################################
|
|
//
|
|
// The per-zone EFFECT DISPATCHER -- the target of the whole authentic chain:
|
|
// MechDeathHandler's class-5 -> RendererManager::StartEntityEffect ->
|
|
// Renderer::StartEntityEffectMessageHandler [T0: resolves the GameModel +
|
|
// applies the ExplosionResourceTable graphic-state remap] -> this virtual.
|
|
// The binary body (@004d097c, part_014.c:5839):
|
|
// 1. resolve the zone -> its transform (+0x74, row 3 = the world position)
|
|
// and its VIDEO INDEX (+0xc4->+0xc0) = the segment slot;
|
|
// 2. look the entity up in the renderer's per-entity tree (renderer+0x3a4,
|
|
// == our mMechRenderTrees) -> the segment's DCS = the effect SOCKET;
|
|
// 3. SearchList(resource, VideoModelResourceType) -> walk the video-object
|
|
// records; atoi(name) < 1000 WARNS (the authentic gate: only INDIE/psfx
|
|
// ids attach per-zone), >= 1000 starts the effect ON the socket, tagged
|
|
// with the owning player (+0x190 -> +0x1e0) for the stop-all sweep.
|
|
// Port mapping: the socket attachment = BTStartPfxAttached (the emitter rides
|
|
// the segment via BTResolveSegmentWorld each frame); the tag = the entity
|
|
// (BTStopEntityPfx kills by entity on respawn). Layout access is by NAMED
|
|
// members only (zone->segmentIndex; the binary micro-offsets differ on our
|
|
// compiled classes -- the databinding trap).
|
|
//
|
|
void
|
|
BTL4VideoRenderer::StartEntityEffectImplementation(
|
|
Entity *entity,
|
|
DamageZone *damage_zone,
|
|
ResourceDescription::ResourceID resource_ID
|
|
)
|
|
{
|
|
if (entity == NULL || damage_zone == NULL)
|
|
{
|
|
DEBUG_STREAM << "StartEntityEffectImplementation: no entity/zone" << std::endl;
|
|
return;
|
|
}
|
|
|
|
// 1. the zone's segment slot (binary +0xc4->+0xc0; our named member)
|
|
int seg_index = ((Mech__DamageZone *)damage_zone)->EffectSegmentIndex();
|
|
|
|
// 2. the segment's world position + frame (binary: zone transform +0x74)
|
|
extern int BTResolveSegmentWorld(void *entity, int seg_index, float *pos3, float *rows9);
|
|
float pos[3], rows[9];
|
|
if (!BTResolveSegmentWorld(entity, seg_index, pos, rows))
|
|
{
|
|
DEBUG_STREAM << "StartEntityEffectImplementation: entity has no segment table" << std::endl;
|
|
return;
|
|
}
|
|
|
|
// 3. the effect resource's VIDEO MODEL (type 10) -- the effect-number list
|
|
Check(application);
|
|
ResourceDescription *res = application->GetResourceFile()->SearchList(
|
|
resource_ID, ResourceDescription::VideoModelResourceType);
|
|
if (res == NULL)
|
|
{
|
|
DEBUG_STREAM << "StartEntityEffectImplementation: " << (long)resource_ID
|
|
<< " has no video resource" << std::endl;
|
|
return;
|
|
}
|
|
res->Lock();
|
|
// VideoModel payload [T0 L4VIDEO.h L4VideoObject, RES byte-verified]:
|
|
// int32 count + count x 32-byte records {char name[15]; pad; int type;
|
|
// int modes; float blinkPeriod; float pctOn} -- atoi(name) = the effect id.
|
|
const unsigned char *pay = (const unsigned char *)res->resourceAddress;
|
|
int count = *(const int *)pay;
|
|
if (count < 0 || count > 16)
|
|
count = 0; // malformed -- refuse
|
|
const unsigned char *rec = pay + 4;
|
|
for (int i = 0; i < count; ++i, rec += 32)
|
|
{
|
|
char name[16];
|
|
memcpy(name, rec, 15);
|
|
name[15] = 0;
|
|
int fx = atoi(name);
|
|
if (fx < 1000)
|
|
{
|
|
// authentic gate (@004d097c: "< 1000" warns) -- per-zone effects
|
|
// are INDIE/psfx-band only
|
|
DEBUG_STREAM << "StartEntityEffectImplementation: non-INDIE effect "
|
|
<< fx << " in zone effect list" << std::endl;
|
|
continue;
|
|
}
|
|
extern void BTStartPfxAttached(int, void *, int, float, float, float, const float *);
|
|
BTStartPfxAttached(fx - 1000, (void *)entity, seg_index,
|
|
pos[0], pos[1], pos[2], rows);
|
|
if (getenv("BT_DEATH_LOG"))
|
|
DEBUG_STREAM << "[zonefx] entity " << entity->GetEntityID()
|
|
<< " seg " << seg_index << " psfx " << (fx - 1000)
|
|
<< " at(" << pos[0] << "," << pos[1] << "," << pos[2] << ")" << std::endl;
|
|
}
|
|
res->Unlock();
|
|
}
|
|
|
|
//
|
|
// StopAllEntityEffectsImplementation @004d0c14 -- the respawn cleanup: the
|
|
// binary kills every effect tagged (playerIdx<<16 .. |0xffff); the port kills
|
|
// every emitter tagged to the entity.
|
|
//
|
|
void
|
|
BTL4VideoRenderer::StopAllEntityEffectsImplementation(Entity *entity)
|
|
{
|
|
if (entity == NULL)
|
|
{
|
|
DEBUG_STREAM << "StopAllEntityEffectsImplementation: no entity" << std::endl;
|
|
return;
|
|
}
|
|
extern void BTStopEntityPfx(void *owner);
|
|
BTStopEntityPfx((void *)entity);
|
|
}
|
|
|
|
|
|
//#############################################################################
|
|
// SwapToWreck (ExplosionScripts effect 104, reconstructed)
|
|
//#############################################################################
|
|
//
|
|
// The authentic death chain: the victim's per-mech death ModelList
|
|
// ('blhdead'/'lokdead'/... resources 22-25) dispatches effect 104, whose 1996
|
|
// script (@0045xxxx, part_008.c:2663 case 4) swaps in the burning WRECK: the
|
|
// destroyed hulk mesh + flame meshes with flicker sweeps and a slow settle.
|
|
// The 2007 port stubbed the whole script layer. This reconstruction does the
|
|
// core swap: hide every segment mesh and hang the victim's own "<prefix>dbr"
|
|
// hulk on the tree root (the 1996 code hardcoded thrdbr.bgf -- a dev shortcut;
|
|
// every mech ships its hulk: BLHDBR/MADDBR/LOKDBR/... + GENDBR the generic
|
|
// fallback). Burning comes from the effect layer (the death list also fires
|
|
// the 1007 boom + 1001 smoke plume; the wreck re-arms the plume while it
|
|
// stands). Mesh flames (flamesml/flamebig + sweep flicker) are a noted
|
|
// follow-up.
|
|
//
|
|
static std::map<Entity*, int> gBTPendingWrecks;
|
|
|
|
int BTTakePendingWreck(Entity *entity)
|
|
{
|
|
std::map<Entity*, int>::iterator it = gBTPendingWrecks.find(entity);
|
|
if (it == gBTPendingWrecks.end())
|
|
return 0;
|
|
gBTPendingWrecks.erase(it);
|
|
return 1;
|
|
}
|
|
|
|
void
|
|
BTL4VideoRenderer::SwapToWreck(Entity *victim)
|
|
{
|
|
std::map<Entity*, MechRenderTree>::iterator tree_it =
|
|
mMechRenderTrees.find(victim);
|
|
if (tree_it == mMechRenderTrees.end())
|
|
{
|
|
gBTPendingWrecks[victim] = 1; // died before the tree was built
|
|
return;
|
|
}
|
|
MechRenderTree &render_tree = tree_it->second;
|
|
if (render_tree.wrecked)
|
|
return;
|
|
|
|
//
|
|
// The victim's model prefix, from any segment's intact video-object name
|
|
// (e.g. "blh_rfot.bgf" -> "blh" -> "blhdbr.bgf").
|
|
//
|
|
char hulk_name[44];
|
|
hulk_name[0] = '\0';
|
|
{
|
|
JointedMover *jm = (JointedMover *)victim;
|
|
EntitySegment::SegmentTableIterator it(jm->segmentTable);
|
|
EntitySegment *segment;
|
|
while ((segment = it.ReadAndNext()) != NULL)
|
|
{
|
|
if (segment->IsSiteSegment() != 0)
|
|
continue;
|
|
CString *nm = segment->GetVideoObjectName(
|
|
(EntitySegment::SkeletonType)render_tree.skeletonType, 0);
|
|
if (nm != NULL && strlen((const char *)*nm) >= 3)
|
|
{
|
|
strncpy(hulk_name, (const char *)*nm, 3);
|
|
hulk_name[3] = '\0';
|
|
strcat(hulk_name, "dbr.bgf");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
d3d_OBJECT *hulk = (hulk_name[0] != '\0')
|
|
? d3d_OBJECT::LoadObject(GetDevice(), hulk_name) : NULL;
|
|
// EMPTY-PLACEHOLDER guard: some shipped hulks are 153-byte stubs with ZERO
|
|
// geometry (THRDBR.BGF, FLAMESML.BGF) -- they "load" fine and draw nothing,
|
|
// so the missing-file fallback alone never fires. Treat no-vertex hulks
|
|
// as missing.
|
|
if (hulk != NULL && hulk->GetVertCount() == 0)
|
|
{
|
|
DEBUG_STREAM << "[BTrender] wreck: '" << hulk_name
|
|
<< "' is an EMPTY placeholder -> gendbr.bgf fallback (entity="
|
|
<< victim->GetEntityID() << ")\n" << std::flush;
|
|
hulk = NULL;
|
|
}
|
|
if (hulk == NULL)
|
|
{
|
|
DEBUG_STREAM << "[BTrender] wreck: '" << hulk_name
|
|
<< "' missing -> gendbr.bgf fallback (entity="
|
|
<< victim->GetEntityID() << ")\n" << std::flush;
|
|
hulk = d3d_OBJECT::LoadObject(GetDevice(), "gendbr.bgf");
|
|
}
|
|
|
|
//
|
|
// The strewn-debris field that accompanies the standing hulk (the 1996
|
|
// script pairs them: thrdbr + ldbr, parented together, sinking together).
|
|
//
|
|
d3d_OBJECT *debris = d3d_OBJECT::LoadObject(GetDevice(), "ldbr.bgf");
|
|
|
|
//
|
|
// The burning-wreck FLAMES (the 1996 case-4 "fires" branch): flamesml +
|
|
// flamebig hung over the pile; flamebig is Y-BILLBOARDED toward the camera
|
|
// (dpl_SetDCSReorientAxes) and the fires DCS falls at -0.01 (slower than
|
|
// the hulk's -0.025, so the flames ride above the sinking pile). In this
|
|
// content build FLAMESML.BGF is an empty placeholder -- only flamebig
|
|
// carries geometry (btfx:firesmoke1_mtl, the scrolling fire-noise card,
|
|
// animated by the authored SCROLL now honoured in the BGF path).
|
|
//
|
|
d3d_OBJECT *flames = d3d_OBJECT::LoadObject(GetDevice(), "flamebig.bgf");
|
|
if (flames != NULL && flames->GetVertCount() == 0)
|
|
flames = NULL;
|
|
|
|
//
|
|
// Hide the body; hang the wreck pieces on the tree root (identity offset --
|
|
// the root renderable already pushes the wreck's localToWorld, so they sit
|
|
// at the mech's ground position with its death yaw). All pieces start
|
|
// HIDDEN: the 1996 script's InstanceSwitchRenderables reveal them 0.25s
|
|
// after the boom (behind the dnboom flash) -- TickWreck runs the reveal.
|
|
//
|
|
for (std::map<int, HierarchicalDrawComponent*>::iterator r =
|
|
render_tree.segRenderable.begin();
|
|
r != render_tree.segRenderable.end(); ++r)
|
|
{
|
|
if (r->second != NULL)
|
|
r->second->SetDrawObj(NULL);
|
|
}
|
|
if (render_tree.rootRenderable != NULL)
|
|
{
|
|
dpl_ISECT_MODE isect_mode;
|
|
LinearMatrix identity(True);
|
|
if (hulk != NULL)
|
|
{
|
|
render_tree.wreckHulk = new DPLStaticChildRenderable(
|
|
victim, false /* main zone */, hulk,
|
|
isect_mode, INTERSECT_ALL, identity, render_tree.rootRenderable);
|
|
render_tree.wreckHulk->SetDrawObj(NULL); // hidden until the reveal
|
|
}
|
|
if (debris != NULL)
|
|
{
|
|
render_tree.wreckDebris = new DPLStaticChildRenderable(
|
|
victim, false /* main zone */, debris,
|
|
isect_mode, INTERSECT_ALL, identity, render_tree.rootRenderable);
|
|
render_tree.wreckDebris->SetDrawObj(NULL);
|
|
}
|
|
if (flames != NULL)
|
|
{
|
|
render_tree.wreckFlames = new DPLStaticChildRenderable(
|
|
victim, false /* main zone */, flames,
|
|
isect_mode, INTERSECT_ALL, identity, render_tree.rootRenderable);
|
|
render_tree.wreckFlames->SetDrawObj(NULL);
|
|
}
|
|
}
|
|
render_tree.wrecked = 1;
|
|
render_tree.wreckAge = 0.0f;
|
|
render_tree.wreckRevealed = 0;
|
|
render_tree.wreckHulkObj = hulk;
|
|
render_tree.wreckDebrisObj = debris;
|
|
render_tree.wreckFlamesObj = flames;
|
|
DEBUG_STREAM << "[BTrender] wreck swap: victim -> '"
|
|
<< (hulk_name[0] ? hulk_name : "gendbr.bgf")
|
|
<< (hulk ? "'" : "' (LOAD FAILED -- body hidden only)")
|
|
<< (debris ? " + ldbr debris" : "")
|
|
<< (flames ? " + flamebig flames" : "")
|
|
<< " (reveal in 0.25s)\n" << std::flush;
|
|
}
|
|
|
|
|
|
//
|
|
//#############################################################################
|
|
// TickWreck -- the wreck's quadratic SINK (the 1996 burial)
|
|
//#############################################################################
|
|
//
|
|
// FUN_00456410 (the 1996 sink renderable): offsetY = rate * t^2, hulk rate
|
|
// -0.025 (armed 0.25s after the boom by a sweep trigger). The ~7-unit hulk is
|
|
// fully underground ~17s after the kill -- the wreck visual "fades away" by
|
|
// burial; the ENTITY (sim/collision) stays, per the wreck-stays rule. Once
|
|
// buried, the pieces are hidden and the sink stops.
|
|
//
|
|
int
|
|
BTL4VideoRenderer::TickWreck(Entity *victim, float dt)
|
|
{
|
|
std::map<Entity*, MechRenderTree>::iterator tree_it =
|
|
mMechRenderTrees.find(victim);
|
|
if (tree_it == mMechRenderTrees.end())
|
|
return 1; // no tree yet -- not buried
|
|
MechRenderTree &render_tree = tree_it->second;
|
|
if (!render_tree.wrecked)
|
|
return 1; // not swapped yet
|
|
if (render_tree.wreckHulk == NULL && render_tree.wreckDebris == NULL)
|
|
return 0; // already buried
|
|
|
|
render_tree.wreckAge += dt;
|
|
|
|
//
|
|
// The 0.25s REVEAL (the 1996 InstanceSwitch delay): the pieces appear
|
|
// behind the dnboom flash, then MakeDCSFall arms and the burial starts.
|
|
//
|
|
const float kRevealDelay = 0.25f; // static_debris/fires_delay
|
|
if (!render_tree.wreckRevealed)
|
|
{
|
|
if (render_tree.wreckAge < kRevealDelay)
|
|
return 1;
|
|
render_tree.wreckRevealed = 1;
|
|
if (render_tree.wreckHulk)
|
|
render_tree.wreckHulk->SetDrawObj(render_tree.wreckHulkObj);
|
|
if (render_tree.wreckDebris)
|
|
render_tree.wreckDebris->SetDrawObj(render_tree.wreckDebrisObj);
|
|
if (render_tree.wreckFlames)
|
|
render_tree.wreckFlames->SetDrawObj(render_tree.wreckFlamesObj);
|
|
}
|
|
|
|
// MakeDCSFall: offsetY = -1/2 g t^2 from the reveal; hulk/debris g=0.025,
|
|
// fires g=0.01 (the flames ride above the sinking pile).
|
|
float t = render_tree.wreckAge - kRevealDelay;
|
|
if (t < 0.0f) t = 0.0f;
|
|
float sink = -0.025f * t * t; // the authored hulk rate
|
|
if (sink < -8.0f)
|
|
{
|
|
// fully buried -> hide + stop ticking (the flames die with the pile:
|
|
// the original removed the whole death entity here)
|
|
if (render_tree.wreckHulk) render_tree.wreckHulk->SetDrawObj(NULL);
|
|
if (render_tree.wreckDebris) render_tree.wreckDebris->SetDrawObj(NULL);
|
|
if (render_tree.wreckFlames) render_tree.wreckFlames->SetDrawObj(NULL);
|
|
render_tree.wreckHulk = NULL;
|
|
render_tree.wreckDebris = NULL;
|
|
render_tree.wreckFlames = NULL;
|
|
DEBUG_STREAM << "[BTrender] wreck buried (sink complete)\n" << std::flush;
|
|
return 0;
|
|
}
|
|
if (render_tree.wreckHulk)
|
|
render_tree.wreckHulk->SetOffsetTranslation(0.0f, sink, 0.0f);
|
|
if (render_tree.wreckDebris)
|
|
render_tree.wreckDebris->SetOffsetTranslation(0.0f, sink, 0.0f);
|
|
if (render_tree.wreckFlames)
|
|
{
|
|
render_tree.wreckFlames->SetOffsetTranslation(0.0f, -0.01f * t * t, 0.0f);
|
|
|
|
//
|
|
// Y-BILLBOARD the flame card at the camera (dpl_SetDCSReorientAxes
|
|
// analog). The parent DCS applies the victim's localToWorld (its
|
|
// death yaw), so the camera direction is taken in the victim's LOCAL
|
|
// frame: d_local = R^T * (cam - wreck) using the orthonormal rotation
|
|
// rows of localToWorld.
|
|
//
|
|
float cx, cy, cz;
|
|
d3d_OBJECT::GetCameraPosition(&cx, &cy, &cz);
|
|
float dx = cx - (float)victim->localOrigin.linearPosition.x;
|
|
float dz = cz - (float)victim->localOrigin.linearPosition.z;
|
|
float lx = dx * (float)victim->localToWorld(0, 0) + dz * (float)victim->localToWorld(0, 2);
|
|
float lz = dx * (float)victim->localToWorld(2, 0) + dz * (float)victim->localToWorld(2, 2);
|
|
if (lx * lx + lz * lz > 1e-6f)
|
|
render_tree.wreckFlames->SetOffsetYaw(atan2f(lx, lz));
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// 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;
|
|
// ORPHAN inventory: drawn ops NO zone claimed. These panels never
|
|
// darken -- if the big hull materials land here (a .DZM name the mesh
|
|
// doesn't carry, or per-zone material instancing collapsed at load),
|
|
// the mech reads "no darkening" live even while every bound op logs
|
|
// perfectly (the #87 visibility gap).
|
|
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;
|
|
int claimed = 0;
|
|
for (size_t b = 0; b < tree.dmgBinds.size() && !claimed; ++b)
|
|
if (tree.dmgBinds[b].obj == obj && tree.dmgBinds[b].op == op)
|
|
claimed = 1;
|
|
if (!claimed)
|
|
DEBUG_STREAM << "[armor] ORPHAN op seg=" << si->first
|
|
<< " '" << draw_op->dzMatName << "'\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;
|
|
}
|
|
}
|
|
|
|
|
|
//
|
|
//#############################################################################
|
|
// TickSearchlight -- the two 1995 searchlight watchers, transcribed
|
|
//#############################################################################
|
|
//
|
|
// External cone (@0045612c, Execute @004561d8): poll the subsystem's LightOn
|
|
// attribute; on change, set the spot instance visible iff the value matches
|
|
// (match value 1) and flush. Our equivalent of SetInstanceOn/Flush is the
|
|
// SetDrawObj object swap (the wreck-reveal mechanism).
|
|
//
|
|
// Cockpit fog (@00456778, Execute @00456814): poll BOTH stashed attrs; when
|
|
// either changed, either-on -> SetFogStyle(searchLightOnFogStyle) else
|
|
// SetFogStyle(searchLightOffFogStyle). The ctor's INVERTED caches make the
|
|
// first poll always fire, normalizing a night start onto nosearchlightfog --
|
|
// the authentic dark (fog= is the LIT set; see BTDPL.INI per-page authoring).
|
|
//
|
|
void
|
|
BTL4VideoRenderer::TickSearchlight(Entity *mech)
|
|
{
|
|
std::map<Entity*, MechRenderTree>::iterator ti = mMechRenderTrees.find(mech);
|
|
if (ti == mMechRenderTrees.end() || ti->second.searchLightCount <= 0)
|
|
return;
|
|
MechRenderTree &tree = ti->second;
|
|
|
|
int any_on = 0, changed = 0;
|
|
for (int sl = 0; sl < tree.searchLightCount; ++sl)
|
|
{
|
|
MechRenderTree::SearchLight &light = tree.searchLight[sl];
|
|
if (light.lightOn == NULL)
|
|
continue;
|
|
const int cur = (*light.lightOn != 0);
|
|
|
|
if (light.cone != NULL && cur != light.shown) // @004561d8
|
|
{
|
|
light.cone->SetDrawObj(cur ? light.coneObj : NULL);
|
|
light.shown = cur;
|
|
if (getenv("BT_FIRE_LOG") || getenv("BT_FOG_LOG"))
|
|
DEBUG_STREAM << "[spot] cone " << (cur ? "SHOWN" : "HIDDEN")
|
|
<< " (seg " << light.mountSeg << ")\n" << std::flush;
|
|
}
|
|
|
|
if (cur != tree.searchFogCache[sl]) // @00456814
|
|
changed = 1;
|
|
tree.searchFogCache[sl] = cur;
|
|
any_on |= cur;
|
|
}
|
|
|
|
if (tree.searchIsCockpit && changed)
|
|
{
|
|
SetFogStyle(any_on ? searchLightOnFogStyle : searchLightOffFogStyle);
|
|
if (getenv("BT_FIRE_LOG") || getenv("BT_FOG_LOG"))
|
|
DEBUG_STREAM << "[spot] cockpit fog -> "
|
|
<< (any_on ? "searchLightOn" : "searchLightOff") << "\n" << std::flush;
|
|
}
|
|
}
|
|
|
|
|
|
//
|
|
// 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);
|
|
renderer->TickSearchlight(mech); // the searchlight watchers ride the same beat
|
|
}
|
|
|
|
|
|
//
|
|
// Sim-side bridge (UpdateDeathState drives the sink each dead frame).
|
|
//
|
|
int BTWreckSinkTick(Entity *victim, float dt)
|
|
{
|
|
if (victim == NULL || application == NULL)
|
|
return 1;
|
|
BTL4VideoRenderer *renderer =
|
|
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
|
if (renderer == NULL)
|
|
return 1;
|
|
return renderer->TickWreck(victim, dt);
|
|
}
|
|
|
|
|
|
//
|
|
// #124 -- the pick's TRIANGLE cache. The 1995 pick was a dpl scene
|
|
// intersection against the DRAWN GEOMETRY (the division card cast from the
|
|
// view); the port's sphere approximation measurably mis-picked (the zone-walk
|
|
// matrix: aim dead-on dtorso -> picked rgun/ruleg -- gun/limb spheres thread
|
|
// the ray before the torso from most angles, and its own comments admitted "a
|
|
// foot can be unhittable behind its own knee"). Restore the authentic
|
|
// semantic: nearest RAY-TRIANGLE hit across the candidate segments' posed
|
|
// meshes. Positions are read ONCE per d3d_OBJECT from its own BGF buffers
|
|
// (managed pool, lockable) and cached CPU-side; the per-frame cost is a
|
|
// sphere pre-filter + Moller-Trumbore over the few threaded segments.
|
|
//
|
|
struct BTPickMesh
|
|
{
|
|
std::vector<float> pos; // xyz per vertex
|
|
std::vector<unsigned int> idx; // triangle list
|
|
int ok;
|
|
};
|
|
static std::map<d3d_OBJECT *, BTPickMesh> gBTPickMeshes;
|
|
|
|
static BTPickMesh *
|
|
BTGetPickMesh(d3d_OBJECT *obj)
|
|
{
|
|
std::map<d3d_OBJECT *, BTPickMesh>::iterator mi = gBTPickMeshes.find(obj);
|
|
if (mi != gBTPickMeshes.end())
|
|
return mi->second.ok ? &mi->second : 0;
|
|
|
|
BTPickMesh &pm = gBTPickMeshes[obj];
|
|
pm.ok = 0;
|
|
if (obj->mBgfVB == 0 || obj->mBgfIB == 0 || obj->mBgfStride < 12)
|
|
return 0;
|
|
|
|
D3DINDEXBUFFER_DESC ibd;
|
|
if (FAILED(obj->mBgfIB->GetDesc(&ibd)))
|
|
return 0;
|
|
int idx32 = (ibd.Format == D3DFMT_INDEX32);
|
|
unsigned int nIdx = ibd.Size / (idx32 ? 4 : 2);
|
|
|
|
void *vp = 0, *ip = 0;
|
|
if (FAILED(obj->mBgfVB->Lock(0, 0, &vp, D3DLOCK_READONLY)))
|
|
return 0;
|
|
if (FAILED(obj->mBgfIB->Lock(0, 0, &ip, D3DLOCK_READONLY)))
|
|
{
|
|
obj->mBgfVB->Unlock();
|
|
return 0;
|
|
}
|
|
pm.pos.resize((size_t)obj->mBgfNumVerts * 3);
|
|
const unsigned char *vb = (const unsigned char *)vp;
|
|
for (UINT v = 0; v < obj->mBgfNumVerts; ++v)
|
|
{
|
|
const float *p = (const float *)(vb + (size_t)v * obj->mBgfStride);
|
|
pm.pos[v*3+0] = p[0]; // position-first vertex layout (the BGF
|
|
pm.pos[v*3+1] = p[1]; // loader's own decl; the cull sphere was
|
|
pm.pos[v*3+2] = p[2]; // computed from these same floats at load)
|
|
}
|
|
pm.idx.resize(nIdx);
|
|
if (idx32)
|
|
{
|
|
const unsigned int *s = (const unsigned int *)ip;
|
|
for (unsigned int k = 0; k < nIdx; ++k) pm.idx[k] = s[k];
|
|
}
|
|
else
|
|
{
|
|
const unsigned short *s = (const unsigned short *)ip;
|
|
for (unsigned int k = 0; k < nIdx; ++k) pm.idx[k] = s[k];
|
|
}
|
|
obj->mBgfIB->Unlock();
|
|
obj->mBgfVB->Unlock();
|
|
pm.ok = (pm.idx.size() >= 3 && pm.pos.size() >= 9);
|
|
return pm.ok ? &pm : 0;
|
|
}
|
|
|
|
// Moller-Trumbore, both-sided (the pod's dpl geometry has no consistent
|
|
// winding guarantee across ported BGF pieces). Returns t >= 0 or -1.
|
|
static float
|
|
BTRayTri(const float o[3], const float d[3],
|
|
const float *a, const float *b, const float *c)
|
|
{
|
|
float e1[3] = { b[0]-a[0], b[1]-a[1], b[2]-a[2] };
|
|
float e2[3] = { c[0]-a[0], c[1]-a[1], c[2]-a[2] };
|
|
float pv[3] = { d[1]*e2[2]-d[2]*e2[1], d[2]*e2[0]-d[0]*e2[2], d[0]*e2[1]-d[1]*e2[0] };
|
|
float det = e1[0]*pv[0] + e1[1]*pv[1] + e1[2]*pv[2];
|
|
if (det > -1e-8f && det < 1e-8f) return -1.0f;
|
|
float inv = 1.0f / det;
|
|
float tv[3] = { o[0]-a[0], o[1]-a[1], o[2]-a[2] };
|
|
float u = (tv[0]*pv[0] + tv[1]*pv[1] + tv[2]*pv[2]) * inv;
|
|
if (u < 0.0f || u > 1.0f) return -1.0f;
|
|
float qv[3] = { tv[1]*e1[2]-tv[2]*e1[1], tv[2]*e1[0]-tv[0]*e1[2], tv[0]*e1[1]-tv[1]*e1[0] };
|
|
float v = (d[0]*qv[0] + d[1]*qv[1] + d[2]*qv[2]) * inv;
|
|
if (v < 0.0f || u + v > 1.0f) return -1.0f;
|
|
float t = (e2[0]*qv[0] + e2[1]*qv[1] + e2[2]*qv[2]) * inv;
|
|
return (t >= 0.0f) ? t : -1.0f;
|
|
}
|
|
|
|
//
|
|
// #73 -- the aimed PER-PART pick (see the header note). #124: now a true
|
|
// DRAWN-GEOMETRY intersection -- sphere pre-filter, then nearest ray-triangle
|
|
// hit across the threaded segments' posed meshes (the 1995 division-card
|
|
// semantic). The old smallest-sphere selection survives only as the fallback
|
|
// when no triangle anywhere is struck (grazing edge shots). World transforms
|
|
// come through the draw-cached mLocalToWorld (at most one frame stale).
|
|
//
|
|
int
|
|
BTL4VideoRenderer::MechSegmentPick(
|
|
Entity *mech,
|
|
const float ray_start[3],
|
|
const float ray_dir[3],
|
|
float max_range,
|
|
float hit_out[3],
|
|
int *zone_out)
|
|
{
|
|
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
|
|
if (it == mMechRenderTrees.end() || it->second.wrecked)
|
|
return -1; // #131: NO TREE (unbuilt replicant / wreck) -- the caller may
|
|
// box-test; distinct from 0 = a true drawn-geometry MISS.
|
|
|
|
// Selection is SPECIFICITY-FIRST: among the spheres the ray pierces, the
|
|
// SMALLEST radius wins (normalized-distance tie-break). Neither nearest-
|
|
// entry nor pure normalized distance works here, and both were measured
|
|
// failing the same way: the torso mesh's sphere (r~4.1 on the MadCat)
|
|
// envelops nearly the whole mech, so its front face is nearest for any aim
|
|
// AND any near-body ray scores ~0 against it (d/r rewards giant spheres).
|
|
// The limb spheres (shoulders r~1.0, guns r~2.2) nest INSIDE the torso
|
|
// envelope; smallest-pierced picks the most specific part on the aim line,
|
|
// and the torso wins only when no limb is threaded -- which is the per-part
|
|
// semantic the 1995 mesh intersection produced.
|
|
float bestR = 1e30f; // sphere-FALLBACK key: radius (ascending)
|
|
float bestScore = 1.0f; // sphere tie-break: normalized d2/r2
|
|
float bestT = max_range;
|
|
int bestZone = -1;
|
|
int hitAny = 0;
|
|
|
|
// triangle-accurate primary: nearest surface hit across all segments
|
|
float triBestT = max_range;
|
|
int triBestZone = -1;
|
|
int triBestSeg = -1;
|
|
int triHit = 0;
|
|
d3d_OBJECT *triBestObj = 0; // #124-PATCH: winning object + tri
|
|
size_t triBestK = 0; // (triangle ordinal in its IB)
|
|
int dbgTriObjs = 0, dbgInvFail = 0, dbgNoTri = 0; // #131 telemetry
|
|
|
|
// #124 CORRECTED (2026-08-04, field pushback vindicated): the pick's zone
|
|
// is the struck PATCH's authored dz_* tag, with the segment's SKL dzone as
|
|
// the fallback for untagged patches. The earlier "struck segment's dzone,
|
|
// ALWAYS" model collapsed the hull to dtorso -- but the art zone-tags the
|
|
// hull PER PANEL (MAD_TOR carries dz_utorso/ltorso/rtorso/dtorso + all four
|
|
// rear panels + searchlight across its patches), the dpl hit result kept
|
|
// GEOGROUP granularity (dplHitInstance/DCS/GeoGroup/Geometry), and the
|
|
// binary's segment->zone map (@49db20) has NO runtime caller (sole caller =
|
|
// CreateStreamedDamageZone, load-time) -- there was nothing to do the
|
|
// segment-level collapse with. Era players' "I stripped the left torso in
|
|
// the pod" stands. The triangle -> patch attribution below reuses the .DZM
|
|
// zone->material bindings (#87's armour darkening -- the same authored
|
|
// mapping that already paints those panels per-zone).
|
|
|
|
std::map<int, MechRenderTree::SegPick>::iterator sp;
|
|
for (sp = it->second.segPick.begin(); sp != it->second.segPick.end(); ++sp)
|
|
{
|
|
d3d_OBJECT *obj = sp->second.obj;
|
|
if (obj == NULL || obj->mCullRadius <= 0.0f)
|
|
continue;
|
|
|
|
D3DXMATRIX l2w = obj->GetLocalToWorld();
|
|
D3DXVECTOR3 cw;
|
|
D3DXVec3TransformCoord(&cw, &obj->mCullCenter, &l2w);
|
|
|
|
float ocx = cw.x - ray_start[0];
|
|
float ocy = cw.y - ray_start[1];
|
|
float ocz = cw.z - ray_start[2];
|
|
float tca = ocx*ray_dir[0] + ocy*ray_dir[1] + ocz*ray_dir[2];
|
|
float r = obj->mCullRadius;
|
|
float oc2 = ocx*ocx + ocy*ocy + ocz*ocz;
|
|
if (tca < 0.0f && oc2 > r*r)
|
|
continue; // wholly behind the ray
|
|
float d2 = oc2 - tca*tca;
|
|
float r2 = r*r;
|
|
if (d2 > r2)
|
|
continue; // ray passes outside the sphere
|
|
float thc = sqrtf(r2 - d2);
|
|
float t = tca - thc;
|
|
if (t < 0.0f)
|
|
t = tca + thc; // ray starts inside: exit point
|
|
if (t < 0.0f || t >= max_range)
|
|
continue;
|
|
|
|
// ---- TRIANGLE TEST (#124): the sphere only nominates ----
|
|
BTPickMesh *pm = BTGetPickMesh(obj);
|
|
if (pm == 0 && getenv("BT_PICK_LOG"))
|
|
{
|
|
static int s_nt = 0;
|
|
if (s_nt++ < 40)
|
|
DEBUG_STREAM << "[picktri] seg=" << sp->first << " zone="
|
|
<< sp->second.zone << " NO TRIS (vb=" << (void *)obj->mBgfVB
|
|
<< " ib=" << (void *)obj->mBgfIB
|
|
<< " stride=" << obj->mBgfStride << ")\n" << std::flush;
|
|
}
|
|
if (pm == 0)
|
|
++dbgNoTri;
|
|
if (pm != 0)
|
|
{
|
|
++dbgTriObjs;
|
|
// ray into object-local space (affine inverse; segment poses are
|
|
// rigid, so local t == world t after direction normalization is
|
|
// preserved by construction below)
|
|
D3DXMATRIX w2l;
|
|
if (D3DXMatrixInverse(&w2l, 0, &l2w) == 0)
|
|
++dbgInvFail;
|
|
if (D3DXMatrixInverse(&w2l, 0, &l2w) != 0)
|
|
{
|
|
D3DXVECTOR3 lo, ld;
|
|
D3DXVECTOR3 wo(ray_start[0], ray_start[1], ray_start[2]);
|
|
D3DXVECTOR3 wd(ray_dir[0], ray_dir[1], ray_dir[2]);
|
|
D3DXVec3TransformCoord(&lo, &wo, &w2l);
|
|
D3DXVec3TransformNormal(&ld, &wd, &w2l);
|
|
float o3[3] = { lo.x, lo.y, lo.z };
|
|
float d3[3] = { ld.x, ld.y, ld.z };
|
|
const float *P = &pm->pos[0];
|
|
size_t nv = pm->pos.size() / 3;
|
|
for (size_t k = 0; k + 2 < pm->idx.size(); k += 3)
|
|
{
|
|
unsigned int i0 = pm->idx[k], i1 = pm->idx[k+1], i2 = pm->idx[k+2];
|
|
if (i0 >= nv || i1 >= nv || i2 >= nv)
|
|
continue;
|
|
float tt = BTRayTri(o3, d3, P + i0*3, P + i1*3, P + i2*3);
|
|
if (tt >= 0.0f && tt < triBestT)
|
|
{
|
|
// world-space t of the local hit (handles any scale)
|
|
D3DXVECTOR3 lh(o3[0]+d3[0]*tt, o3[1]+d3[1]*tt, o3[2]+d3[2]*tt);
|
|
D3DXVECTOR3 wh;
|
|
D3DXVec3TransformCoord(&wh, &lh, &l2w);
|
|
float wt = (wh.x - ray_start[0]) * ray_dir[0]
|
|
+ (wh.y - ray_start[1]) * ray_dir[1]
|
|
+ (wh.z - ray_start[2]) * ray_dir[2];
|
|
if (wt >= 0.0f && wt < triBestT)
|
|
{
|
|
triBestT = wt;
|
|
triBestZone = sp->second.zone; // segment dzone (fallback)
|
|
triBestSeg = sp->first;
|
|
triBestObj = obj;
|
|
triBestK = k; // first index of the tri
|
|
triHit = 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- sphere FALLBACK bookkeeping -- #131 MISS-MEANS-MISS: only an
|
|
// object whose drawn mesh is UNREADABLE (pm == 0) may answer by
|
|
// sphere. A readable mesh the ray misses is a MISS. The old
|
|
// any-object sphere halo produced lock-without-mech: level rays over
|
|
// the squat blackhawk read tri=0 / sphereFB=168 on the sweep bench,
|
|
// and the operator watched the reticle pass over its head with the
|
|
// ring lit. The 1995 card cast against the DRAWN geometry -- no halo.
|
|
if (pm != 0)
|
|
continue;
|
|
float score = d2 / r2; // 0 = dead-center thread
|
|
if (r > bestR
|
|
|| (r == bestR && score >= bestScore))
|
|
continue;
|
|
|
|
bestR = r;
|
|
bestScore = score;
|
|
bestT = t;
|
|
bestZone = sp->second.zone;
|
|
hitAny = 1;
|
|
}
|
|
|
|
// #131 telemetry: how did this pick answer? (TRI = drawn-geometry hit,
|
|
// the authentic semantic; SPHERE = the fallback answered with no triangle
|
|
// struck -- the false-lock candidate.) Throttled under BT_PICK_LOG.
|
|
// objs/inv/noTri localize a dead triangle pass: objs = sphere-nominated
|
|
// objects entering the tri test, inv = SILENT skips from a failed
|
|
// world-matrix inverse (degenerate l2w), noTri = pick-mesh read failures.
|
|
if (getenv("BT_PICK_LOG"))
|
|
{
|
|
static int s_src[3] = {0,0,0}; // [0]=tri [1]=sphere [2]=miss
|
|
static int s_objs = 0, s_inv = 0, s_noTri = 0;
|
|
static int s_rep = 0;
|
|
s_objs += dbgTriObjs; s_inv += dbgInvFail; s_noTri += dbgNoTri;
|
|
++s_src[triHit ? 0 : (hitAny ? 1 : 2)];
|
|
if ((++s_rep % 240) == 0)
|
|
DEBUG_STREAM << "[picksrc] tri=" << s_src[0]
|
|
<< " sphereFB=" << s_src[1] << " miss=" << s_src[2]
|
|
<< " objs=" << s_objs << " invFail=" << s_inv
|
|
<< " noTri=" << s_noTri
|
|
<< "\n" << std::flush;
|
|
}
|
|
|
|
if (triHit)
|
|
{
|
|
bestT = triBestT;
|
|
bestZone = triBestZone; // segment dzone unless a patch tags it
|
|
hitAny = 1;
|
|
// #124-PATCH: attribute the struck triangle to its DRAW OP (each op is
|
|
// an index RANGE in the same IB the pick mesh copied) and take the
|
|
// op's .DZM-bound zone -- the authored dz_* panel. dmgBinds is the
|
|
// #87 armour mapping: {obj, op, zone} for every material a zone paints.
|
|
if (triBestObj != 0)
|
|
{
|
|
const std::vector<MechRenderTree::DmgBind> &binds = it->second.dmgBinds;
|
|
for (size_t b = 0; b < binds.size(); ++b)
|
|
{
|
|
if (binds[b].obj != triBestObj)
|
|
continue;
|
|
L4DRAWOP *op = triBestObj->GetDrawOp(binds[b].op);
|
|
if (op == 0 || op->bgfPrimCount <= 0)
|
|
continue;
|
|
size_t first = (size_t)op->bgfStartIndex;
|
|
size_t count = (size_t)op->bgfPrimCount * 3;
|
|
if (triBestK >= first && triBestK < first + count)
|
|
{
|
|
bestZone = binds[b].zone; // the struck PANEL's zone
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
(void)triBestSeg;
|
|
}
|
|
|
|
// #92 probe: which spheres did the ray actually THREAD, and which won?
|
|
// "smallest radius wins" means a big sphere can never beat a small one that
|
|
// the ray also grazes -- so a foot can be unhittable behind its own knee.
|
|
if (getenv("BT_PICK_LOG"))
|
|
{
|
|
static int s_pc = 0;
|
|
if ((s_pc++ % 60) == 0)
|
|
{
|
|
// one-shot: EVERY sphere in world space, so the foot/knee geometry
|
|
// can be worked out analytically rather than by aim-hunting
|
|
static int s_dumped = 0;
|
|
if (!s_dumped)
|
|
{
|
|
s_dumped = 1;
|
|
DEBUG_STREAM << "[pickgeom] ray_start=(" << ray_start[0] << ","
|
|
<< ray_start[1] << "," << ray_start[2] << ")" << std::endl;
|
|
std::map<int, MechRenderTree::SegPick>::iterator gp;
|
|
for (gp = it->second.segPick.begin(); gp != it->second.segPick.end(); ++gp)
|
|
{
|
|
d3d_OBJECT *o3 = gp->second.obj;
|
|
if (o3 == NULL || o3->mCullRadius <= 0.0f) continue;
|
|
D3DXMATRIX m3 = o3->GetLocalToWorld();
|
|
D3DXVECTOR3 c3;
|
|
D3DXVec3TransformCoord(&c3, &o3->mCullCenter, &m3);
|
|
DEBUG_STREAM << "[pickgeom] zone=" << gp->second.zone
|
|
<< " r=" << o3->mCullRadius
|
|
<< " world=(" << c3.x << "," << c3.y << "," << c3.z << ")"
|
|
<< std::endl;
|
|
}
|
|
}
|
|
DEBUG_STREAM << "[pickcand]";
|
|
for (sp = it->second.segPick.begin(); sp != it->second.segPick.end(); ++sp)
|
|
{
|
|
d3d_OBJECT *o2 = sp->second.obj;
|
|
if (o2 == NULL || o2->mCullRadius <= 0.0f) continue;
|
|
D3DXMATRIX m2 = o2->GetLocalToWorld();
|
|
D3DXVECTOR3 c2;
|
|
D3DXVec3TransformCoord(&c2, &o2->mCullCenter, &m2);
|
|
float ax = c2.x - ray_start[0], ay = c2.y - ray_start[1], az = c2.z - ray_start[2];
|
|
float tc = ax*ray_dir[0] + ay*ray_dir[1] + az*ray_dir[2];
|
|
float rr = o2->mCullRadius;
|
|
float a2 = ax*ax + ay*ay + az*az;
|
|
if (tc < 0.0f && a2 > rr*rr) continue;
|
|
float dd = a2 - tc*tc;
|
|
if (dd > rr*rr) continue; // not threaded
|
|
DEBUG_STREAM << " {z" << sp->second.zone << " r=" << rr
|
|
<< " d=" << sqrtf(dd > 0.0f ? dd : 0.0f) << "}";
|
|
}
|
|
DEBUG_STREAM << " -> WON z" << bestZone << " r=" << bestR << std::endl;
|
|
}
|
|
}
|
|
|
|
if (!hitAny)
|
|
return 0;
|
|
|
|
if (getenv("BT_PICK_LOG"))
|
|
{
|
|
static int s_pl = 0;
|
|
if ((s_pl++ % 60) == 0)
|
|
DEBUG_STREAM << "[pickwin] zone=" << bestZone
|
|
<< " score=" << bestScore << " t=" << bestT
|
|
<< " tri=" << triHit << " triSeg=" << triBestSeg
|
|
<< " triZone=" << triBestZone << "\n" << std::flush;
|
|
}
|
|
|
|
hit_out[0] = ray_start[0] + bestT * ray_dir[0];
|
|
hit_out[1] = ray_start[1] + bestT * ray_dir[1];
|
|
hit_out[2] = ray_start[2] + bestT * ray_dir[2];
|
|
*zone_out = bestZone; // -1 = zone-less segment (lottery downstream)
|
|
return 1;
|
|
}
|
|
|
|
//
|
|
// #124 zone walker: the zone's visual aim anchor (see the hpp note).
|
|
//
|
|
int
|
|
BTL4VideoRenderer::ZoneAimPoint(Entity *mech, int zone, float out3[3])
|
|
{
|
|
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
|
|
if (it == mMechRenderTrees.end() || it->second.wrecked)
|
|
return 0;
|
|
|
|
// #124-PATCH: a zone with .DZM-bound panel patches aims at the PATCH
|
|
// CENTROID (the authored dz_* panel -- upper/left/right/rear torso are
|
|
// distinct patch clusters on ONE torso object, so the old cull-center
|
|
// answer aimed every hull zone at the same chest point). Centroid =
|
|
// average of the bound ops' triangle vertices, world-transformed.
|
|
{
|
|
double acc[3] = { 0, 0, 0 };
|
|
long nAcc = 0;
|
|
const std::vector<MechRenderTree::DmgBind> &binds = it->second.dmgBinds;
|
|
for (size_t b = 0; b < binds.size(); ++b)
|
|
{
|
|
if (binds[b].zone != zone || binds[b].obj == NULL)
|
|
continue;
|
|
d3d_OBJECT *obj = binds[b].obj;
|
|
L4DRAWOP *op = obj->GetDrawOp(binds[b].op);
|
|
if (op == 0 || op->bgfPrimCount <= 0)
|
|
continue;
|
|
BTPickMesh *pm = BTGetPickMesh(obj);
|
|
if (pm == 0)
|
|
continue;
|
|
D3DXMATRIX l2w = obj->GetLocalToWorld();
|
|
size_t first = (size_t)op->bgfStartIndex;
|
|
size_t last = first + (size_t)op->bgfPrimCount * 3;
|
|
if (last > pm->idx.size())
|
|
continue;
|
|
for (size_t k = first; k < last; ++k)
|
|
{
|
|
unsigned int vi = pm->idx[k];
|
|
if ((size_t)vi * 3 + 2 >= pm->pos.size())
|
|
continue;
|
|
D3DXVECTOR3 lp(pm->pos[vi*3], pm->pos[vi*3+1], pm->pos[vi*3+2]);
|
|
D3DXVECTOR3 wp;
|
|
D3DXVec3TransformCoord(&wp, &lp, &l2w);
|
|
acc[0] += wp.x; acc[1] += wp.y; acc[2] += wp.z;
|
|
++nAcc;
|
|
}
|
|
}
|
|
if (nAcc > 0)
|
|
{
|
|
out3[0] = (float)(acc[0] / nAcc);
|
|
out3[1] = (float)(acc[1] / nAcc);
|
|
out3[2] = (float)(acc[2] / nAcc);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
d3d_OBJECT *best = 0;
|
|
std::map<int, MechRenderTree::SegPick>::iterator sp;
|
|
for (sp = it->second.segPick.begin(); sp != it->second.segPick.end(); ++sp)
|
|
{
|
|
if (sp->second.zone != zone || sp->second.obj == NULL)
|
|
continue;
|
|
if (best == 0 || sp->second.obj->mCullRadius > best->mCullRadius)
|
|
best = sp->second.obj;
|
|
}
|
|
if (best == 0 || best->mCullRadius <= 0.0f)
|
|
return 0;
|
|
D3DXMATRIX l2w = best->GetLocalToWorld();
|
|
D3DXVECTOR3 cw;
|
|
D3DXVec3TransformCoord(&cw, &best->mCullCenter, &l2w);
|
|
out3[0] = cw.x; out3[1] = cw.y; out3[2] = cw.z;
|
|
return 1;
|
|
}
|
|
|
|
// The carrier-SEGMENT variant: anchor at segPick[seg]'s cull center (the
|
|
// piece's visual middle). For hull-family zones no object claims the zone,
|
|
// and the zone table's carrier is the hull joint whose ORIGIN sits in the
|
|
// crotch gap -- rays aimed there sail between the legs to the terrain
|
|
// sentinel. The cull center is the chest.
|
|
int
|
|
BTL4VideoRenderer::SegAimPoint(Entity *mech, int seg, float out3[3])
|
|
{
|
|
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
|
|
if (it == mMechRenderTrees.end() || it->second.wrecked)
|
|
return 0;
|
|
std::map<int, MechRenderTree::SegPick>::iterator sp = it->second.segPick.find(seg);
|
|
if (sp == it->second.segPick.end() || sp->second.obj == NULL
|
|
|| sp->second.obj->mCullRadius <= 0.0f)
|
|
return 0;
|
|
D3DXMATRIX l2w = sp->second.obj->GetLocalToWorld();
|
|
D3DXVECTOR3 cw;
|
|
D3DXVec3TransformCoord(&cw, &sp->second.obj->mCullCenter, &l2w);
|
|
out3[0] = cw.x; out3[1] = cw.y; out3[2] = cw.z;
|
|
return 1;
|
|
}
|
|
|
|
int BTMechSegAimPoint(void *mech, int seg, float out3[3])
|
|
{
|
|
if (mech == NULL || application == NULL)
|
|
return 0;
|
|
BTL4VideoRenderer *renderer =
|
|
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
|
if (renderer == NULL)
|
|
return 0;
|
|
return renderer->SegAimPoint((Entity *)mech, seg, out3);
|
|
}
|
|
|
|
int BTMechZoneAimPoint(void *mech, int zone, float out3[3])
|
|
{
|
|
if (mech == NULL || application == NULL)
|
|
return 0;
|
|
BTL4VideoRenderer *renderer =
|
|
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
|
if (renderer == NULL)
|
|
return 0;
|
|
return renderer->ZoneAimPoint((Entity *)mech, zone, out3);
|
|
}
|
|
|
|
//
|
|
// Game-side bridge (mech4.cpp's per-frame target pick; same access pattern as
|
|
// the wreck swap below).
|
|
//
|
|
int BTMechSegmentPick(void *mech, const float ray_start[3], const float ray_dir[3],
|
|
float max_range, float hit_out[3], int *zone_out)
|
|
{
|
|
// #131 contract: 1 = drawn-geometry hit, 0 = TRUE MISS (mesh tested),
|
|
// -1 = no renderer/tree (caller may box-test as pre-tree grace).
|
|
if (mech == NULL || application == NULL)
|
|
return -1;
|
|
BTL4VideoRenderer *renderer =
|
|
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
|
if (renderer == NULL)
|
|
return -1;
|
|
return renderer->MechSegmentPick((Entity *)mech, ray_start, ray_dir,
|
|
max_range, hit_out, zone_out);
|
|
}
|
|
|
|
//
|
|
// Engine-side bridge (the ExplosionClassID dispatch calls this on effect 104).
|
|
//
|
|
void BTSwapMechToWreck(Entity *victim)
|
|
{
|
|
if (victim == NULL || application == NULL)
|
|
return;
|
|
BTL4VideoRenderer *renderer =
|
|
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
|
if (renderer != NULL)
|
|
renderer->SwapToWreck(victim);
|
|
}
|
|
|
|
|
|
//
|
|
//#############################################################################
|
|
// BTReticleRenderable -- ctor (@004cc40c) + Draw (Execute @004cdcf0 is in an
|
|
// un-exported gap; the draw dynamics here are [T3], the GLYPHS are [T1])
|
|
//#############################################################################
|
|
//
|
|
// The live reticle instance (the player's; drawn by BTDrawReticle in the
|
|
// cockpit view only).
|
|
//
|
|
static BTReticleRenderable *gBTReticle = 0;
|
|
extern Scalar gBTHudRangeStorage; // live target range (defined below)
|
|
static int gBTHudInside = 0; // cockpit view live (set by SetViewInside)
|
|
|
|
// the dpl2d rasteriser (dpl2d.cpp; device type opaque here)
|
|
extern void dpl2d_ExecuteList(dpl2d_DISPLAY *list, struct IDirect3DDevice9 *device);
|
|
|
|
void BTSetHudTargetRange(Scalar range) { gBTHudRangeStorage = range; }
|
|
void BTSetHudInside(int inside) { gBTHudInside = inside; }
|
|
|
|
//
|
|
// FUN_004cd938 -- the tick-LADDER builder: `count` ticks stepped along an axis
|
|
// (dir 0/1 = +x/-x travel with vertical ticks, 2/3 = +y/-y with horizontal
|
|
// ticks), a MAJOR tick (halfMajor) every (majorEvery+1)th, minor otherwise.
|
|
//
|
|
static void
|
|
BTReticleTickLadder(dpl2d_DISPLAY *list, int count, int majorEvery,
|
|
float span, float x0, float y0, int dir, float halfMinor, float halfMajor)
|
|
{
|
|
float step = span / (float)(count - 1);
|
|
float tickMinX = 0, tickMinY = 0, tickMajX = 0, tickMajY = 0;
|
|
float stepX = 0, stepY = 0;
|
|
switch (dir)
|
|
{
|
|
case 0: stepX = step; tickMajY = halfMajor; tickMinY = halfMinor; break;
|
|
case 1: stepX = -step; tickMajY = halfMajor; tickMinY = halfMinor; break;
|
|
case 2: stepY = step; tickMajX = halfMajor; tickMinX = halfMinor; break;
|
|
case 3: stepY = -step; tickMajX = halfMajor; tickMinX = halfMinor; break;
|
|
}
|
|
float x = x0, y = y0;
|
|
int untilMajor = 0;
|
|
dpl2d_OpenLines(list);
|
|
for (int i = 0; i < count; ++i)
|
|
{
|
|
if (untilMajor == 0)
|
|
{
|
|
dpl2d_AddPoint(list, x + tickMajX, y + tickMajY);
|
|
dpl2d_AddPoint(list, x - tickMajX, y - tickMajY);
|
|
untilMajor = majorEvery;
|
|
}
|
|
else
|
|
{
|
|
dpl2d_AddPoint(list, x + tickMinX, y + tickMinY);
|
|
dpl2d_AddPoint(list, x - tickMinX, y - tickMinY);
|
|
--untilMajor;
|
|
}
|
|
x += stepX; y += stepY;
|
|
}
|
|
dpl2d_CloseLines(list);
|
|
}
|
|
|
|
//
|
|
// The authentic calibration constants (the binary ctor's own values).
|
|
//
|
|
static const float kRetOriginX = 0.35f; // [0x7f] right range-ladder x
|
|
static const float kRetOriginY = 0.25f; // [0x80] ladder bottom y
|
|
static const float kRetScaleY = 0.5f; // [0x81] ladder span
|
|
static const float kRetTickMinor = 0.008f; // [0x83]
|
|
static const float kRetTickMajor = 0.016f; // [0x82]
|
|
static const float kRetBotX = -0.25f; // [0x84] bottom heading-ladder x0
|
|
static const float kRetBotY = 0.35f; // [0x85] heading ladder y
|
|
static const float kRetBotSpan = 0.5f; // [0x86]
|
|
static const float kRetCaret = 0.025f; // _DAT_004cd7f4 (caret triangle size)
|
|
// [T1] read from the binary 2026-07-24
|
|
// (was a 0.02f guess -- 20% small)
|
|
static const int kRetTicksR = 13; // [9] right ladder tick count
|
|
static const int kRetTicksB = 21; // [0xb] bottom ladder tick count
|
|
static const float kRetMaxRange = 1200.0f; // ctor param 11 (0x44960000)
|
|
|
|
BTReticleRenderable::BTReticleRenderable(Entity *entity, Scalar *range_attr)
|
|
: VideoRenderable(entity, VideoRenderable::Dynamic)
|
|
{
|
|
weaponCount = 0;
|
|
originX = kRetOriginX; originY = kRetOriginY;
|
|
scaleY = kRetScaleY; biasX = kRetTickMajor;
|
|
maxRange = kRetMaxRange; minRange = 0.0f;
|
|
rangeScale= maxRange - minRange; // [0x8d] -- AddWeapon divides by it
|
|
rangeAttr2= range_attr;
|
|
pipsBuilt = 0; // recovered-Execute dynamic state
|
|
lockShown = 0;
|
|
lockSpinDeg= 0.0f;
|
|
|
|
masterList = dpl2d_NewDisplayList();
|
|
simpleXList = dpl2d_NewDisplayList();
|
|
aimDotList = dpl2d_NewDisplayList();
|
|
rangeCaretR = dpl2d_NewDisplayList();
|
|
rangeCaretB = dpl2d_NewDisplayList();
|
|
headingList = dpl2d_NewDisplayList();
|
|
bottomAnchor = dpl2d_NewDisplayList();
|
|
leftArrow = dpl2d_NewDisplayList();
|
|
rightArrow = dpl2d_NewDisplayList();
|
|
crossList = dpl2d_NewDisplayList();
|
|
subB6 = dpl2d_NewDisplayList();
|
|
subB7 = dpl2d_NewDisplayList();
|
|
subB8 = dpl2d_NewDisplayList();
|
|
subB9 = dpl2d_NewDisplayList();
|
|
subBA = dpl2d_NewDisplayList();
|
|
|
|
//
|
|
// The MASTER list (@4511-4601, faithfully transcribed). Colours: green
|
|
// 0.75 for the frame, yellow for the range caret; widths 1/2/3.
|
|
//
|
|
dpl2d_DISPLAY *m = masterList;
|
|
dpl2d_Begin(m, 1);
|
|
dpl2d_SetLineWidth(m, 1.0f);
|
|
dpl2d_FullScreenClip(m);
|
|
dpl2d_SetColor(m, 0.75f, 0.0f, 0.0f);
|
|
dpl2d_CallList(m, crossList); // [0xa1] target-box slot (empty until lock;
|
|
// Draw rebuilds it: designator ring at the
|
|
// target's screen point / off-screen arrows)
|
|
dpl2d_SetColor(m, 0.0f, 0.75f, 0.0f);
|
|
// the AIM GROUP (task #36): [0x9a] is the aim TRANSLATE -- CallList state
|
|
// persists to the caller (the dpl2d inline-include semantic), so the slew
|
|
// translate it carries positions the dot + crosses that follow. The
|
|
// PushState/PopState pair contains it so the fixed frame (tick ladders,
|
|
// tapes) stays put. [T3 -- the binary Execute is un-exported; mechanism
|
|
// per the engine ReticleRenderable's position-list pattern, T0.]
|
|
dpl2d_PushState(m);
|
|
dpl2d_CallList(m, aimDotList); // [0x9a] the aim translate
|
|
dpl2d_OpenPolypoint(m); // centre dot
|
|
dpl2d_AddPoint(m, 0.0f, 0.0f);
|
|
dpl2d_ClosePolypoint(m);
|
|
dpl2d_OpenLines(m); // inner cross (gap at centre)
|
|
dpl2d_AddPoint(m, 0.04f, 0.0f); dpl2d_AddPoint(m, 0.10f, 0.0f);
|
|
dpl2d_AddPoint(m, -0.04f, 0.0f); dpl2d_AddPoint(m, -0.10f, 0.0f);
|
|
dpl2d_AddPoint(m, 0.0f, 0.04f); dpl2d_AddPoint(m, 0.0f, 0.10f);
|
|
dpl2d_AddPoint(m, 0.0f, -0.04f); dpl2d_AddPoint(m, 0.0f, -0.10f);
|
|
dpl2d_CloseLines(m);
|
|
dpl2d_SetLineWidth(m, 3.0f);
|
|
dpl2d_OpenLines(m); // heavy outer cross
|
|
dpl2d_AddPoint(m, 0.10f, 0.0f); dpl2d_AddPoint(m, 0.16f, 0.0f);
|
|
dpl2d_AddPoint(m, -0.10f, 0.0f); dpl2d_AddPoint(m, -0.16f, 0.0f);
|
|
dpl2d_AddPoint(m, 0.0f, 0.10f); dpl2d_AddPoint(m, 0.0f, 0.16f);
|
|
dpl2d_AddPoint(m, 0.0f, -0.10f); dpl2d_AddPoint(m, 0.0f, -0.16f);
|
|
dpl2d_CloseLines(m);
|
|
dpl2d_PopState(m); // contain the aim translate
|
|
dpl2d_SetLineWidth(m, 1.0f);
|
|
// the RIGHT range ladder (13 ticks up the right side, dir 3 = -y travel)
|
|
BTReticleTickLadder(m, kRetTicksR, 1, kRetScaleY,
|
|
kRetOriginX, kRetOriginY, 3, kRetTickMinor, kRetTickMajor);
|
|
// the range group (ctor @4546-4560 [T1], color-corrected vs the reference
|
|
// screenshot which CONFIRMS the binary): the YELLOW width-2 state applies
|
|
// to the CALLED live list (the range BAR + caret translate the Draw
|
|
// rebuilds); the caret TRIANGLE itself is GREEN width 1 (@4550-4551 --
|
|
// SetLineWidth(1) + SetColor(0,0.75,0) BEFORE the polyline).
|
|
dpl2d_PushState(m);
|
|
dpl2d_SetLineWidth(m, 2.0f);
|
|
dpl2d_SetColor(m, 0.75f, 0.75f, 0.0f); // yellow: the live bar
|
|
dpl2d_CallList(m, rangeCaretR);
|
|
dpl2d_SetLineWidth(m, 1.0f);
|
|
dpl2d_SetColor(m, 0.0f, 0.75f, 0.0f); // green: the caret triangle
|
|
dpl2d_OpenPolyline(m);
|
|
dpl2d_AddPoint(m, kRetOriginX - kRetTickMajor, kRetOriginY);
|
|
dpl2d_AddPoint(m, kRetOriginX - kRetCaret - kRetTickMajor, kRetOriginY + kRetCaret);
|
|
dpl2d_AddPoint(m, kRetOriginX - kRetCaret - kRetTickMajor, kRetOriginY - kRetCaret);
|
|
dpl2d_AddPoint(m, kRetOriginX - kRetTickMajor, kRetOriginY); // close @4558
|
|
dpl2d_ClosePolyline(m);
|
|
dpl2d_PopState(m);
|
|
// the BOTTOM heading ladder (21 ticks across, dir 0 = +x travel)
|
|
BTReticleTickLadder(m, kRetTicksB, 1, kRetBotSpan,
|
|
kRetBotX, kRetBotY, 0, kRetTickMinor, kRetTickMajor);
|
|
// the heading carets (over/under triangles at the ladder centre, shifted
|
|
// by the called heading translate)
|
|
// (ctor @4565-4587 [T1], color-corrected like the range caret: yellow
|
|
// width 2 for the CALLED live twist-deflection list, then GREEN width 1
|
|
// @4569-4570 for the bowtie triangles. The binary pops state between the
|
|
// over- and under-caret (@4579) -- same green/1 either way.)
|
|
{
|
|
float cx = kRetBotX + kRetBotSpan * 0.5f; // _DAT_004cd7f8 = 0.5 [T1]
|
|
dpl2d_PushState(m);
|
|
dpl2d_SetLineWidth(m, 2.0f);
|
|
dpl2d_SetColor(m, 0.75f, 0.75f, 0.0f); // yellow: the live deflection line
|
|
dpl2d_CallList(m, rangeCaretB);
|
|
dpl2d_SetLineWidth(m, 1.0f);
|
|
dpl2d_SetColor(m, 0.0f, 0.75f, 0.0f); // green: the bowtie carets
|
|
dpl2d_OpenPolyline(m);
|
|
dpl2d_AddPoint(m, cx, kRetBotY - kRetTickMajor);
|
|
dpl2d_AddPoint(m, cx + kRetCaret, kRetBotY - kRetCaret - kRetTickMajor);
|
|
dpl2d_AddPoint(m, cx - kRetCaret, kRetBotY - kRetCaret - kRetTickMajor);
|
|
dpl2d_AddPoint(m, cx, kRetBotY - kRetTickMajor);
|
|
dpl2d_ClosePolyline(m);
|
|
dpl2d_OpenPolyline(m);
|
|
dpl2d_AddPoint(m, cx, kRetBotY + kRetTickMajor);
|
|
dpl2d_AddPoint(m, cx + kRetCaret, kRetBotY + kRetCaret + kRetTickMajor);
|
|
dpl2d_AddPoint(m, cx - kRetCaret, kRetBotY + kRetCaret + kRetTickMajor);
|
|
dpl2d_AddPoint(m, cx, kRetBotY + kRetTickMajor);
|
|
dpl2d_ClosePolyline(m);
|
|
dpl2d_PopState(m);
|
|
}
|
|
// COMPASS group (Execute @4ce6e0-4ce7e4 [T1]): [0x278] bottomAnchor holds a
|
|
// rotate(CompassHeading rad->deg) + translate to (botX, botY - 3*tickMajor
|
|
// - 0.03) -- the compass rose (circle + north stem, authored at the origin)
|
|
// spins with the mech heading at the bottom-left of the twist tape. The
|
|
// THREAT trail [0x2e8] draws in the same frame: 0.05-unit direction marks
|
|
// from the compass centre toward recent damage sources (fresh = red, aging
|
|
// = yellow, expired at 6s -- Execute @4ce3e2-4ce6ce [T1]).
|
|
dpl2d_PushState(m);
|
|
dpl2d_CallList(m, bottomAnchor); // [0x9e] the compass rotate+translate
|
|
dpl2d_Circle(m, 0.0f, 0.0f, 0.03f, 0); // the rose ring [T3 radius]
|
|
dpl2d_OpenLines(m);
|
|
dpl2d_AddPoint(m, 0.0f, -0.04f); // the north stem
|
|
dpl2d_AddPoint(m, 0.0f, -0.005f);
|
|
dpl2d_CloseLines(m);
|
|
dpl2d_CallList(m, subBA); // [0xba] the threat-direction trail
|
|
dpl2d_PopState(m);
|
|
dpl2d_CallList(m, subB6); // [0xb6] the composed weapon pips
|
|
dpl2d_CallList(m, headingList); // [0x9d] the LOCK-RING SPIN matrix (4 deg/frame)
|
|
dpl2d_CallList(m, subB7); // [0xb7] the lock-ring slot (subB9 when locked)
|
|
dpl2d_End(m);
|
|
dpl2d_Compile(m);
|
|
|
|
//
|
|
// The green centre-ring sub-lists ([0xb8]/[0xb9]) -- the lock indicator
|
|
// rings the binary Execute swaps in on target state.
|
|
//
|
|
dpl2d_Begin(subB8, 1);
|
|
dpl2d_SetColor(subB8, 0.0f, 0.75f, 0.0f);
|
|
dpl2d_Circle(subB8, 0.0f, 0.0f, 0.12f, 0);
|
|
dpl2d_End(subB8); dpl2d_Compile(subB8);
|
|
dpl2d_Begin(subB9, 1);
|
|
dpl2d_SetColor(subB9, 0.0f, 0.75f, 0.0f);
|
|
dpl2d_Circle(subB9, 0.0f, 0.0f, 0.12f, 0);
|
|
dpl2d_OpenLines(subB9);
|
|
dpl2d_AddPoint(subB9, 0.14f, 0.0f); dpl2d_AddPoint(subB9, 0.10f, 0.0f);
|
|
dpl2d_AddPoint(subB9, -0.14f, 0.0f); dpl2d_AddPoint(subB9, -0.10f, 0.0f);
|
|
dpl2d_AddPoint(subB9, 0.0f, 0.14f); dpl2d_AddPoint(subB9, 0.0f, 0.10f);
|
|
dpl2d_AddPoint(subB9, 0.0f, -0.14f); dpl2d_AddPoint(subB9, 0.0f, -0.10f);
|
|
dpl2d_CloseLines(subB9);
|
|
dpl2d_End(subB9); dpl2d_Compile(subB9);
|
|
|
|
//
|
|
// The off-screen turn ARROWS ([0x9f]/[0xa0]) -- big width-12 chevrons at
|
|
// x = +-1.2..1.5 (screen edges), half-alpha; positioned/enabled by the
|
|
// un-exported Execute -> built but not drawn statically.
|
|
//
|
|
dpl2d_Begin(leftArrow, 1);
|
|
dpl2d_SetLineWidth(leftArrow, 12.0f);
|
|
dpl2d_OpenLines(leftArrow);
|
|
dpl2d_AddPoint(leftArrow, -1.2f, -0.30011f);
|
|
dpl2d_AddPoint(leftArrow, -1.5f, 0.0f);
|
|
dpl2d_AddPoint(leftArrow, -1.5f, 0.0f);
|
|
dpl2d_AddPoint(leftArrow, -1.2f, 0.30011f);
|
|
dpl2d_CloseLines(leftArrow);
|
|
dpl2d_SetLineWidth(leftArrow, 1.0f);
|
|
dpl2d_End(leftArrow); dpl2d_Compile(leftArrow);
|
|
dpl2d_Begin(rightArrow, 1);
|
|
dpl2d_SetLineWidth(rightArrow, 12.0f);
|
|
dpl2d_OpenLines(rightArrow);
|
|
dpl2d_AddPoint(rightArrow, 1.2f, 0.30011f);
|
|
dpl2d_AddPoint(rightArrow, 1.5f, 0.0f);
|
|
dpl2d_AddPoint(rightArrow, 1.5f, 0.0f);
|
|
dpl2d_AddPoint(rightArrow, 1.2f, -0.30011f);
|
|
dpl2d_CloseLines(rightArrow);
|
|
dpl2d_SetLineWidth(rightArrow, 1.0f);
|
|
dpl2d_End(rightArrow); dpl2d_Compile(rightArrow);
|
|
|
|
//
|
|
// The SIMPLE X [0x99] (ctor @4689-4705 [T1]): the minimal reticle used
|
|
// when PrimaryHudOn is OFF -- a small green cross (arms +-0.02..0.08)
|
|
// riding the same aim translate. Draw switches master <-> this on the
|
|
// element-mask bit (the recovered Execute's state-list logic @4cdd9d).
|
|
//
|
|
dpl2d_Begin(simpleXList, 1);
|
|
dpl2d_SetLineWidth(simpleXList, 1.0f);
|
|
dpl2d_FullScreenClip(simpleXList);
|
|
dpl2d_SetColor(simpleXList, 0.0f, 0.75f, 0.0f);
|
|
dpl2d_CallList(simpleXList, aimDotList); // slews with the crosshair
|
|
dpl2d_OpenLines(simpleXList);
|
|
dpl2d_AddPoint(simpleXList, -0.08f, 0.0f);
|
|
dpl2d_AddPoint(simpleXList, -0.02f, 0.0f);
|
|
dpl2d_AddPoint(simpleXList, 0.02f, 0.0f);
|
|
dpl2d_AddPoint(simpleXList, 0.08f, 0.0f);
|
|
dpl2d_AddPoint(simpleXList, 0.0f, -0.08f);
|
|
dpl2d_AddPoint(simpleXList, 0.0f, -0.02f);
|
|
dpl2d_AddPoint(simpleXList, 0.0f, 0.02f);
|
|
dpl2d_AddPoint(simpleXList, 0.0f, 0.08f);
|
|
dpl2d_CloseLines(simpleXList);
|
|
dpl2d_End(simpleXList);
|
|
dpl2d_Compile(simpleXList);
|
|
|
|
// empty placeholders (filled per frame / on lock)
|
|
dpl2d_Begin(crossList, 1); dpl2d_End(crossList); dpl2d_Compile(crossList);
|
|
dpl2d_Begin(aimDotList, 1); dpl2d_End(aimDotList); dpl2d_Compile(aimDotList);
|
|
dpl2d_Begin(rangeCaretR, 1); dpl2d_End(rangeCaretR); dpl2d_Compile(rangeCaretR);
|
|
dpl2d_Begin(rangeCaretB, 1); dpl2d_End(rangeCaretB); dpl2d_Compile(rangeCaretB);
|
|
dpl2d_Begin(headingList, 1); dpl2d_End(headingList); dpl2d_Compile(headingList);
|
|
dpl2d_Begin(bottomAnchor,1); dpl2d_End(bottomAnchor);dpl2d_Compile(bottomAnchor);
|
|
dpl2d_Begin(subB6, 1); dpl2d_End(subB6); dpl2d_Compile(subB6);
|
|
dpl2d_Begin(subB7, 1); dpl2d_End(subB7); dpl2d_Compile(subB7);
|
|
dpl2d_Begin(subBA, 1); dpl2d_End(subBA); dpl2d_Compile(subBA);
|
|
}
|
|
|
|
BTReticleRenderable::~BTReticleRenderable()
|
|
{
|
|
if (gBTReticle == this)
|
|
gBTReticle = 0;
|
|
}
|
|
|
|
//
|
|
// Per-frame draw. Rebuild the live translate lists (the range caret slides
|
|
// along its ladder with the target range -- the ctor's translate(0,
|
|
// -scaleY * rangeFraction) at @4608-4613), then draw the master, then each
|
|
// weapon's pip: the LIT pip (list A) while its within-range flag is up, the
|
|
// dark ring (list B) otherwise. [T3 dynamics / T1 geometry]
|
|
//
|
|
void
|
|
BTReticleRenderable::Draw(struct IDirect3DDevice9 *device)
|
|
{
|
|
// the range caret translate, from the live target range
|
|
Scalar range = (rangeAttr2 != 0) ? *rangeAttr2 : 0.0f;
|
|
// #147: NaN-SAFE clamp. `range < minRange` and `range > maxRange` are BOTH
|
|
// false for NaN, so the old pair let a poisoned value straight through into
|
|
// AddPoint/ConcatMatrix below -- degenerate geometry, and the caret + its
|
|
// bar silently STOP DRAWING while the static ticks remain. Test for NaN
|
|
// first (x == x is false only for NaN) and fall back to the binary's
|
|
// no-target default rather than rendering nothing.
|
|
if (!(range == range))
|
|
range = maxRange; // 1200: the authentic no-target peg
|
|
if (range < minRange) range = minRange;
|
|
if (range > maxRange) range = maxRange;
|
|
Scalar frac = (range - minRange) / (maxRange - minRange);
|
|
// [0x26c] = the range BAR (a line from the ladder TOP down to the caret
|
|
// height) + the caret translate (Execute @4ceb16-4cebf8: AddPoint(originX,
|
|
// originY-scaleY), AddPoint(originX, originY-scaleY*frac), then the
|
|
// SetMatrix(translate(0, -scaleY*frac)) the caret triangles ride).
|
|
dpl2d_Begin(rangeCaretR, 1);
|
|
dpl2d_OpenLines(rangeCaretR);
|
|
dpl2d_AddPoint(rangeCaretR, originX, originY - scaleY);
|
|
dpl2d_AddPoint(rangeCaretR, originX, originY - scaleY * frac);
|
|
dpl2d_CloseLines(rangeCaretR);
|
|
{
|
|
Scalar six[6] = { 1, 0, 0, 1, 0, -scaleY * frac };
|
|
dpl2d_ConcatMatrix(rangeCaretR, six);
|
|
}
|
|
dpl2d_End(rangeCaretR);
|
|
dpl2d_Compile(rangeCaretR);
|
|
|
|
// the AIM translate [0x9a] (Execute @4cde59-4cdedd [T1]: rebuilt on slew
|
|
// move with SetMatrix(translate(reticlePosition))).
|
|
{
|
|
extern float gBTAimX, gBTAimY;
|
|
Scalar t6[6] = { 1, 0, 0, 1, gBTAimX, gBTAimY };
|
|
dpl2d_Begin(aimDotList, 1);
|
|
dpl2d_ConcatMatrix(aimDotList, t6);
|
|
dpl2d_End(aimDotList);
|
|
dpl2d_Compile(aimDotList);
|
|
}
|
|
|
|
// the TORSO-TWIST TAPE carets [0x9c] (Execute @4ce7e5-4cea9a [T1]): the
|
|
// bottom 21-tick tape is the TWIST indicator -- a deflection line from the
|
|
// tape centre plus the over/under carets translated by
|
|
// offset = -/+(span/2) * (RotationOfTorsoHorizontal / twist limit)
|
|
// (attrs 4/5/6: the live twist over the per-side limits; full deflection =
|
|
// the torso hard against its stop). The fixed-torso BLH reads 0 (centred).
|
|
{
|
|
extern float gBTHudTwist, gBTHudTwistLimit;
|
|
float off = 0.0f;
|
|
if (gBTHudTwistLimit > 1e-4f)
|
|
{
|
|
off = -(kRetBotSpan * 0.5f) * (gBTHudTwist / gBTHudTwistLimit);
|
|
if (off < -kRetBotSpan * 0.5f) off = -kRetBotSpan * 0.5f;
|
|
if (off > kRetBotSpan * 0.5f) off = kRetBotSpan * 0.5f;
|
|
}
|
|
const float cx = kRetBotX + kRetBotSpan * 0.5f;
|
|
dpl2d_Begin(rangeCaretB, 1);
|
|
dpl2d_OpenLines(rangeCaretB);
|
|
dpl2d_AddPoint(rangeCaretB, cx, kRetBotY);
|
|
dpl2d_AddPoint(rangeCaretB, cx + off, kRetBotY);
|
|
dpl2d_CloseLines(rangeCaretB);
|
|
{
|
|
Scalar t6[6] = { 1, 0, 0, 1, off, 0 };
|
|
dpl2d_ConcatMatrix(rangeCaretB, t6);
|
|
}
|
|
dpl2d_End(rangeCaretB);
|
|
dpl2d_Compile(rangeCaretB);
|
|
}
|
|
|
|
// the COMPASS rotate [0x9e] (Execute @4ce6e0-4ce7e4 [T1]): the rose spins
|
|
// by CompassHeading (radians; the binary converts x57.2958 for its degree
|
|
// recorder) and sits at (botX, botY - 3*tickMajor - 0.03).
|
|
{
|
|
extern float gBTHudHeading;
|
|
const float c = (float)cos((double)gBTHudHeading);
|
|
const float s = (float)sin((double)gBTHudHeading);
|
|
Scalar r6[6] = { c, s, -s, c,
|
|
kRetBotX, kRetBotY - 3.0f * kRetTickMajor - 0.03f };
|
|
dpl2d_Begin(bottomAnchor, 1);
|
|
dpl2d_ConcatMatrix(bottomAnchor, r6);
|
|
dpl2d_End(bottomAnchor);
|
|
dpl2d_Compile(bottomAnchor);
|
|
}
|
|
|
|
// the THREAT trail [0xba] (Execute @4ce3e2-4ce6ce [T1]): direction marks
|
|
// from the compass centre toward recent damage sources. Each mark is a
|
|
// 0.05-unit line along the (mech-local x,z) attack direction; FRESH marks
|
|
// (< 2s) draw red, aging ones yellow, expired (> 6s) drop.
|
|
{
|
|
extern int BTTakeHudThreats(float out_xz[][2], float out_age[], int max_n);
|
|
float txz[16][2]; float tage[16];
|
|
const int n = BTTakeHudThreats(txz, tage, 16);
|
|
dpl2d_Begin(subBA, 1);
|
|
if (n > 0)
|
|
{
|
|
// stale (yellow) first, then fresh (red) -- the binary's color split
|
|
dpl2d_SetColor(subBA, 0.75f, 0.75f, 0.0f);
|
|
for (int pass = 0; pass < 2; ++pass)
|
|
{
|
|
const int wantFresh = (pass == 1);
|
|
if (pass == 1)
|
|
dpl2d_SetColor(subBA, 0.75f, 0.0f, 0.0f);
|
|
for (int i = 0; i < n; ++i)
|
|
{
|
|
const int isFresh = (tage[i] < 2.0f);
|
|
if (isFresh != wantFresh)
|
|
continue;
|
|
dpl2d_OpenLines(subBA);
|
|
dpl2d_AddPoint(subBA, 0.0f, 0.0f);
|
|
dpl2d_AddPoint(subBA, txz[i][0] * 0.05f, txz[i][1] * 0.05f);
|
|
dpl2d_CloseLines(subBA);
|
|
}
|
|
}
|
|
dpl2d_SetColor(subBA, 0.0f, 0.75f, 0.0f); // restore green
|
|
}
|
|
dpl2d_End(subBA);
|
|
dpl2d_Compile(subBA);
|
|
}
|
|
|
|
// the WEAPON PIPS [0xb6] (Execute @4ce2c2-4ce3e1 [T1]): the composed pip
|
|
// list the master calls. Per weapon: skip unless its GROUP is displayed
|
|
// (weaponMode & elementMask low bits); HIDE it when destroyed (attr 1 ==
|
|
// 1); the LIT pip (A) when the fire cycle is LOADED (attr 0x1C WeaponState
|
|
// == stateConst2 == 2), else the dark ring (B, charging). The pip source
|
|
// is the weaponAlarm StateIndicator (WeaponStatePtr), which cycles
|
|
// Firing(0)->Loading(3)->Loaded(2) on EVERY fire -- so a missile/AC pip
|
|
// momentarily drops when fired, exactly like the emitter's. (The old
|
|
// rechargeLevel>=1 approximation was static on projectile weapons -- whose
|
|
// rechargeLevel is authentically fixed at 1.0 -- so their pips never
|
|
// blinked; see mechweap.hpp WeaponStatePtr.) Range plays NO part -- the
|
|
// binary never reads TargetWithinRange here.
|
|
{
|
|
extern int gBTHudGroupMask; // element-mask low bits (0xF = all)
|
|
int dirty = 0;
|
|
for (int i = 0; i < weaponCount; ++i)
|
|
{
|
|
const int destroyed = (simStateAttr[i] != 0 && *simStateAttr[i] == 1);
|
|
const int loaded = (cycleReady[i] != 0 && *cycleReady[i] == stateConst2[i]);
|
|
if (destroyed != simStateCache[i] || loaded != alarmCache[i])
|
|
{
|
|
simStateCache[i] = destroyed;
|
|
alarmCache[i] = loaded;
|
|
dirty = 1;
|
|
}
|
|
}
|
|
static int s_lastMask = -1;
|
|
if (gBTHudGroupMask != s_lastMask) { s_lastMask = gBTHudGroupMask; dirty = 1; }
|
|
if (dirty || !pipsBuilt)
|
|
{
|
|
pipsBuilt = 1;
|
|
dpl2d_Begin(subB6, 1);
|
|
for (int i = 0; i < weaponCount; ++i)
|
|
{
|
|
if ((weaponMode[i] & gBTHudGroupMask) == 0)
|
|
continue; // group not displayed
|
|
if (simStateCache[i])
|
|
continue; // destroyed: no pip at all
|
|
dpl2d_CallList(subB6,
|
|
alarmCache[i] ? pipDisplayListA[i] : pipDisplayListB[i]);
|
|
}
|
|
dpl2d_End(subB6);
|
|
dpl2d_Compile(subB6);
|
|
}
|
|
}
|
|
|
|
// the LOCK RING [0xb7] + its SPIN [0x9d] (Execute @4cebf9-4cee54 [T1]):
|
|
// while a target is locked the ring+cross (subB9) draws at the reticle
|
|
// frame centre, spinning 4 degrees per frame; unlocked it clears. The SAME
|
|
// lock-change block drives the PNAMEx.bgf player NAME PLATE (the target's
|
|
// callsign under the crosshair) -- reconstructed at the end of this
|
|
// function, no longer deferred.
|
|
{
|
|
extern int gBTHudLockState;
|
|
const int locked = (gBTHudLockState == 2); // the Lock attr rule, not just a target
|
|
if (locked != lockShown)
|
|
{
|
|
lockShown = locked;
|
|
dpl2d_Begin(subB7, 1);
|
|
if (locked)
|
|
dpl2d_CallList(subB7, subB9);
|
|
dpl2d_End(subB7);
|
|
dpl2d_Compile(subB7);
|
|
}
|
|
if (locked)
|
|
{
|
|
lockSpinDeg += 4.0f; // per FRAME, the binary's rate
|
|
if (lockSpinDeg >= 360.0f) lockSpinDeg -= 360.0f;
|
|
const float a = lockSpinDeg * 0.0174532925f;
|
|
const float c = (float)cos((double)a), s = (float)sin((double)a);
|
|
Scalar r6[6] = { c, s, -s, c, 0, 0 };
|
|
dpl2d_Begin(headingList, 1);
|
|
dpl2d_ConcatMatrix(headingList, r6);
|
|
dpl2d_End(headingList);
|
|
dpl2d_Compile(headingList);
|
|
}
|
|
}
|
|
|
|
// the TARGET HOTBOX / edge arrows [0xa1] (Execute @4cdf6f-4ce28b [T1]):
|
|
// the box is a RECTANGLE hugging the target's projected extents -- x +-4
|
|
// around the hotbox point, +1 above / -11.5 below it (the authored pod
|
|
// mech envelope) -- switching to the left/right edge arrow when both
|
|
// edges pass +-1.6 or the target is behind.
|
|
{
|
|
extern int gBTHudLockState;
|
|
extern float gBTHudLockWorld[3]; // the target's hotbox point (top)
|
|
extern int BTProjectHotBox(const float top[3], float *xl, float *xr,
|
|
float *yt, float *yb, int *side);
|
|
dpl2d_Begin(crossList, 1);
|
|
if (gBTHudLockState != 0)
|
|
{
|
|
float xl, xr, yt, yb; int side;
|
|
if (BTProjectHotBox(gBTHudLockWorld, &xl, &xr, &yt, &yb, &side))
|
|
{
|
|
dpl2d_OpenPolyline(crossList); // the closed hotbox rectangle
|
|
dpl2d_AddPoint(crossList, xl, yt);
|
|
dpl2d_AddPoint(crossList, xl, yb);
|
|
dpl2d_AddPoint(crossList, xr, yb);
|
|
dpl2d_AddPoint(crossList, xr, yt);
|
|
dpl2d_ClosePolyline(crossList);
|
|
}
|
|
else
|
|
{
|
|
dpl2d_CallList(crossList, (side < 0) ? leftArrow : rightArrow);
|
|
}
|
|
}
|
|
dpl2d_End(crossList);
|
|
dpl2d_Compile(crossList);
|
|
}
|
|
|
|
// the STATE-list switch (Execute @4cdd9d [T1]): PrimaryHudOn (element mask
|
|
// 0x20) selects the full HUD; off = just the simple aim cross.
|
|
{
|
|
extern int gBTHudPrimary;
|
|
dpl2d_ExecuteList(gBTHudPrimary ? masterList : simpleXList, device);
|
|
}
|
|
|
|
//
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// THE TARGET NAME PLATE (PNAME1-8.bgf) -- the floating callsign under the
|
|
// crosshair. Reconstructed from the reticle Execute disassembly
|
|
// (reference/decomp/reticle_execute_004cdcf0.disasm.txt) [T1]:
|
|
//
|
|
// @004cdede, every frame while the full HUD is up:
|
|
// dpl_IdentityDCS (plate);
|
|
// dpl_ScaleDCS (plate, 0.12f, 0.12f, 0.12f);
|
|
// dpl_TranslateDCS(plate, K*reticlePos.x + -0.0f,
|
|
// K*reticlePos.y + -0.08f, -1.0f);
|
|
// dpl_FlushDCS (plate);
|
|
// K = the x87 long double @0x4cee64 = 0.35714286322496386, which
|
|
// reproduces 1.0f/2.8f bit-for-bit -- it converts the reticle's -1..+1
|
|
// screen space into eye-space extents at z = -1. NOTE the HOTBOX uses a
|
|
// DIFFERENT authored constant (the double 2.8145 with the +/-1.6 edge
|
|
// thresholds) -- 2.8 != 2.8145, do not unify them. [T1]
|
|
// @004cec98: the plate's MESH is the target player's (see
|
|
// BTReticleTargetPlate in mech4.cpp), visibility 1; no owning player
|
|
// -> visibility 0.
|
|
// @004cdd75 / @004cddc1: reticle Off, or On-but-simple-X (PrimaryHudOn
|
|
// clear) -> visibility 0 and the cached target cleared.
|
|
// @004cebf9-@004cec47 -- THE LOCK GATE: the block is entered on a change
|
|
// of EITHER the target (Reticle+0x1c) or the LOCK attribute (HUD attr
|
|
// id 10 "Lock", an int/Logical* at this+0x184 cached at +0x188 -- the
|
|
// producer writes 0/1, the consumer does `mov eax,[ecx]; test eax,eax`
|
|
// @0x4cec12), and when that lock value
|
|
// reads 0 it branches to the hide path (@004ced87). So the plate needs
|
|
// a real fire-control LOCK, not merely something under the crosshair --
|
|
// the same `gBTHudLockState == 2` rule the lock RING uses below.
|
|
//
|
|
// So the plate TRACKS THE AIM POINT (it is not fixed under screen centre),
|
|
// sits 0.08 eye-space units below it, and shows the TARGET'S CALLSIGN.
|
|
//
|
|
// PORT: identical placement expressed in reticle units -- dividing the
|
|
// eye-space offsets by K (i.e. x2.8) puts the plate centre at
|
|
// (reticlePos.x, reticlePos.y +/- 0.08*2.8 = 0.224)
|
|
// spanning the plate quad's own 1.0 x 0.25 extents (content/VIDEO/GEO/
|
|
// PNAME1.BGF, verified) scaled by 0.12 and back-converted: 0.336 x 0.084.
|
|
// The art is the player's callsign raster, exactly as the 1995 plate's
|
|
// `bmap:name12_mtl` material supplied it -- here the egg's 128x32 name
|
|
// texture (4:1 == the quad's aspect) via BTGetPlayerNameTexture.
|
|
//
|
|
{
|
|
extern int gBTHudPrimary;
|
|
extern int gBTHudLockState; // 2 == authentic LOCK
|
|
extern int BTReticleTargetPlate(void); // mech4.cpp
|
|
extern void *BTGetPlayerNameTexture(int index0); // L4VIDEO.cpp
|
|
extern void dpl2d_DrawTexturedRect(struct IDirect3DDevice9 *device,
|
|
void *texture, float cx, float cy, float w, float h,
|
|
unsigned long argb);
|
|
extern float gBTAimX, gBTAimY;
|
|
|
|
const float kPlateScale = 0.12f; // dpl_ScaleDCS, uniform
|
|
const float kPlateW = 1.0f; // the PNAMEx quad's x extent
|
|
const float kPlateH = 0.25f; // ... and its y extent (4:1)
|
|
const float kInvK = 2.8f; // 1/K -- eye-space -> reticle
|
|
const float kPlateDY = 0.08f * kInvK; // = 0.224 below the aim point
|
|
// content/VIDEO/MAT/BMAP.BMF: name12/34/56/78_mtl carry ONLY a 0x0021
|
|
// MATERIAL_TEXTURE plus 0x0027 = `dpfB_MATERIAL_OPACITY_TAG` {0.5,0.5,0.5}
|
|
// [T0 libDPL/dsys/PFBIZTAG.H]. There is NO diffuse / ambient / emissive /
|
|
// ramp tag -- so the material contributes NO COLOUR, only a texture and
|
|
// ~50% opacity. The plate is therefore the UNMODULATED callsign raster at
|
|
// half alpha (it blends with the scene behind it), NOT a grey label and
|
|
// NOT phosphor green like the reticle glyphs.
|
|
// [T3 on the exact ARGB: the opacity payload is an RGB triple, so it could
|
|
// be per-channel transmissivity; all three channels are 0.5 here, so a
|
|
// scalar alpha is the only reading the data distinguishes.]
|
|
const unsigned long kPlateARGB = 0x80FFFFFF;
|
|
|
|
const int plate = (gBTHudPrimary && gBTHudLockState == 2)
|
|
? BTReticleTargetPlate() : 0;
|
|
if (plate > 0)
|
|
{
|
|
void *texture = BTGetPlayerNameTexture(plate - 1);
|
|
if (texture != 0)
|
|
{
|
|
// reticle y is +DOWN on screen (dpl2d MapY), eye-space y is
|
|
// +UP, so the binary's -0.08 eye offset is +kPlateDY here.
|
|
dpl2d_DrawTexturedRect(device, texture,
|
|
gBTAimX, gBTAimY + kPlateDY,
|
|
kPlateW * kPlateScale * kInvK,
|
|
kPlateH * kPlateScale * kInvK,
|
|
kPlateARGB);
|
|
}
|
|
if (getenv("BT_HUD_LOG"))
|
|
{
|
|
static int s_plog = 0;
|
|
if ((s_plog++ % 120) == 0)
|
|
DEBUG_STREAM << "[hud] name plate: bmp=" << plate
|
|
<< " tex=" << (texture != 0)
|
|
<< " at (" << gBTAimX << "," << (gBTAimY + kPlateDY)
|
|
<< ")\n" << std::flush;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//
|
|
// The render-loop hook: draw the player's reticle over the finished 3D frame,
|
|
// COCKPIT VIEW ONLY (the 1996 build constructed it only for insideEntity).
|
|
//
|
|
void BTDrawReticle(struct IDirect3DDevice9 *device)
|
|
{
|
|
if (gBTReticle != 0 && gBTHudInside)
|
|
gBTReticle->Draw(device);
|
|
}
|
|
|
|
//
|
|
// Build the player's reticle + register one pip per weapon (the 1996 wiring:
|
|
// @part_014.c:5386-5436). The binary's gate is IsDerivedFrom(0x511830) --
|
|
// MechWeapon::ClassDerivations [T1: the loop hard-aborts on missing
|
|
// WeaponRange/PipPosition/... attrs, which only MechWeapons publish] -- so
|
|
// EVERY mounted weapon registers a pip: lasers, PPCs AND missile launchers.
|
|
// Per weapon it reads WeaponRange / PipPosition / TargetWithinRange /
|
|
// PipExtendedRange / PipColor / SimulationState (attrs 1 + 0x1c) / RearFiring;
|
|
// mode 1 = front (RearFiring==0), which every BLH weapon is.
|
|
//
|
|
BTReticleRenderable *BTBuildReticle(Entity *mech)
|
|
{
|
|
if (gBTReticle != 0)
|
|
return gBTReticle;
|
|
|
|
extern void BTSetHudTargetRange(Scalar range); // (self; range fed by mech4)
|
|
extern Scalar gBTHudRangeStorage; // defined below
|
|
|
|
gBTReticle = new BTReticleRenderable(mech, &gBTHudRangeStorage);
|
|
|
|
Mech *m = (Mech *)mech;
|
|
for (int wi = 0; wi < m->GetSubsystemCount(); ++wi)
|
|
{
|
|
Subsystem *ws = m->GetSubsystem(wi);
|
|
if (ws == 0)
|
|
continue;
|
|
if (!ws->IsDerivedFrom(MechWeapon::ClassDerivations)) // 0x511830
|
|
continue;
|
|
MechWeapon *wp = (MechWeapon *)ws;
|
|
RGBColor pc = wp->PipColor();
|
|
float r = (float)pc.Red, g = (float)pc.Green, b = (float)pc.Blue;
|
|
if (r < 0.0f || g < 0.0f || b < 0.0f) { r = 0.78f; g = 0.08f; b = 0.02f; }
|
|
DEBUG_STREAM << "[hud] pip: classID=" << (int)ws->GetClassID()
|
|
<< " pos=" << wp->PipPosition()
|
|
<< " range=" << wp->WeaponRange()
|
|
<< " ext=" << wp->PipExtendedRange()
|
|
<< " rgb=(" << r << "," << g << "," << b << ")\n" << std::flush;
|
|
gBTReticle->AddWeapon(
|
|
wp->WeaponRange(),
|
|
wp->PipPosition(),
|
|
(int *)wp->WithinRangePtr(),
|
|
wp->PipExtendedRange(),
|
|
r, g, b,
|
|
wp->WeaponStatePtr(), // attr 0x1C WeaponState: loaded when == 2
|
|
2, 3,
|
|
(int *)wp->SimulationStatePtr(), // attr 1: damage state (1 = destroyed)
|
|
1,
|
|
// (task #68) the AUTHENTIC pip group: the binary registration reads
|
|
// the weapon's RearFiring attr and passes group 1 (front) / 2 (rear)
|
|
// (part_014.c:5429-5434) -- the reticle draws the group matching
|
|
// the current look view (mech reticleElementMask low bits). The
|
|
// old hardcoded 1 rode the disproven "no BLH weapon is rear" belief.
|
|
wp->GetRearFiring() ? 2 : 1);
|
|
}
|
|
DEBUG_STREAM << "[hud] reticle built: " << gBTReticle->WeaponCount()
|
|
<< " weapon pip(s) registered\n" << std::flush;
|
|
return gBTReticle;
|
|
}
|
|
|
|
// the live target-range storage the reticle's caret binds to
|
|
Scalar gBTHudRangeStorage = 0.0f;
|
|
|
|
//
|
|
// THREAT trail store (recovered Execute @4ce3e2-4ce6ce): timestamped attack
|
|
// directions pushed on player damage (mech.cpp handler); Draw ages them --
|
|
// fresh < 2s (red), expired > 6s (dropped). World-frame (x,z) directions:
|
|
// they draw inside the compass's rotated frame, so the marks stay
|
|
// world-referenced on the rose like a true compass bearing.
|
|
//
|
|
struct BTHudThreat { float x, z; clock_t born; };
|
|
static BTHudThreat gBTHudThreats[16];
|
|
static int gBTHudThreatCount = 0;
|
|
|
|
void BTPushHudThreat(float wx, float wz)
|
|
{
|
|
float len = sqrtf(wx * wx + wz * wz);
|
|
if (len < 1e-4f)
|
|
return;
|
|
if (gBTHudThreatCount >= 16) // drop the oldest
|
|
{
|
|
for (int i = 1; i < 16; ++i)
|
|
gBTHudThreats[i - 1] = gBTHudThreats[i];
|
|
gBTHudThreatCount = 15;
|
|
}
|
|
BTHudThreat &t = gBTHudThreats[gBTHudThreatCount++];
|
|
t.x = wx / len;
|
|
t.z = wz / len;
|
|
t.born = clock();
|
|
}
|
|
|
|
int BTTakeHudThreats(float out_xz[][2], float out_age[], int max_n)
|
|
{
|
|
const clock_t now = clock();
|
|
int n = 0;
|
|
for (int i = 0; i < gBTHudThreatCount; ++i)
|
|
{
|
|
const float age = (float)(now - gBTHudThreats[i].born) / (float)CLOCKS_PER_SEC;
|
|
if (age > 6.0f)
|
|
continue; // expired
|
|
gBTHudThreats[n] = gBTHudThreats[i]; // compact in place
|
|
if (n < max_n)
|
|
{
|
|
out_xz[n][0] = gBTHudThreats[n].x;
|
|
out_xz[n][1] = gBTHudThreats[n].z;
|
|
out_age[n] = age;
|
|
}
|
|
++n;
|
|
}
|
|
gBTHudThreatCount = n;
|
|
return (n < max_n) ? n : max_n;
|
|
}
|
|
|
|
//
|
|
//#############################################################################
|
|
// BTReticleRenderable::AddWeapon
|
|
//#############################################################################
|
|
//
|
|
// @004cdac0
|
|
//
|
|
// Append one weapon range/pip marker to the reticle (max 10). Stores the
|
|
// weapon's attribute pointers in parallel arrays indexed by weaponCount, then
|
|
// pre-builds the two 2D display lists (the pip glyph + its extended-range arc)
|
|
// at the screen position computed from the (clamped) weapon range.
|
|
//
|
|
void
|
|
BTReticleRenderable::AddWeapon(
|
|
Scalar weapon_range, // param_2
|
|
int pip_position, // param_3
|
|
int *within_range_value, // param_4 TargetWithinRange
|
|
int extended_range, // param_5 PipExtendedRange
|
|
Scalar pip_red, // param_6..8 PipColor
|
|
Scalar pip_green,
|
|
Scalar pip_blue,
|
|
const int *cycle_ready, // param_9 attr 0x1C WeaponState (== 2 loaded)
|
|
int const2, // param_10 (2 = loaded)
|
|
int const3, // param_11 (3 = charging)
|
|
int *sim_state_value, // param_12 weapon attr 1
|
|
int const1, // param_13 (1 = destroyed)
|
|
int weapon_mode) // param_14 (group bit)
|
|
{
|
|
if (this->weaponCount /* [0x38] */ >= 10)
|
|
{
|
|
Fail("Tried to display too many weapons"); // @0051d24f, line 0x338
|
|
}
|
|
|
|
int n = this->weaponCount;
|
|
|
|
//
|
|
// Record this weapon's control state in the parallel arrays -- the exact
|
|
// store order of @004cdac0 (part_014.c:4827-4837). The caches hold the
|
|
// pip's DERIVED display flags (loaded / destroyed) for the Execute-style
|
|
// change detection in Draw.
|
|
//
|
|
this->stateConst3[n] /* [0x64+n*4] = param_11 */ = const3;
|
|
this->stateConst2[n] /* [0x8c+n*4] = param_10 */ = const2;
|
|
this->stateConst1[n] /* [0xb4+n*4] = param_13 */ = const1;
|
|
this->cycleReady[n] /* [0x130+n*4] = param_9 */ = cycle_ready;
|
|
this->alarmCache[n] /* [0xdc+n*4] */ = (*cycle_ready == const2);
|
|
this->simStateAttr[n] /* [0x158+n*4] = param_12 */ = sim_state_value;
|
|
this->simStateCache[n] /* [0x104+n*4] */ = (*sim_state_value == const1);
|
|
this->withinRangePtr[n] /* [0x18c+n*4] = param_4 */ = within_range_value;
|
|
this->withinRangeCache[n] /* [0x1b4+n*4] = *param_4 */ = *within_range_value;
|
|
this->weaponMode[n] /* [0x3c+n*4] = param_14 */ = weapon_mode;
|
|
|
|
dpl2d_DISPLAY *pip_list = dpl2d_NewDisplayList(); // FUN_00487f34
|
|
dpl2d_DISPLAY *arc_list = dpl2d_NewDisplayList();
|
|
this->pipDisplayListA[n] /* [0x2b0+n*4] */ = pip_list;
|
|
this->pipDisplayListB[n] /* [0x288+n*4] */ = arc_list;
|
|
|
|
//
|
|
// Clamp range into [minRange .. maxRange].
|
|
//
|
|
if (weapon_range >= this->minRange /* [0x230] */)
|
|
{
|
|
if (weapon_range > this->maxRange /* [0x22c] */)
|
|
weapon_range = this->maxRange;
|
|
}
|
|
else
|
|
{
|
|
weapon_range = this->minRange;
|
|
}
|
|
|
|
//
|
|
// Screen position of this pip from the reticle's calibrated geometry.
|
|
//
|
|
float x = this->originX /* [0x1fc] */ + this->biasX /* [0x208] */ +
|
|
(float)pip_position * PIP_SPACING /* _DAT_004cdce8 */;
|
|
float y = -this->scaleY /* [0x204] */ *
|
|
((weapon_range - this->minRange) / this->rangeScale /* [0x234] */) +
|
|
this->originY /* [0x200] */;
|
|
|
|
//
|
|
// Pip glyph display list: a coloured ring (+ a small filled marker when
|
|
// this is an extended-range / "rear" weapon).
|
|
//
|
|
dpl2d_Begin(pip_list, 1); // FUN_00487fbc
|
|
dpl2d_SetColor(pip_list, pip_red, pip_green, pip_blue); // param_6,7,8
|
|
dpl2d_Circle(pip_list, x, y, 0.012f /* 0x3c449ba6 */, 1);
|
|
dpl2d_SetColor(pip_list, 0, 0, 0);
|
|
dpl2d_Circle(pip_list, x, y, 0.014f /* 0x3c656042 */, 0);
|
|
if (extended_range != 0) // param_5
|
|
{
|
|
dpl2d_SetColor(pip_list, 0.7f, 0.7f, 0.7f); // 0x3f333333
|
|
dpl2d_PushMatrix(pip_list);
|
|
dpl2d_MoveTo(pip_list, x, y);
|
|
dpl2d_PopMatrix(pip_list);
|
|
}
|
|
dpl2d_End(pip_list);
|
|
dpl2d_Compile(pip_list);
|
|
|
|
//
|
|
// Extended-range arc display list (black outline ring).
|
|
//
|
|
dpl2d_Begin(arc_list, 1);
|
|
dpl2d_SetColor(arc_list, 0, 0, 0);
|
|
dpl2d_Circle(arc_list, x, y, 0.014f, 0);
|
|
dpl2d_End(arc_list);
|
|
dpl2d_Compile(arc_list);
|
|
|
|
this->weaponCount = n + 1;
|
|
}
|
|
|
|
|
|
//
|
|
//#############################################################################
|
|
// SetupMaterialSubstitutionList
|
|
//#############################################################################
|
|
//
|
|
// @004d0cc0
|
|
//
|
|
// Read the "vehicletable" resource and build the per-mech material-name
|
|
// substitution list, expanding the %color% / %badge% / %patch% / %serno%
|
|
// placeholders. Directly parallels RPL4VideoRenderer::SetupMaterialSubstitution-
|
|
// List (which handles %color%/%badge%); BT adds %patch% and a per-load
|
|
// incrementing %serno% (serial number, "0".."9","A"...).
|
|
//
|
|
void
|
|
BTL4VideoRenderer::SetupMaterialSubstitutionList(Entity *entity)
|
|
{
|
|
//
|
|
// One-shot cache of the placeholder string lengths.
|
|
//
|
|
static int colorLen = -1, badgeLen, patchLen, sernoLen; // guards @0051d19c..d1b4
|
|
if (colorLen < 0)
|
|
{
|
|
colorLen = strlen(colorParameter);
|
|
badgeLen = strlen(badgeParameter);
|
|
patchLen = strlen(patchParameter);
|
|
sernoLen = strlen(sernoParameter);
|
|
}
|
|
|
|
//
|
|
// Fetch + lock the vehicle table resource, copy it out, and parse it as a
|
|
// NotationFile.
|
|
//
|
|
ResourceDescription *res = application->GetResourceFile()->FindResourceDescription( // FUN_00406ff8
|
|
"vehicletable" /* @0051d941 */, ResourceDescription::VehicleTableResourceType);
|
|
if (res == NULL)
|
|
return;
|
|
|
|
res->Lock();
|
|
long len = (long)res->resourceSize; // [0x40]
|
|
char *copy = new char[len];
|
|
memcpy(copy, res->resourceAddress /* [0x3c] */, len); // FUN_004d4918
|
|
res->Unlock();
|
|
|
|
NotationFile *veh_tbl = new NotationFile(); // FUN_00403e84
|
|
veh_tbl->ReadText(copy, len); // FUN_00404d00
|
|
delete [] copy;
|
|
|
|
//
|
|
// Look up this mech's colour / badge / patch codes from the table, using
|
|
// the egg-supplied names carried on the entity (badge=resourceNameA @0x844,
|
|
// color=resourceNameB @0x848, patch=resourceNameC @0x84c). The binary
|
|
// reads the backing text straight off the ref-counted name objects
|
|
// (*(char**)(*(int*)(mech+0x848)+8) etc.); the reconstructed Mech now
|
|
// carries them as real CString members deep-copied from the MakeMessage,
|
|
// exposed via the paint-name accessors.
|
|
//
|
|
// DEVIATION (documented): on a table miss the binary Fail()ed hard
|
|
// ("Exiting" @BTL4VID.CPP:0xbeb/0xbf2/0xbf9). We follow the RP analogue
|
|
// (RPL4VID.cpp:1562) and tolerate it -- a NULL/missing name leaves veh_* ==
|
|
// NULL and that placeholder expands empty (base materials) -- so a sparse
|
|
// test egg degrades to the old gray-metal look instead of killing the pod.
|
|
//
|
|
Mech *mech = (Mech *)entity; // caller dispatched on MechClassID
|
|
const char *egg_color = mech->GetVehicleColorName(); // [0x848] resourceNameB
|
|
const char *egg_badge = mech->GetVehicleBadgeName(); // [0x844] resourceNameA
|
|
const char *egg_patch = mech->GetVehiclePatchName(); // [0x84c] resourceNameC
|
|
if (egg_color && !*egg_color) egg_color = NULL;
|
|
if (egg_badge && !*egg_badge) egg_badge = NULL;
|
|
if (egg_patch && !*egg_patch) egg_patch = NULL;
|
|
|
|
const char *veh_color = NULL, *veh_badge = NULL, *veh_patch = NULL;
|
|
if (egg_color && !veh_tbl->GetEntry("color", egg_color, &veh_color)) // @0051d94e
|
|
{
|
|
DEBUG_STREAM << " Color value '" << egg_color
|
|
<< "' from egg not found in vehicle table\n"; // @0051d954
|
|
veh_color = NULL;
|
|
}
|
|
if (egg_badge && !veh_tbl->GetEntry("badge", egg_badge, &veh_badge)) // @0051d9b8
|
|
{
|
|
DEBUG_STREAM << " Badge value '" << egg_badge
|
|
<< "' from egg not found in vehicle table\n";
|
|
veh_badge = NULL;
|
|
}
|
|
if (egg_patch && !veh_tbl->GetEntry("patch", egg_patch, &veh_patch)) // @0051da22
|
|
{
|
|
DEBUG_STREAM << " Patch value '" << egg_patch
|
|
<< "' from egg not found in vehicle table\n";
|
|
veh_patch = NULL;
|
|
}
|
|
|
|
DEBUG_STREAM << "[paint] mech egg color='" << (egg_color ? egg_color : "<none>")
|
|
<< "' badge='" << (egg_badge ? egg_badge : "<none>")
|
|
<< "' patch='" << (egg_patch ? egg_patch : "<none>")
|
|
<< "' -> codes color=" << (veh_color ? veh_color : "-")
|
|
<< " badge=" << (veh_badge ? veh_badge : "-")
|
|
<< " patch=" << (veh_patch ? veh_patch : "-")
|
|
<< " serno=" << gSerno
|
|
<< " entity=" << BTMatchHostOf(mech->GetEntityID())
|
|
<< ":" << (int)mech->GetEntityID()
|
|
<< "\n" << std::flush;
|
|
|
|
//
|
|
// Generic substitution list, then expand placeholders per entry.
|
|
//
|
|
materialSubstitutionList = veh_tbl->MakeEntryList("substitute"); // @0051da8c, DAT_004f1aac
|
|
for (NameList::Entry *entry = materialSubstitutionList->GetFirstEntry();
|
|
entry != NULL;
|
|
entry = entry->GetNextEntry())
|
|
{
|
|
char buffer[80];
|
|
char *dst = buffer;
|
|
const char *src = entry->GetChar();
|
|
*dst = '\0';
|
|
|
|
const char *pc;
|
|
while ((pc = strchr(src, '%')) != NULL) // FUN_004d49f4
|
|
{
|
|
int n = (int)(pc - src);
|
|
const char *resume = src;
|
|
if (n != 0)
|
|
{
|
|
memcpy(dst, src, n);
|
|
dst += n;
|
|
resume = pc;
|
|
}
|
|
|
|
if (!strncmp(pc, sernoParameter, sernoLen))
|
|
{
|
|
//
|
|
// %serno% -> the current one-character serial (gSerno, which
|
|
// increments '0'->'9'->'A' each mech loaded).
|
|
//
|
|
if (gSerno /* @0051d1b5 */ != '\0')
|
|
*dst++ = gSerno;
|
|
src = resume + sernoLen;
|
|
}
|
|
else if (!strncmp(pc, colorParameter, colorLen))
|
|
{
|
|
if (veh_color) { strcpy(dst, veh_color); dst += strlen(veh_color); }
|
|
src = resume + colorLen;
|
|
}
|
|
else if (!strncmp(pc, badgeParameter, badgeLen))
|
|
{
|
|
if (veh_badge) { strcpy(dst, veh_badge); dst += strlen(veh_badge); }
|
|
src = resume + badgeLen;
|
|
}
|
|
else if (!strncmp(pc, patchParameter, patchLen))
|
|
{
|
|
if (veh_patch) { strcpy(dst, veh_patch); dst += strlen(veh_patch); }
|
|
src = resume + patchLen;
|
|
}
|
|
else
|
|
{
|
|
*dst++ = *resume; // stray '%'
|
|
src = resume + 1;
|
|
}
|
|
}
|
|
strcpy(dst, src); // tail
|
|
|
|
//
|
|
// Store the expanded copy back into the list entry.
|
|
//
|
|
char *result = new char[strlen(buffer) + 1];
|
|
strcpy(result, buffer);
|
|
entry->dataReference = result;
|
|
}
|
|
|
|
delete veh_tbl;
|
|
|
|
//
|
|
// Advance the global serial number ('9' wraps to 'A') and install the
|
|
// per-frame material-name substitution callback.
|
|
//
|
|
if (gSerno == '9') gSerno = 'A';
|
|
else gSerno = gSerno + 1;
|
|
dpl_SetMaterialNameCallback(substituteMaterial); // FUN_0049664c(FUN_00459eb8)
|
|
}
|
|
|
|
|
|
//
|
|
//#############################################################################
|
|
// TearDownMaterialSubstitutionList
|
|
//#############################################################################
|
|
//
|
|
// @004d11e8
|
|
//
|
|
// Free the expanded substitution strings + the list, and clear the DPL
|
|
// material-name callback.
|
|
//
|
|
void
|
|
BTL4VideoRenderer::TearDownMaterialSubstitutionList()
|
|
{
|
|
dpl_SetMaterialNameCallback(NULL); // FUN_0049664c(0) -- first, as the binary does
|
|
if (materialSubstitutionList != NULL)
|
|
{
|
|
for (NameList::Entry *entry = materialSubstitutionList->GetFirstEntry();
|
|
entry != NULL;
|
|
entry = entry->GetNextEntry())
|
|
{
|
|
char *p = entry->GetChar();
|
|
if (p) { delete [] p; entry->dataReference = NULL; }
|
|
}
|
|
delete materialSubstitutionList;
|
|
materialSubstitutionList = NULL;
|
|
}
|
|
}
|
|
|
|
//===========================================================================//
|
|
// BTL4VideoRenderer ctor/dtor/TestInstance
|
|
//---------------------------------------------------------------------------//
|
|
// TODO(bring-up): the shipped BT ctor took the 1995 IG-board calibration tuple
|
|
// (rate/complexity/priority/interest/depth) and drove the Division renderer.
|
|
// WinTesla replaced that renderer with the D3D DPLRenderer, whose ctor now needs
|
|
// (HWND, width, height, fullscreen, interest_type, depth). The old calibration
|
|
// args have no D3D analogue, so for the first link we forward the interest/depth
|
|
// and bind the renderer to the active window at the pod main-view size (800x600).
|
|
// Real window/size wiring belongs in the BTL4Application video bring-up.
|
|
//===========================================================================//
|
|
BTL4VideoRenderer::BTL4VideoRenderer(
|
|
RendererRate /*calibration_rate*/,
|
|
RendererComplexity /*calibration_complexity*/,
|
|
RendererPriority /*calibration_priority*/,
|
|
InterestType interest_type,
|
|
InterestDepth depth_calibration
|
|
)
|
|
:
|
|
DPLRenderer(::GetActiveWindow(), 800, 600, false, interest_type, depth_calibration)
|
|
{
|
|
Check_Pointer(this);
|
|
mEyeCockpit = 0;
|
|
mEyeChase = 0;
|
|
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;
|
|
|
|
//
|
|
// PAINT ON RELOAD (Gitea #38 root cause, 2026-07-24). The loop below
|
|
// re-parses every shown segment's BGF through d3d_OBJECT::LoadObject, and
|
|
// there is NO geometry cache (only mTextureCache) -- so each call is a fresh
|
|
// parse. The per-pilot colour/badge/patch is applied by REWRITING MATERIAL
|
|
// NAMES during that parse, and only while the substitution callback is
|
|
// installed (`dpl_ApplyMaterialNameCallback`, engine/MUNGA_L4/bgfload.cpp:15-18;
|
|
// installed by SetupMaterialSubstitutionList, removed by TearDown). Reloading
|
|
// outside that bracket resolved the raw `%color%` placeholders -> unpainted
|
|
// materials -> the mech rendered GREY. Reproduced live 2026-07-24: a crimson
|
|
// MadCat went grey in its own view after a V (inside/outside) toggle, with NO
|
|
// new [paint]/MakeMechRenderables line in the log -- i.e. no rebuild, just
|
|
// this re-parse. The RESPAWN path lands here too (that is what the
|
|
// fresh-graphic-state read below is for), which is issue #38's mechanism.
|
|
// Re-install the list with the serial the mech was BUILT with, then restore
|
|
// the global counter so other mechs' serials are untouched.
|
|
// [T2 -- our port re-parses where the 1995 engine kept every skeleton variant
|
|
// resident, so this bracket is a PORT-NECESSARY restoration of the binary's
|
|
// invariant: mech geometry is only ever parsed with its paint installed.]
|
|
//
|
|
const int reinstall_paint = (render_tree.paintSerno != 0);
|
|
if (reinstall_paint)
|
|
{
|
|
const char saved_serno = gSerno;
|
|
gSerno = render_tree.paintSerno;
|
|
SetupMaterialSubstitutionList(viewpoint);
|
|
gSerno = saved_serno; // Setup advanced it -- keep the sequence
|
|
}
|
|
|
|
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 canopy shell (*_cop -- the frame around the eyepoint) SHOWS by
|
|
// default: with the authentic eye (baseOffset + parent-segment DCS + inverse
|
|
// view) and the loader's single-sided dark-ramp treatment it renders as the
|
|
// dark frame with the world through the openings (task #55, verified vs
|
|
// gameplay footage). BT_HIDE_COCKPIT=1 hides it (diagnostic).
|
|
if (inside && nm != NULL && strstr((const char *)*nm, "_cop") != NULL
|
|
&& getenv("BT_HIDE_COCKPIT"))
|
|
nm = NULL;
|
|
// #91 attribution diag: BT_HIDE_INSIDE_SEG=<substr> hides any OTHER
|
|
// inside-view (type-A) mesh whose name contains the substring -- the
|
|
// thor authors THREE type-A meshes (cop + thx_tor + thx_msl) and one
|
|
// of the extras is the reported black rectangle.
|
|
{
|
|
const char *hide = getenv("BT_HIDE_INSIDE_SEG");
|
|
if (inside && nm != NULL && hide && *hide
|
|
&& strstr((const char *)*nm, hide) != NULL)
|
|
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;
|
|
// 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;
|
|
// Inside view: NAME what renders. The KB says the inside view is the
|
|
// _cop alone; any OTHER type-A mesh here is a black-material stowaway
|
|
// (the #91 rectangle investigation) -- keep the roster visible.
|
|
if (inside && obj != NULL)
|
|
DEBUG_STREAM << "[view] shown seg " << slot << ": "
|
|
<< (const char *)segment->GetName()
|
|
<< " mesh " << (nm ? (const char *)*nm : "?")
|
|
<< "\n" << std::flush;
|
|
}
|
|
// This reloaded every segment mesh, so every armour-damage binding is stale.
|
|
BindArmourDamage(viewpoint, render_tree);
|
|
if (reinstall_paint)
|
|
{
|
|
TearDownMaterialSubstitutionList();
|
|
}
|
|
DEBUG_STREAM << "[view] skeleton "
|
|
<< (inside ? "A (inside)" : "N (outside)") << ": "
|
|
<< shown << " segment mesh(es) shown, " << hidden
|
|
<< " hidden (paint serno "
|
|
<< (render_tree.paintSerno ? render_tree.paintSerno : '-')
|
|
<< ")\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
|
|
// cockpit eye on a mech with no siteeyepoint) leaves the current view.
|
|
//
|
|
void
|
|
BTL4VideoRenderer::SetViewInside(int inside)
|
|
{
|
|
mViewInside = inside; // persists across renderable rebuilds
|
|
if (inside && mEyeCockpit != 0)
|
|
mCamera = mEyeCockpit;
|
|
else if (!inside && mEyeChase != 0)
|
|
mCamera = mEyeChase;
|
|
|
|
//
|
|
// Swap the player's DISPLAYED skeleton with the view: the INSIDE view uses
|
|
// the inside-skeleton mesh set (SkeletonType_A -- most body segments have
|
|
// no inside mesh, so the pilot isn't wrapped in his own torso textures;
|
|
// 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())
|
|
{
|
|
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")
|
|
<< (mCamera == mEyeCockpit ? " (cockpit live)" : " (chase live)")
|
|
<< "\n" << std::flush;
|
|
|
|
// the HUD overlay draws in the cockpit view only
|
|
{
|
|
extern void BTSetHudInside(int inside);
|
|
BTSetHudInside(mCamera == mEyeCockpit ? 1 : 0);
|
|
}
|
|
}
|
|
|
|
//
|
|
// Sim-side bridge (the mech4 keyboard poll drives it).
|
|
//
|
|
void BTSetViewInside(int inside)
|
|
{
|
|
if (application == NULL)
|
|
return;
|
|
BTL4VideoRenderer *renderer =
|
|
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
|
if (renderer != NULL)
|
|
renderer->SetViewInside(inside);
|
|
}
|
|
|
|
BTL4VideoRenderer::~BTL4VideoRenderer()
|
|
{
|
|
}
|
|
|
|
Logical
|
|
BTL4VideoRenderer::TestInstance() const
|
|
{
|
|
return True;
|
|
}
|
|
|
|
//===========================================================================//
|
|
// The "blue warp" translocation sphere (task #52)
|
|
//
|
|
// Reconstructed from the engine's POVTranslocateRenderable (L4VIDRND.cpp:1749):
|
|
// a sphere (tsphere.bgf) that COLLAPSES onto the respawn point then EXPANDS to
|
|
// reveal the reborn mech, rotating throughout. Loaded by FILENAME (not via the
|
|
// RES table) -- which is why every resource-name search missed it. Drawn direct
|
|
// from the render loop (BTDrawTranslocationSpheres, beside BTDrawBeams).
|
|
//
|
|
// SELF-CONTAINED ONE-SHOT: the engine keys this off the player's SimulationState
|
|
// dial, but in our reconstruction that dial ALSO drives the camera/POV +
|
|
// targeting, so pulsing it for the sphere regressed all of those (inside-view,
|
|
// no-fire). Instead the respawn path (btplayer.cpp) calls BTStartWarpEffect at
|
|
// the drop-zone origin and the effect plays its own collapse->expand -- touching
|
|
// nothing but the render. (The BTTranslocationRenderable objects the entity
|
|
// tree still builds for the replicant/POV wiring are now inert.)
|
|
//===========================================================================//
|
|
namespace {
|
|
// The engine #defines VERBATIM (L4VIDRND.cpp:1763-1770): collapse from 100x down
|
|
// to 1x over 1.3s, throb at 1x, then expand 1x -> 150x over 1.0s. TRANSLATE_LIMIT
|
|
// is the WaitForReincarnate wobble amplitude. Only these five are live (the
|
|
// ROTATE_* / TRANSLATE_RATE members are dead -- no geometry rotation).
|
|
const float TLOC_COLLAPSE_TIME = 1.3f; // COLLAPSE_TIME
|
|
const float TLOC_EXPAND_TIME = 1.0f; // EXPAND_TIME
|
|
const float TLOC_TRANSLATE_LIMIT = 2.0f; // wait wobble amplitude
|
|
float gWarpCollapseScale = 100.0f; // COLLAPSE_START_SCALE
|
|
float gWarpExpandScale = 150.0f; // EXPAND_END_SCALE
|
|
|
|
d3d_OBJECT *gTLocSphere = 0;
|
|
int gTLocSphereTried = 0;
|
|
|
|
// The single active warp one-shot (one local player per node). Faithful state
|
|
// machine: 0 Idle, 1 InitialCollapse, 3 WaitForReincarnate (world masked, wobble),
|
|
// 2 ExpandReveal (L4VIDRND.cpp POVTranslocateRenderable states).
|
|
int gWarpPhase = 0;
|
|
float gWarpT = 0.0f; // phase clock (collapse/expand)
|
|
float gWaitClock = 0.0f; // WaitForReincarnate elapsed (wobble + stuck-black failsafe)
|
|
float gWarpSpin = 0.0f; // accumulated throat-axis (Z) spin -- the SPIRAL (decomp FUN_00453dc4)
|
|
float gWarpX = 0.0f, gWarpY = 0.0f, gWarpZ = 0.0f;
|
|
int gWarpPOV = 0; // 1 = centre on the local eye (own death/respawn); 0 = world-anchored (peer)
|
|
int gWarpMasked = 0; // 1 while we have raised the SetIsDead world mask
|
|
}
|
|
|
|
extern void BTSetWorldDead(int dead); // L4VIDRND.cpp bridge -> l4_application->SetIsDead
|
|
|
|
// The renderable objects the entity tree builds for the translocation wiring are
|
|
// inert now (the warp is the self-contained one-shot below); the ctor/dtor just
|
|
// satisfy MakeEntityRenderables.
|
|
BTTranslocationRenderable::BTTranslocationRenderable(
|
|
Entity *entity, int, dpl_VIEW *,
|
|
StateIndicator *effect_trigger, Point3D *drop_zone, int effect_control_state)
|
|
: BTRenderableBase(entity),
|
|
myWatchedEntity(entity),
|
|
myTrigger(effect_trigger),
|
|
myDropZone(drop_zone),
|
|
myControlState((unsigned)effect_control_state),
|
|
mySphereState(TLoc_Idle),
|
|
myTimer(0.0f),
|
|
myRotateY(0.0f),
|
|
mySphereVisible(false)
|
|
{
|
|
}
|
|
|
|
BTTranslocationRenderable::~BTTranslocationRenderable()
|
|
{
|
|
}
|
|
|
|
static void BTWarpApplyScaleEnv()
|
|
{
|
|
if (const char *s = getenv("BT_WARP_SCALE"))
|
|
{
|
|
float v = (float)atof(s);
|
|
if (v > 0.0f) { gWarpCollapseScale = v; gWarpExpandScale = v * 1.5f; }
|
|
}
|
|
}
|
|
|
|
//
|
|
// LOCAL DEATH -> InitialCollapse (engine: trigger becomes == control state, Idle ->
|
|
// InitialCollapse, L4VIDRND.cpp:1900-1911). The sphere collapses 100x -> 1x onto
|
|
// your own eye over 1.3s (world still visible), then raises the SetIsDead world mask
|
|
// and THROBS (WaitForReincarnate) until the respawn kicks the expand. POV only.
|
|
//
|
|
void BTStartWarpCollapsePOV()
|
|
{
|
|
BTWarpApplyScaleEnv();
|
|
gWarpPhase = 1; // InitialCollapse
|
|
gWarpT = 0.0f;
|
|
gWaitClock = 0.0f;
|
|
gWarpPOV = 1;
|
|
gWarpX = gWarpY = gWarpZ = 0.0f;
|
|
if (getenv("BT_TLOC_LOG"))
|
|
DEBUG_STREAM << "[tloc] warp COLLAPSE (POV) start\n" << std::flush;
|
|
}
|
|
|
|
//
|
|
// LOCAL RESPAWN -> ExpandReveal (engine: trigger becomes != control state,
|
|
// WaitForReincarnate -> ExpandReveal + SetIsDead(false), L4VIDRND.cpp:1987-1989).
|
|
// Drops the world mask and blasts the sphere 1x -> 150x, revealing the reborn world.
|
|
//
|
|
void BTStartWarpExpandPOV()
|
|
{
|
|
BTWarpApplyScaleEnv();
|
|
if (gWarpMasked) { BTSetWorldDead(0); gWarpMasked = 0; } // == SetIsDead(false) :1989
|
|
gWarpPhase = 2; // ExpandReveal
|
|
gWarpT = 0.0f;
|
|
gWarpPOV = 1;
|
|
gWarpX = gWarpY = gWarpZ = 0.0f;
|
|
if (getenv("BT_TLOC_LOG"))
|
|
DEBUG_STREAM << "[tloc] warp EXPAND (POV) start\n" << std::flush;
|
|
}
|
|
|
|
//
|
|
// World-anchored warp (a PORT EXTENSION -- the authentic effect is POV only): an
|
|
// OBSERVER seeing a peer respawn over THERE. No world mask (the observer is alive);
|
|
// expand-reveal at the peer's world point.
|
|
//
|
|
void BTStartWarpEffect(float x, float y, float z)
|
|
{
|
|
// ONE warp slot (the authentic effect is POV-only; this world-anchored peer
|
|
// sphere is the port extension). The local pilot's own death/respawn
|
|
// lifecycle OWNS the slot: a peer's un-wreck landing mid-collapse/wait/expand
|
|
// would overwrite gWarpPhase/gWarpPOV and kill the POV vortex ("my respawn
|
|
// had no blue vortex"). Harmless before the #81 respawn fix only because
|
|
// overlapping respawns barely existed; now they are routine. Skip the peer
|
|
// sphere instead -- the observer still sees the un-wreck swap itself.
|
|
if (gWarpPhase != 0 && gWarpPOV)
|
|
{
|
|
if (getenv("BT_TLOC_LOG"))
|
|
DEBUG_STREAM << "[tloc] peer warp SKIPPED (own POV warp active, phase "
|
|
<< gWarpPhase << ")\n" << std::flush;
|
|
return;
|
|
}
|
|
BTWarpApplyScaleEnv();
|
|
gWarpPhase = 2; // ExpandReveal
|
|
gWarpT = 0.0f;
|
|
gWarpPOV = 0;
|
|
gWarpX = x; gWarpY = y; gWarpZ = z;
|
|
if (getenv("BT_TLOC_LOG"))
|
|
DEBUG_STREAM << "[tloc] warp EXPAND (world) at (" << x << "," << y << "," << z << ")\n" << std::flush;
|
|
}
|
|
|
|
//
|
|
// FAILSAFE: drop the world mask + end the effect. MUST be called on every path
|
|
// where a collapse fired but no respawn/expand can follow (mission end, out of
|
|
// lives, dropped DropZoneReply) -- otherwise the SetIsDead world stays BLACK forever.
|
|
//
|
|
void BTWarpForceUnmask()
|
|
{
|
|
if (gWarpMasked) { BTSetWorldDead(0); gWarpMasked = 0; }
|
|
if (gWarpPhase == 3) gWarpPhase = 0;
|
|
}
|
|
|
|
//
|
|
// Play the warp one-shot: tsphere.bgf COLLAPSES onto the respawn point (scale
|
|
// -> 1 over 1.3s) then EXPANDS to reveal the reborn mech (1 -> max over 1.0s),
|
|
// rotating. Alpha pass (beside the beams) so it blends + Z-tests vs the world.
|
|
//
|
|
void
|
|
BTDrawTranslocationSpheres(LPDIRECT3DDEVICE9 device, const D3DXMATRIX *view,
|
|
float dt, Time frame_time)
|
|
{
|
|
// DIAG (off by default; MUST sit before the phase-0 early-out -- this
|
|
// fn is the per-frame alpha-pass hook, L4VIDEO.cpp:8831): BT_SHOT_EVERY=<n> dumps the backbuffer every n
|
|
// frames as <BT_SHOT_PREFIX|shot>_NNN.png -- the GENERAL headless-eyes
|
|
// harness (view/HUD/layout debugging without foregrounding the window).
|
|
{
|
|
static int s_shotEvery = -2;
|
|
if (s_shotEvery == -2)
|
|
{
|
|
const char *se = getenv("BT_SHOT_EVERY");
|
|
s_shotEvery = (se && *se) ? atoi(se) : 0;
|
|
if (s_shotEvery < 0) s_shotEvery = 0;
|
|
}
|
|
if (s_shotEvery > 0)
|
|
{
|
|
static int s_frameN = 0, s_dumpN = 0;
|
|
if ((++s_frameN % s_shotEvery) == 0 && s_dumpN < 400)
|
|
{
|
|
const char *pfx = getenv("BT_SHOT_PREFIX");
|
|
char fn[600];
|
|
_snprintf(fn, sizeof fn, "%s_%03d.png", (pfx && *pfx) ? pfx : "shot", s_dumpN++);
|
|
IDirect3DSurface9 *bb = 0;
|
|
if (SUCCEEDED(device->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, &bb)) && bb)
|
|
{
|
|
D3DXSaveSurfaceToFileA(fn, D3DXIFF_PNG, bb, 0, 0);
|
|
bb->Release();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// DIAG (off by default): BT_WARP_SELFTEST=1 forces a steady POV warp
|
|
// (held in WaitForReincarnate, world masked) so the swirl can be frame-captured and
|
|
// compared against the original (capture.png) in a solo game -- no death/respawn
|
|
// needed. Remove once the visual is signed off.
|
|
static int s_selftest = -1;
|
|
if (s_selftest < 0) s_selftest = getenv("BT_WARP_SELFTEST") ? 1 : 0;
|
|
if (s_selftest && gWarpPhase == 0)
|
|
{
|
|
gWarpPhase = 3; gWarpPOV = 1; gWaitClock = 0.0f;
|
|
gWarpX = gWarpY = gWarpZ = 0.0f;
|
|
if (!gWarpMasked) { BTSetWorldDead(1); gWarpMasked = 1; }
|
|
}
|
|
|
|
if (gWarpPhase == 0)
|
|
return;
|
|
|
|
if (gTLocSphere == 0 && !gTLocSphereTried)
|
|
{
|
|
gTLocSphereTried = 1;
|
|
gTLocSphere = d3d_OBJECT::LoadObject(device, "tsphere.bgf");
|
|
if (gTLocSphere != 0)
|
|
{
|
|
for (int op = 0; op < gTLocSphere->GetDrawOpCount(); ++op)
|
|
{
|
|
L4DRAWOP *dop = gTLocSphere->GetDrawOp(op);
|
|
// THE SWIRL MOTION. The authentic material scrolls its texture
|
|
// (tsphere_scr_tex SPECIAL "SCROLL 0.0 0.0 0.1 0.5"), but the port only
|
|
// picks scroll up from a per-texture .met file (L4D3D.cpp:640), which
|
|
// tsphere has none of, and the ramp-bake path leaves doScroll=false --
|
|
// so our swirl was FROZEN. Set the authored scroll rates here so
|
|
// SetTextureScrolling animates it (u -0.1/s, v +0.5/s -> the churning
|
|
// swirl that "spins around"), REPEAT wrap so the scroll tiles.
|
|
dop->texture.doScroll = getenv("BT_WARP_NOSCROLL") ? false : true; // DIAG toggle
|
|
dop->texture.scrollUDelta = 0.1f;
|
|
dop->texture.scrollVDelta = 0.5f;
|
|
dop->texture.wrap_u = L4TEXOP::REPEAT;
|
|
dop->texture.wrap_v = L4TEXOP::REPEAT;
|
|
// The pass routing (drawAsSky for POV / alphaTest for the peer overlay)
|
|
// is set per-frame below, since it differs by mode.
|
|
}
|
|
}
|
|
if (getenv("BT_TLOC_LOG"))
|
|
DEBUG_STREAM << "[tloc] tsphere.bgf load "
|
|
<< (gTLocSphere ? "OK" : "FAILED")
|
|
<< " ops=" << (gTLocSphere ? gTLocSphere->GetDrawOpCount() : 0)
|
|
<< (gTLocSphere ? " center=(" : "")
|
|
<< (gTLocSphere ? gTLocSphere->mCullCenter.x : 0.0f) << ","
|
|
<< (gTLocSphere ? gTLocSphere->mCullCenter.y : 0.0f) << ","
|
|
<< (gTLocSphere ? gTLocSphere->mCullCenter.z : 0.0f) << ") r="
|
|
<< (gTLocSphere ? gTLocSphere->GetRadius() : 0.0f)
|
|
<< "\n" << std::flush;
|
|
}
|
|
if (gTLocSphere == 0)
|
|
{
|
|
gWarpPhase = 0;
|
|
return;
|
|
}
|
|
|
|
// ===== The POVTranslocateRenderable::Execute() state machine (L4VIDRND.cpp) =====
|
|
float scale = 1.0f;
|
|
if (gWarpPhase == 1) // InitialCollapse (:1928): 101 -> 1
|
|
{
|
|
gWarpT += dt;
|
|
float left = 1.0f - (gWarpT / TLOC_COLLAPSE_TIME);
|
|
if (left <= 0.0f) // collapse finished (:1935)
|
|
{
|
|
scale = 1.0f;
|
|
gWarpPhase = 3; // -> WaitForReincarnate (:1946)
|
|
gWaitClock = 0.0f; // rebaseline for the wobble (:1945)
|
|
if (gWarpPOV) { BTSetWorldDead(1); gWarpMasked = 1; } // SetIsDead(true) (:1947)
|
|
}
|
|
else
|
|
scale = left * gWarpCollapseScale + 1.0f; // (pct_left*100)+1
|
|
}
|
|
else if (gWarpPhase == 3) // WaitForReincarnate (:1978): throb black
|
|
{
|
|
gWaitClock += dt;
|
|
scale = 1.0f;
|
|
// FAILSAFE: the respawn kicks Wait->Expand (BTStartWarpExpandPOV). If it never
|
|
// arrives (mission end / out of lives / dropped DropZoneReply) the SetIsDead
|
|
// world would stay BLACK forever -- so time out and un-mask after 12s.
|
|
if (!s_selftest && gWaitClock > 12.0f)
|
|
{
|
|
if (gWarpMasked) { BTSetWorldDead(0); gWarpMasked = 0; }
|
|
gWarpPhase = 0;
|
|
return;
|
|
}
|
|
}
|
|
else // ExpandReveal (:2013): 1 -> 151
|
|
{
|
|
gWarpT += dt;
|
|
float used = gWarpT / TLOC_EXPAND_TIME;
|
|
if (used >= 1.0f) // reveal done (:2030)
|
|
{
|
|
if (gWarpMasked) { BTSetWorldDead(0); gWarpMasked = 0; } // safety (mask should already be off)
|
|
gWarpPhase = 0;
|
|
return;
|
|
}
|
|
scale = used * gWarpExpandScale + 1.0f; // (pct_used*150)+1
|
|
}
|
|
|
|
if (getenv("BT_TLOC_LOG"))
|
|
{
|
|
static int s_lt = 0;
|
|
if ((++s_lt % 15) == 1)
|
|
DEBUG_STREAM << "[tloc] warp phase=" << gWarpPhase << " scale=" << scale
|
|
<< " pov=" << gWarpPOV << " masked=" << gWarpMasked << "\n" << std::flush;
|
|
}
|
|
|
|
// PLACEMENT (authentic POVTranslocateRenderable, L4VIDRND.cpp:1812 "rotated and
|
|
// scaled around the VTV"): the sphere is PURE SCALE -- NO geometry spin. The
|
|
// engine's myRotateY is dead code; the swirl is entirely the texture SCROLL.
|
|
// - POV (your OWN respawn): centre the sphere ON your eye and orient it to the
|
|
// view: world = Scale(s) * inverse(view). In view space that puts the sphere
|
|
// at the origin, so you sit at its centre looking out through the swirl -- the
|
|
// authentic tunnel. (World-fixing it at the mech's feet was the "blob from my
|
|
// own perspective / not aligned to my orientation" the user reported.)
|
|
// - Non-POV (observing a PEER respawn): anchor at the peer's world point so the
|
|
// swirl plays over there -- pure scale, then translate.
|
|
const float s = scale;
|
|
// PLACEMENT -- the authentic VTV/eye parenting (L4VIDRND.cpp:2069-2074): the mesh
|
|
// LOCAL ORIGIN is the eye, so world = localToWorld * inverse(view) with NO recenter.
|
|
// tsphere is authored OFF-origin ON PURPOSE (centre +8.25y) so the eye sits ~0.38r
|
|
// low INSIDE the sphere; recentring it to dead-centre (my earlier attempt) was WRONG
|
|
// and only worsened the funnel. In WaitForReincarnate the localToWorld is a pure
|
|
// Lissajous TRANSLATION (the throb, :1996-2003); otherwise a pure SCALE (no spin --
|
|
// myRotateY is dead; the swirl is the texture scroll).
|
|
D3DXMATRIX local;
|
|
if (gWarpPhase == 3) // WaitForReincarnate throb
|
|
D3DXMatrixTranslation(&local,
|
|
(float)(cos(gWaitClock * 3.33) * TLOC_TRANSLATE_LIMIT),
|
|
(float)(sin(gWaitClock * 2.5) * TLOC_TRANSLATE_LIMIT), 0.0f);
|
|
else
|
|
D3DXMatrixScaling(&local, s, s, s);
|
|
|
|
// THE SPIRAL (the piece I wrongly dropped): a continuous per-frame SPIN about the
|
|
// throat axis (mesh local Z). The 1995 binary's translocate Execute (decomp
|
|
// FUN_00453dc4) accumulates an angle and writes a Z-rotation into the sphere every
|
|
// frame; the WinTesla port STUBBED it out and I followed the stub, calling
|
|
// myRotateY "dead". Spin + the axial V texture-scroll = a HELIX = the smooth
|
|
// spiralling vortex, and the rotation SWEEPS the coarse 12 facet edges so the mesh
|
|
// stops reading as a faceted "sphincter". BT_WARP_SPIN = rad/s (default 4).
|
|
static float s_spinRate = -1.0e9f;
|
|
if (s_spinRate == -1.0e9f)
|
|
{
|
|
const char *sv = getenv("BT_WARP_SPIN");
|
|
s_spinRate = sv ? (float)atof(sv) : 4.0f;
|
|
}
|
|
gWarpSpin += s_spinRate * dt;
|
|
if (gWarpSpin > 6.2831853f) gWarpSpin -= 6.2831853f;
|
|
else if (gWarpSpin < 0.0f) gWarpSpin += 6.2831853f;
|
|
D3DXMATRIX spin;
|
|
D3DXMatrixRotationZ(&spin, gWarpSpin);
|
|
|
|
// tsphere is a 12-facet BICONE tunnel; its long axis (the vortex throat) is the
|
|
// mesh LOCAL Z, which Scale*inverse(view) already points down the view forward --
|
|
// so NO reorientation is needed (default tilt 0). BT_WARP_TILT (deg, pitch about
|
|
// X) is kept only to NUDGE the off-axis convergence toward screen-centre if wanted
|
|
// (the eye sits 8.25 below the throat axis, authentically, so it reads a touch
|
|
// high).
|
|
static float s_tiltDeg = -1.0e9f;
|
|
if (s_tiltDeg == -1.0e9f)
|
|
{
|
|
const char *tv = getenv("BT_WARP_TILT");
|
|
s_tiltDeg = tv ? (float)atof(tv) : 0.0f;
|
|
}
|
|
D3DXMATRIX tilt;
|
|
D3DXMatrixRotationX(&tilt, s_tiltDeg * (float)(3.14159265358979 / 180.0));
|
|
|
|
// EYE-ON-AXIS: bintA is smooth CLOUD noise (no rings of its own); the vortex rings
|
|
// come from that cloud mapped in POLAR (U=angle around the Z throat, V=radius) --
|
|
// which only reads as CONCENTRIC rings when you look straight DOWN the throat axis.
|
|
// The eye is authentically 8.25 below the axis, which skews the rings to diagonal
|
|
// bands (the "sphincter"). BT_WARP_EYE_UP shifts the eye ONTO the axis (mesh local
|
|
// +Y) so the rings go concentric; default 8.25 (the throat-axis offset), =0 for the
|
|
// raw authentic off-axis view.
|
|
static float s_eyeUp = -1.0e9f;
|
|
if (s_eyeUp == -1.0e9f)
|
|
{
|
|
const char *ev = getenv("BT_WARP_EYE_UP");
|
|
s_eyeUp = ev ? (float)atof(ev) : 8.25f;
|
|
}
|
|
D3DXMATRIX eyeUp;
|
|
D3DXMatrixTranslation(&eyeUp, 0.0f, -s_eyeUp, 0.0f);
|
|
|
|
D3DXMATRIX world;
|
|
if (gWarpPOV && view != 0)
|
|
{
|
|
D3DXMATRIX invView;
|
|
D3DXMatrixInverse(&invView, 0, view);
|
|
// throat-axis -> origin (eyeUp), SPIN about Z, scale/throb, tilt, eye->world.
|
|
// eyeUp before spin so the rotation is about the THROAT, not the off-axis eye.
|
|
world = eyeUp * spin * local * tilt * invView;
|
|
}
|
|
else
|
|
{
|
|
D3DXMATRIX anchorM;
|
|
D3DXMatrixTranslation(&anchorM, gWarpX, gWarpY, gWarpZ);
|
|
world = local * anchorM; // peer overlay: external view, keep upright
|
|
}
|
|
gTLocSphere->SetLocalToWorld(world);
|
|
|
|
// The swirl COLOUR: the "sky" ramp already bakes the bintA cloud blue->white
|
|
// (bgfload.cpp un-gate; L4D3D.cpp:480), and we MODULATE that by the material's
|
|
// authored EMISSIVE {0.7,0.5,1} = 0xB380FF (the lavender-blue energy cast) which
|
|
// the LIGHTING-off path would otherwise drop -- that lavender is the original's
|
|
// signature colour. BT_WARP_COLOR=AARRGGBB 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) : 0xFFB380FF;
|
|
if (s_warpColor == 0) s_warpColor = 0xFFB380FF;
|
|
}
|
|
|
|
if (gWarpPOV)
|
|
{
|
|
// ===== POV: draw as SKY, exactly like the engine (isDeathDraw -> drawAsSky,
|
|
// L4VIDRND.cpp:1802-1808). OPAQUE + backface-cull (CW) + z-test ON is what makes
|
|
// an inside-viewed dome render CLEAN: no translucent double-blend, no coincident
|
|
// double-winding z-fight (that WAS the "glitchy funnel"), and the huge expand
|
|
// shell is occluded by the world coming back on = the reveal. The world itself
|
|
// is blacked by the SetIsDead mask (raised at collapse-end), so during the throb
|
|
// you see ONLY the swirling dome. Sky-pass states: L4VIDEO.cpp:7526 (cull CW),
|
|
// 7568-7570 (zwrite on / blend off), 7693 (light off). Leave ZENABLE default. =====
|
|
for (int op = 0; op < gTLocSphere->GetDrawOpCount(); ++op)
|
|
{
|
|
L4DRAWOP *dop = gTLocSphere->GetDrawOp(op);
|
|
dop->drawAsSky = true; dop->alphaTest = false; dop->drawAsDecal = false;
|
|
}
|
|
DWORD sZW, sAB, sCull, sLight, sTF, sCOp, sCA1, sCA2, sAOp, sAA1;
|
|
DWORD sMin, sMag, sMip, sAniso, sFog;
|
|
device->GetRenderState(D3DRS_ZWRITEENABLE, &sZW);
|
|
device->GetRenderState(D3DRS_ALPHABLENDENABLE, &sAB);
|
|
device->GetRenderState(D3DRS_CULLMODE, &sCull);
|
|
device->GetRenderState(D3DRS_LIGHTING, &sLight);
|
|
device->GetRenderState(D3DRS_FOGENABLE, &sFog);
|
|
device->GetRenderState(D3DRS_TEXTUREFACTOR, &sTF);
|
|
device->GetSamplerState(0, D3DSAMP_MINFILTER, &sMin);
|
|
device->GetSamplerState(0, D3DSAMP_MAGFILTER, &sMag);
|
|
device->GetSamplerState(0, D3DSAMP_MIPFILTER, &sMip);
|
|
device->GetSamplerState(0, D3DSAMP_MAXANISOTROPY, &sAniso);
|
|
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);
|
|
|
|
device->SetRenderState(D3DRS_ZWRITEENABLE, TRUE);
|
|
device->SetRenderState(D3DRS_ALPHABLENDENABLE, FALSE); // OPAQUE -> single write/pixel
|
|
{ // DIAG: BT_WARP_CULL = none|cw|ccw (default cw). The mesh is double-sided
|
|
// (every tri emitted fwd+rev); if CW doesn't cleanly cull one winding the two
|
|
// coincident surfaces z-fight into radial sparkle "spokes".
|
|
static DWORD s_cull = 0xffffffff;
|
|
if (s_cull == 0xffffffff) {
|
|
const char *cv = getenv("BT_WARP_CULL");
|
|
s_cull = (cv && cv[0]=='n') ? D3DCULL_NONE : (cv && cv[1]=='c') ? D3DCULL_CCW : D3DCULL_CW;
|
|
}
|
|
device->SetRenderState(D3DRS_CULLMODE, s_cull);
|
|
}
|
|
device->SetRenderState(D3DRS_LIGHTING, FALSE);
|
|
device->SetRenderState(D3DRS_FOGENABLE, FALSE); // tsphere_mtl is "IMMUNE 1" (fog-immune) -- world fog was washing the swirl
|
|
device->SetRenderState(D3DRS_TEXTUREFACTOR, s_warpColor);
|
|
// FILTERING: era-authentic ISOTROPIC linear+mip (default). My earlier
|
|
// anisotropic "fix" was the SPOKE driver: on the grazing funnel wall the texel
|
|
// footprint is stretched RADIALLY (down the throat/V), so anisotropy averages
|
|
// ALONG that (smears radially) while keeping azimuthal sharpness -> the soft
|
|
// concentric rings collapse into radial STREAKS/spokes. Plain isotropic linear
|
|
// keeps the rings soft + concentric. BT_WARP_ANISO=N re-enables it (diag only).
|
|
{
|
|
static int s_aniso = -2;
|
|
if (s_aniso == -2) { const char *a = getenv("BT_WARP_ANISO"); s_aniso = a ? atoi(a) : 0; }
|
|
if (s_aniso > 0)
|
|
{
|
|
device->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_ANISOTROPIC);
|
|
device->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR);
|
|
device->SetSamplerState(0, D3DSAMP_MIPFILTER, D3DTEXF_LINEAR);
|
|
device->SetSamplerState(0, D3DSAMP_MAXANISOTROPY, (DWORD)s_aniso);
|
|
}
|
|
else // force isotropic linear (undo any anisotropy a prior draw left set)
|
|
{
|
|
device->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_LINEAR);
|
|
device->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR);
|
|
// Trilinear mips. (The grainy radial "spokes" were NOT a mip problem -- they were
|
|
// the texture-scroll precision collapse, fixed in L4D3D::SetTextureScrolling via fmod.
|
|
// BT_WARP_MIP=0 forces base-level-only (diag).)
|
|
{ static int s_mip=-1; if(s_mip<0){const char*mv=getenv("BT_WARP_MIP"); s_mip=(mv&&mv[0]=='0')?0:1;}
|
|
device->SetSamplerState(0, D3DSAMP_MIPFILTER, s_mip?D3DTEXF_LINEAR:D3DTEXF_NONE); }
|
|
}
|
|
}
|
|
// The BANDS are the per-VERTEX Gouraud colour (concentric shade-ramp contours
|
|
// baked from the geometry in bgfload::finish); the bintA texture (lifted to a
|
|
// gentle range) MODULATES churn within them. COLOROP = TEXTURE x DIFFUSE(bands).
|
|
// BT_WARP_TEXMOD=0 shows the bands ALONE (diagnostic: is the geometry shade right).
|
|
device->SetRenderState(D3DRS_DIFFUSEMATERIALSOURCE, D3DMCS_COLOR1); // vertex colour = diffuse
|
|
{
|
|
static int s_texmod = -1;
|
|
// Ramp is BAKED into the texture now -> default SELECTARG1(TEXTURE) (else branch).
|
|
// BT_WARP_TEXMOD=1 re-enables the TEXTURExDIFFUSE modulate (diag: double-tints).
|
|
if (s_texmod < 0) { const char *t = getenv("BT_WARP_TEXMOD"); s_texmod = (t && t[0] == '1') ? 1 : 0; }
|
|
if (s_texmod)
|
|
{
|
|
device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE);
|
|
device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); // bintA cloud churn
|
|
device->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE); // the band colour
|
|
}
|
|
else
|
|
{
|
|
device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
|
|
device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); // baked lavender ramp -- direct
|
|
}
|
|
}
|
|
device->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1);
|
|
device->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_TFACTOR);
|
|
|
|
gTLocSphere->Draw(PASS_SKY, view, frame_time);
|
|
|
|
device->SetSamplerState(0, D3DSAMP_MINFILTER, sMin);
|
|
device->SetSamplerState(0, D3DSAMP_MAGFILTER, sMag);
|
|
device->SetSamplerState(0, D3DSAMP_MIPFILTER, sMip);
|
|
device->SetSamplerState(0, D3DSAMP_MAXANISOTROPY, sAniso);
|
|
device->SetRenderState(D3DRS_ZWRITEENABLE, sZW);
|
|
device->SetRenderState(D3DRS_ALPHABLENDENABLE, sAB);
|
|
device->SetRenderState(D3DRS_CULLMODE, sCull);
|
|
device->SetRenderState(D3DRS_LIGHTING, sLight);
|
|
device->SetRenderState(D3DRS_FOGENABLE, sFog);
|
|
device->SetRenderState(D3DRS_TEXTUREFACTOR, sTF);
|
|
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);
|
|
}
|
|
else
|
|
{
|
|
// ===== Peer overlay (port extension): the observer is ALIVE (no world mask), so
|
|
// a sky-drawn sphere would be occluded by the world -> invisible. Draw it in the
|
|
// alpha pass, Z-tested + translucent, so the swirl reads at the peer's spot. =====
|
|
for (int op = 0; op < gTLocSphere->GetDrawOpCount(); ++op)
|
|
{
|
|
L4DRAWOP *dop = gTLocSphere->GetDrawOp(op);
|
|
dop->alphaTest = true; dop->drawAsSky = false; dop->drawAsDecal = false;
|
|
}
|
|
const DWORD peerColor = (s_warpColor == 0xFFFFFFFF) ? 0xB0FFFFFF : s_warpColor; // ~70% alpha
|
|
DWORD sSrc, sDst, sLight, sTF, sCOp, sCA1, sCA2, sAOp, sAA1;
|
|
device->GetRenderState(D3DRS_SRCBLEND, &sSrc);
|
|
device->GetRenderState(D3DRS_DESTBLEND, &sDst);
|
|
device->GetRenderState(D3DRS_LIGHTING, &sLight);
|
|
device->GetRenderState(D3DRS_TEXTUREFACTOR, &sTF);
|
|
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);
|
|
|
|
device->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA);
|
|
device->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHA);
|
|
device->SetRenderState(D3DRS_LIGHTING, FALSE);
|
|
device->SetRenderState(D3DRS_TEXTUREFACTOR, peerColor);
|
|
device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE);
|
|
device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
|
|
device->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TFACTOR);
|
|
device->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1);
|
|
device->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_TFACTOR);
|
|
|
|
gTLocSphere->Draw(PASS_ALPHABLEND, view, frame_time);
|
|
|
|
device->SetRenderState(D3DRS_SRCBLEND, sSrc);
|
|
device->SetRenderState(D3DRS_DESTBLEND, sDst);
|
|
device->SetRenderState(D3DRS_LIGHTING, sLight);
|
|
device->SetRenderState(D3DRS_TEXTUREFACTOR, sTF);
|
|
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);
|
|
}
|
|
|
|
// DIAG (off by default): BT_WARP_SELFSHOT=<prefix> dumps a backbuffer FRAME
|
|
// SEQUENCE (<prefix>_00.png ..) once the warp has settled, so the swirl can be turned
|
|
// into a GIF / compared to capture.png WITHOUT bringing the window to the foreground.
|
|
// Retained as the warp's visual-verification harness (needed for the open peer/collapse work).
|
|
{
|
|
const char *shotPath = getenv("BT_WARP_SELFSHOT");
|
|
if (shotPath && gWarpPhase != 0)
|
|
{
|
|
static int s_shotN = 0;
|
|
++s_shotN;
|
|
// 40 frames, every 3rd, starting at frame 45 (~0.75s in) -> ~2s of the
|
|
// spinning/churning swirl at 60fps.
|
|
if (s_shotN >= 45 && ((s_shotN - 45) % 3 == 0))
|
|
{
|
|
int idx = (s_shotN - 45) / 3;
|
|
if (idx < 40)
|
|
{
|
|
char fn[600];
|
|
_snprintf(fn, sizeof fn, "%s_%02d.png", shotPath, idx);
|
|
IDirect3DSurface9 *bb = 0;
|
|
if (SUCCEEDED(device->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, &bb)) && bb)
|
|
{
|
|
D3DXSaveSurfaceToFileA(fn, D3DXIFF_PNG, bb, 0, 0);
|
|
bb->Release();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//===========================================================================//
|