BT410 5.3.80: joint articulation is live -- 22 nodes, and the twist reaches the board

RecurseSKLFile now builds a joint renderable for any node whose page name
resolves to a live skeleton Joint: HingeX/Y/Z -> HingeRenderable (watching
Joint::GetHinge), Ball -> BallJointRenderable (watching GetEulerAngles),
otherwise the static path.  Each holds the rest offset in one DCS and the live
rotation in a child DCS, and its Execute diffs the watched value and calls
DPL_FLUSH_DCS -- the engine's own mechanism (L4VIDRND.CPP:1026+).  The value
comes from the mech's JointSubsystem via ResolveJoint, so sim and renderer
read one source.  Gated on BT_JOINTS while it proves out.

  [skl] video\max.skl -> 26 nodes, 1 objects, 1 eye, 22 articulated

The bridge reported anim_abs=1 joints=0 twist=+0.00 before; it now reports
joints=1 twist=-0.86, matching the game's [torso] twist=-0.856.  With the mech
stationary, frames that differed by 0.0% now differ by 62-80%.

A crash it exposed: Mech::ResolveJoint passed segment->GetJointIndex()
straight to GetJoint unchecked, and a segment with no joint reports -1 --
GetNthImplementation then indexes [base + -1*4] and dies (guest 00426A1D).
Torso never hit it because it only asks for its own authored joint name; the
walk asks for every page.  Now bounds-checked against GetJointCount.

Open: the canopy does not stay rigid in the view, though it and the eye hang
off the same articulated node.  Cancelling the bridge's cage compensation
(CAGE_TWIST_SIGN=0) did not close it.  Leading hypothesis: SetupCull builds
worldToEyeMatrix from GetSegmentToWorld(siteeyepoint) -- the SIMULATION's
segment transform -- independent of the render tree, so the canopy follows our
render chain and the eye follows the sim's, and they diverge whenever one
carries the twist and the other does not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-29 20:46:18 -05:00
co-authored by Claude Fable 5
parent 8769dae0ac
commit 07eb74d1f7
11 changed files with 2181 additions and 1097 deletions
+16 -1
View File
@@ -858,7 +858,22 @@ Joint*
return NULL;
}
return joints->GetJoint(segment->GetJointIndex());
//
// A segment that HAS no joint reports index -1, and GetJoint indexes a
// table with it unchecked -- TableIterator::GetNthImplementation walks to
// [base + -1*4] and dies (guest 00426A1D, ECX=FFFFFFFF). Torso never hit
// this because it only ever asks for its own authored twist-joint name;
// the skeleton walk asks for EVERY page, and most .SKL pages are sites or
// static segments with no joint at all.
//
int
joint_index = segment->GetJointIndex();
if (joint_index < 0 || joint_index >= joints->GetJointCount())
{
return NULL;
}
return joints->GetJoint(joint_index);
}
//
+163 -43
View File
@@ -21,6 +21,15 @@
#if !defined(MATRIX_HPP)
# include <matrix.hpp>
#endif
//
// Mech::ResolveJoint + the Joint types the articulation path switches on.
//
#if !defined(MECH_HPP)
# include <mech.hpp>
#endif
#if !defined(JOINT_HPP)
# include <joint.hpp>
#endif
//
// The engine's damage-zone tagging callback reads this while geometry is
@@ -126,11 +135,13 @@ dpl_DCS *
dpl_DCS *parent_dcs,
const char *skeleton_filename,
ViewFrom view_type,
int *eye_count)
int *eye_count,
int *joint_count)
{
Check(this);
Check_Pointer(skeleton_filename);
Check_Pointer(eye_count);
Check_Pointer(joint_count);
char
path[256];
@@ -169,12 +180,13 @@ dpl_DCS *
dpl_DCS
*root = RecurseSKLFile(
entity, parent_dcs, skeleton, "ROOT", 0, view_type,
zone, &node_count, &object_count, eye_count);
zone, &node_count, &object_count, eye_count, joint_count);
DEBUG_STREAM << "[skl] " << path
<< " -> " << node_count << " nodes, "
<< object_count << " objects, "
<< *eye_count << " eye\n" << flush;
<< *eye_count << " eye, "
<< *joint_count << " articulated\n" << flush;
//
// The engine guards this the same way (L4VIDEO.CPP, DPLReadEnvironment):
@@ -203,7 +215,8 @@ dpl_DCS *
dpl_ZONE *zone,
int *node_count,
int *object_count,
int *eye_count)
int *eye_count,
int *joint_count)
{
Check(this);
Check(skeleton);
@@ -227,11 +240,6 @@ dpl_DCS *
return NULL;
}
dpl_DCS
*dcs = dpl_NewDCS();
Check_Pointer(dcs);
dpl_SetDCSZone(dcs, zone);
//
// The node's LOCAL TRANSFORM, in the engine's own terms -- the wire
// convention is no longer inferred from captures, it is READ from the
@@ -281,35 +289,146 @@ dpl_DCS *
node_matrix(3,2) = tran_z;
//
// Write in place and flush -- the same idiom as RootRenderable's ctor
// (dpl_GetDCSMatrix + assign), not dpl_SetDCSMatrix.
//
float32
*dcs_matrix = dpl_GetDCSMatrix(dcs);
Check_Pointer(dcs_matrix);
*(Matrix4x4 *)dcs_matrix = node_matrix;
if (parent_dcs != NULL)
{
dpl_AddDCSToDCS(parent_dcs, dcs);
}
else
{
dpl_AddDCSToScene(dcs);
}
++(*node_count);
//
// This node's geometry, if it has any. Entity_Being_Created is already
// set by our caller so the library's C callback can tag the geometry with
// damage zones (L4VIDEO.CPP:4176).
// This node's geometry, if it has any. Loaded BEFORE the node is built,
// because a joint renderable takes its object as a ctor argument and
// builds/flushes the instance itself (DCSObjectRenderable, L4VIDRND.CPP:
// 649). Entity_Being_Created is already set by our caller so the
// library's C callback can tag the geometry with damage zones
// (L4VIDEO.CPP:4176).
//
dpl_OBJECT
*object = NULL;
const char
*object_name;
if (skeleton->GetEntry(page_name, "Object", &object_name) && object_name)
{
dpl_OBJECT
*object = dpl_LoadObject((char *)object_name, dpl_load_normal);
object = dpl_LoadObject((char *)object_name, dpl_load_normal);
if (object == NULL)
{
DEBUG_STREAM << "[skl] couldn't load object " << object_name
<< " for '" << page_name << "'\n" << flush;
}
}
dpl_DCS
*dcs = NULL;
//
// ARTICULATION. A node whose page name resolves to a live skeleton Joint
// gets a JOINT RENDERABLE instead of a static DCS, so the board hears
// about the joint MOVING.
//
// Why this is the whole game: a static node's matrix is written and
// flushed once at build time. Torso::TorsoSimulation faithfully calls
// SetRotation() on its Joint every frame, but nothing carried that back
// to the DCS -- measured 5.3.79, twist sweeping the full authored range
// with anim_abs=1 joints=0 on the wire and a cockpit view that did not
// move by more than 74 pixels. The engine's answer is this family
// (L4VIDRND.CPP:1026+): each renderable holds the joint's rest offset in
// one DCS and the live rotation in a child DCS, and its Execute compares
// the watched Hinge/EulerAngles against a cached copy, writes the axis
// and calls DPL_FLUSH_DCS -- which is what puts more than one node into
// vr_flush_dcs_artic (0x1f).
//
// The joint's live value is read straight out of the mech's own
// JointSubsystem (Mech::ResolveJoint by segment name -- the .SKL page
// names ARE the segment names, which is how Torso already resolves
// 'jointtorso'), so the simulation and the render read ONE source.
//
// Env-gated while it proves out: the static path is a working cockpit
// render and this replaces the node construction wholesale.
//
Joint
*joint = NULL;
if (
getenv("BT_JOINTS") != NULL &&
parent_dcs != NULL &&
entity->GetClassID() == RegisteredClass::MechClassID
)
{
joint = ((Mech *)entity)->ResolveJoint(page_name);
}
if (joint != NULL)
{
LinearMatrix
offset;
offset = EulerAngles(rot_pitch, rot_yaw, rot_roll);
offset(3,0) = tran_x;
offset(3,1) = tran_y;
offset(3,2) = tran_z;
ChildOffsetRenderable
*joint_renderable = NULL;
switch (joint->GetJointType())
{
case Joint::HingeXJointType:
case Joint::HingeYJointType:
case Joint::HingeZJointType:
joint_renderable = new HingeRenderable(
entity, VideoRenderable::Dynamic, object, zone,
dpl_isect_mode_obj, 0, parent_dcs, &offset,
&joint->GetHinge());
break;
case Joint::BallJointType:
joint_renderable = new BallJointRenderable(
entity, VideoRenderable::Dynamic, object, zone,
dpl_isect_mode_obj, 0, parent_dcs, &offset,
&joint->GetEulerAngles());
break;
default:
//
// StaticJointType and BallTranslation: the static path below.
// (BallTranslate has its own renderable, but no MAD.SKL node
// animates a translation -- only jointeye is balltranslate, and
// it carries the camera, not geometry.)
//
break;
}
if (joint_renderable != NULL)
{
Register_Object(joint_renderable);
dcs = joint_renderable->GetDCS();
if (object != NULL)
{
++(*object_count);
}
++(*joint_count);
}
}
if (dcs == NULL)
{
//
// STATIC node: baked matrix, flushed once. Correct for anything the
// simulation never moves.
//
dcs = dpl_NewDCS();
Check_Pointer(dcs);
dpl_SetDCSZone(dcs, zone);
//
// Write in place and flush -- the same idiom as RootRenderable's ctor
// (dpl_GetDCSMatrix + assign), not dpl_SetDCSMatrix.
//
float32
*dcs_matrix = dpl_GetDCSMatrix(dcs);
Check_Pointer(dcs_matrix);
*(Matrix4x4 *)dcs_matrix = node_matrix;
if (parent_dcs != NULL)
{
dpl_AddDCSToDCS(parent_dcs, dcs);
}
else
{
dpl_AddDCSToScene(dcs);
}
if (object != NULL)
{
dpl_INSTANCE
@@ -320,14 +439,11 @@ dpl_DCS *
dpl_FlushInstance(instance);
++(*object_count);
}
else
{
DEBUG_STREAM << "[skl] couldn't load object " << object_name
<< " for '" << page_name << "'\n" << flush;
}
dpl_FlushDCS(dcs);
}
dpl_FlushDCS(dcs);
++(*node_count);
//
// Children. Repeated "joint=" entries: the entry NAME is "joint" and the
@@ -352,7 +468,8 @@ dpl_DCS *
{
RecurseSKLFile(
entity, dcs, skeleton, child_page, recursion_depth + 1,
view_type, zone, node_count, object_count, eye_count);
view_type, zone, node_count, object_count, eye_count,
joint_count);
}
}
@@ -629,7 +746,8 @@ void
// inside the OUTSIDE model staring at torso panels.
//
int
eye_count = 0;
eye_count = 0,
joint_count = 0;
const char
*skeleton_name = video_object->GetObjectFilename();
char
@@ -648,7 +766,8 @@ void
(inside_name[2] >= 'a' && inside_name[2] <= 'z')
? 'x' : 'X';
skl_result = ReadSKLFile(entity, root_DCS,
inside_name, view_type, &eye_count);
inside_name, view_type, &eye_count,
&joint_count);
if (skl_result == NULL)
{
DEBUG_STREAM << "[skl] no cockpit variant '"
@@ -660,7 +779,8 @@ void
if (skl_result == NULL)
{
ReadSKLFile(entity, root_DCS,
skeleton_name, view_type, &eye_count);
skeleton_name, view_type, &eye_count,
&joint_count);
}
//
+4 -2
View File
@@ -79,7 +79,8 @@
dpl_DCS *parent_dcs,
const char *skeleton_filename,
ViewFrom view_type,
int *eye_count);
int *eye_count,
int *joint_count);
dpl_DCS *
RecurseSKLFile(
@@ -92,7 +93,8 @@
dpl_ZONE *zone,
int *node_count,
int *object_count,
int *eye_count);
int *eye_count,
int *joint_count);
protected:
int reserved[16];
@@ -1867,3 +1867,55 @@ hangs off that chain), the LEG GAIT (the 12-joint flurry the real-pod capture
shows while walking), and weapon-pod aim. Until then the mech translates
through the world as a rigid body -- which is exactly what every frame so far
has shown.
--------------------------------------------------------------------------------
JOINT ARTICULATION IS LIVE -- 22 NODES, AND THE TWIST REACHES THE BOARD
--------------------------------------------------------------------------------
RecurseSKLFile now builds a JOINT RENDERABLE for any node whose page name
resolves to a live skeleton Joint, instead of a static baked DCS:
HingeX/Y/Z -> HingeRenderable (watches Joint::GetHinge)
Ball -> BallJointRenderable (watches Joint::GetEulerAngles)
otherwise -> the static path, unchanged
Each holds the node's rest offset in one DCS and the live rotation in a child
DCS, and its Execute compares the watched value against a cached copy and
calls DPL_FLUSH_DCS -- the engine's own mechanism (L4VIDRND.CPP:1026+). The
joint's value comes from the mech's JointSubsystem via Mech::ResolveJoint, so
the simulation and the renderer read ONE source. Env-gated on BT_JOINTS
while it proves out; the static path is a working cockpit render.
[skl] video\max.skl -> 26 nodes, 1 objects, 1 eye, 22 articulated
and the bridge, which reported `anim_abs=1 joints=0 twist=+0.00` before, now
reports `joints=1 twist=-0.86` -- matching the game's own
[torso] twist=-0.856 to two decimals. The board is hearing the joint move.
VISUALLY: with the mech stationary, frames that differed by 0.0%% now differ
by 62-80%%. The view swings with the torso. That is the brick landing.
A CRASH IT EXPOSED, worth keeping: Mech::ResolveJoint passed
segment->GetJointIndex() straight to GetJoint with NO validity check, and a
segment that HAS no joint reports -1 -- TableIterator::GetNthImplementation
then indexes [base + -1*4] and dies (guest 00426A1D, ECX=FFFFFFFF). Torso
never hit it because it only ever asks for its own authored twist-joint name;
the skeleton walk asks for every page, and most .SKL pages are sites or
static segments. Now bounds-checked against GetJointCount.
OPEN, and the next thing to chase: the canopy does not stay rigid in the
view. Both the canopy (Object on jointtorso) and the eye (site on jointeye,
whose parent is jointtorso) hang off the SAME articulated node, so they
should move together and the world alone should pan. Instead the canopy
silhouette varies 144k-207k px across a sweep. Cancelling the bridge's own
cage compensation (CAGE_TWIST_SIGN=0 -- it exists because the SHIPPED game
links the cage under the vehicle root and never touches it, which is no
longer true of ours) did not close it.
Leading hypothesis: the eye is not actually driven by our DCS chain at all.
DPLRenderer::SetupCull builds worldToEyeMatrix from
GetSegmentToWorld(siteeyepoint) -- the SIMULATION's segment transform --
independent of the render tree. So the canopy follows our render chain while
the eye follows the sim's segment chain, and they diverge exactly like this
whenever one carries the twist and the other does not. Test: log both
per frame and compare; if that is it, the fix is making the segment chain and
the render chain agree about which node owns the twist.