Compare commits
62
Commits
+6
-1
@@ -157,7 +157,12 @@ add_library(munga_engine STATIC
|
||||
"engine/MUNGA/TERRAIN.cpp"
|
||||
"engine/MUNGA/TESTALL.cpp"
|
||||
"engine/MUNGA/TIME.cpp"
|
||||
"engine/MUNGA/TIMESTUB.cpp"
|
||||
# TIMESTUB.cpp REMOVED (2026-07-14): its GetRTC/GetHiRes were 2007 call-counter
|
||||
# stubs (`return time++` -- the "clock" advanced per CALL, not per ms) that WON
|
||||
# the /FORCE duplicate-symbol race over the REAL QueryPerformanceCounter clock
|
||||
# in L4TIME.cpp (LNK4006 "second definition ignored"). Every Now()-domain
|
||||
# consumer (dead-reckoning above all) ran on call-count pseudo-time -> the
|
||||
# replicant stall/jump chop. L4TIME.cpp covers every symbol TIMESTUB defined.
|
||||
"engine/MUNGA/TOOL.cpp"
|
||||
"engine/MUNGA/TRACE.cpp"
|
||||
"engine/MUNGA/TRACSTUB.cpp"
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
[mission]
|
||||
adventure=BattleTech
|
||||
map=arena1
|
||||
scenario=freeforall
|
||||
time=day
|
||||
weather=fog
|
||||
temperature=27
|
||||
length=600
|
||||
[ordinals]
|
||||
bitmap=Ordinal::BitMap::1
|
||||
bitmap=Ordinal::BitMap::2
|
||||
bitmap=Ordinal::BitMap::3
|
||||
bitmap=Ordinal::BitMap::4
|
||||
[Ordinal::BitMap::1]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=000038000003C000001FF00000000F00
|
||||
bitmap=0000F8000003C000003FF80000000F00
|
||||
bitmap=0001F8000003C00000707C0000000F00
|
||||
bitmap=0001F8000003C00000603C0000000F00
|
||||
bitmap=00007801FC0FF00000003C3DF807FF00
|
||||
bitmap=00007803FE0FF00000003C3FFC0FFF00
|
||||
bitmap=00007803C703C00000003C3E3C1F0F00
|
||||
bitmap=000078078303C0000000783C1E1E0F00
|
||||
bitmap=000078078003C0000000783C1E1E0F00
|
||||
bitmap=00007807C003C0000000F03C1E1E0F00
|
||||
bitmap=00007807F003C0000001E03C1E1E0F00
|
||||
bitmap=00007803FC03C0000003C03C1E1E0F00
|
||||
bitmap=00007801FE03C0000007803C1E1E0F00
|
||||
bitmap=000078007F03C000000F003C1E1E0F00
|
||||
bitmap=000078001F03C000001E003C1E1E0F00
|
||||
bitmap=000078000F03C000003C003C1E1E0F00
|
||||
bitmap=000078060F03C0000078003C1E1E0F00
|
||||
bitmap=000078071E03E0000078003C1E1F1F00
|
||||
bitmap=00007803FE01F000007FFC3C1E0FFF00
|
||||
bitmap=00007801FC00F000007FFC3C1E07EF00
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[Ordinal::BitMap::2]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=001FFF0000003C0000003C03C0780000
|
||||
bitmap=001FFF0000003C0000007C03C0780000
|
||||
bitmap=00000E0000003C000000FC03C0780000
|
||||
bitmap=00003C0000003C000001BC03C0780000
|
||||
bitmap=0000700F3E1FFC000003BC0FF07BF000
|
||||
bitmap=0001E00F7E3FFC0000073C0FF07FF800
|
||||
bitmap=0003800FFE7C3C0000063C03C07C7800
|
||||
bitmap=0007F80FFE783C00000C3C03C0783C00
|
||||
bitmap=0007FE0F80783C0000183C03C0783C00
|
||||
bitmap=00001E0F00783C0000383C03C0783C00
|
||||
bitmap=00000F0F00783C0000703C03C0783C00
|
||||
bitmap=00000F0F00783C00007FFF03C0783C00
|
||||
bitmap=00000F0F00783C00007FFF03C0783C00
|
||||
bitmap=00000F0F00783C0000003C03C0783C00
|
||||
bitmap=00000F0F00783C0000003C03C0783C00
|
||||
bitmap=00000F0F00783C0000003C03C0783C00
|
||||
bitmap=00180F0F00783C0000003C03C0783C00
|
||||
bitmap=001C1F0F007C7C0000003C03E0783C00
|
||||
bitmap=000FFE0F003FFC0000003C01F0783C00
|
||||
bitmap=0007FC0F001FBC0000003C00F0783C00
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[Ordinal::BitMap::3]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=001FFF03C07800000000FC03C0780000
|
||||
bitmap=001FFF03C07800000003FC03C0780000
|
||||
bitmap=001E0003C07800000007C003C0780000
|
||||
bitmap=001E0003C0780000000F0003C0780000
|
||||
bitmap=001E000FF07BF000000F000FF07BF000
|
||||
bitmap=001E000FF07FF800001E000FF07FF800
|
||||
bitmap=001E0003C07C7800001E0003C07C7800
|
||||
bitmap=001FFC03C0783C00001EFC03C0783C00
|
||||
bitmap=001FFE03C0783C00001FFE03C0783C00
|
||||
bitmap=00001F03C0783C00001F1F03C0783C00
|
||||
bitmap=00000F03C0783C00001E0F03C0783C00
|
||||
bitmap=00000F03C0783C00001E0F03C0783C00
|
||||
bitmap=00000F03C0783C00001E0F03C0783C00
|
||||
bitmap=00000F03C0783C00001E0F03C0783C00
|
||||
bitmap=00000F03C0783C00001E0F03C0783C00
|
||||
bitmap=00000F03C0783C00001E0F03C0783C00
|
||||
bitmap=00180F03C0783C00001E0F03C0783C00
|
||||
bitmap=001C1F03E0783C00001F1F03E0783C00
|
||||
bitmap=000FFE01F0783C00000FFE01F0783C00
|
||||
bitmap=0007FC00F0783C000007FC00F0783C00
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[Ordinal::BitMap::4]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=001FFF03C07800000007FC03C0780000
|
||||
bitmap=001FFF03C0780000000FFE03C0780000
|
||||
bitmap=00000F03C0780000001F1F03C0780000
|
||||
bitmap=00000F03C0780000001E0F03C0780000
|
||||
bitmap=00000F0FF07BF000001E0F0FF07BF000
|
||||
bitmap=00001F0FF07FF800001E0F0FF07FF800
|
||||
bitmap=00001E03C07C7800001E0F03C07C7800
|
||||
bitmap=00003E03C0783C00001E0F03C0783C00
|
||||
bitmap=00003C03C0783C00000F1E03C0783C00
|
||||
bitmap=00003C03C0783C000007FC03C0783C00
|
||||
bitmap=00007803C0783C000007FC03C0783C00
|
||||
bitmap=00007803C0783C00000F1E03C0783C00
|
||||
bitmap=00007803C0783C00001E0F03C0783C00
|
||||
bitmap=0000F003C0783C00001E0F03C0783C00
|
||||
bitmap=0000F003C0783C00001E0F03C0783C00
|
||||
bitmap=0000F003C0783C00001E0F03C0783C00
|
||||
bitmap=0000F003C0783C00001E0F03C0783C00
|
||||
bitmap=0000F003E0783C00000F1E03E0783C00
|
||||
bitmap=0000F001F0783C00000FFE01F0783C00
|
||||
bitmap=0000F000F0783C000007FC00F0783C00
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[pilots]
|
||||
pilot=127.0.0.1:1502
|
||||
pilot=127.0.0.1:1602
|
||||
[127.0.0.1:1502]
|
||||
hostType=0
|
||||
advancedDamage=1
|
||||
loadzones=1
|
||||
name=Aeolus
|
||||
bitmapindex=1
|
||||
experience=expert
|
||||
badge=VGL
|
||||
patch=Yellow
|
||||
role=Role::Default
|
||||
dropzone=one
|
||||
vehicle=bhk1
|
||||
vehicleValue=1000
|
||||
color=White
|
||||
[127.0.0.1:1602]
|
||||
hostType=0
|
||||
advancedDamage=1
|
||||
loadzones=1
|
||||
name=Boreas
|
||||
bitmapindex=2
|
||||
experience=expert
|
||||
badge=VGL
|
||||
patch=Yellow
|
||||
role=Role::Default
|
||||
dropzone=one
|
||||
vehicle=ava1
|
||||
vehicleValue=1000
|
||||
color=Red
|
||||
[largebitmap]
|
||||
bitmap=BitMap::Large::Aeolus
|
||||
[BitMap::Large::Aeolus]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=000003FE0000000001E0000000000000
|
||||
bitmap=000007FF0000000001E0000000000000
|
||||
bitmap=00000F8F8000000001E0000000000000
|
||||
bitmap=00000F078000000001E0000000000000
|
||||
bitmap=00000F0781FF00FF81E0F0781FC00000
|
||||
bitmap=00000F0783FF81FFC1E0F0783FE00000
|
||||
bitmap=00000F0787C7C3E3E1E0F0783C700000
|
||||
bitmap=00000F078783C3C1E1E0F07878300000
|
||||
bitmap=00000F078783C3C1E1E0F07878000000
|
||||
bitmap=00000FFF8783C3C1E1E0F0787C000000
|
||||
bitmap=00000FFF8783C3C1E1E0F0787F000000
|
||||
bitmap=00000F0787FFC3C1E1E0F0783FC00000
|
||||
bitmap=00000F0787FFC3C1E1E0F0781FE00000
|
||||
bitmap=00000F07878003C1E1E0F07807F00000
|
||||
bitmap=00000F07878003C1E1E0F07801F00000
|
||||
bitmap=00000F07878003C1E1E0F07800F00000
|
||||
bitmap=00000F078780C3C1E1E0F07860F00000
|
||||
bitmap=00000F0787C1C3E3E1E078F871E00000
|
||||
bitmap=00000F0783FF81FFC1E07FF83FE00000
|
||||
bitmap=00000F0781FF00FF81E03F781FC00000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[smallbitmap]
|
||||
bitmap=BitMap::Small::Aeolus
|
||||
[BitMap::Small::Aeolus]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=0000000000000000003E00000C000000
|
||||
bitmap=006300000C00000000631F0F8CC63C00
|
||||
bitmap=00633198CCC66200007F3198CCC66000
|
||||
bitmap=00633F98CCC6780000633018CCC61E00
|
||||
bitmap=00633018CCC6060000633098CCC64600
|
||||
bitmap=00631F0F8C7E3C000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=64
|
||||
y=16
|
||||
width=4
|
||||
[Role::Default]
|
||||
model=dfltrole
|
||||
[Role::NoReturn]
|
||||
model=noretun
|
||||
@@ -20,9 +20,16 @@ spec is `docs/ASSET_PIPELINE.md`. Full list: `docs/PROGRESS_LOG.md §5`.
|
||||
Object=part.bgf, dzone=, tranx/y/z, pitch/yaw/roll`. Build a **DCS tree**; neutral pose ≈
|
||||
translations (pyr≈0). [T1]
|
||||
- **`.ANI`** — INI keyframe animation. `[HEADER] framecount/framerate(30)/skeletonfile`; `[frameN]`
|
||||
joint→`rx ry rz` radians (balltranslate→translate); `[RootTranslation]` per-keyframe `x y z`
|
||||
where **`.z` = forward root SPEED** (units/s). Walk anims have no baked root translation — forward
|
||||
motion is animation-driven from `.z` ([[locomotion]]). [T1]
|
||||
joint→**`pitch yaw roll` radians for EVERY joint type** (ANIMTOOL.cpp:140-142 parses the triple as
|
||||
EulerAngles unconditionally — the old "balltranslate→translate" note was wrong [T0]); a
|
||||
balltranslate joint ADDITIONALLY gets a per-frame translation from the separate **`[KeyJointPos]`**
|
||||
section (ANIMTOOL.cpp:478-483 packs EulerAngles+Vector3D; JMOVER/seqctl apply SetRotation +
|
||||
SetTranslation). For the root `jointlocal`, KeyJointPos.y = the gait vertical bob;
|
||||
`[RootTranslation]` per-keyframe `x y z` where **`.z` = forward root SPEED** (units/s). Walk anims
|
||||
have no baked root translation — forward motion is animation-driven from `.z` ([[locomotion]]).
|
||||
Walk/run CYCLE clips author NO body pitch — root keys are a ≤1.5° roll sway only; the authored
|
||||
LEAN lives in the transition (`rwl/rwr/wrl/wrr` jointhip ~7-12°) + `bmp` stagger (~25°) clips
|
||||
(2026-07-13 sweep of all 331 source ANIs). [T1]
|
||||
|
||||
## Materials / textures
|
||||
- **`.BMF`** — `DIV-BIZ2` (FILETYPE=1) material+texture LIBRARY (no pixels). Material → DIFFUSE color
|
||||
|
||||
+14
-1
@@ -61,7 +61,20 @@ inverse(eyeWorld)`. No LookAt anywhere.
|
||||
- offset matrix = the segment's **GetBaseOffset()** (segment+0x74, the LOCAL rest transform) —
|
||||
NOT GetSegmentToEntity, NOT an upright basis;
|
||||
- parent = the eyepoint segment's **parent draw component** (`dcs_array[GetParentIndex()]`) —
|
||||
liveness (torso pose, gait, gyro) reaches the eye ONLY through this parent chain;
|
||||
liveness reaches the eye through this parent chain in the PORT TOO (task #58 FINAL): the
|
||||
twist path is `Torso::PushTwist → Joint::SetRotation → hinge rotationAmount →
|
||||
HingeRenderable::Execute` (multiplies the live hinge into the matrix stack), and the eye
|
||||
executes inside that scope (`jointtorso → jointeye → siteeyepoint`; VERIFIED: with the real
|
||||
jointtorso swept, the active cockpit `[eyefwd]` swings ~60° and the torso hinge renderable
|
||||
sits in the eye's parent chain). The CAMERA yaws with the twist, the canopy rides the same
|
||||
subtree, the crosshair stays screen-centered — see [[combat-damage]] Targeting.
|
||||
⚠ TWO instrumentation traps produced false "eye is frozen" readings first: (1) the headless
|
||||
default camera is the CHASE eye (root-parented, same DPLEyeRenderable class — gate probes on
|
||||
the COCKPIT eye being live); (2) `BT_FORCE_TORSO` used to override the joint names with the
|
||||
BLH demo pair (`jointshakey2`/`jointtshadow`), silently redirecting a MadCat sweep into the
|
||||
SHADOW hinge — the hook now only fills joints the resource left unresolved. An explicit
|
||||
eye-side twist compose added on those false readings DOUBLE-ROTATED the view (2× vs the 1×
|
||||
canopy) and is retired;
|
||||
- the `EyepointRotation` entity attribute (EulerAngles; Mech member, init 0).
|
||||
- **Combine order:** `FUN_0040b104(dst,A,B)` computes `dst = B × A` (translation from B) →
|
||||
the eye local = `baseOffset × R`, R applied FIRST in eye-local space. Recomputed only when the
|
||||
|
||||
+173
-31
@@ -6,7 +6,7 @@ source_sections: "PROGRESS_LOG.md §10c; docs/HARD_PROBLEMS.md (P5)"
|
||||
related_topics: [subsystems, decomp-reference, reconstruction-gotchas, locomotion, rendering, translocation-warp, multiplayer]
|
||||
key_terms: [damage-zone, TakeDamage, viewpoint-entity, master, replicant]
|
||||
open_questions:
|
||||
- "Per-impact aim (cylinder lookup); collision damage application; DeathShutdown + collapse anim"
|
||||
- "Missile cluster-hit variance/concentration (task #60-D deferred); per-player splash enable sub-gate (Missile+0x360 [T3]); ~100pt ram lethality vs the pod unverified [T3]"
|
||||
---
|
||||
|
||||
# Combat & Damage
|
||||
@@ -32,29 +32,55 @@ player input). [T1]
|
||||
`mech+0x38c` = targeted sub-zone (−1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint,
|
||||
refreshed each frame. [T1]
|
||||
|
||||
**The AUTHENTIC acquisition model (recovered 2026-07-08; corrected 2026-07-08, task #39):** THERE
|
||||
IS NO FREE-AIM MOUSE CURSOR. The pod had a stick whose yaw drove the **torso twist**
|
||||
(`MechControlsMapper` writes `HUD::SetFreeAimSlew(stick_x)` → cockpit+0x28C, gated on the torso's
|
||||
horizontal-twist being enabled — `mechmppr.cpp:735/762`, hud.hpp:167 [T1]); the crosshair marks
|
||||
where the **torso guns boresight** relative to the body-mounted view. You aim by **steering the
|
||||
mech / twisting the torso**, not by moving a cursor. The engine `Reticle` struct
|
||||
(`engine/MUNGA/RETICLE.h`) carries `reticlePosition`, `pickPointingOn`, `rayIntersection`,
|
||||
`targetEntity`, `targetDamageZone` — a general struct shared with Red Planet — but in BT
|
||||
`reticlePosition`/HotBoxVector (@mech-HUD+0x1FC) is **COMPUTED by HudSimulation** from the mech
|
||||
pose quaternion (`FUN_0040954c` quat→euler of mech+0x10C) + the target geometry, and **zeroed to
|
||||
centre when there is no target** (part_013.c:5680, `FUN_00408440(...,&DAT_004e0f74)` [T1]) — it is
|
||||
NOT a free-floating cursor. The pick then hits whatever the guns point at; that entity + its
|
||||
DAMAGE ZONE become the target (`0x37c` rayIntersection, `0x388` targetEntity, `0x38c`
|
||||
targetDamageZone → aimed-zone damage). Fixed-torso mechs (the BLH: `TorsoHorizontalEnabled=0`)
|
||||
boresight dead-ahead — the crosshair sits centred and you point the whole mech. Once locked,
|
||||
`Emitter::FireWeapon` converges with NO aim/arc test (part_013.c:7758). `MechWeapon::
|
||||
UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange = dist < (1 − hostZoneDamage) ×
|
||||
weaponRange`. The 0x388 WRITER (the per-frame reticle→mech copy) is in an un-exported decomp gap.
|
||||
**Port status: RECONSTRUCTED + runtime-verified (tasks #36/#39, 2026-07-08) [T2].** The chain:
|
||||
the crosshair = **torso boresight** (`BTTwistToReticleX(torsoTwist)`, L4VIDEO — tan(twist)
|
||||
projected through the live per-axis projection; **0 = dead-centre on the fixed-torso BLH**;
|
||||
`BT_AIM="x y"` pins it for headless tests) → `BTGetAimRay` (the ACTIVE eye publishes pos+LookAtRH
|
||||
basis via `BTSetAimCamera`, the render loop publishes proj._11/_22 + backbuffer dims) →
|
||||
**The AUTHENTIC acquisition model (recovered 2026-07-08; task #39; RE-CORRECTED 2026-07-13, task
|
||||
#58 crosshair-twist forensics):** THERE IS NO FREE-AIM MOUSE CURSOR — and **the VIEW is
|
||||
TORSO-MOUNTED, not body-mounted**. The cockpit eye hangs off the torso yaw hinge in every
|
||||
twist-capable skeleton (`jointtorso` (hinge-Y) → `jointeye` → `siteeyepoint`; all 14 twist-enabled
|
||||
Torso records name `TorsoHorizontalJoint=jointtorso`; `Torso::UpdateJoints` writes currentTwist
|
||||
into that joint each frame; VIEW = inverse of the LIVE joint chain, `FUN_004c22c4`
|
||||
part_013.c:11742, no de-twist anywhere [T1]). So when the torso twists, **the CAMERA yaws with
|
||||
the guns and the crosshair stays SCREEN-CENTERED** — truthfully, because screen center always IS
|
||||
the boresight; the world rotates past it. The twist reads on INSTRUMENTS: the bottom 21-tick tape
|
||||
carets, the compass (= body yaw + twist, part_013.c:5674-5676), the radar wedge
|
||||
(part_013.c:11836-11859) [T1]. The old "crosshair = torso boresight offset in a body-mounted
|
||||
view" model was falsified — it was never discriminated before because every prior live test used
|
||||
the fixed-torso BLH, where both models center the crosshair.
|
||||
The engine `Reticle` struct (`engine/MUNGA/RETICLE.h`) carries `reticlePosition` (a variable
|
||||
screen position −1..+1 [T0]) — the reticle CAN move, but its writer is in an un-exported decomp
|
||||
gap (`s_TargetReticle` has exactly ONE xref binary-wide: the read-side lookup part_014.c:5132);
|
||||
the only coherent twist-era mover is the **fixed-torso free-aim channel**: `MechControlsMapper`
|
||||
writes `HUD::SetFreeAimSlew(stick_x)` → cockpit+0x28C **only when torso twist is disabled**
|
||||
(part_013.c:339-345/381-387 [T1]); HudSimulation integrates it (negated, part_013.c:5717) into
|
||||
+0x294 clamped ±(+0x29C) → `mech+0x36c` every frame (:5735) — no exported consumer; the
|
||||
mech+0x36c → reticlePosition link is [T4]. NB: an earlier version of this section conflated
|
||||
`reticlePosition` with **HotBoxVector** (HUD+0x1FC — the TARGET designator HudSimulation zeroes
|
||||
with no target, part_013.c:5680); they are different things.
|
||||
The pick hits whatever the guns point at; that entity + its DAMAGE ZONE become the target
|
||||
(`0x37c` rayIntersection, `0x388` targetEntity, `0x38c` targetDamageZone → aimed-zone damage).
|
||||
Fixed-torso mechs (the BLH: `TorsoHorizontalEnabled=0` — no jointtorso in the skeleton at all)
|
||||
boresight dead-ahead. Once locked, `Emitter::FireWeapon` converges with NO aim/arc test
|
||||
(part_013.c:7758). `MechWeapon::UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange =
|
||||
dist < (1 − hostZoneDamage) × weaponRange`. The 0x388 WRITER is in the same un-exported gap.
|
||||
⚠ **`hostZoneDamage` is `Subsystem::damageZone->damageLevel` (weapon `@0xE0 → +0x158`), NOT
|
||||
heatLoad** — the port originally computed `effectiveRange = (1 − heatLoad) × weaponRange`
|
||||
(mechweap.cpp), the SAME `@0xE0`-DamageZone-vs-heat misattribution corrected in
|
||||
`HeatSink::UpdateCoolant`. For a charge/discharge weapon (ER laser) the weapon's own `heatLoad`
|
||||
swings 0→1 every cycle, so `effectiveRange` collapsed to ~0 and the weapon was perpetually "out
|
||||
of range" → `SendDamageMessage` never ran → NO damage and NO impact explosion (the "lackluster/
|
||||
absent laser hits, esp. the ER medium, on mechs AND buildings" report). FIXED 2026-07-15: read
|
||||
the QUALIFIED `this->Subsystem::damageZone->damageLevel`; an undamaged weapon now holds full
|
||||
STABLE `weaponRange`. Verified: laser `effRange` 500 stable (was 0/59/340/424), impact explosions
|
||||
13/22s (was ~2). [T2] See [[reconstruction-gotchas]] (`@0xE0` shadow/heat class).
|
||||
**Port status: RECONSTRUCTED + runtime-verified (tasks #36/#39/#58) [T2].** The chain: the
|
||||
cockpit eye inherits the twist AUTHENTICALLY through the draw traversal (`Torso::PushTwist` →
|
||||
hinge `rotationAmount` → `HingeRenderable::Execute` multiplies the live hinge into the matrix
|
||||
stack; the eye executes under `jointtorso → jointeye`; verified `[eyefwd]` sweeps with the real
|
||||
jointtorso — NO explicit eye-side compose exists, one briefly added on false "frozen eye"
|
||||
probe readings double-rotated the view and is retired, see [[cockpit-view]]); the crosshair sits
|
||||
at screen center (`gBTAimX = 0` — the old `BTTwistToReticleX` slew was THE bug: the camera
|
||||
already yawed, so the crosshair counter-slid to HULL-forward, "twisting leaves the crosshairs
|
||||
behind"; `BT_AIM="x y"` still pins it for headless tests) → `BTGetAimRay` (the ACTIVE eye
|
||||
publishes pos+basis via `BTSetAimCamera`, leveled in pitch, the twist yaw riding in the basis) →
|
||||
`Mech::PickRayHit` (world ray → local frame → `BoundingBox::HitBy` slab test vs the collision
|
||||
template's ExtentBox → world hit point).
|
||||
|
||||
@@ -88,6 +114,32 @@ bug was fixed en route: every
|
||||
renderable rebuild stomped `mCamera` back to the chase eye (btl4vid `mViewInside` now persists
|
||||
the chosen view — the aim camera feed and the V toggle both depend on it).
|
||||
|
||||
**WORLD STRUCTURES ARE TARGETABLE via the STATIC COLLISION TREE (task #50, 2026-07-15) [T2].**
|
||||
The `BTGroundRayHit` "terrain" tier only samples the VISUAL heightfield, which on arena1 is a
|
||||
single flat `'sky'`-named ground mesh (`[visgnd] sampler ready: 1 terrain instances`) — and the
|
||||
class-42 map-instance stream (`BuildTables`) likewise holds only `'sky'`. So the garages/walls
|
||||
were invisible to the pick → shots passed through them, and only mechs moved the range axis (the
|
||||
user-reported regression). The FIX: the boresight now also ray-tests the **zone's static collision
|
||||
solid tree** — the SAME geometry that blocks the mech's walk. Mechanism is authentic engine code:
|
||||
`Mover::FindBoxedSolidHitBy` (MOVER.cpp) already tests movers, doors, AND the static world
|
||||
(`application->GetInterestManager()->GetInterestZone(interestZoneID)->GetCollisionRoot()->FindBoundingBoxHitBy(line)`).
|
||||
We factored its "test against the static world" tail into **`Mover::FindStaticSolidHitBy(Line*)`**
|
||||
(static solids only, no movers), wrapped by **`Mech::WorldStructurePick(start,dir,range,&hit)`**
|
||||
(mech.cpp — builds the world-space `Line`, reads the entry point back via `line->FindEnd` since
|
||||
`FindBoundingBoxHitBy`→`HitByBounded` clips `line->length = enter`). The pick order in mech4.cpp is
|
||||
now: closest MECH (`PickRayHit`, damage zones + lock) → closest STRUCTURE (`WorldStructurePick`,
|
||||
occludes a mech BEHIND it) → flat ground (`BTGroundRayHit`) → sky (fire-at-nothing). A structure
|
||||
hit designates `mech+0x388 = gBTTerrainEntity` (the no-damage-zone sentinel) + `0x37c` = the entry
|
||||
point, so the range caret reads the structure distance and NO lock ring draws (mech4.cpp:4529, the
|
||||
`des != hotTarget` branch), exactly the reported authentic behavior. ⚠ There is NO ray query
|
||||
against the ground/`containedByNode` `BoundingBoxTree` — `FindBoundingBoxUnder` is DOWNWARD-only
|
||||
(gravity/ground-snap: `*height = FindDistanceBelowBounded`), useless for a horizontal boresight;
|
||||
the static SOLID tree (`BoxedSolidTree::FindBoundingBoxHitBy`) is the only ray-vs-world query.
|
||||
Verified: a `BT_WSWEEP` horizontal ray-fan on arena1 tracks position (3/24 hits near the boundary
|
||||
→ 17-21/24 inside the interior garage cluster → 3/24 past it — discrete interior solids, not just
|
||||
an enclosing box), no crash. The interactive boresight (`BTGetAimRay`) can't be exercised headless
|
||||
(no window → `noRay`), so the SWEEP is the headless proof; interactive aim is user-verified.
|
||||
|
||||
## Firing arc + muzzles
|
||||
There is NO firing-arc field in the binary (`Emitter::FireWeapon` fires whenever HasActiveTarget) —
|
||||
the 1995 game got away with it because it was **cockpit-only** (a beam behind you is off-camera). The
|
||||
@@ -106,9 +158,16 @@ offsets). [T2]
|
||||
|
||||
## The RAM economy — CLOSED (2026-07-12) [T1 evidence, T3 normalization]
|
||||
Three-layer story, measured live + decomp-verified:
|
||||
1. **The armor economy is POINTS**: every zone streams `damageScale[5]` EVEN = `1/armorPoints`
|
||||
(armor 50–140; `damageLevel += amount/armor`, 1.0 = destroyed — the `[zone-armor]` dump,
|
||||
BT_DMG_LOG, mechdmg.cpp). Weapons author point-scale amounts (laser 11.77 ≈ 10 torso hits). ✓
|
||||
1. **The armor economy is POINTS**: every zone streams `damageScale[5]` — 5 cells indexed by
|
||||
`damageType` DIRECTLY (Collision/Ballistic/Explosive/Laser/Energy; task #60 correction — NOT an
|
||||
"even/odd slot" parity), each destructive type's cell ≈ `1/armorPoints` (armor 50–140).
|
||||
`damageLevel += amount × damageScale[type]`, 1.0 = destroyed — engine `DAMAGE.cpp:379` (arcade
|
||||
`@0041e4e0`), called from `mechdmg.cpp:427`; the `[zone-armor]` + per-hit `[dmghit]` dumps,
|
||||
BT_DMG_LOG. Weapons author point-scale amounts (laser 11.77 ≈ 10 torso hits). **`burstCount` is
|
||||
NOT in this formula — zone damage IGNORES it** [T1]; one Damage message = one `amount×scale`
|
||||
application regardless of burstCount (burstCount is read only by the gyro-bounce math + the
|
||||
SplashDamage falloff). A cluster missile therefore lands its damageAmount ONCE — see the
|
||||
SALVO-LEAD FIX below for why the port's N-round missiles must damage once per salvo. ✓
|
||||
2. **StaticBounce prices rams with the AUTHORED moverMass ≈ 1.3e6 units** → ~600×v² ≈ 59,000
|
||||
points for a 10 m/s ram (measured: 59221@9.94, 398@0.81; `[collide-tx]` now logs mass/e).
|
||||
The binary DISPATCHES it raw (@part_012:15324-15358) — but in pod MP it landed on the local
|
||||
@@ -178,10 +237,11 @@ Real sample (`ava1`, 7 rows × 8 cells): rows 0-2 `rotateWithTorso=0` (feet/legs
|
||||
rows 3-6 `=1` (upper body — rotates with torso twist); a foot cell = `{0.5→16, 0.8→10, 1.0→5}`. The table
|
||||
is found by the mech's **type-0x14 DamageZoneStream NAME** (`ResourceDescription::resourceName`) →
|
||||
`ResourceFile::FindResourceDescription(name, DamageLookupTableStreamResourceType/*29*/)`.
|
||||
So STEP 6 remaining = (6b) fix the name-load (current `ResourceFindByName` is a NO-OP template stub → empty
|
||||
name → 0 rows); (6c) make the table functional + `ResolveHit→zone`; (6d) register the
|
||||
`Mech::TakeDamageMessageHandler` override + verify. **Nothing is guessed — geometry, roll, handler, and byte
|
||||
format are all verified.**
|
||||
So STEP 6 was = (6b) the name-load; (6c) the table + `ResolveHit→zone`; (6d) the
|
||||
`Mech::TakeDamageMessageHandler` override. **✅ ALL DONE — see "STEP 6 COMPLETE" below (`d07ac7d`):** the
|
||||
name-load BYPASSED the no-op `ResourceFindByName` via `SearchList(type=0x14)` (mech.cpp:1631), so the table
|
||||
loads live (`[cyl] table 'bhk1'/'madcat'/'ava1' layers=7`) and `ResolveHit` resolves unaimed hits to zones.
|
||||
**Nothing was guessed — geometry, roll, handler, and byte format all verified.**
|
||||
|
||||
## The classes ALREADY EXIST — `game/reconstructed/dmgtable.cpp` (2026-07 discovery)
|
||||
The three container classes are **already reconstructed** in `dmgtable.cpp`/`.hpp` (in the build):
|
||||
@@ -402,6 +462,88 @@ is the death `movementMode`: `IsDestroyed == (movementMode 2 || 9)` (`FUN_0049fb
|
||||
the death modes never revert. Fix: `IsMechDestroyed()` = `movementMode 2||9` (latch) **OR**
|
||||
`graphicAlarm >= 9` (the vital-kill TRIGGER that first enters the transition). Lesson: **a gauge/status
|
||||
alarm is never a state latch** — later writers rewrite it; latch on the state machine's own mode.
|
||||
**ROOT-CAUSE now fixed AT SOURCE (task #60):** the illegitimate wreck rewrite to 4/3 came from
|
||||
`mechdmg.cpp:458` gating the leg partial-failure graphic on the always-0 `IsAirborne()` stub instead
|
||||
of `IsDisabled()` (`@0049fb54` = movementMode 2||9). On a wreck the binary SUPPRESSES that write; the
|
||||
stub let it fire and corrupt `graphicAlarm`. Now `mech->IsDisabled()` — the latch above remains as
|
||||
defence-in-depth. Sibling fix: `mechdmg.cpp:451` read the phantom `stance` (perma-0) instead of
|
||||
`MovementMode()`, so the leg-shot-out → `graphicAlarm=9` fall/death branch was entirely DEAD; now live.
|
||||
|
||||
## Kill-score damage (task #60)
|
||||
`BTPostKillScore` (btplayer.cpp:1491) feeds the ScoreMessage `damageAmount` into the kill award
|
||||
(`@0x4c02e4` → `(damageAmount + scoreAward) × roleScalar × teamMult × tonnageRatio`, `@0x4c052c`).
|
||||
The port passed a flat `kShotDamage=12` (mech4.cpp:1551), so every kill scored identically regardless
|
||||
of the finishing weapon. Now it passes `lastInflictingDamage` — the real killing-blow magnitude
|
||||
latched in `TakeDamageMessageHandler` (mech.cpp:624), mirroring the working per-hit score path
|
||||
(mech4.cpp:1207). `scoreAward=0` (the authentic kill BONUS) stays an open bring-up gap — it lives in
|
||||
the un-exported master-perf writer (0x4a9770-0x4ab188 [T4]); the exact authentic `damageAmount` that
|
||||
writer emitted (msg+0x24) is likewise unrecovered, so the killing-blow magnitude is the faithful
|
||||
stand-in until it is. [T1 mechanism / T4 exact authentic value]
|
||||
|
||||
## Missile splash damage — `Explosion::SplashDamage` (task #62, 2026-07-13) [T1/T2]
|
||||
Area-of-effect blast on **missile** detonation — NOT the vestigial whole-mech `DeathSplashDamage`
|
||||
above (mech+0x520, never read). The T0 source is `engine/MUNGA/EXPLODE.cpp:50-254` (`@0042fad0`),
|
||||
called ONLY from the Missile's per-frame `Perform` (`@004bef78`, part_013.c:10097) inside the
|
||||
collision branch — so it fires on **any** missile impact (mover OR world geometry), gated on
|
||||
`Missile+0x360`. The AC's tracer is not a Missile and never splashes.
|
||||
|
||||
**The model [T0, `EXPLODE.cpp`]:** build a Y-axis cylinder of `SplashRadius` at the blast, collect
|
||||
every `Mover` in it (+ static world), then for each target that has `damageZones` and is not the
|
||||
direct victim (`entityHit`), the detonator (`this`), or `excluded`:
|
||||
- `burstCount = baseBurst / pow(dist, 1.2f)`, `Min_Clamp(…, 1)` — dist = `|target.localOrigin −
|
||||
blastCenter|` (center-to-center). The **arcade** exponent is **1.25** (decomp `0x3ff40000` at
|
||||
part_004.c:865; the decompiler hid the x87 FDIV + the `FUN_004dcd94`=ROUND that WinTesla writes as
|
||||
truncation). Point-blank AMPLIFIES (dist<1 → >baseBurst); no distance floor (real victims are never
|
||||
at dist~0 — the one AT the center is the excluded direct victim).
|
||||
- `damageType`/`damageAmount` pass through UNCHANGED; only `burstCount`, `damageForce` (radial), and
|
||||
`impactPoint` (= blastCenter+offset) are set. Delivered via `TakeDamageMessage(inflicting, zone=-1)`.
|
||||
|
||||
**The SplashRadius source [T1]:** it is the ROUND's GameModel resource `+0x50`, NOT the launcher's
|
||||
`ExplosionModelFile`. The launcher seeds each spawned Missile's model from its linked **AmmoBin**
|
||||
`ammoModelFile @0x1e8` (part_013.c:8778); the Missile ctor `SearchList(that_id, GameModelResourceType)`
|
||||
→ `+0x50` → `Missile+0x364` (part_013.c:10184). The MissileThruster GameModel parser
|
||||
`FUN_004bf8ec` writes `"SplashRadius"` to `+0x50` of the type-`0xf`/size-`0x54` record. Live-resolved
|
||||
radius for both MP missile launchers = **30** (`BT_ROSTER` dump).
|
||||
|
||||
**Port impl (`mech4.cpp`):** `BTResolveSplashRadius` gates on `MissileLauncher::ClassDerivations`,
|
||||
resolves launcher → `BTWeaponAmmoBin` → `BTAmmoRoundModelResource` (+0x1e8) → GameModel +0x50.
|
||||
`BTApplySplashDamage` walks `BTGetTargetCandidates` (excludes shooter), skips the direct victim +
|
||||
destroyed/zoneless mechs, applies the `dist>radius` gate + the burst falloff, and delivers via a
|
||||
DIRECT `e->Dispatch` to each victim (T0 `EXPLODE.cpp:246`). Hooked at BOTH detonation paths
|
||||
(world-impact + contact). **⚠ MUST NOT route splash through the shooter's `SubsystemMessageManager`:
|
||||
`AddDamageMessage` CONSOLIDATES every damage message of a frame onto the FIRST hit entity
|
||||
(`commonDamageInformation.entityHit`, messmgr.cpp:279) — so splash for the bystander landed on the
|
||||
DIRECT victim instead (2026-07-13 bug: the direct victim took 8 direct + 8 mis-routed splash = 16
|
||||
applications, the bystander 0; a direct `Dispatch` splits them correctly 8/8).** Dispatch reroutes
|
||||
cross-pod for a replicant victim on its own.
|
||||
|
||||
**⚠ THE SALVO-LEAD FIX (task #62 bug, found 2026-07-13 by live regression) — the N-round trap.**
|
||||
**KEY FACT [T1]: `DamageZone::TakeDamage` (arcade `@0041e4e0` == WinTesla `DAMAGE.cpp:379`) is
|
||||
`damageLevel += damageAmount * damageScale[type]` and IGNORES `burstCount`.** So ONE arcade cluster
|
||||
Missile per trigger (damageAmount = authored/missileCount, burstCount = missileCount) applies its hit
|
||||
EXACTLY ONCE to a zone — `burstCount` is cosmetic for zone damage (only the gyro-bounce math
|
||||
`gyro.cpp:834` and the SplashDamage falloff `damage.burstCount/dist^exp` read it). The port
|
||||
re-expresses that ONE cluster as N flying `BTProjectile` rounds (visual tracers), and task #62
|
||||
damaged + splashed on EVERY round → **~`missileCount`× too lethal on BOTH the direct hit and the
|
||||
splash** (user-reported "missiles kill in 2 shots" — a mech that should take many salvos). Fix
|
||||
(`mislanch.cpp` FireWeapon): only the salvo-LEAD round (`i==0`) carries damage (`damageData.
|
||||
damageAmount`) AND the cluster splash (`splashBurst = nmiss`); the other N-1 rounds are VISUAL
|
||||
(damage 0, `splashBurst 0`) — the ripple of tracers. `BTProjectile.splashBurst` gates the two splash
|
||||
hooks; the direct-hit block already skips `p.damage<=0`. Replicant mirror rounds carry 0 (master
|
||||
delivers cross-pod — no double). **Lesson:** a cluster weapon re-expressed as N rounds must apply ALL
|
||||
zone effects (direct damage AND splash) ONCE per salvo, not once per round — the arcade's single
|
||||
Missile is one damage event, `burstCount` notwithstanding.
|
||||
**Verified [T2]:** per-hit missile damage = clean 5.83, burst 1, ONCE (`[dmghit]` trace); radius 30
|
||||
for missiles / 0 for AC+Emitters. Missiles-ONLY, single enemy, 45s: 8 damaging hits, top zone reaches
|
||||
only 0.18, **NO death** (was death in ~3 pulls pre-fix) — "way more than 2 shots" restored. AC (one
|
||||
round/shot, not a cluster) unaffected. Env `BT_SPLASH_LOG`, `BT_DMG_LOG` (per-hit `[dmghit]` +
|
||||
zone-armor), `BT_AF_MISSILE` (missile-only autofire, independent of BT_AUTOFIRE), `BT_SPAWN_ENEMY=N`
|
||||
(N clustered dummies). ⚠ `BT_AUTOFIRE` fires ALL weapon groups (laser+PPC+AC) — use `BT_AF_MISSILE`
|
||||
alone to isolate missile TTK.
|
||||
**Deferred [T3]:** the per-player enable sub-gate `Missile+0x360 = BTPlayer+0x264` (part_013.c:8757) —
|
||||
its decomp writers (4668/10512) read as a per-frame state toggle, not a clean config flag, and the
|
||||
port's `showDamageInflicted` label is itself a guess; port treats authored `SplashRadius>0` as the
|
||||
enable. See [[open-questions]].
|
||||
|
||||
## Key Relationships
|
||||
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
|
||||
|
||||
@@ -58,13 +58,13 @@ reconciled via `CLASSMAP.md`, not the case label. [T1]
|
||||
| 0xBC3 | **Sensor** | — | `case`=MyomersClassID |
|
||||
| 0xBC4 | **Gyroscope** | @004b3778 | LIVE (task #56: byte-exact ctor/integrators/writers; hit-bounce fan-out wired) |
|
||||
| 0xBC5 | **Torso** | @004b6b0c | sizeof 0x280; `case`=SinkSource |
|
||||
| 0xBC6 | **Myomers** | @4b8fec | `case`=Actuator; INERT un-stub |
|
||||
| 0xBC6 | **Myomers** | @4b8fec | `case`=Actuator; un-stubbed (`8b36440`, full recon; per-tick Sim SetPerformance call-site still best-effort) |
|
||||
| 0xBC8 | **Emitter** | @004b1d18 | sizeof 0x478; energy weapon |
|
||||
| 0xBCB | **AmmoBin** | — | `case`=JumpJet; sizeof 0x22C |
|
||||
| 0xBCD | **ProjectileWeapon** | @4bc3fc | sizeof 0x448 (`case`≠ this — see VDATA.h) |
|
||||
| 0xBCE | **GaussRifle** | @4bdcb4 | : Emitter; FireWeapon is a **no-op** in this build |
|
||||
| 0xBD0 | **MissileLauncher** | @4bcff0 | sizeof 0x44C |
|
||||
| 0xBD3 | **SubsystemMessageManager** | @0049bca4 | damage/explosion consolidation hub (ConsolidateAndSendDamage, weaponExplosions); cached to `Mech[0x10d]`=**0x434** (recon MISLABELS it `controlsMapper`; the drive squats there — the real mapper is roster slot 0). NOT the valve/Myomers gate (that's the owning **BTPlayer** @mech+0x190). WAVE 8 = untangle + damage-hub. |
|
||||
| 0xBD3 | **SubsystemMessageManager** | @0049bca4 | damage/explosion consolidation hub (ConsolidateAndSendDamage, weaponExplosions); cached to `Mech[0x10d]`=**0x434** (✅ task #7 `afefaee`: factory builds the real class + `mech.hpp` names it `messageManager`; the `controlsMapper` mislabel is swept — the real mapper is roster slot 0). NOT the valve/Myomers gate (that's the owning **BTPlayer** @mech+0x190). |
|
||||
| 0xBD4 | **PPC** (: Emitter) | — | same FireWeapon as Emitter; `factory calls it GaussRifle` |
|
||||
| 0xBD6 | **HUD** | — | alloc 0x2a4; `case`=MechDisplay-ish |
|
||||
| 0xBD8 | **Searchlight** | — | `case`=LegSubsystem |
|
||||
@@ -83,12 +83,24 @@ Identify a class at runtime with `sub->GetClassID()` (== `sub+4`). [T1]
|
||||
| **0x511830** | **MechWeapon**::ClassDerivations | The reticle AddWeapon loop (part_014.c:5386) hard-aborts on missing WeaponRange/PipPosition/… attrs for every member; part_012.c:9344 counts ready weapons; the Mech ctor roster at this[0x1ef] (`weaponRoster`, ex-mislabel "poweredSubsystems") ORs weapon `capabilityFlags`@+0x334. [T1] |
|
||||
| 0x50f4bc | PoweredSubsystem | btl4gau2 gauge wave. [T1] |
|
||||
| 0x50e3ec | HeatableSubsystem | btl4gaug widget gate. [T1] |
|
||||
| 0x50e604 | HeatSink | btl4gaug widget gate. [T1] |
|
||||
| 0x50e604 | **HeatWatcher** (task #57 CORRECTION — was "HeatSink") | `HeatWatcher::TestInstance` @4aec38 tests its OWN class vs this tag; the factory watcher-CONNECT loop (slot +0x38) selects on it; the btl4gaug widget gate ORs it after HeatableSubsystem (a HeatSink test there would be redundant — HeatSink : HeatableSubsystem). 0x50e590 = the HeatSinkBank (0xBBE) tag. [T1] |
|
||||
| 0x51155c | roster at Mech this[0x1eb] (`heatableSubsystems` label) | tag ≠ 0x50e3ec — the label is UNVERIFIED [T4]; arbitrate before relying on it. |
|
||||
| 0x50e4fc | roster at Mech this[499] (`damageableSubsystems` label) | [T3] |
|
||||
| 0x50bdb4 | Mech | emitter.cpp destroyed-check. [T1] |
|
||||
| 0x5121a8 | ProjectileWeapon-family (checked after 0x511830; reads ammo state @+0x364) | [T3] |
|
||||
|
||||
### Watcher electrical chain (task #57 — all [T1], several from RAW-BYTE recovery)
|
||||
| Address | What |
|
||||
|---|---|
|
||||
| @004aec54 | `HeatWatcher::CreateStreamedSubsystem` — parses `WatchedSubsystem=<name>` → segment index **+2** at resource+0xE4 |
|
||||
| @004aee2c / @004b1a40 | vtable slot 14 (+0x38) CONNECT bodies (HeatWatcher / PowerWatcher+Torso, byte-identical): master-gated `watchedLink(+0x114).Add(owner->roster[watchedSubsystem(+0x128)])`. **Ghidra missed both starts** — recovered from raw exe bytes (vtables.tsv rows have GAPS at skipped slots; dump vtable+slot*4 bytes when a slot looks absent) |
|
||||
| @004b181c | `PowerWatcher::UpdateWatch` — THE registered Performance (PTR @0050f5fc); watchdogAlarm(+0x184) MIRRORS watched subsystem's electrical level (+0x278); brownout→1 when `gen outputVoltage(+0x1DC) <= minVoltage(+0x180) × ratedVoltage(+0x1D8)`. Torso sims call it first-line |
|
||||
| @004b1804 | `PowerWatcher::ResetToInitialState` (slot 10) — old recon MISLABELED it "Simulation" |
|
||||
| 0x4b1924 | `MinVoltageScale` — a **10-byte x87 literal** (`0a d7 a3 70 3d 0a d7 a3 f8 3f` = **0.01**); the port had 1.0f → permanent brownout |
|
||||
| @004b6a78 | `Torso::ReadUpdateRecord` (slot 6; base 41bd34) — REPLICANT apply of twist extras +0x10/14/18. Old recon mislabeled it Write |
|
||||
| @004b6a1c | `Torso::WriteUpdateRecord` (slot 7; base 41c500) — MASTER serialize; recordLength=0x1C; **Ghidra missed the start** |
|
||||
| 41bd34 / 41c500 | base Subsystem Read (apply) / Write (produce) update-record fns — engine [T0]: **Write=produce, Read=apply**; don't flip |
|
||||
|
||||
---
|
||||
|
||||
## 3. Mech struct offsets (`Mech*`, binary layout)
|
||||
@@ -101,7 +113,7 @@ valid for reading the decomp, NOT for our compiled layout (see [[reconstruction-
|
||||
| +0x11c / +0x120 | `damageZoneCount` / `damageZones[]` | inherited Entity; Mech ctor populates the array |
|
||||
| +0x124 / +0x128 | `subsystemCount` / `subsystemArray[]` | the [[subsystem-roster]] (NOT the segment table) |
|
||||
| +0x190 | **owning `BTPlayer`** (`GetPlayerLink()`) | set by `FUN_0049f624` (mech↔player bind; also `player->playerVehicle(+0x1fc)=mech`). The valve guard (`FUN_004ac9c8 →player+0x274`) + Myomers gate (`FUN_004ad7d4 →player+0x260`) read the owning player's game-mode flags (NOT the messmgr @0x434). `MECH_OWNING_PLAYER`. |
|
||||
| +0x434 (word 0x10d) | `SubsystemMessageManager` cache | the 0xBD3 damage/explosion hub (factory @10142). Our recon MISLABELS this `controlsMapper` + the drive squats here; the real mapper is roster slot 0. |
|
||||
| +0x434 (word 0x10d) | `SubsystemMessageManager` cache | the 0xBD3 damage/explosion hub (factory @10142). ✅ task #7 (`afefaee`): `mech.hpp` names this `messageManager`; the `controlsMapper` mislabel is swept — the real mapper is roster slot 0. |
|
||||
| +0x260 / +0x26c | `localOrigin` = { Point3D linearPosition; Quaternion angularPosition } | ORIGIN.h:15; position@+0x100 in some subsystem views |
|
||||
| +0x300 | segment table | skeleton `EntitySegment`s (muzzle sites, joints) |
|
||||
| +0x37c | target world `Point3D` | aim source |
|
||||
@@ -120,7 +132,7 @@ valid for reading the decomp, NOT for our compiled layout (see [[reconstruction-
|
||||
## 4. Damage delivery
|
||||
|
||||
- `Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)` → `target->Dispatch(&msg)`.
|
||||
- Base `Entity::TakeDamageMessageHandler` **IGNORES zone==-1** — send a VALID zone index (the Mech cylinder-lookup override for unaimed hits is not reconstructed). [T1]
|
||||
- Base `Entity::TakeDamageMessageHandler` **IGNORES zone==-1** — the Mech STEP-6 cylinder-lookup override (✅ done, `d07ac7d`; `dmgtable.cpp` `ResolveHit` + `Mech::damageLookupTable`) resolves an unaimed (zone==-1) hit to a zone before base-routing. [T1/T2]
|
||||
- `class Damage { damageType(enum Collision/Ballistic/Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint, burstCount }`. [T1]
|
||||
- Weapon effect id: **"explode" = 13** (`Explosion::Make`). [T2]
|
||||
- `DestroyEntityMessage(id,size)` removes an entity — but a killed mech STAYS (a WRECK); death = a STATE transition (`SetGraphicState(DestroyedGraphicState)`), NOT removal. Issuing removal-on-death is the P5 teardown bug (do not). [T2]
|
||||
|
||||
+10
-3
@@ -146,7 +146,8 @@ queue. `SeekVoltageGraph`'s 4 Seek* attrs are a cluster-child (not config-called
|
||||
view only, via the recovered **dpl2d** 2D display-list API (recorders @0x487f34-0x488630; opcode
|
||||
map + coordinate model in `phases/phase-02-dpl2d-reticle.md`; port: `game/reconstructed/dpl2d.cpp`).
|
||||
Geometry is the ctor's hardcoded calibration (originX 0.35, originY 0.25, scaleY 0.5, right range
|
||||
ladder 0..1200 m, bottom heading tape, center cross + dot; tick ladders via FUN_004cd938). The
|
||||
ladder 0..1200 m, bottom TORSO-TWIST tape (NOT a heading tape — stale wording swept task #58; see
|
||||
the tape entry below), center cross + dot; tick ladders via FUN_004cd938). The
|
||||
range caret binds to the live target range (`BTSetHudTargetRange`, fed by mech4's targeting step).
|
||||
**Weapon pips:** the build loop (part_014.c:5386) registers EVERY subsystem
|
||||
`IsDerivedFrom(0x511830 = MechWeapon)` — lasers, PPCs AND missile launchers (BLH = 7 pips) — via
|
||||
@@ -196,8 +197,14 @@ and every instrument is now live [T2]:**
|
||||
around the hotbox point, +1/−11.5 vertical (K=2.8145 baked projection; the port uses the live
|
||||
per-axis projection) — switching to the left/right edge ARROW past ±1.6 or behind.
|
||||
- Reticle state Off/On + `PrimaryHudOn` (mask 0x20) picks full HUD vs the "simple X" list;
|
||||
the aim group translates by `reticlePosition` = the TORSO BORESIGHT (SetMatrix; NOT a free
|
||||
cursor — see [[combat-damage]] Targeting: the stick twists the torso, no mouse-aim exists).
|
||||
the aim group SetMatrix-translates by `Reticle::reticlePosition` (screen −1..+1 [T0]) every
|
||||
frame — but **NOT by torso twist** (task #58 CORRECTION): the VIEW is torso-mounted (the eye
|
||||
hangs off jointtorso → jointeye → siteeyepoint), so the crosshair stays SCREEN-CENTERED through
|
||||
a twist — screen center IS the boresight; the twist reads on the bottom tape/compass/radar
|
||||
wedge instead. The reticlePosition writer is un-exported (one xref binary-wide: the read-side
|
||||
lookup part_014.c:5132); its coherent use is the FIXED-torso free-aim channel (mech+0x36c
|
||||
[T4]). The old "translates to the torso boresight" wording here was the falsified
|
||||
body-mounted-view model — see [[combat-damage]] Targeting for the full re-correction.
|
||||
This recovery also CONFIRMS the HUD attr-table ids 4/5/6/8/0xA/0xB/0xC/0xD name↔use pairings
|
||||
(hud.hpp had flagged them uncertain). Deferred: PNAME1-8.bgf 3D marker chain. (The canopy shell
|
||||
is now authentic and shows by default — see [[cockpit-view]]; `BT_HIDE_COCKPIT=1` hides it.)
|
||||
|
||||
+125
-11
@@ -6,7 +6,7 @@ source_sections: "PROGRESS_LOG.md §7, §10 (gait/ground/collision/shadow); docs
|
||||
related_topics: [asset-formats, decomp-reference, reconstruction-gotchas, combat-damage, rendering]
|
||||
key_terms: [RootTranslation, ground-model, SequenceController, MechControlsMapper, SLD, joint]
|
||||
open_questions:
|
||||
- "Authentic per-mech turn-rate constant (currently bring-up constant; the authored WalkingTurnRate/RunningTurnRate now STREAM correctly since the task #4 record-layout fix -- rec words 0x18/0x19 deg->rad -> mech+0x574/0x578, Wword-absorbed -- wiring them in retires the bring-up rate)"
|
||||
- "Turn-in-place FEEL re-verification (authentic release at standSpeed vs the earlier 0.25x stand-in -- subjective playtest, non-gating)"
|
||||
- "Airborne clip callbacks (FUN_004a7970) deferred"
|
||||
---
|
||||
|
||||
@@ -22,6 +22,64 @@ engine `AnimationInstance::Animate(dt, move_joints)` integrates `movement += Δt
|
||||
moves the body that far → feet plant by construction (no stride constant; k=1). Idle anims have
|
||||
z=0. The `.MOD` physics governed thrust VEHICLES, NOT walk gait. [T1]
|
||||
|
||||
## The walk lean IS authored — and INTERIOR clips omit it (2026-07-13, task #59 CORRECTION) [T1/T2]
|
||||
**A walking mech DOES lean forward — it's authored, sustained, and clip-set-specific.** (This
|
||||
supersedes the earlier "NO walk lean" audit conclusion, which was right that no CODE writes body
|
||||
pitch and that the CYCLE clips carry no pitch keys, but wrong about the net behavior: the
|
||||
TRANSITION clips ramp `jointhip` (hingex, parent of `jointtorso` → the whole upper body + cockpit)
|
||||
to a steady pose that the cycle clips never rebind, so it HOLDS.) Decoded from BTL4.RES + live
|
||||
`[hip]` probe [T2]:
|
||||
- `<pfx>swr` ramps `jointhip` 0 → **−8°** (stand→walk), `<pfx>wrl/wrr` −8 → **−11.1°** (walk→run),
|
||||
`<pfx>wsl/wsr` −8 → 0 (walk→stand). The dedicated return clip only exists because the lean
|
||||
persists. The BLH has NO `jointhip` channel in any clip → it stays level (both views).
|
||||
- **Two clip sets per mech.** The ctor gate (@part_012.c:10308-10320) binds the **INTERIOR** set
|
||||
(4-char `'i'` suffix, `LoadLocomotionClipsExt` @004a86c8) to the **local cockpit master** — those
|
||||
clips animate `jointshakey` (cockpit shake, a BALL joint) and OMIT `jointhip`, so the PILOT's own
|
||||
view stays level. The **EXTERIOR** set (3-char, `LoadLocomotionClips` @004a80d4) goes to network
|
||||
REPLICANTS and the forced `L4VIEWEXT` external view — it carries the `jointhip` lean, so everyone
|
||||
ELSE sees the mech lean into its stride.
|
||||
- **Port fix (task #59):** the interior loader was a stub aliased to the exterior loader, and the
|
||||
authentic ctor gate was a no-op (`LoadLowDetailBody`/`LoadHighDetailBody` mislabeled the
|
||||
FUN_004a80d4/86c8 clip-loader addresses as a body-LOD pair) — so the local cockpit mech played
|
||||
exterior clips and pitched −8°. Now `LoadLocomotionClipsExt` is the real 4-char interior loader
|
||||
and the ctor gate selects it (`getenv("L4VIEWEXT") || (instanceFlags&0xC)==4` → exterior; else
|
||||
interior). Verified live: default (interior) → 4 stray `jointhip` writes over a continuous walk;
|
||||
`L4VIEWEXT=1` (exterior) → 74 sustained writes at −8/−11°.
|
||||
- **Copy-flag → VIEWPOINT adaptation (the other half, DONE):** the port never sets the replicant
|
||||
COPY bit (`instanceFlags & 0xC == 4`) — all MP mechs build as local masters (replication rides
|
||||
the registry + update-records; heat-sim/scoring/torso-watcher-connect all run on both by design,
|
||||
so flipping the bit would break them). The ctor gate therefore lands EVERY mech on interior. To
|
||||
restore the authentic RESULT, `Mech::MaintainViewClipSet()` (mech3.cpp, called each frame from
|
||||
`PerformAndWatch`) picks the set by VIEWPOINT: the mech you pilot (== `GetViewpointEntity()`)
|
||||
keeps INTERIOR (level cockpit — you never see your own mech externally in the real pod); every
|
||||
other mech flips ONCE to EXTERIOR (the −8/−11° lean). Reloads only on a viewpoint-status change;
|
||||
the two sets share stride data so the swap is seamless. Verified live: your mech `jointhip`=0
|
||||
while walking; the peer's replica leans at −8°/−11.1° in your view. (Model pointers stashed at
|
||||
ctor via `BTStashClipState`; the GameModel resource persists for the mission.)
|
||||
|
||||
Other pitch sources (unchanged, still correct): `bmp` stagger (≈25° — THE knockdown pitch); JAK's
|
||||
four directional fall clips (`JAKFB/FF/FL/FR`, root pitch to 1.79 rad — the only mech with them);
|
||||
`IntegrateMotion`@0x4ab1c8 rotates orientation by `angularVelocity`@0x2d4·dt (yaw only); ground
|
||||
snap is Y-only (terrain pitch/roll → `jointshadow`). The gyro's `bodyOrientation` spring
|
||||
(@004b30ec) writes only `jointeye` (cockpit hit-bounce, not a lean). `fallDirection`@0x4a8/
|
||||
`fallScalar`@0x4b4 are record-payload only. [T1]
|
||||
|
||||
## The MARCHING-GHOST desync — ROOT-CAUSED + FIXED (2026-07-13, live diagnosis) [T2]
|
||||
The long-parked "replicant walks in place while its master stands still" reproduced live with
|
||||
telemetry running: the master's `[gaitSM]` showed `state=0 cycleSpeed=-2.50725 kfCur=19` — BOTH
|
||||
channels stuck at a REVERSE cadence inside Standing. Mechanism: the walk-family stop gate
|
||||
(`cycleSpeed <= ZeroSpeed`, mech2.cpp) passes trivially for NEGATIVE (reverse) cadences, and
|
||||
several stand-entry paths (turn exit, terminal poses) never touch the cycle — so a reverse-stop
|
||||
enters Standing at full reverse cycle speed. The master looks still (case-0 never advances the
|
||||
clip; `kfCur` freezes) but the stale cycle REPLICATES, and the peer's replicant animates it =
|
||||
the marching ghost. A clean FORWARD stop decays the cycle first (`legSum ~3e-8` on the healthy
|
||||
node) — which is why the bug was intermittent and direction-dependent. FIX: all four
|
||||
Advance*Animation Standing cases zero their channel's cycle on entry + ForceUpdate(8) so the
|
||||
peer hears the stop immediately. (The signed-demand change (task #15) is what let negative
|
||||
cycles reach machinery written for forward decel.) The earlier "bob reduction + stutter"
|
||||
symptom family did NOT share this root — it was the body channel's mj=1 joint writes leaking
|
||||
through on channel phase-splits, closed by v6 (task #64, see the two-channel section).
|
||||
|
||||
## The two-channel gait (real controls)
|
||||
The BT `SequenceController` (`seqctl.cpp`, from `SelectSequence@004277a8`+`Advance@0042790c`) drives
|
||||
locomotion. Under `BT_REAL_CONTROLS` two channels run — **roles corrected 2026-07-09 (task #49) from
|
||||
@@ -36,20 +94,76 @@ raw disasm** (both Advance fns had NO static decomp caller; found by byte-scanni
|
||||
locally INVISIBLE in the binary. The literal projectedVelocity store is unwired, but the
|
||||
update-record velocity feed is live via the localVelocity/worldLinearVelocity maintenance
|
||||
(mech4.cpp VELOCITY STORAGE block) — replicant gait (task #50) runs off it. [T1]
|
||||
**v5 (current): display == travel BY CONSTRUCTION through the LEG** (body advances first, leg last).
|
||||
History: v3 (travel=leg, display=body) foot-slipped — two channels, different consumers; v4 unified
|
||||
BOTH on the body (mis-reading 0x4ab430 as the travel source) — feet planted, but whenever the two
|
||||
state machines phase-split, the out-of-phase LEG pose showed through on frames the body didn't write
|
||||
(Standing/wind-down) = the RECURRING "legs stutter / lose sync" report. The split mechanism [T2,
|
||||
reproduced via `BT_FORCE_OSC` + `BT_FORCE_TURN` + `BT_SYNC_LOG`]: (a) SEED — the bring-up trn trigger
|
||||
arms only the leg (authentic trn dispatcher still unlocated), so the walk re-entry lands 1 frame
|
||||
apart; (b) AMPLIFIER — with offset clips the end-of-clip callbacks fire on different frames, so a
|
||||
demand change landing between them picks DIFFERENT next states (one winds down, one keeps walking)
|
||||
→ opposite-phase churn. Under v5 both effects still exist but only skew the projection channel. [T2]
|
||||
**v6 (current, task #64 2026-07-13): the body channel does NOT write joints — `AdvanceBodyAnimation(dt, mj=0)`.**
|
||||
Display == travel == the LEG channel STRUCTURALLY (one skeleton writer), matching the binary's own
|
||||
observable ("the body channel's phase drift is locally INVISIBLE in the binary"). History: v3
|
||||
(travel=leg, display=body) foot-slipped; v4 unified BOTH on the body — the out-of-phase LEG pose
|
||||
showed through on frames the body didn't write = the recurring stutter; **v5 (body-first/leg-last,
|
||||
believing the leg's overwrite hid body drift) was WRONG the same way MIRRORED** — user-verified
|
||||
2026-07-13: whenever the channels phase-split, the BODY's out-of-phase mj=1 writes leaked into the
|
||||
displayed skeleton (rhythmic leg skips + visibly REDUCED BOB) while every leg-channel trace read
|
||||
clean — the leg DATA was fine; the RENDERED pose wasn't the leg's. The turn-then-walk repro (below)
|
||||
made it 100% reproducible. `BT_BODY_MJ=1` restores the double-writer for A/B. The old split
|
||||
mechanism [T2]: (a) SEED — the trn trigger armed only the leg → walk re-entry frames apart;
|
||||
(b) AMPLIFIER — offset clips → end-of-clip callbacks on different frames → divergent transitions.
|
||||
Both are ALSO mitigated at the source now (the lockstep trn arming below), but mj=0 is what makes any
|
||||
residual split invisible, structurally. ⚠ The "bob reduction + stutter" symptom family (previously
|
||||
parked as "likely shares the reverse-stop root") is THIS — CLOSED. [T2 user-verified]
|
||||
The gait STATE MACHINE (`AdvanceBodyAnimation`, mech2.cpp) slews `bodyCycleSpeed` toward the demand
|
||||
through walk/run caps (`LoadLocomotionClips`, mech3.cpp:326 — measures `walkStride`/`standSpeed` via
|
||||
`legAnimation.SelectSequence`). Transition callbacks (`Mech::BodyClipFinished @0x4a6d8c`,
|
||||
`LegClipFinished @0x4a6928` — PE-parsed jump tables) alternate walk 6↔7 / run 12↔13 + walk→run. [T2]
|
||||
|
||||
## Turn-in-place (trn, state 4) — AUTHENTIC dispatcher decoded (task #64, 2026-07-14)
|
||||
**The entry dispatcher is NOT in the Ghidra decomp** — it lives in the master-perf gap
|
||||
`0x4a9b5c–0x4ab188` (functions_index.tsv jumps FUN_004a9770→FUN_004ab188; the SOLE reader of
|
||||
`turnDemand`@mapper+0x12c and `turnCapable`@0x588) [T1 proof of absence]. **Ghidra never lifted it, so
|
||||
it was disassembled with objdump** (`scratchpad/masterperf.asm`, 1530 lines) and hand-decoded — this
|
||||
REPLACES the earlier [T3] stand-in guesses (`0.25·standSpeed` entry gate + `×4` fast-forward) with the
|
||||
real logic. `animationClips[4]` = the `trn`/`trni` clip (loaders 13767/13971). Port recon (mech2.cpp):
|
||||
- **THE POSE-MATCH INVARIANT:** this engine has NO pose blending — `SelectSequence` hard-resets to
|
||||
frame 0 — and avoids pops purely by AUTHORING (every transition clip starts in its predecessor's end
|
||||
pose; a turn-in-place CYCLE returns to the neutral/stand pose at its last frame). The trn→walk seam
|
||||
therefore steps `trn legFrm7 (cycle end) → swr legFrm1` — a small ONE-TIME bob step that is authentic
|
||||
(the binary cuts on speed, not at an arbitrary frame). **What made it STUTTER was NOT this cut** — it
|
||||
was the body channel leaking out-of-phase joints into the rendered skeleton every walk cycle (mj=1);
|
||||
fixed at the source by `AdvanceBodyAnimation(dt, mj=0)` (see two-channel section). With mj=0 the
|
||||
authentic cut renders cleanly.
|
||||
- **Turn rate (disasm 0x4aa3d3–0x4aa4ff) [T1] — WIRED (task #64b):** `spd = |linearSpeed|`; base
|
||||
`turnRate = walkingTurnRate`(mech+0x574, rad/s). If `spd ≥ reverseSpeedMax`(0x538): `t = (spd −
|
||||
walkStride 0x534)/(topSpeed=reverseStrideLength 0x34c − walkStride)`; `turnRate = t≤1 ?
|
||||
lerp(walkingTurnRate, runningTurnRate 0x578, t) : runningTurnRate/t²` (over-run falloff). `turnRate =
|
||||
max(0, turnRate)`. Then yaw `= turnDemand(mapper+0x12c) × turnRate`, ZEROED if legState∈{1,2,3} or
|
||||
the death latch (`deathAnimationLatched`@0x650) is set. **Note the lerp is a RUN-speed refinement**
|
||||
— below `reverseSpeedMax` (walk / turn-in-place) turnRate is the constant walkTR base; the walk→run
|
||||
interpolation only engages above it, and `runningTurnRate < walkingTurnRate` (a running mech is less
|
||||
maneuverable). Port: `walkingTurnRate`/`runningTurnRate` are now REAL named members (mech.hpp, set
|
||||
from the model deg→rad at mech.cpp:1334-5 — the old `Wword(0x15d/0x15e)` writes were no-ops into a
|
||||
static scratch bank, so the values were silently discarded). The drive (mech4.cpp ~2617) computes
|
||||
`authTurnRate` per the formula and yaws by it instead of the bring-up constant `kDriveTurnRate`
|
||||
(kept as the fallback when a model supplies no rates). Diagnostic: `BT_TURN_LOG` → `[turnrate]`.
|
||||
Verified headless (DEV mech): walkTR 1.309 rad/s (75°/s), runTR 0.873 (50°/s); turn-in-place + walk
|
||||
yaw at walkTR, no crash. [T1 logic / T2 runtime]
|
||||
- **Entry** (leg Standing case, disasm 0x4aa505 [T1]): arm trn when TURNING (`|angVel|>1e-4`, port
|
||||
proxy = turnDemand deadband ±0.05) AND `0 ≤ spd ≤ standSpeed` (the FULL sub-walk range — CORRECTS the
|
||||
`0.25·standSpeed` stand-in) AND `turnCapable`(hasCrashSet@0x588). **LOCKSTEP: arms BOTH channels the
|
||||
same frame** (SetLegAnimation(4)+SetBodyAnimation(4), gated on body also Standing) — arming only the
|
||||
leg seeded the channel phase-split. The disasm also gates on `legResetLatch`(0x654), a master-perf
|
||||
ONE-FRAME debounce (cleared every frame @0x4a9bff, set on wind-down/turn-stop) — **omitted in the
|
||||
port** because its split leg-SM never per-frame-clears the latch, so gating on it would wrongly block
|
||||
trn after every walk.
|
||||
- **Exits:** leg SM (part_012.c:12013 [T1], restored VERBATIM) — `standSpeed<spd` → Standing (walk
|
||||
takes over) or `spd<ZeroSpeed` → Standing (reverse), else advance the pivot at `idleStrideScale`
|
||||
(goto advance_normally). PLUS the master-perf turn-STOP exit (disasm 0x4aa5c6 [T1]) folded into
|
||||
case 4: turn stopped (turnDemand back in deadband) → Standing + `legResetLatch=1`. No fast-forward,
|
||||
no 0.25× — both were stand-ins, now removed. Body case-4 twin mirrors the exits (does NOT set the
|
||||
latch; mj=0, so it only tracks state for lockstep).
|
||||
- Verified headless (DEV.EGG, `BT_AUTODRIVE=0.5 BT_FORCE_TURN=1 BT_WALK_DELAY=2 BT_GAIT_TRACE=1`):
|
||||
turn-in-place enters state 4; throttle ramp past standSpeed → 4→Standing→5(swr)→walk cycle;
|
||||
legFrm7(trn end)→legFrm1(swr) at the pose-matched boundary; both channels enter walk the same frame.
|
||||
Harness: `BT_FORCE_TURN` (reaches gBTDrive.turn), `BT_WALK_DELAY=<s>` (turn-then-ramp repro),
|
||||
`BT_GAIT_TRACE=1` (per-frame [gaitSM]). [T2] **Feel re-verification pending** (release now at
|
||||
standSpeed vs the invented 0.25×). [T1 logic / T2 runtime]
|
||||
⚠ **Aliasing bug class (namedClip):** `namedClip[]`@0x5e0 == `animationClips[]`@0x5cc+0x14 (ONE array);
|
||||
`gimpBaseClip`@0x64c == `animationClips[0x20]`; `bodyAnimationState`@0x728 == `bodyStateAlarm.level`.
|
||||
Declared-separate members don't see each other's writes — alias them.
|
||||
|
||||
+52
-6
@@ -6,7 +6,7 @@ source_sections: "PROGRESS_LOG.md §7 (Phase 7), §8 (P6); docs/HARD_PROBLEMS.md
|
||||
related_topics: [wintesla-port, combat-damage, locomotion, decomp-reference]
|
||||
key_terms: [master, replicant, EGG, dead-reckon]
|
||||
open_questions:
|
||||
- "pod-LAN config (real IPs); update-record velocity from the body-channel projection; peer SimulationState/DropZoneLocation replication (authentic peer warp spheres); authentic per-mech turn rates now streamed (task #4) but not yet wired into the turn code"
|
||||
- "pod-LAN config (real IPs); peer warp DropZoneLocation-anchoring [T3] (world-anchored peer sphere already visible, 160b78e); console-death replication freeze (pending recurrence to diagnose)"
|
||||
---
|
||||
|
||||
# Multiplayer
|
||||
@@ -237,6 +237,30 @@ engine/port member. Wire sizes verified live (0x14/0x20/0x2c/0x78). Key mechan
|
||||
A TU-local stub type forced the `BTMapperSpeedDemandRaw(void*)` bridge (mech.cpp carries a local
|
||||
`struct MechControlsMapper` recon stub — typed cross-TU signatures mangle differently).
|
||||
|
||||
## Subsystem update-record DIRECTION + the Torso pair (task #57, 2026-07-13) [T0/T1]
|
||||
|
||||
**Engine semantics [T0 SIMULATE.cpp:270-297]: `WriteUpdateRecord(record, model)` PRODUCES the
|
||||
outgoing record from the object (master serialize; base sets `recordLength=sizeof`, stream
|
||||
advances by `recordLength` — variable-length records are the mechanism); `ReadUpdateRecord(record)`
|
||||
APPLIES the record into the object (replicant).** The names read backwards — do not flip them.
|
||||
Base fns in every subsystem vtable: slot 6 = 41bd34 (Read/apply), slot 7 = 41c500 (Write/produce,
|
||||
3 args).
|
||||
|
||||
- **Torso pair:** slot 6 `@004b6a78` = `ReadUpdateRecord` (apply: lastUpdateTime clock stamp +
|
||||
twistAtUpdate/twistVelocity/twistRate ← record +0x10/14/18). Slot 7 `@004b6a1c` =
|
||||
`WriteUpdateRecord` — **Ghidra missed the function start**; raw-disasm recovery: chains base,
|
||||
sets `recordLength=0x1C`, writes currentTwist/twistVelocity/twistRate → +0x10/14/18, snapshots
|
||||
`twistAtUpdate = currentTwist`.
|
||||
- **The bug this fixed:** the old recon had the APPLY body on the WRITE virtual (and no Write at
|
||||
all) — the master consumed its own uninitialized stream buffer (0xCDCDCDCD → twistAtUpdate =
|
||||
−4.316e8) and never serialized twist; the replicant MadCat pinned at −140° (the "torso ghost
|
||||
twist"). With the pair correct the replicant tracks `targetTwist = twistAtUpdate +
|
||||
twistRate·elapsed` cleanly (cur=0 when master still). [T2 live]
|
||||
- **Residual (authentic):** the initial full-state snapshot record does NOT carry the torso
|
||||
extras; the replicant's blind +0x10 read can pick up 0xCD fill ONCE at spawn. The binary does
|
||||
the same read; the twist clamp contains it (on the fixed-torso BLH it pins to ±0.01° —
|
||||
invisible). First real twist record overwrites it.
|
||||
|
||||
## Remaining (P6 phase 4 / Phase 7)
|
||||
The pod-LAN config (real IPs, bare-IP pilot entries); update-record velocity sourced from the body-channel
|
||||
projection (above). See [[open-questions]]. [T3]
|
||||
@@ -318,15 +342,21 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
|
||||
defect that hid it for the whole effort was NOT the warp code — it was a general
|
||||
**texture-scroll precision-collapse bug** in `L4D3D::SetTextureScrolling` (offset = `scrollDelta ×
|
||||
absolute_time` → unbounded → UV precision loss → radial "spokes"), fixed with `fmodf`; that also
|
||||
cleans the scrolling beam grit. See [[reconstruction-gotchas]] §13. REMAINING: the AUTHENTIC peer
|
||||
path (see OTHERS' spheres) still needs player SimulationState/DropZoneLocation replication — until
|
||||
then the local-player sphere stands in; and confirm the COLLAPSE (death-side) warp fires on every
|
||||
MP death path (the force-damage test logged EXPAND reliably, COLLAPSE less so).
|
||||
cleans the scrolling beam grit. See [[reconstruction-gotchas]] §13. PEER WARP IS WIRED + VISIBLE
|
||||
(`160b78e`): an observer sees a peer's un-wreck warp via the world-anchored `BTStartWarpEffect`
|
||||
(mechdmg.cpp:1074, `ReplicantInstance`-gated; `simulationState` rides every record header).
|
||||
REMAINING [T3, non-gating]: the peer sphere is anchored to the peer's WORLD position, not the
|
||||
peer's authentic `DropZoneLocation` (that attribute isn't replicated) — a fidelity refinement.
|
||||
Also confirm the COLLAPSE (death-side) warp fires on every MP death path (force-damage test logged
|
||||
EXPAND reliably, COLLAPSE less so).
|
||||
NOTE the respawn-cycle SIM path is separate + done: `DropZoneReply` (FUN_004bffd0) → `Mech::Reset`
|
||||
(@0049fb74) + the level-2 "translocated" cockpit alarm; `CreatePlayerVehicle` (FUN_004bfcac,
|
||||
disassembled — make+link only, matches RP analog). The sphere is a pure RENDER-side watcher on the
|
||||
SimulationState dial, independent of that sim path.
|
||||
Mech::Reset (@0049fb74) full subsystem-reset sweep is still a bring-up TODO.
|
||||
Mech::Reset (@0049fb74) does the full subsystem-reset sweep (mech4.cpp:1616 loops every subsystem →
|
||||
`DeathReset(mode)`, restoring heat/power/ammo/charge) + heals all damage zones + ForceUpdate(0x1f).
|
||||
(Per-subsystem `DeathReset` bodies are authentically trivial/empty for some classes — the shipped
|
||||
build folds those overrides — so "fuller" reset behavior is per-subsystem, not a missing sweep.)
|
||||
**Three respawn bugs found + fixed on the way (all [T2], 2-node force-damage verified):**
|
||||
(i) **death-latch un-latch** — `IsMechDestroyed` tested `graphicAlarm>=9` alone; a later leg hit
|
||||
on the wreck rewrites the alarm to 4/3, un-latching → the death transition RE-RAN (double kill,
|
||||
@@ -369,3 +399,19 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
|
||||
## Key Relationships
|
||||
- Base: [[wintesla-port]] (L4NET). Depends on: [[locomotion]] (update writer), [[combat-damage]]
|
||||
(entity lifecycle). Detail: `docs/HARD_PROBLEMS.md` (P6).
|
||||
|
||||
|
||||
## Authentic coupled peer motion — DONE (task #50, 2026-07-14) [T1]
|
||||
Decomp-verified (workflows w0odszxro/wh1h5gnmc, 3 make-or-break claims adversarially CONFIRMED; committed c52a1ad + default-promoted). The 1995 peer per-frame path FUN_004ab430 -> FUN_004ab1c8 (IntegrateMotion) drives motion SINGLE-SOURCE:
|
||||
- LINEAR position = GAIT. IntegrateMotion advances the BODY gait channel FUN_004a5678 (FUN_004a5028 is DEAD CODE, zero call sites), turns its cycleDistance into velocity `{0,0,-cd/dt}`, rotates by heading, integrates into projectedOrigin.linear@0x260 (part_012.c:14994/14997/15008); localOrigin.linear@0x100 is copied VERBATIM from it (FUN_00408440, part_012.c:15131 -- NOT a lerp). updateVelocity.linear is never used for position.
|
||||
- ANGULAR/heading = replicated-velocity slerp (the ONLY thing Mover::DeadReckon-style velocity extrapolation drives): projectedOrigin.angular@0x26c built from updateVelocity.angular@0x2d4 (FUN_004ab188), localOrigin.angular slerped toward it.
|
||||
- 0x260 aliasing: "motionDelta@0x260" and "projectedOrigin@0x260" are LITERALLY THE SAME field (base Mover::projectedOrigin, Origin 0x1c); "worldPose@0x26c" is its quaternion half. Our reconstruction's shadow names hid this and drove the false velocity-vs-gait "contradiction".
|
||||
- Re-anchor: motionEventVector@0x598 = updateOrigin - localOrigin captured on poseSyncLatch, DECAYED into localOrigin over ~_DAT_004ab9cc (~0.2s) -- no snap. Zeroed in idle states.
|
||||
- MASTER + PEER run the SAME predictor. The master's SEND-mirror (FUN_004a9b5c @0x4aab9c) advances projectedOrigin by IntegrateMotion(mj=0) fed the LAST-SENT bodyTargetSpeed, then deadbands |localOrigin-projectedOrigin|^2 vs UpdatePositionDiffrence@0x768. A gait-driven peer REQUIRES this gait mirror (a constant-velocity mirror under-sends -> tug-of-war, measured ratio 1.86).
|
||||
- T4 CLOSED: both leg(0x65c) and body(0x6bc) animators are AnimationInstances of the SAME JointedMover/JointSubsystem (JMOVER.cpp:1382) -- either poses the WHOLE skeleton. Our port keeps the peer on the LEG channel (body unbound on the peer); faithful functional equivalent.
|
||||
|
||||
RECONSTRUCTION STATE: coupled path is DEFAULT ON (mech4.cpp: s_drPos gait-coupled, s_gaitMirror/Send). Reverts: BT_DR_POS=1 (peer velocity), BT_NO_MASTER_GAITMIRROR (master const-vel mirror). Measured: single-source coupling backward-steps 0.1% (vs the two-source split's churn), position ratio 1.043. RESIDUAL (follow-up): occasional ~2.9u snap from the leg(peer)-vs-body(mirror) channel mismatch -- to close, run the master mirror on a leg-channel prediction fed last-sent speed, matching the peer's poser. Also open: exact _DAT_004ab9cc decay constant; whether IntegrateMotion's 2-stage angular integrate is intended.
|
||||
|
||||
|
||||
## Peer motion: the "random shakiness" is single-box packet jitter, NOT the game (task #50, 2026-07-15) [T2]
|
||||
After the coupled body-channel peer landed (96a896a + turn-step f094d78 + cadence-mirror 23f1532), residual peer shakiness on accel/decel was RANDOM -- sometimes perfect, sometimes shaky. Root cause is the TEST RIG, proven with BT_RXJIT (record inter-arrival ms on the peer): two Debug btl4 nodes on ONE box contend for CPU, so Windows BATCHES their TCP delivery -- update records arrive in bursts (min ~0ms back-to-back, max 56-226ms gaps, burstiness max/avg 3-7x) instead of an even ~17ms. A peer dead-reckons across the long gaps then snaps when a record lands -> random shake. Pinning each node to a DISJOINT core set (ProcessorAffinity 0x00F / 0x3C0) restored even ~17ms delivery (burstiness ~1.0) and the shakiness vanished (user-confirmed). REAL pods are dedicated machines with no contention -- they never see this. DO NOT add an interpolation/jitter buffer or other un-authentic netcode to mask a single-box artifact. Use tools/mp_launch.sh (bakes in the affinity) for all 2-node tests. Diagnostic env: BT_RXJIT (arrival jitter), BT_SLIDE ([slide]/[mslide] slide-in-stand), BT_GAITEV, BT_MIRDIV, BT_NO_MIRROR_CAD / BT_PEER_LEGCH (revert the coupled-peer fixes).
|
||||
+242
-9
@@ -21,7 +21,85 @@ authentic path scoped.
|
||||
- Pod specifics for Phase 8: the 7-monitor driver setup, the RIO cockpit I/O protocol, current
|
||||
Win10+wrapper pod config. Status: OPEN.
|
||||
|
||||
## BINARY-COVERAGE AUDIT discoveries (2026-07-13, 6-agent decomp census) [T1 verified-uncited]
|
||||
New unaccounted functionality no prior list knew (addresses verified absent from game/+context/+docs):
|
||||
- **✅ `BTL4VideoRenderer::StartEntityEffectImplementation` @004d097c — DONE (2026-07-13) [T2].**
|
||||
The per-zone EFFECT DISPATCHER (+helpers @4d0c14 StopAll / @4d0c5c) is reconstructed
|
||||
(btl4vid.cpp:889) + WIRED: `MechDamageZone` change → `BTStartZoneEffect` →
|
||||
RendererManager::StartEntityEffect → this virtual → the band effect ATTACHED to the struck
|
||||
zone's segment (smoke/fire follows the mech); the Destroyed descriptor fires on kill
|
||||
(mechdmg.cpp:1120). Band smoke/fire (psfx ≥1000) render through the live PFX layer
|
||||
(BTStartPfxAttached); the 3-15 specialfx band + scaling booms/flames render through the
|
||||
reconstructed L4VIDEO specialfx engine + `ScalingExplosionRenderable` (see next bullet).
|
||||
**LIVENESS-VERIFIED (2026-07-14):** `BT_CRIT_PROBE=8` on solo DEV.EGG (hammers a local zone) drove
|
||||
the full chain — `[zonefx] entity 1:321 seg 12 psfx 12/13 at(527,5.8,-669)`: TakeDamage → the
|
||||
mechdmg damage-effect loop → BTStartZoneEffect → StartEntityEffectImplementation resolves the
|
||||
struck segment's world pos → BTStartPfxAttached. (The 2-node harness `scratchpad/fx_liveness.sh`
|
||||
did NOT engage — nodes spawned but never targeted/drove into combat, `mechPicks=0`; an MP-harness
|
||||
spawn/connect issue, orthogonal to FX. `BT_CRIT_PROBE` is the reliable solo FX-liveness path.)
|
||||
- **✅ The impact/destruction FX visual chain — RECONSTRUCTED (Fire VISUALS wave 48c9c84 +
|
||||
Impact-FX FORENSICS wave 065c114, 2026-07-12/13) [T2].** The i860-firmware-decoded specialfx
|
||||
engine (L4VIDEO.cpp: `specialfxN` descriptor pages, per-round detonations, authored firesmoke
|
||||
sheet, vertex-alpha effect cards), the case-4 wreck dressing, and `ScalingExplosionRenderable`
|
||||
(L4VIDRND.cpp:3413 — real ctor/Execute, the scaling-boom motion) are all live. The audit's six
|
||||
renderable-body addresses (@4540ac/45447c/455eb8/456cf0/4589e0/458e5c) map to this reconstructed
|
||||
set; a byte-level coverage cross-check of each vs the raw bodies is a minor open audit, NOT a
|
||||
dead chain. (NOTE: the `//STUBBED: DPL RB 1/14/07` markers scattered in L4VIDRND are benign 2007
|
||||
empty ctor/dtor notes on UNRELATED renderables — InnerProjectile/DPLObjectWrapper/ChildLight —
|
||||
not the explosion FX; do not mistake them for a gap.)
|
||||
- **FUN_00472480** (273 ln) — unnamed analog→discrete stepper Execute in the gauge band
|
||||
(hysteresis + key-repeat), no caller found; the one hole in "gauge system complete".
|
||||
Settle via vtable lookup (plausibly a config/roster scroller).
|
||||
- **FUN_00454a70** (193 ln) — the PNAME1-8/PLACE1-8.bgf MP name-billboard loader (the known
|
||||
"MP target identification" gap's exact function).
|
||||
- ✅ **Explosion::SplashDamage @0042fad0 — RECONSTRUCTED (task #62, 2026-07-13) [T1/T2].** ONE call
|
||||
site, **Missile::Perform (FUN_004bef78, part_013.c:10097)**, in the collision branch (fires on ANY
|
||||
missile impact — mover OR world), gated on `missile+0x360`, radius = `missile+0x364`. **Radius source
|
||||
corrected:** it is the ROUND's OWN GameModel (type-0xf) `+0x50`, seeded from the launcher's linked
|
||||
**AmmoBin `ammoModelFile @0x1e8`** (part_013.c:8778 → missile ctor SearchList @10184) — NOT the
|
||||
launcher's `ExplosionModelFile`. The MissileThruster parser `FUN_004bf8ec` writes `"SplashRadius"` to
|
||||
+0x50 (size-0x54 record). Burst falloff `baseBurst / dist^exp` floored at 1 (arcade 1.25 / WinTesla
|
||||
`EXPLODE.cpp:209` 1.2f). Only missiles splash (AC tracer is not a Missile). The engine `EXPLODE.cpp`
|
||||
diff is DONE — the T0 source confirmed the model; the only 1995-vs-WinTesla drift is the exponent.
|
||||
Port: `BTResolveSplashRadius`/`BTApplySplashDamage` (mech4.cpp), see [[combat-damage]]. **Remaining
|
||||
T3:** the per-player enable sub-gate `missile+0x360 = BTPlayer+0x264` (its writers read as a per-frame
|
||||
toggle, not a config flag; the `showDamageInflicted@0x264` label is a guess) — port treats authored
|
||||
`SplashRadius>0` as the enable. The mech's `DeathSplashDamage`/`DeathSplashRadius` (mech+0x520/0x524)
|
||||
stay **VESTIGIAL** (written, never read; no mech-death splash mechanic — death is visual only).
|
||||
- **The binary's own VelociRender BGF/BMF/BSL/VTX loader (~4,550 ln, dead code)** — a free
|
||||
ground-truth cross-check for [[bgf-format]]/[[asset-formats]] claims.
|
||||
Full ranked audit: the workflow output (2026-07-13); top-10 ranking cross-checked against this
|
||||
register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTORY in the KB
|
||||
(task #8 "landed" vs the kShotDamage=12 bring-up residue) — audit before reworking.
|
||||
|
||||
## Deferred subsystems / feeds (authentic path scoped, marked in code)
|
||||
- **Searchlight-driven fog swap — DEFERRED, and it is an ORIGINAL 1995 LATENT BUG (task #63,
|
||||
2026-07-13) [T1 decomp].** The arcade swaps fog between `fog=` (lights on) and `nosearchlightfog=`
|
||||
(off) via `PullFogRenderable` watching the Searchlight's `lightState`. It never fires — in the PORT
|
||||
because `PullFogRenderable` is never constructed, AND in the BINARY ITSELF because the Searchlight
|
||||
never lights: `ToggleLamp` (@004b860c) toggles `commandedOn` @0x1DC but `SearchlightSimulation`
|
||||
(@004b841c) reads `requestedOn` @0x1E0, which is zeroed by the ctor and never written — no
|
||||
0x1DC→0x1E0 bridge exists (self-consistent sibling ThermalSight reads the field it toggles, 0x1DC).
|
||||
So `lightState` is perpetually 0. The port reproduces this faithfully (searchlight.cpp:189).
|
||||
**DECISION: left as-is + documented (faithful to the buggy original); port keeps the static
|
||||
lights-ON `fog=`.** A working swap is a DESIGNER-INTENT deviation, fully scoped if ever wanted:
|
||||
(1) sim reads `commandedOn` (mirror ThermalSight); (2) construct `PullFogRenderable` at
|
||||
btl4vid.cpp `MakeMechRenderables` reticle-build/inside pass (== arcade part_014.c:5173, Dynamic,
|
||||
bound per Searchlight `lightState` via a new `LightStatePtr()` accessor); (3) a toggle input (the
|
||||
authentic cockpit button-5→`ToggleLamp` dispatch is a controls-family reconstruction; a dev key
|
||||
stands in). Also flags a deferred **`Mech::ControlsAllowLights()`** (searchlight.hpp:158 stub).
|
||||
See [[rendering]] fog section. Verified inert live: BT_FOG_LOG shows zero `SetFogStyle(2/3)`.
|
||||
- **Factory capability-roster loops 2-4 are STILL DEAD (task #57 discovery).** mech.cpp's
|
||||
post-roster loops add to `heatableSubsystems`(0x51155c)/`weaponRoster`(0x511830)/
|
||||
`damageableSubsystems`(0x50e4fc) through the local `SubProxy` stub whose `IsDerivedFrom`
|
||||
RETURNS 0 — so those three mech-level rosters stay EMPTY (whatever consumes them sees
|
||||
nothing; weapons demonstrably work through other paths, so audit consumers before assuming
|
||||
impact). Loop 1 (the watcher CONNECT, 0x50e604) was reconstructed for real in task #57 —
|
||||
use the same bridge pattern (family-side derivation test + typed body) for the other three. [T2]
|
||||
- **Initial-snapshot torso record carries no twist extras**: the replicant's blind +0x10 read
|
||||
can latch 0xCDCDCDCD ONCE at spawn until the first real twist record; clamps contain it
|
||||
(invisible on fixed-torso mechs). Binary-authentic blind read — fix only if a visibly wrong
|
||||
spawn pose ever surfaces. See [[multiplayer]]. [T2]
|
||||
- **✅ 0xBD3 SubsystemMessageManager — UNTANGLED + LIVE (task #7, 2026-07-11) [T1/T2].**
|
||||
Both halves landed: (1) the factory case builds the REAL messmgr (ctor @0049bca4, 0x130,
|
||||
static reconstruction messmgr.cpp) cached at `mech+0x434` — the binary-wide census found
|
||||
@@ -76,6 +154,17 @@ authentic path scoped.
|
||||
centred); (c) pre-burial, the pick still
|
||||
tests the sinking wreck's collision box at its parked position (pre-existing wreck-targeting
|
||||
behavior).
|
||||
- **✅ WORLD STRUCTURES (garages/walls) now targetable (task #50, 2026-07-15)** — the world-pick's
|
||||
non-mech tier previously only sampled the visual heightfield (`BTGroundRayHit`), which on arena1
|
||||
is a single flat `'sky'` ground mesh → shots passed THROUGH the garages and only mechs moved the
|
||||
range axis (user-reported regression). Resolved by ray-testing the **zone's static collision
|
||||
solid tree** (the geometry that already blocks the walk) via the authentic engine query
|
||||
`Mover::FindBoxedSolidHitBy`'s static-world tail — factored into `Mover::FindStaticSolidHitBy` +
|
||||
`Mech::WorldStructurePick`. See [[combat-damage]] "WORLD STRUCTURES ARE TARGETABLE". This also
|
||||
closes the old "0x388 writer is un-exported / unknown world-pick mechanism" residue: the
|
||||
authentic non-mech world pick is the static-solid-tree ray query (`BoxedSolidTree::FindBoundingBoxHitBy`),
|
||||
the same one `FindBoxedSolidHitBy` uses for mover-vs-world collision. Interactive aim is
|
||||
user-verified (headless has no `BTGetAimRay`); the `BT_WSWEEP` ray-fan is the headless proof.
|
||||
- **Cockpit HUD leftovers (tasks #35-#38 residue, 2026-07-08)** — the reticle + 7 weapon pips +
|
||||
ALL the instrument dynamics are LIVE (the Execute @004cdcf0 gap was RECOVERED via capstone
|
||||
disasm, task #37; the authentic Lock producer + the simple-X mode landed in #38 — see
|
||||
@@ -123,8 +212,9 @@ authentic path scoped.
|
||||
Also: `deathAnimationLatched`/`legResetLatch` were never ctor-initialised (0xCDCDCDCD gate bug,
|
||||
fixed in mech.cpp ctor). See [[cockpit-view]].
|
||||
- **MechControlsMapper look/eyepoint commit** — reconstructed but its offsets collide with declared
|
||||
members; arbitrate before enabling (no port consumer yet). The fire-trigger-through-mapper is
|
||||
still the `gBTWeaponTrigger` bring-up.
|
||||
members; arbitrate before enabling (no port consumer yet). (The fire-trigger-through-mapper is NO
|
||||
LONGER the bring-up: the `gBT*Trigger` globals are retired / never-read — the authentic LBE4
|
||||
buttonGroup path replaced them, task #5 `8ed6184`.)
|
||||
- **✅ Weapon groups — the AUTHENTIC defaults + fire path LIVE (task #5, 2026-07-11) [T1/T2].**
|
||||
The system decoded end-to-end: a fire "channel" = a physical `buttonGroup` slot in the global
|
||||
`LBE4ControlsManager` (fire buttons 0x40 Trigger / 0x45 Pinky / 0x46 ThumbLow / 0x47 ThumbHigh);
|
||||
@@ -221,7 +311,11 @@ authentic path scoped.
|
||||
in the task #8 synthesis) → the pod's 4-8-hit heavy-weapon pacing. The weapon-side
|
||||
SendDamageMessage (@004b9728) is LIVE from Emitter::FireWeapon (damageData filled incl.
|
||||
**damageForce = target−muzzle → the gyro's directional hit-bounce feed**); the mech4
|
||||
bring-up damage block + flat kShotDamage are retired to diag hooks. **ROOT-CAUSE find: the
|
||||
bring-up damage block + flat kShotDamage are retired to diag hooks **(CORRECTED, task #60:
|
||||
`kShotDamage=12` was only HALF-retired — still LIVE at the kill-score `mech4.cpp:1551` until
|
||||
task #60 replaced it with `lastInflictingDamage`, the real killing-blow magnitude; only the
|
||||
cross-pod force hook `mech4.cpp:3255` was env-gated. See the task #60 reconciliation below).**
|
||||
**ROOT-CAUSE find: the
|
||||
CheckFireEdge NaN latch** -- TriggerState carries ControlsButton INTS (release −65 = a
|
||||
negative NaN); the binary's x87 unordered-compare treated it as "released" but an
|
||||
IEEE-correct float compare latched the detector shut after the first release (why the
|
||||
@@ -231,6 +325,38 @@ authentic path scoped.
|
||||
LODReuseHysteresis corrected 0.82→0.33 (a double); the MP beam-kill live-verify awaits a
|
||||
clear-sightline spawn (mechanism identical to solo; force-dmg cross-pod cycles verified).
|
||||
|
||||
- **✅ DAMAGE-ECONOMY RECONCILIATION (task #60, 2026-07-13) [T1/T2].** Full 5-path audit vs the
|
||||
decomp (5-finder + adversarial-verify workflow). **Clean as-is:** energy beam (`emitter.cpp`,
|
||||
closed form byte-exact; `_DAT_004bafbc` dumped = x87 1e-7), autocannon (`projweap.cpp`, full
|
||||
authored DamageAmount from resource +0x19C; the 0.0625 at :667 is the shooter's own gyro recoil,
|
||||
not the round), zone-armor BASE model (`damageLevel += amount·damageScale[type]`, engine
|
||||
DAMAGE.cpp:379, legs ×0.5, 1.0=destroyed). **3 stand-ins FIXED:** (A) `mechdmg.cpp:451`
|
||||
`mech->stance` (phantom, perma-0 → the leg-shot-out fall/death branch was DEAD) → `MovementMode()`
|
||||
(mech+0x40, @part_012.c:6910); (B) `mechdmg.cpp:458` `IsAirborne()` (always-0 stub) → `IsDisabled()`
|
||||
(@0049fb54) — the task-#52 wreck-graphic corruption fixed AT SOURCE (was only masked by the
|
||||
IsMechDestroyed latch); (C) `mech4.cpp:1551` flat `kShotDamage=12` kill-score → `lastInflictingDamage`
|
||||
(real killing-blow, latched in TakeDamageMessageHandler; the score handler @0x4c02e4 derives the
|
||||
whole kill award from it, so a flat 12 scored every kill identically). Phantom `int stance` slot
|
||||
reused for `lastInflictingDamage`. **KB corrected:** the "damageScale[5] EVEN=1/armorPoints"
|
||||
prose is a mild inaccuracy — the array is 5 cells indexed by damageType directly
|
||||
(Collision/Ballistic/Explosive/Laser/Energy); the per-type value IS ≈1/armorPoints for the
|
||||
destructive types (net model unchanged). See [[combat-damage]].
|
||||
- **DEFERRED (task #60-D): the missile CLUSTER model.** The port fires N flying rounds each
|
||||
carrying `authored/N` damage (net salvo total = authored, so per-armor damage is correct), but
|
||||
the binary (@004bcc60) fires ONE missile whose `burstCount` is a RANDOM cluster-hit roll
|
||||
(`min(rand(0..N-1)+N/4, N)`, FUN_004bef78) applied to ONE cylinder-resolved zone. The port thus
|
||||
loses (i) cluster-hit VARIANCE (always full N) and (ii) single-zone CONCENTRATION (N rounds
|
||||
scatter across zones). Net-neutral on total armor, so LOW priority. **OPEN semantic gating the
|
||||
severity:** does `burstCount` multiply ARMOR damage or only the gyro kick? DAMAGE.h:49-54 [T0]
|
||||
says "times to apply the damage" (→ apply-count → port total authentic); settle by reading the
|
||||
consumer chain FUN_004bef78 → FUN_004be078 → EXPLODE.cpp:209-210 (does it loop burstCount over
|
||||
DamageZone::TakeDamage or apply once?). Fix only if made byte-faithful (roll clusterHits, thread
|
||||
`burst` through BTPushProjectile, one damage-bearing round + rest visual-only).
|
||||
- Also open (bring-up, not stand-ins): `scoreAward=0` (btplayer.cpp:1526 — the authentic kill
|
||||
bonus lives in the un-exported master-perf writer 0x4a9770-0x4ab188, T4); the exact authentic
|
||||
kill `damageAmount` from that same writer (msg+0x24) is unrecovered — the killing-blow magnitude
|
||||
is the faithful stand-in until it is.
|
||||
|
||||
- **HEAT (task #9, 2026-07-11): authentic 1e7-unit heat LIVE; conduction ROUTING defect
|
||||
remains [T2/T3].** Landed + byte-anchored: emitters emit `heatCostToFire x 1e7 x
|
||||
(charge/seekV)^2` (closed form; PPC = 1.1e8 -> +632 K on its 174000-mass sink); the missing
|
||||
@@ -241,8 +367,12 @@ authentic path scoped.
|
||||
three coolant epsilon corrections (0.0025/0.003/1e-4 for the old single 1e-4) +
|
||||
CoolantCapacityScale = 0.05 float80 (both prior readings wrong). Verified: heat flows,
|
||||
exits (~2/3 of a 1e10 spam run drained), weapons overheat under max-rate autofire exactly
|
||||
as the authored-constant simulation predicts (thermal spam is unsustainable BY DESIGN;
|
||||
heatLoad -> effectiveRange 0 = the authentic overheat cutout).
|
||||
as the authored-constant simulation predicts (thermal spam is unsustainable BY DESIGN; the
|
||||
authentic overheat cutout is the FailureHeat CHARGE-HOLD -- `ResetFiringState` + currentLevel=0
|
||||
until it cools, @004baa88 -- NOT a range collapse. ⚠ CORRECTION (`aab7a8a`, task #50):
|
||||
effectiveRange = (1 - HOST-ZONE damage) x weaponRange reads `Subsystem::damageZone->damageLevel`
|
||||
(weapon+0xE0), NOT heatLoad; the old "heatLoad -> effectiveRange 0" mechanism here was the same
|
||||
@0xE0-DamageZone-vs-heat misattribution fixed in heat.cpp:803).
|
||||
- **Task #10 -- the "scrambled routing" RESOLVED (2026-07-11) [T2].** The routing was NEVER
|
||||
scrambled: a `[heat-link]` attach log proved every subsystem links its authored sink
|
||||
exactly (bhk1: PPC_1->C4, PPC_2->C6, ERM_1/2/3->C1/C6/C4, SRM6s->C2, generators->C1/C2/C3/C5,
|
||||
@@ -299,6 +429,33 @@ authentic path scoped.
|
||||
under reconstruction: config-mode regrouping (task #6), generator reassignment (ids 4-8),
|
||||
coolant valves.
|
||||
|
||||
- **✅ AUTOCANNON JAM / coolant-priority — FAITHFUL + CONFIRMED against QA ground-truth, SOLVED
|
||||
purely from the decomp (2026-07-14) [T1/T2].** An original QA dev reported: "all AFCs jam on their
|
||||
3rd shot if coolant priority isn't boosted; boosted → almost never jam." VERDICT: the whole chain
|
||||
is reconstructed end-to-end, FAITHFUL, and reproduces the reported behavior — no bug, no stand-in.
|
||||
**DO NOT "fix" it by driving a weapon's `coolantFlowScale` from the valve; that field being a
|
||||
constant 1.0 for weapons is AUTHENTIC** (the ONLY writer of any HeatSink `+0x15C` from a valve value
|
||||
is `RecomputeCondenserValves`/FUN_0049f788, which iterates ONLY the condenser chain `mech+0x7cc`).
|
||||
Mechanism: a weapon dumps `heatCostToFire` (AFC100=6.5e7 raw) into its OWN linked condenser (AFC100→
|
||||
Condenser4); the condenser sheds to the shared bank at a rate scaled by its valve SHARE
|
||||
(`coolantFlowScale = valveState_i / Σ valveState`, MoveValve msg id 4, detents 1/5/50/0). The
|
||||
weapon's OWN thermal conductance (35500) caps how fast it sheds into its condenser, so coolant
|
||||
priority shifts the weapon's equilibrium only ~100 K — but that is EXACTLY enough to straddle the
|
||||
`degradationTemperature`(1000) jam line at combat fire rates. Cross `1000` → `heatAlarm`≥1 → the
|
||||
jam roll arms (`p=clamp(0.41·T/failT, 0.05, 1)`); on a hit → sticky `JammedState`(5), clears only on
|
||||
ResetToInitialState. (Distinct: `failureTemperature`(2000) → self-clearing overheat lockout,
|
||||
recoil-pinned + weaponAlarm 7.) **ALL constants VERIFIED AUTHENTIC vs the raw BTL4.RES bytes**
|
||||
(`scratchpad/afc_dump.py`): AFC100 startT=77, degradT=1000, failT=2000, thermalConductance=35500,
|
||||
thermalMass=94700, heatCostToFire(+0x1A4)=6.5e7, recharge(+0x190)=8, damage=25, minJamChance=0.05 —
|
||||
no mis-scaling. **THE ANSWER IS FIRE-RATE-DEPENDENT** (measured, BT_JAM_LOG/BT_VALVE_BOOST/BT_AF_PERIOD,
|
||||
madcat AFC100 → Condenser4): at MAX spam (8s recharge) the AFC overheats regardless (~1460 boosted
|
||||
~1360 — both jam); at a SLOW cadence (16s) it plateaus ~770 and never jams; at the ~11s COMBAT cadence
|
||||
it is DECISIVE — **not boosted → 1058 K (jams), boosted (Condenser4 valve→50, others→1) → 953 K (never
|
||||
jams)**. So the QA is right and the reconstruction is faithful; the earlier "boost is weak" read was
|
||||
an artifact of testing at max spam (above the window). Diag probes committed: `BT_JAM_LOG`
|
||||
(projweap.cpp), `BT_VALVE`/`BT_VALVE_BOOST=<condenserNum>` (heatfamily_reslice.cpp), `BT_AF_PERIOD=<sec>`
|
||||
(mech4.cpp). See [[combat-damage]], [[subsystems]] heat.
|
||||
|
||||
- **Subsystem-panel online/offline gate — fields unidentified [T4] (2026-07-12).** The binary's
|
||||
SubsystemCluster draw-state reads `*(subsystem+0x40)==1` and `*(subsystem+0x278)!=4`; neither
|
||||
1995 field is identified (weapon simulationState is 0/2/3/4 -- +0x40 isn't it). The port pins
|
||||
@@ -308,7 +465,24 @@ authentic path scoped.
|
||||
destroyed-subsystem dark-panel look.
|
||||
|
||||
## Locomotion / combat polish (non-gating)
|
||||
- Authentic per-mech TURN-RATE constant (currently a bring-up constant rate).
|
||||
- **✅ Interior vs exterior gait CLIP SET — DONE 2026-07-13 (task #59) [T2].** The local cockpit
|
||||
mech was leaning −8° into every walk because the authentic ctor clip-set gate
|
||||
(@part_012.c:10308-10320) was a no-op (`LoadLowDetailBody`/`LoadHighDetailBody` mislabeled the
|
||||
FUN_004a80d4/86c8 clip-loader addresses) and `LoadLocomotionClipsExt` was a stub aliased to the
|
||||
exterior loader. Reconstructed the real 4-char INTERIOR (`'i'`-suffix) loader (omits `jointhip`,
|
||||
shakes `jointshakey`). Because the port never sets the replicant copy bit the ctor gate lands
|
||||
everyone on interior, so `Mech::MaintainViewClipSet()` picks the set by VIEWPOINT each frame
|
||||
(your mech → interior/level; every other mech → exterior/lean). Both verified live: cockpit
|
||||
level, peer replica leans −8/−11°. See [[locomotion]] "The walk lean IS authored".
|
||||
- FOLLOW-UP (non-blocking): if the copy bit ever gets set at construction for other reasons,
|
||||
the ctor gate already handles it and MaintainViewClipSet becomes a no-op agreement — no
|
||||
conflict, but revisit whether the per-frame maintenance is still needed.
|
||||
- ✅ **Authentic per-mech TURN-RATE — DONE (task #64b, 2026-07-14) [T1/T2].** The master-perf
|
||||
disasm (0x4aa3d3-0x4aa4ff) turn-rate lerp (walkingTurnRate@0x574 → runningTurnRate@0x578 by
|
||||
ground speed, run-speed refinement) is wired into the drive, replacing the `kDriveTurnRate`
|
||||
bring-up constant; the rates (which the `Wword(0x15d/0x15e)` no-op was discarding) are real
|
||||
members now. Also the trn-in-place DISPATCHER (entry/exit) reconstructed from the same disasm,
|
||||
replacing the task-#64 `0.25×`/fast-forward stand-ins. See [[locomotion]].
|
||||
- Body-callback gimp handlers (states 16-19, targets 0x70b2/0x7161 undecoded → fall back to stand);
|
||||
airborne callbacks (`FUN_004a6344`/`FUN_004a7970`).
|
||||
- Wall-block-vs-climb tuning. **✅ Collision DAMAGE application — DONE 2026-07-08 [T2].** The two deferred
|
||||
@@ -378,11 +552,25 @@ authentic path scoped.
|
||||
ctor per mech, zone destroyed → crits damaged/DESTROYED with authentic accumulation.
|
||||
Residue: roster idx 0/1 (mapper installed post-ctor / voltage-bus stub) stay unbound with a
|
||||
logged skip — none of the 8 mechs' streams crit-list them in practice.
|
||||
- **Two dead Wword comparison branches (multiplayer)**: `mech.cpp:1511` + `:1613` (replicant
|
||||
leg-state/stability sync in ReadUpdateRecord) — always-false BTVal comparisons ([[reconstruction-gotchas]] §2);
|
||||
needs slots 0xf/0x10 promoted to named members when P6 replication work resumes.
|
||||
- **✅ Two dead Wword comparison branches — DONE (task #1, `c9f0c2a`).** `Mech::ReadUpdateRecord`
|
||||
case 3 now reads real named fields off `Mech__StateUpdateRecord` (legState / stability / legResetLatch /
|
||||
speedDemand); the always-false Wword/BTVal branches are gone (the old `mech.cpp:1511`/`:1613` line
|
||||
numbers no longer point at that code).
|
||||
|
||||
## Multiplayer (Phase 7 / P6)
|
||||
- **OPEN: console death froze peer replication BOTH ways (2026-07-14) [T4 mechanism].** Live 2-node
|
||||
session: the btconsole.py emulator process died mid-match (cause unknown — its recv loop only
|
||||
caught socket.timeout; any OSError killed threads silently; NOW hardened + `-u` + console.log) and
|
||||
peer↔peer update replication froze in BOTH directions at ~that moment (each node's replicant held a
|
||||
frozen stale velocity; own sims + rendering fine). The engine NEVER logged a console disconnect —
|
||||
though `CheckBuffers` recv==0 dispatches `HostDisconnectedMessage` and the loop keeps servicing
|
||||
other hosts — so either the console host's receive pad was full (recv skipped → close never seen)
|
||||
or the dispatch doesn't reach `HostDisconnectedMessageHandler` in the port (routing gap). ⚠ Also
|
||||
found (unfired, latent): that handler's ConsoleHostType branch closes **`gameListenerSocket`** (the
|
||||
GAME listener) where the comment says it re-posts a CONSOLE listen (L4NET.CPP:969) — suspect
|
||||
`consoleListenerSocket` was intended. Diagnose on recurrence: console.log (death reason) +
|
||||
whether `[repltrn]` velocities freeze at console death. Why peer replication depends on the
|
||||
console connection at all is the core unknown.
|
||||
- ✅ Cross-pod COMBAT — DONE (tasks #46/#47: replicant targeting + damage rerouted to the owning
|
||||
master, full cross-pod kill verified). ✅ Replicant GAIT animation — DONE (task #50: replicant
|
||||
derives speedDemand from the replicated velocity, full stand→walk→run lifecycle verified).
|
||||
@@ -396,6 +584,25 @@ authentic path scoped.
|
||||
- The WinSock2 stack (L4NET.CPP) is 2-node verified end-to-end (cross-pod kills, beams,
|
||||
replicant gait — tasks #46-#51); remaining = the real pod-LAN config (real IPs, bare-IP
|
||||
pilot entries).
|
||||
- **✅ Subsystem-record replication IS wired — the AC just wasn't using it (task #61,
|
||||
2026-07-13) [T2, CORRECTED].** (An earlier draft of this note WRONGLY claimed "no
|
||||
subsystem-record channel" — that was concluded from an AC-only test before the AC's
|
||||
ForceUpdate gap was found. The channel exists and works.) MECHANISM: the mech's per-frame
|
||||
tick calls `subsystem->PerformAndWatch(till, update_stream)` for every subsystem
|
||||
(mech4.cpp:3893) → base `Simulation::WriteSimulationUpdate` walks the subsystem's `updateModel`
|
||||
and serializes any set record; `Entity::UpdateMessageHandler` (ENTITY.cpp:387) routes each
|
||||
incoming record to `GetSimulation(subsystemID-1)->ReadUpdateRecord` on the peer. A subsystem
|
||||
replicates its fire IFF its `FireWeapon` calls **`ForceUpdate()`** (sets the updateModel bit).
|
||||
The EMITTER (beam) and MISSILELAUNCHER (salvo mirror, mislanch.cpp:326) both do → enemy lasers
|
||||
+ missiles ARE visible on the peer. The AUTOCANNON set only `simulationFlags |= 0x1` (the
|
||||
+0x28 instance flag, NOT updateModel) and had no fire record → its shot never serialized → the
|
||||
enemy's cannon (tracer + DAFC muzzle flash) was invisible. FIX: added
|
||||
`ProjectileWeapon::WriteUpdateRecord/ReadUpdateRecord` (fire counter + aim, the AC twin of the
|
||||
salvo mirror) + `ForceUpdate()` in FireWeapon. Verified live: watching node now logs
|
||||
REPLICANT AC shots + muzzle flashes. **LESSON (methodology):** the binary-coverage audit is a
|
||||
function-PRESENCE census — it can't see "reconstructed but inert" code (a function that exists
|
||||
but is never CALLED, like the salvo mirror was thought to be, or a missing ForceUpdate). A
|
||||
LIVENESS audit (does a live call path reach each reconstructed fn?) would catch this class.
|
||||
|
||||
## Rendering follow-ups (non-blocking)
|
||||
- **.PFX effect-layer polish (deferred 2026-07-08; the layer itself is LIVE + verified).** The
|
||||
@@ -420,3 +627,29 @@ authentic path scoped.
|
||||
## Key Relationships
|
||||
- Feeds from: every subsystem/render topic (their deferral notes collect here).
|
||||
- Gating master: [[source-completeness]] (the missing BT source).
|
||||
|
||||
## Peer coupled-motion architecture — ✅ DONE (task #50, 2026-07-14/15) [T1/T2]
|
||||
RESOLVED. (This block was the old COMPLETION PLAN from `ea39af1`; it was left stale after
|
||||
the work actually landed — reconciled 2026-07-15.) The coupled single-source gait pipeline
|
||||
is reconstructed, DEFAULT ON, and the residual was root-caused. See [[multiplayer]] two
|
||||
sections: **"Authentic coupled peer motion — DONE"** (linear position = BODY gait channel
|
||||
integrated into `projectedOrigin@0x260`; angular = replicated-velocity slerp; master
|
||||
SEND-mirror runs the SAME `IntegrateMotion` predictor fed the last-sent `bodyTargetSpeed`)
|
||||
and **"Peer motion: the 'random shakiness' is single-box packet jitter, NOT the game"**.
|
||||
- Landed: coupled path promoted to default (`c52a1ad` + `b013742`); peer poses from the
|
||||
BODY channel (`96a896a`); turn-step on the body channel (`f094d78`); true-mirror cadence
|
||||
driving the clip from actual replicated motion (`23f1532`). Groundwork: exact quaternion
|
||||
integration (`a8eb8a4`), incremental peer heading (`d78e77b`), clock guard (`93456be`),
|
||||
commanded-speed cadence feed (`6374efc`), Abs-macro fix (`7615ecd`).
|
||||
- **The old "needs the MASTER HALF" conclusion was SUPERSEDED:** the leftover accel/decel
|
||||
shakiness was ROOT-CAUSED as single-box **packet jitter** (`49d73dc`) — two Debug nodes on
|
||||
one box batch TCP delivery, so records arrive bursty (BT_RXJIT burstiness 3-7x) and the peer
|
||||
dead-reckons across the gaps then snaps. Pinning each node to a disjoint core set
|
||||
(`ProcessorAffinity 0x00F/0x3C0`, baked into `tools/mp_launch.sh`) restored even ~17ms
|
||||
delivery and the shakiness vanished (user-confirmed). Real pods are dedicated machines and
|
||||
never see it. **DO NOT add an interpolation/jitter buffer** to mask a rig artifact.
|
||||
- Minor non-gating follow-ups (tracked in [[multiplayer]]): an occasional ~2.9u
|
||||
leg(peer)-vs-body(mirror) channel-mismatch snap; the exact `_DAT_004ab9cc` offset-decay
|
||||
constant; whether `IntegrateMotion`'s 2-stage angular integrate is intended.
|
||||
- Speed model decomp-settled (`2c6db6a`): analog throttle, '5 speeds' false, `throttleState@0x4a4`
|
||||
is actually the fall-surface material (RENAME PENDING).
|
||||
|
||||
@@ -25,6 +25,36 @@ to keyboard/gamepad on a dev box; real wiring is a pod bring-up task (Phase 8).
|
||||
model — `LBE4ControlsManager` groups are fed by all devices (RIO on the pod, DirectInput on dev);
|
||||
the `MechControlsMapper` interprets them ([[locomotion]]). [T2]
|
||||
|
||||
### The pod throttle is ANALOG-CONTINUOUS, not notched (verified end-to-end) [T1]
|
||||
The authentic pod throttle path — traced 2026-07 (task #50 throttle-fidelity question):
|
||||
1. **Hardware → RIO:** serial `AnalogReply` packet → `Ranger("Throttle", 0, 800, .05)` — raw counts
|
||||
0–800, 5% deadband, auto-ranging offset, output a CONTINUOUS Scalar; the sign is inverted
|
||||
("Throttle counts BACKWARDS", `engine/MUNGA_L4/L4RIO.cpp:1374-1377`, Ranger @L4RIO.cpp:461-701).
|
||||
No quantization/notching anywhere in `Ranger::Update`.
|
||||
2. **Manager:** `LBE4ControlsManager::Execute` → `scalarGroup[ScalarThrottle].Update(&rioPointer->
|
||||
Throttle, mode_mask)` on every AnalogReply (`L4CTRL.cpp:1379-1382`). ScalarThrottle = index 0 →
|
||||
manager+0x24 (scalarGroup base 0x24, 0x20/entry; buttonGroup base 0x1c0, keyboardGroup 0x160).
|
||||
3. **Streamed `.CTL` mapping (the "handled elsewhere"):** `MechRIOMapper`'s ctor @004d266c binds NO
|
||||
throttle — the bind comes from the type-19 `ControlMappingStream` resource named **"L4"** (child
|
||||
of the per-mech type-6 `ControlMappingsList`; installed by `BTL4APP MakeViewpointEntity` via
|
||||
`CreateStreamedMappings` @0047703c). BTL4.RES "L4" record [1]:
|
||||
`Scalar Throttle → subsystemID 0 (ControlsMapper slot), DirectMapping, attr 4, mask 0xffffffff`.
|
||||
Attr 4 = "ThrottlePosition" @ mapper+0x11c (binary IndexEntry table @0050efd8: id 4 → offset
|
||||
0x11d-1 = 0x11c, name "ThrottlePosition" @0050f28f). Reverse = record [2]: `Button Throttle1
|
||||
(0x3F, on the throttle handle) → attr 6 ReverseThrust@0x124`. Turn = pedals value-bound in the
|
||||
ctor (manager+0x44/+0x64 → mapper+0x1b4/+0x1b8).
|
||||
4. **Interpretation:** `L4MechControlsMapper::InterpretControls` @004d196c applies the ONLY software
|
||||
detent — snap to 1.0 when |t−1.0| ≤ 0.05 — then `MechControlsMapper::InterpretControls` @004afd10
|
||||
computes `speedDemand@0x128 = maxSpeed(mech+0x34c) × throttlePosition(0x11c) × scale(mech+0x5c0)`
|
||||
(forward) or `−maxSpeed × throttlePosition` (reverse flag 0x124).
|
||||
|
||||
**Consequence:** the original pod produced a continuously varying speedDemand while the lever moved
|
||||
(updated per serial AnalogReply, NOT per render frame). There are NO "5 throttle notches" anywhere
|
||||
in the software path. (Keyboard keys '1'-'5' in @004d1bf0 set controls-manager MODE masks; the
|
||||
'+'/'-' pair steps a [0,5] value that drives `pow(2,x)` into mech+0x404 = HUD zoom 1×–32× — neither
|
||||
is a speed setting.) The mech's `throttleState@0x4a4` writer remains un-exported (likely in the
|
||||
0x4a9b5a–0x4ab188 gap) — [[open-questions]].
|
||||
|
||||
## Multi-surface gauge path (intact, pod-only by default)
|
||||
The pod multi-surface path EXISTS and is intact: `DPLRenderer::FindBestAdapterIndices` (multi-
|
||||
adapter selector, honors PRIMGAUGE/SECGAUGE/MFDGAUGE/SPANDISABLE), `SVGA16::BuildWindows` (a
|
||||
|
||||
+34
-1
@@ -6,7 +6,7 @@ source_sections: "PROGRESS_LOG.md §10 (render-fidelity notes); docs/BGF_FORMAT.
|
||||
related_topics: [bgf-format, asset-formats, wintesla-port, locomotion, pod-hardware, cockpit-view]
|
||||
key_terms: [L4D3D, material-ramp, BSL, LOD, DPL, beam]
|
||||
open_questions:
|
||||
- "Replacement-LOD for NON-additive multi-LOD models; day/night material path priority (BT_MATPRI)"
|
||||
- "Replacement-LOD for NON-additive multi-LOD models"
|
||||
---
|
||||
|
||||
# Rendering
|
||||
@@ -52,6 +52,39 @@ DECLOUDS must stay in the sky pass). Authored TEXTURE **SCROLL** (BMF TEXTURE ta
|
||||
`L4DPLCFG`): resolves map+time → clip/fog/ambient + a directional sun. Bring-up stubs
|
||||
(EnsureValidProjection / per-frame ambient / uninit cloud colours) had clobbered it — now they
|
||||
RE-ASSERT the env values. Debug: copy BTDPL.INI → *DBG.INI with `debug=True`. [T2]
|
||||
- **Fog — per map/time/WEATHER (task #63).** `BTDPL.INI` authors `fog=<near> <far> <r> <g> <b>` (+ often
|
||||
`nosearchlightfog=`) on EVERY map/time/weather leaf. WEATHER is a fixed authored egg field
|
||||
(`clear`/`fog`/`soup`, `mission->GetMissionWeather()`; missing = fatal in the arcade); the env pipeline
|
||||
branches location→time→weather to the leaf. Shipped eggs pin `cavern/night/clear` → page `dsnitclear`
|
||||
(near 90, far 1100, dark blue 0.1,0.1,0.12); `fog`→`dsnitfog` (500), `soup`→`dsnitsoup` (300). The
|
||||
arcade fog model is per-PIXEL linear (`dpl_fog_type_pixel_lin`, dpl.h:265). **It rendered INVISIBLE in
|
||||
the port:** `D3DRS_FOGTABLEMODE=D3DFOG_LINEAR` (table fog) reads the perspective-NONLINEAR z-buffer, so
|
||||
without WFOG the authored 90..1100 range collapsed to fog-factor≈1 (no fog). FIX (L4VIDEO.cpp world
|
||||
pass ~8194): auto-detect `D3DPRASTERCAPS_WFOG` → per-pixel W-fog (smooth, == the arcade); else VERTEX
|
||||
fog (eye-space but per-vertex → splotchy on coarse terrain tris); FOGENABLE + mode re-asserted each
|
||||
world-pass frame (the old one-time set got clobbered per-pass). **Task #20's "shipped maps define no
|
||||
fog" was WRONG** — it checked `.MAP`/`.RES`, but fog lives in BTDPL.INI (the DPL env INI), as in the
|
||||
arcade. Night fog is dark BY DESIGN (distance→murk, not light haze); day pages author light haze. Env:
|
||||
`BT_FOGMODE=table|vertex|off`, `BT_WEATHER=clear|fog|soup`, `BT_FOG="near far r g b"` (all branches);
|
||||
`[fog] resolved`/`[fog] model`/`[fogstyle]` (BT_FOG_LOG) log lines. [T1 pipeline / T0 arcade model / T2 fix]
|
||||
**⚠ SEARCHLIGHT SWAP IS INERT — and it was inert in the ARCADE too (task #63 workflow, 2026-07-13).**
|
||||
The arcade swaps the active fog between `fog=` (headlight ON) and `nosearchlightfog=` (OFF,
|
||||
darker/tighter) via `PullFogRenderable::Execute` (L4VIDRND.cpp:4601, edge-triggered on two light attrs →
|
||||
`SetFogStyle(searchLightOn/OffFogStyle)`). TWO independent reasons it never fires:
|
||||
(1) **Port:** `PullFogRenderable` is NEVER CONSTRUCTED (grep: only its .h decl + .cpp def; no `new`);
|
||||
`searchLightOn/Off` is called from nowhere else (BT_FOG_LOG: over 22s only `SetFogStyle(0)`, zero 2/3).
|
||||
(2) **Original 1995 LATENT BUG [T1, decomp]:** even if wired, the Searchlight never lights —
|
||||
`ToggleLamp` (@004b860c) toggles `commandedOn` @0x1DC (part_013.c:6087) but `SearchlightSimulation`
|
||||
(@004b841c) reads `requestedOn` @0x1E0 (part_013.c:5978), which the ctor zeroes and NOTHING ever writes;
|
||||
no bridge 0x1DC→0x1E0 exists anywhere in the binary. So `lightState`@0x1D8 is perpetually 0. The
|
||||
self-consistent sibling **ThermalSight** reads the SAME field it toggles (0x1DC) — the tell that
|
||||
Searchlight's sim reads the wrong slot. The port reproduces this faithfully (searchlight.cpp:189 reads
|
||||
requestedOn). **DECISION (2026-07-13): left as-is + documented** (faithful to the buggy original); the
|
||||
port keeps the static lights-ON `fog=` values. To make the swap WORK is a DESIGNER-INTENT fix that
|
||||
DEVIATES from the shipped binary — repair the sim to read `commandedOn` (mirror ThermalSight) + construct
|
||||
`PullFogRenderable` at btl4vid.cpp `MakeMechRenderables` (the reticle-build/inside pass, == arcade
|
||||
part_014.c:5173, ExecutionType Dynamic, bound to each Searchlight `lightState`) + a toggle input. See
|
||||
[[open-questions]]. Core per-map/time/weather fog is unaffected.
|
||||
- **Projection / stencil (task #55):** the BTDPL.INI `viewangle=60` is the **HORIZONTAL** FOV —
|
||||
authentic 60×47 frustum at 4:3; fovY derived via `BTFovYFromHorizontal` (L4VIDEO.cpp), applied at
|
||||
all 5 projection sites (it was mis-applied as vertical). The device depth-stencil is now
|
||||
|
||||
+40
-6
@@ -6,7 +6,7 @@ source_sections: "PROGRESS_LOG.md §10d; docs/SUBSYS_PLAN.md; docs/VEHICLE_SUBSY
|
||||
related_topics: [decomp-reference, reconstruction-gotchas, combat-damage, gauges-hud]
|
||||
key_terms: [subsystem, factory, ClassID, subsystem-roster, PoweredSubsystem, bridge]
|
||||
open_questions:
|
||||
- "Myomers coupling (gates on owning-player @mech+0x190); 0xBD3 damage-hub untangle (WAVE 8)"
|
||||
- "Myomers coupling (gates on owning-player @mech+0x190); factory capability-roster loops 2-4 still dead (SubProxy stub)"
|
||||
---
|
||||
|
||||
# Subsystems
|
||||
@@ -41,6 +41,11 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
|
||||
- **WAVE 3** — power bus (Generator/PoweredSubsystem) + Emitter/PPC fire-path (end-to-end fire; heat
|
||||
conducts to the central sink via the linked-sink roster).
|
||||
- **WAVE 4** — standalone readouts: Sensor/Searchlight/ThermalSight/AmmoBin (de-shim, gate fixes).
|
||||
- ⚠ **Searchlight has an ORIGINAL latent bug [T1, task #63]:** `SearchlightSimulation` (@004b841c)
|
||||
reads `requestedOn`@0x1E0 (never written) while `ToggleLamp` (@004b860c) toggles `commandedOn`@0x1DC
|
||||
— no bridge, so `lightState`@0x1D8 is perpetually 0 (lamp never lights). Present in the 1995 binary
|
||||
(sibling ThermalSight reads the field it toggles, 0x1DC); the port reproduces it faithfully. Left
|
||||
as-is by decision; it makes the searchlight→fog swap inert. See [[open-questions]] + [[rendering]] fog.
|
||||
- **WAVE 5** — Torso (aim twist/elevation; joint-I/O reconstructed; the BLH record disables torso →
|
||||
faithfully no visible twist).
|
||||
- **WAVE 6** — Myomers (mover-coupled; structural un-stub is INERT — no live mover/heat coupling).
|
||||
@@ -49,11 +54,40 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
|
||||
- **TASK #56 (2026-07-11)** — Gyroscope LIVE byte-exact (ctor @004b3778, sizeof 0x3D0 locked;
|
||||
integrators + `FUN_004b2980` damage fan-out; joint dispatch from the Mech performance tail
|
||||
@0x4aaf74/83; `BT_GYRO_LOG`/`BT_GYRO_TRACE`) — see [[cockpit-view]].
|
||||
- **DEFERRED** — SubsystemMessageManager 0xBD3 (WAVE 8 — a
|
||||
**damage/explosion consolidation hub** cached to `Mech[0x10d]`=0x434; recon MISLABELS the slot
|
||||
`controlsMapper` + the drive squats there while the real mapper is roster slot 0, so wiring it needs
|
||||
an untangle. NOT the valve/advanced-damage gate — that's the owning **BTPlayer** @mech+0x190, see
|
||||
[[combat-damage]]/[[open-questions]]). [T2]
|
||||
- **✅ DONE (task #7, `afefaee`)** — SubsystemMessageManager 0xBD3 (a
|
||||
**damage/explosion consolidation hub** cached to `Mech[0x10d]`=0x434; the factory builds the REAL
|
||||
class and `mech.hpp` names the slot `messageManager` — the old `controlsMapper` mislabel is swept,
|
||||
the real mapper is roster slot 0. NOT the valve/advanced-damage gate — that's the owning **BTPlayer**
|
||||
@mech+0x190, see [[combat-damage]]/[[open-questions]]). [T2]
|
||||
|
||||
## The watcher electrical chain (task #57, 2026-07-13 — the torso power gate)
|
||||
The Watcher branch is POWERED indirectly: a watcher WATCHES another roster subsystem and mirrors
|
||||
its electrical state. The full chain, byte-verified [T1]:
|
||||
- **Data:** the model entry `WatchedSubsystem=<name>` → segment index **+2** at resource+0xE4
|
||||
(`HeatWatcher::CreateStreamedSubsystem` @004aec54); the ctor @004aeb40 stores it at
|
||||
watcher+0x128 (`watchedSubsystem`).
|
||||
- **Bind (factory post-roster loop 1):** vtable slot 14 (+0x38) — `@004aee2c` (HeatWatcher) /
|
||||
`@004b1a40` (PowerWatcher/Torso override, byte-identical); **Ghidra missed both function
|
||||
starts** (recovered from raw bytes; the vtables.tsv rows have GAPS where the exporter skipped
|
||||
slots — dump the exe bytes at vtable+slot*4 when a slot looks missing). Master-gated
|
||||
(`(owner->simulationFlags & 0xC)==0 && (flags & 0x100)`); binds `watchedLink(+0x114).Add(
|
||||
owner->roster[+0x128][watchedSubsystem])`. Port: `BTWatcherWatchedIndex`/`BTWatcherBindTarget`
|
||||
bridges (heatfamily_reslice.cpp) called from the mech.cpp factory loop.
|
||||
- **Tick:** `@004b181c` = the REAL `PowerWatcher` Performance (PTR @0050f5fc → 004b181c) =
|
||||
`UpdateWatch()`: heat mirror (FUN_004aeac4) + `watchdogAlarm.SetLevel(watched->electrical
|
||||
level @+0x278)` + brownout downgrade to 1 when `gen->outputVoltage(+0x1DC) <= minVoltage(+0x180)
|
||||
× gen->ratedVoltage(+0x1D8)`. The Torso sims (@004b5cf0/@004b65f8) call it first-line.
|
||||
(`@004b1804` is slot-10 **ResetToInitialState**, NOT the Simulation — old recon mislabel, fixed.)
|
||||
- **MinVoltageScale = 0.01** — a 10-byte x87 literal at 0x4b1924 (`0a d7 a3 70 3d 0a d7 a3 f8 3f`);
|
||||
the port had 1.0f, making minVoltage 100× too big → the brownout latched every watchdog at 1.
|
||||
- **PowerWatcher::GetClassDerivations chains HeatWatcher** (real base) — the old HeatableSubsystem
|
||||
stand-in broke `IsDerivedFrom(HeatWatcher)` for Torso/Searchlight/ThermalSight and silently
|
||||
skipped them in the connect pass.
|
||||
- **Effect:** `Torso::ElectricalStateLevel()==Ready(4)` un-gates `effectiveTwistRate` — the MadCat
|
||||
torso twists at its authored 50°/s (±140° limits, roster 17 watching 15 → generator @10000V);
|
||||
the BLH is authentically fixed (`horizontalEnabled=0`, limits ±0.01°). [T2 live-verified]
|
||||
- **STILL DEAD:** factory loops 2-4 (heatable/weapon/damageable capability rosters) go through the
|
||||
`SubProxy` stub whose `IsDerivedFrom` returns 0 — they add NOTHING. See [[open-questions]].
|
||||
|
||||
## The four systemic checks (every subsystem)
|
||||
See [[reconstruction-gotchas]]: (1) shadowing (re-declared engine-base fields), (2) the `Wword` trap,
|
||||
|
||||
@@ -6,7 +6,7 @@ source_sections: "multiplayer.md (lifecycle + trigger wiring, task #52); referen
|
||||
related_topics: [rendering, multiplayer, combat-damage, reconstruction-gotchas, asset-formats, bgf-format]
|
||||
key_terms: [material-ramp]
|
||||
open_questions:
|
||||
- "AUTHENTIC peer path (seeing OTHERS' spheres) needs player SimulationState/DropZoneLocation replication; until then the LOCAL-player sphere stands in."
|
||||
- "Peer warp IS wired + visible -- observer sees a peer's un-wreck warp (world-anchored BTStartWarpEffect, 160b78e). Remaining nuance [T3]: anchor to the peer's authentic DropZoneLocation (not replicated) vs the peer's world position."
|
||||
- "In the 2-node force-damage test only the EXPAND (respawn) warp logs reliably; confirm the COLLAPSE (death-side) fires on every MP death path."
|
||||
---
|
||||
|
||||
@@ -118,9 +118,13 @@ it impossible to ignore. Generalized in [[reconstruction-gotchas]] §13.
|
||||
## Trigger wiring (summary; full detail in [[multiplayer]]) [T2]
|
||||
Local player's own death → `BTStartWarpCollapsePOV()` (btplayer.cpp `VehicleDeadMessageHandler`,
|
||||
`deathCount==-1`, guarded `this==GetMissionPlayer()`). Respawn `DropZoneReply` → `BTStartWarpExpandPOV()`
|
||||
(local-guarded). `BTWarpForceUnmask()` on mission-end / no-DropZones. Observer replicant un-wreck fires
|
||||
the world-anchored `BTStartWarpEffect(x,y,z)` (mechdmg.cpp). The peer path is a stand-in until player
|
||||
`SimulationState`/`DropZoneLocation` replication exists.
|
||||
(local-guarded). `BTWarpForceUnmask()` on mission-end / no-DropZones. **Peer warp IS wired + visible
|
||||
(`160b78e`):** an observer sees a peer's death/respawn warp — the replicant un-wreck fires the
|
||||
world-anchored `BTStartWarpEffect(x,y,z)` at the peer's position (mechdmg.cpp:1074, gated to
|
||||
`ReplicantInstance`; `simulationState` rides every update-record header so the observer tracks the
|
||||
peer's state). The ONLY remaining nuance [T3, non-gating]: the observer's peer sphere is anchored to
|
||||
the peer's WORLD position rather than the peer's authentic `DropZoneLocation` (that attribute isn't
|
||||
replicated) — a fidelity refinement, not a missing effect.
|
||||
|
||||
## How it was solved (method) + verification [T2]
|
||||
1. **Dump the real asset** (`dump_tsphere.py`) — established it's a bicone with cylindrical UVs, ending
|
||||
|
||||
+7
-3
@@ -1173,9 +1173,13 @@ flight entity.
|
||||
- `SetPerformance(&ProjectileWeapon::ProjectileWeaponSimulation)` — wired (ctor).
|
||||
|
||||
- **MissileLauncher** (`mislanch.cpp/.hpp`) — `FireWeapon` (override @004bcc60)
|
||||
reconstructed: `ConsumeRound()`, recoil cooldown, then spawn `missileCount`
|
||||
guided `Missile::New` entities per salvo (damage already pre-split per-missile
|
||||
in the ctor). Ticks via the inherited `ProjectileWeaponSimulation`; its vtable
|
||||
reconstructed: `ConsumeRound()`, recoil cooldown, then spawn ONE guided
|
||||
`Missile::New` entity per salvo (task #60 correction — NOT `missileCount`
|
||||
entities; the single missile carries `burstCount` = the random cluster-hit
|
||||
roll, and per-missile damage = authored/missileCount pre-split in the ctor).
|
||||
The PORT re-expresses this as N flying rounds (net armor total identical;
|
||||
loses cluster variance + single-zone concentration — see [[open-questions]]
|
||||
task #60-D). Ticks via the inherited `ProjectileWeaponSimulation`; its vtable
|
||||
`FireWeapon` override is dispatched from there.
|
||||
|
||||
- **Missile** (`missile.cpp/.hpp`) — flight-entity tick wiring ADDED.
|
||||
|
||||
+60
-8
@@ -11,6 +11,34 @@
|
||||
#include "line.h"
|
||||
#include "app.h"
|
||||
#include "notation.h"
|
||||
#include <math.h>
|
||||
|
||||
//
|
||||
// EXACT axis-angle rotation composition -- matches the 1995 BT binary's angular
|
||||
// integrator (FUN_00409f58): build a unit rotation quaternion from the rotation
|
||||
// VECTOR `rotVec` (angle = |rotVec|, axis = rotVec/angle) as { axis*sin(angle/2),
|
||||
// cos(angle/2) } and Hamilton-multiply it onto `base`. The dead-reckoner previously
|
||||
// did `out.Add(base, rotVec)` -- adding a scaled angular-velocity vector to the heading
|
||||
// quaternion. That is only a small-angle approximation: fine per-frame (tiny angle),
|
||||
// but over a long replicant dead-reckon gap it DIVERGES (the heading drifts to ~180deg
|
||||
// then snaps -- the spinning-peer hesitation). This composition is exact for any angle
|
||||
// and stays on the unit sphere.
|
||||
//
|
||||
static void ExactAngularProject(Quaternion &out, const Quaternion &base, const Vector3D &rotVec)
|
||||
{
|
||||
const Scalar ang = rotVec.Length();
|
||||
if (ang > 1.0e-6f)
|
||||
{
|
||||
const Scalar h = 0.5f * (Scalar)fmodf((float)ang, 6.2831853f); // half of angle mod 2pi
|
||||
const Scalar s = (Scalar)(sinf((float)h) / ang); // sin(angle/2)/angle
|
||||
const Quaternion dq(rotVec.x * s, rotVec.y * s, rotVec.z * s, (Scalar)cosf((float)h));
|
||||
out.Multiply(base, dq); // base (X) dq
|
||||
}
|
||||
else
|
||||
{
|
||||
out = base;
|
||||
}
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
//############################### Mover #################################
|
||||
@@ -395,10 +423,8 @@ Logical
|
||||
//-------------------------------
|
||||
//
|
||||
position_delta.Multiply(updateVelocity.angularMotion, time_slice);
|
||||
projectedOrigin.angularPosition.Add(
|
||||
updateOrigin.angularPosition,
|
||||
position_delta
|
||||
);
|
||||
ExactAngularProject(projectedOrigin.angularPosition, // was .Add (diverging vector-add)
|
||||
updateOrigin.angularPosition, position_delta);
|
||||
projectedVelocity = updateVelocity;
|
||||
|
||||
Check_Fpu();
|
||||
@@ -460,10 +486,8 @@ Logical
|
||||
updateVelocity.angularMotion,
|
||||
time_slice
|
||||
);
|
||||
projectedOrigin.angularPosition.Add(
|
||||
updateOrigin.angularPosition,
|
||||
position_delta
|
||||
);
|
||||
ExactAngularProject(projectedOrigin.angularPosition, // was .Add (diverging vector-add)
|
||||
updateOrigin.angularPosition, position_delta);
|
||||
|
||||
//
|
||||
//-----------------------------------
|
||||
@@ -1113,6 +1137,34 @@ BoxedSolid*
|
||||
return solid;
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
// The "static world" tail of FindBoxedSolidHitBy on its own: ray-test ONLY the
|
||||
// zone's static solid tree (world structures -- garages, walls, props), NOT the
|
||||
// tangible movers/doors. Same tree the mech's walk collides against, so a shot
|
||||
// designated through this lands on exactly the geometry that blocks the mech.
|
||||
// FindBoundingBoxHitBy clips line->length to the hit distance (HitByBounded ->
|
||||
// line->length = enter), so the caller reads the entry point via line->FindEnd.
|
||||
//
|
||||
BoxedSolid*
|
||||
Mover::FindStaticSolidHitBy(
|
||||
Line *line
|
||||
)
|
||||
{
|
||||
Check(this);
|
||||
Check(line);
|
||||
|
||||
InterestManager *interest_mgr = application->GetInterestManager();
|
||||
Check(interest_mgr);
|
||||
InterestZone *zone = interest_mgr->GetInterestZone(interestZoneID);
|
||||
Check(zone);
|
||||
BoxedSolidTree* tree = zone->GetCollisionRoot();
|
||||
Check(tree);
|
||||
BoxedSolid *result = (BoxedSolid*)tree->FindBoundingBoxHitBy(line);
|
||||
Check_Fpu();
|
||||
return result;
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
BoxedSolidCollisionList*
|
||||
|
||||
@@ -300,6 +300,16 @@ public:
|
||||
Line *line,
|
||||
Entity *except_by
|
||||
);
|
||||
// STATIC-WORLD-ONLY ray query: the "test against the static world" tail of
|
||||
// FindBoxedSolidHitBy factored out -- ray-tests ONLY the zone's static solid
|
||||
// tree (the world structures: garages, walls, props), skipping the tangible
|
||||
// movers/doors. Used by the weapon boresight pick so a shot lands on the SAME
|
||||
// static geometry that already blocks the mech's walk (mechs are picked
|
||||
// separately, with their damage zones/lock). Clips line->length to the entry.
|
||||
BoxedSolid*
|
||||
FindStaticSolidHitBy(
|
||||
Line *line
|
||||
);
|
||||
BoxedSolidCollisionList*
|
||||
CollideCenterOfMotion(
|
||||
Line *line,
|
||||
|
||||
@@ -2118,6 +2118,12 @@ Logical L4NetworkManager::CheckBuffers(NetworkPacket *network_packet)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// TCP_NODELAY on the accepted GAME socket (not the console):
|
||||
// without it Nagle coalesces the inbound per-frame update
|
||||
// records into bursts -> the peer is received in lurches.
|
||||
BOOL gameNoDelay = TRUE;
|
||||
if (setsockopt(tempSocket, IPPROTO_TCP, TCP_NODELAY, (char*)&gameNoDelay, sizeof(gameNoDelay)))
|
||||
DEBUG_STREAM << "WARN: could not set TCP_NODELAY on accepted game socket; setsockopt() failed with " << WSAGetLastError() << "\n" << std::flush;
|
||||
}
|
||||
|
||||
remote_host->SetNetworkSocket(tempSocket);
|
||||
@@ -2798,6 +2804,18 @@ SOCKET L4NetworkManager::OpenConnection(
|
||||
WSACleanup();
|
||||
PostQuitMessage(AbortExitCodeID);
|
||||
}
|
||||
// TCP_NODELAY: disable Nagle so the small per-frame update records ship
|
||||
// immediately instead of being coalesced. With Nagle on (the default),
|
||||
// Nagle + delayed-ACK batch the tiny position packets into ~40-200ms
|
||||
// bursts, so a peer that moves at a steady speed is RECEIVED in lurches
|
||||
// (measured: dead-reckoned ground speed swinging 3x per 0.25s window).
|
||||
// The pod net carries steady real-time state where latency, not
|
||||
// throughput, is what matters -- exactly the case NODELAY is for.
|
||||
{
|
||||
BOOL noDelay = TRUE;
|
||||
if (setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (char*)&noDelay, sizeof(noDelay)))
|
||||
DEBUG_STREAM << "WARN: could not set TCP_NODELAY on active socket; setsockopt() failed with " << WSAGetLastError() << "\n" << std::flush;
|
||||
}
|
||||
return sock;
|
||||
}
|
||||
else if(connection_type == NETNUB_TCP_LISTEN)
|
||||
|
||||
+165
-8
@@ -669,6 +669,12 @@ struct BTPfxEmitter
|
||||
float emitAccum; // fractional particles owed (rate * dt integration)
|
||||
int issued;
|
||||
int active;
|
||||
// ATTACHED effects (the @004d097c per-zone dispatcher): the emitter rides
|
||||
// the owner entity's SEGMENT -- zone smoke follows the walking mech, and
|
||||
// StopAllEntityEffects (@004d0c14: the respawn cleanup) kills every
|
||||
// emitter tagged to the entity. ownerTag==0 = a free world effect.
|
||||
void *ownerTag;
|
||||
int followSeg;
|
||||
};
|
||||
struct BTPfxParticle
|
||||
{
|
||||
@@ -798,6 +804,8 @@ void BTStartPfxFrame(int effect_number, float x, float y, float z,
|
||||
e.emitAccum = 1.0f; // first particle immediately
|
||||
e.issued = 0;
|
||||
e.active = 1;
|
||||
e.ownerTag = 0;
|
||||
e.followSeg = -1;
|
||||
gBTPfxEmitters.push_back(e);
|
||||
}
|
||||
|
||||
@@ -806,6 +814,52 @@ void BTStartPfx(int effect_number, float x, float y, float z)
|
||||
BTStartPfxFrame(effect_number, x, y, z, 0, 0, 0);
|
||||
}
|
||||
|
||||
// ATTACHED effect start (the @004d097c per-zone dispatcher): like
|
||||
// BTStartPfxFrame, but the emitter rides the owner entity's segment -- the
|
||||
// per-frame sim re-resolves the segment's world transform through the
|
||||
// game-side bridge below, so a damage-band smoke plume TRAILS the walking
|
||||
// mech instead of hanging at the hit point. rows9 = the initial 3x3 basis
|
||||
// (rows = local axes in world), pos = the segment's current world position.
|
||||
extern int BTResolveSegmentWorld(void *entity, int seg_index,
|
||||
float *pos3, float *rows9); // mech4.cpp (game side)
|
||||
|
||||
void BTStartPfxAttached(int effect_number, void *owner, int seg_index,
|
||||
float x, float y, float z, const float *rows9)
|
||||
{
|
||||
if (effect_number < 0 || effect_number >= BT_PFX_SLOTS)
|
||||
return;
|
||||
const BTPfxDef &d = gBTPfxDefs[effect_number];
|
||||
if (!d.valid)
|
||||
return;
|
||||
if (gBTPfxEmitters.size() > 256)
|
||||
return;
|
||||
BTPfxEmitter e;
|
||||
e.def = &d;
|
||||
e.pos = D3DXVECTOR3(x, y, z);
|
||||
e.ax = rows9 ? D3DXVECTOR3(rows9[0], rows9[1], rows9[2]) : D3DXVECTOR3(1, 0, 0);
|
||||
e.ay = rows9 ? D3DXVECTOR3(rows9[3], rows9[4], rows9[5]) : D3DXVECTOR3(0, 1, 0);
|
||||
e.az = rows9 ? D3DXVECTOR3(rows9[6], rows9[7], rows9[8]) : D3DXVECTOR3(0, 0, 1);
|
||||
e.emitAccum = 1.0f;
|
||||
e.issued = 0;
|
||||
e.active = 1;
|
||||
e.ownerTag = owner;
|
||||
e.followSeg = seg_index;
|
||||
gBTPfxEmitters.push_back(e);
|
||||
}
|
||||
|
||||
// StopAllEntityEffects (@004d0c14 analog): the firmware killed every effect
|
||||
// instance tagged (playerIdx<<16 .. |0xffff); the port kills every emitter
|
||||
// tagged to the entity (the respawn cleanup -- a respawned mech must not
|
||||
// trail its corpse's zone smoke). Emitted particles fade out naturally.
|
||||
void BTStopEntityPfx(void *owner)
|
||||
{
|
||||
if (owner == 0)
|
||||
return;
|
||||
for (size_t i = 0; i < gBTPfxEmitters.size(); ++i)
|
||||
if (gBTPfxEmitters[i].ownerTag == owner)
|
||||
gBTPfxEmitters[i].active = 0;
|
||||
}
|
||||
|
||||
// Spawn a few particles of a slot's effect DIRECTLY at a moving point (no
|
||||
// emitter instance) -- the per-frame projectile SMOKE TRAIL (psfx 0 = dsrm,
|
||||
// "the lrm smoke trail": its velocities stream +Z = BEHIND the round, so the
|
||||
@@ -832,6 +886,7 @@ void BTPfxTrailPuff(int effect_number, float x, float y, float z,
|
||||
D3DXVECTOR3 ay; D3DXVec3Cross(&ay, &az, &ax);
|
||||
e.ax = ax; e.ay = ay; e.az = az;
|
||||
e.emitAccum = 0.0f; e.issued = 0; e.active = 0;
|
||||
e.ownerTag = 0; e.followSeg = -1;
|
||||
for (int i = 0; i < count; ++i)
|
||||
BTPfxSpawn(e);
|
||||
}
|
||||
@@ -1064,6 +1119,18 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
|
||||
{
|
||||
BTPfxEmitter &e = gBTPfxEmitters[ei];
|
||||
if (!e.active) continue;
|
||||
// attached emitters ride their owner's segment (@004d097c dispatcher)
|
||||
if (e.ownerTag != 0)
|
||||
{
|
||||
float p3[3], r9[9];
|
||||
if (BTResolveSegmentWorld(e.ownerTag, e.followSeg, p3, r9))
|
||||
{
|
||||
e.pos = D3DXVECTOR3(p3[0], p3[1], p3[2]);
|
||||
e.ax = D3DXVECTOR3(r9[0], r9[1], r9[2]);
|
||||
e.ay = D3DXVECTOR3(r9[3], r9[4], r9[5]);
|
||||
e.az = D3DXVECTOR3(r9[6], r9[7], r9[8]);
|
||||
}
|
||||
}
|
||||
const BTPfxDef &d = *e.def;
|
||||
// CONTINUOUS emission at `rate` particles/second until maximum_issue is
|
||||
// exhausted. Data-verified semantics: in EVERY shipped .PFX,
|
||||
@@ -4010,6 +4077,10 @@ void
|
||||
void
|
||||
DPLRenderer::SetFogStyle(FogStyle my_fog)
|
||||
{
|
||||
if (getenv("BT_FOG_LOG"))
|
||||
DEBUG_STREAM << "[fogstyle] SetFogStyle(" << (int)my_fog
|
||||
<< ") 0=update 1=noUpdate 2=searchLightOn 3=searchLightOff 4=winnersCircle\n"
|
||||
<< std::flush;
|
||||
//STUBBED: DPL RB 1/14/07
|
||||
switch(my_fog)
|
||||
{
|
||||
@@ -4630,6 +4701,11 @@ void
|
||||
if(master_notation_file->GetEntry(starting_page_name, "fog" ,&TempStringPtr))
|
||||
{
|
||||
sscanf(TempStringPtr, "%f %f %f %f %f", &fogNear, &fogFar, &fogRed, &fogGreen, &fogBlue);
|
||||
// BT (task #63): BT_FOG="near far r g b" overrides the AUTHORED fog for
|
||||
// tuning/demo (works on the main branch now, not just the no-fog fallback).
|
||||
if (const char *bf = getenv("BT_FOG"))
|
||||
if (!(bf[0]=='0' && bf[1]==0))
|
||||
sscanf(bf, "%f %f %f %f %f", &fogNear, &fogFar, &fogRed, &fogGreen, &fogBlue);
|
||||
searchLightFogRed = fogRed;
|
||||
searchLightFogGreen = fogGreen;
|
||||
searchLightFogBlue = fogBlue;
|
||||
@@ -4642,6 +4718,9 @@ void
|
||||
noSearchLightFogFar = fogFar;
|
||||
currentFogNear = fogNear;
|
||||
currentFogFar = fogFar;
|
||||
DEBUG_STREAM << "[fog] resolved page='" << starting_page_name << "' near=" << fogNear
|
||||
<< " far=" << fogFar << " rgb=(" << fogRed << "," << fogGreen << ","
|
||||
<< fogBlue << ")\n" << std::flush;
|
||||
// Force a 0-0 black fog on startup
|
||||
// dpl_SetViewFog(dplMainView, dpl_fog_type_pixel_lin, 0.0, 0.0, 0.0, 0.01, 0.05);
|
||||
//TODO: for fog testing, just set the values here
|
||||
@@ -4655,13 +4734,15 @@ void
|
||||
else
|
||||
{
|
||||
//
|
||||
// BT port (task #20): the SHIPPED maps define no fog entry at all (verified:
|
||||
// no 'fog' key in any .MAP source nor in BTL4.RES strings) -- so the world
|
||||
// edge and the void beyond are fully visible, which no pod player ever saw
|
||||
// (cockpit FOV + mission design kept views inward). Provide a period-
|
||||
// plausible default curtain: color matched to the horizon so geometry fades
|
||||
// into the void seamlessly. env BT_FOG="near far r g b" overrides;
|
||||
// BT_FOG=0 disables.
|
||||
// CORRECTION (task #63): task #20's claim "the SHIPPED maps define no fog"
|
||||
// was WRONG -- it looked in .MAP/.RES, but the arcade sourced fog from the
|
||||
// DPL env INI (L4DPLCFG = content/BTDPL.INI), which authors a fog= (usually
|
||||
// nosearchlightfog= too) key on EVERY map/time/weather leaf page. The main
|
||||
// (if) branch above handles the real authored fog; this else branch is only
|
||||
// reached by a page that genuinely lacks a fog= key (e.g. a *_default page
|
||||
// whose weather compare is commented out AND has no own fog). Keep a period-
|
||||
// plausible fallback curtain there so such a page still fades to the horizon;
|
||||
// BT_FOG="near far r g b" overrides, BT_FOG=0 disables.
|
||||
//
|
||||
float fN = 150.0f, fF = 520.0f, fR = 0.44f, fG = 0.44f, fB = 0.62f;
|
||||
int fogOn = 1;
|
||||
@@ -5100,7 +5181,11 @@ void
|
||||
}
|
||||
else if(strcmp(target_string,"weather") == 0)
|
||||
{
|
||||
compare_strings[token_count] = mission->GetMissionWeather();
|
||||
// BT (task #63): BT_WEATHER overrides the egg's authored weather so
|
||||
// clear/fog/soup fog curtains can be A/B'd without re-authoring eggs
|
||||
// (the shipped eggs pin weather=clear). Authentic default = the egg.
|
||||
const char *wov = getenv("BT_WEATHER");
|
||||
compare_strings[token_count] = (wov && *wov) ? wov : mission->GetMissionWeather();
|
||||
// std::cout<<target_string<<" = "<<compare_strings[token_count]<<"\n";
|
||||
}
|
||||
else if(strcmp(target_string,"scenario") == 0)
|
||||
@@ -8124,6 +8209,47 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
|
||||
}
|
||||
mDevice->SetRenderState(D3DRS_FOGSTART, *((DWORD*)(¤tFogNear)));
|
||||
mDevice->SetRenderState(D3DRS_FOGEND, *((DWORD*)(¤tFogFar)));
|
||||
// BT (task #63): the arcade fog was EYE-SPACE linear (dpl_fog_type_pixel_lin).
|
||||
// The port left D3DRS_FOGTABLEMODE = D3DFOG_LINEAR (table/PIXEL fog), whose fog
|
||||
// factor comes from the perspective-NONLINEAR z-buffer -- and without a WFOG cap
|
||||
// FOGSTART/END are z-buffer [0,1] units, so the authored 90..1100 range gave a
|
||||
// fog factor of ~1 (NO fog) across the entire scene: fog was wired but invisible.
|
||||
// Use VERTEX fog: it is computed per-vertex in EYE SPACE, so FOGSTART/END are the
|
||||
// authored world near/far and the haze curtain renders across the real range.
|
||||
// This is the world-geometry pass (SHOULD be fogged; sky/cockpit passes manage
|
||||
// their own). BT_FOGMODE=table = the old z-based mode (A/B); BT_FOGMODE=off.
|
||||
{
|
||||
static const char *s_fm = getenv("BT_FOGMODE");
|
||||
static const int s_fOff = (s_fm && strcmp(s_fm, "off") == 0) ? 1 : 0;
|
||||
// Fog MODEL. The arcade used per-PIXEL linear fog (dpl_fog_type_pixel_lin) --
|
||||
// smooth. D3D fixed-function per-pixel (TABLE) fog is only eye-space (correct
|
||||
// over the authored near/far) when the GPU exposes D3DPRASTERCAPS_WFOG; without
|
||||
// it, table fog reads the NONLINEAR z-buffer and the 90..1100 range collapses
|
||||
// to no fog. Auto-detect: WFOG -> table (smooth, faithful); else VERTEX fog
|
||||
// (eye-space but per-vertex -> splotchy on coarse terrain tris). s_mode:
|
||||
// 1=table, 0=vertex. BT_FOGMODE=table|vertex|off forces.
|
||||
static int s_mode = -1;
|
||||
if (s_mode < 0)
|
||||
{
|
||||
if (s_fm && strcmp(s_fm, "table") == 0) s_mode = 1;
|
||||
else if (s_fm && strcmp(s_fm, "vertex") == 0) s_mode = 0;
|
||||
else
|
||||
{
|
||||
D3DCAPS9 caps; memset(&caps, 0, sizeof(caps));
|
||||
s_mode = (SUCCEEDED(mDevice->GetDeviceCaps(&caps))
|
||||
&& (caps.RasterCaps & D3DPRASTERCAPS_WFOG)) ? 1 : 0;
|
||||
}
|
||||
DEBUG_STREAM << "[fog] model=" << (s_mode ? "table/WFOG (per-pixel, smooth)"
|
||||
: "vertex (per-vertex; splotchy on coarse tris)")
|
||||
<< " (BT_FOGMODE=table|vertex|off overrides)\n" << std::flush;
|
||||
}
|
||||
mDevice->SetRenderState(D3DRS_FOGENABLE, s_fOff ? FALSE : TRUE);
|
||||
if (!s_fOff)
|
||||
{
|
||||
mDevice->SetRenderState(D3DRS_FOGVERTEXMODE, s_mode ? D3DFOG_NONE : D3DFOG_LINEAR);
|
||||
mDevice->SetRenderState(D3DRS_FOGTABLEMODE, s_mode ? D3DFOG_LINEAR : D3DFOG_NONE);
|
||||
}
|
||||
}
|
||||
mDevice->SetRenderState(D3DRS_ZWRITEENABLE, true);
|
||||
mDevice->SetRenderState(D3DRS_ALPHABLENDENABLE, false);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1);
|
||||
@@ -8451,6 +8577,37 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
|
||||
const double presentMs = (double)(_rt2.QuadPart - _rt1.QuadPart) * 1000.0 / (double)_rf.QuadPart;
|
||||
static double sAcc = 0.0, sMaxD = 0.0, sMaxP = 0.0; static int sFrames = 0;
|
||||
sAcc += drawMs + presentMs; ++sFrames;
|
||||
// RENDER-frame heading probe (BT_RENDHDG): count render frames that redraw
|
||||
// the SAME peer heading (stale between sim updates) vs frames where it moved.
|
||||
// sameHeading >> moved => the peer sim updates slower than the render draws,
|
||||
// so the rotation stutters regardless of how smooth each sim step is.
|
||||
if (getenv("BT_RENDHDG"))
|
||||
{
|
||||
extern volatile float gBTReplRenderYaw;
|
||||
static float sPrevY = -999.0f, sMaxStep = 0.0f, sSumStep = 0.0f;
|
||||
static int sN = 0, sBack = 0; static double sHAcc = 0.0;
|
||||
const float y = gBTReplRenderYaw;
|
||||
if (sPrevY > -900.0f)
|
||||
{
|
||||
float d = y - sPrevY; // per-RENDER-frame heading change
|
||||
if (d > 3.14159f) d -= 6.28319f; // unwrap
|
||||
if (d < -3.14159f) d += 6.28319f;
|
||||
const float ad = (d < 0.0f) ? -d : d;
|
||||
sSumStep += ad; if (ad > sMaxStep) sMaxStep = ad;
|
||||
if (d > 0.0f) ++sBack; // spin is -yaw here; +step == backward
|
||||
++sN;
|
||||
}
|
||||
sPrevY = y; sHAcc += drawMs + presentMs;
|
||||
if (sHAcc >= 1000.0 && sN > 0)
|
||||
{
|
||||
const float avg = sSumStep / sN;
|
||||
DEBUG_STREAM << "[rendhdg] avgStep=" << avg << " maxStep=" << sMaxStep
|
||||
<< " max/avg=" << (avg > 1e-6f ? sMaxStep / avg : 0.0f)
|
||||
<< " backwardFrames=" << sBack << "/" << sN
|
||||
<< " (smooth: max/avg~1, backward~0)\n" << std::flush;
|
||||
sMaxStep = 0.0f; sSumStep = 0.0f; sN = 0; sBack = 0; sHAcc = 0.0;
|
||||
}
|
||||
}
|
||||
if (drawMs > sMaxD) sMaxD = drawMs;
|
||||
if (presentMs > sMaxP) sMaxP = presentMs;
|
||||
if (drawMs + presentMs > 150.0)
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
// (Authentic path = the gyro-driven eye-joint DCS chain -- deferred.)
|
||||
float gBTEyeBobY = 0.0f;
|
||||
float gBTEyeSwayX = 0.0f; // lateral weight-shift (the walk "swagger"), same source
|
||||
// task #58: the player torso's live twist (rad), published by mech4.cpp's HUD
|
||||
// tick. NOT consumed by the eye anymore -- the cockpit eye inherits the twist
|
||||
// through the jointtorso HingeRenderable in the draw traversal (see the note in
|
||||
// DPLEyeRenderable::Execute). Kept published for diagnostics/correlation.
|
||||
float gBTEyeTwist = 0.0f;
|
||||
#pragma hdrstop
|
||||
|
||||
#include "l4vidrnd.h"
|
||||
@@ -1340,6 +1345,21 @@ void
|
||||
//
|
||||
if(oldHinge != *myHinge)
|
||||
{
|
||||
// task #58 probe (BT_HINGE_LOG): which hinge renderables actually see
|
||||
// their joint move (standing + BT_FORCE_TORSO => only the torso hinge
|
||||
// and any doors change). kids = child renderables that would inherit
|
||||
// the rotation through the stack.
|
||||
{
|
||||
static const int s_hl = getenv("BT_HINGE_LOG") ? 1 : 0;
|
||||
static int s_hln = 0;
|
||||
if (s_hl && (s_hln++ % 60) == 0)
|
||||
DEBUG_STREAM << "[hinge] this=" << (void*)this
|
||||
<< " hinge=" << (void*)myHinge
|
||||
<< " rot=" << (float)(Radian)(myHinge->rotationAmount)
|
||||
<< " kids=" << (int)(End() - Enumerate())
|
||||
<< " par=" << (void*)GetParentProbe()
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
oldHinge = *myHinge;
|
||||
hingeOffsetMatrix.BuildIdentity();
|
||||
//#if SINGLE_AXIS_HINGE
|
||||
@@ -5604,6 +5624,24 @@ void
|
||||
// that assumed +Z=forward/+Y=up (which mis-framed the cockpit); the look/up axes now
|
||||
// fall out of the eye's own basis. pos/at/up above are kept only for the aim-boresight.
|
||||
D3DXMATRIX eyeW = mat4.ToD3DMatrix();
|
||||
// task #58 NOTE (torso twist vs the camera): the COCKPIT eye needs
|
||||
// NO explicit twist compose -- it executes inside the mech's draw
|
||||
// traversal under jointtorso's HingeRenderable (Torso::PushTwist ->
|
||||
// Joint::SetRotation -> hinge rotationAmount; HingeRenderable
|
||||
// multiplies the live hinge into the matrix stack), so `prev` above
|
||||
// already carries the twist and the view yaws authentically with
|
||||
// the canopy. VERIFIED live: with the real jointtorso swept, the
|
||||
// active cockpit [eyefwd] swings ~60 deg and the torso hinge sits
|
||||
// in the eye's parent chain (kids=2). An explicit
|
||||
// W' = W*E^-1*R_y(twist)*E compose was briefly added here and
|
||||
// DOUBLE-ROTATED the cockpit view (2x view vs 1x canopy: "the HUD
|
||||
// passes through the cockpit frame") and swung the CHASE camera
|
||||
// (parented on the root, same class) with the twist ("torso fixed,
|
||||
// legs twisting"). Both "eye is frozen" measurements that motivated
|
||||
// it were instrumentation artifacts: (1) the chase eye was the
|
||||
// active camera in the headless default view; (2) BT_FORCE_TORSO
|
||||
// used to redirect the sweep into the SHADOW hinge (see the hook's
|
||||
// trap note in torso.cpp).
|
||||
D3DXMATRIX view;
|
||||
D3DXMatrixInverse(&view, NULL, &eyeW);
|
||||
|
||||
@@ -5618,6 +5656,30 @@ void
|
||||
D3DXMatrixInverse(&view, NULL, &eyeW);
|
||||
}
|
||||
}
|
||||
// TWIST-FOLLOW PROBE (task #58, BT_EYE_LOG): does the ACTIVE eye's
|
||||
// forward yaw with the torso twist? Correlate with [torso] twist.
|
||||
// Also print the STACK TOP the eye composed with (prev) -- its
|
||||
// translation tells whether the eye executed inside the full
|
||||
// parent-chain traversal (t ~= eye rest position) or with a bare
|
||||
// stack (t == entity origin / zero).
|
||||
{
|
||||
static const int s_eyeLog = getenv("BT_EYE_LOG") ? 1 : 0;
|
||||
static int s_eyeLogN = 0;
|
||||
if (s_eyeLog && myRenderer->mCamera == this && (s_eyeLogN++ % 120) == 0)
|
||||
{
|
||||
DEBUG_STREAM << "[eyefwd] this=" << (void*)this
|
||||
<< " fwd=(" << eyeFwdW.x << "," << eyeFwdW.y
|
||||
<< "," << eyeFwdW.z << ") stackT=(" << prev(3,0) << ","
|
||||
<< prev(3,1) << "," << prev(3,2) << ") chain:";
|
||||
HierarchicalDrawComponent *p = GetParentProbe();
|
||||
for (int pc = 0; p != NULL && pc < 8; ++pc)
|
||||
{
|
||||
DEBUG_STREAM << " " << (void*)p;
|
||||
p = p->GetParentProbe();
|
||||
}
|
||||
DEBUG_STREAM << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
if (dbgEyeExec < 8)
|
||||
{
|
||||
DEBUG_STREAM << "[EYECHK] eyeW=(" << eyeW._41 << "," << eyeW._42 << "," << eyeW._43
|
||||
@@ -5650,9 +5712,10 @@ void
|
||||
// on a slope (body pitched ~8 deg to conform to the ground) aim
|
||||
// its pick ray INTO the ground, short of a distant target
|
||||
// (mechPicks=0). Level the boresight: drop the view direction's
|
||||
// pitch and rebuild an upright basis (world +Y up). The reticle
|
||||
// X still carries the torso twist (BTTwistToReticleX); the reticle
|
||||
// Y carries any aim elevation. Falls back to the raw basis only
|
||||
// pitch and rebuild an upright basis (world +Y up). The torso
|
||||
// twist rides IN eyeFwdW now (task #58 eye compose above) --
|
||||
// yaw survives the leveling, so the centered crosshair's pick
|
||||
// ray follows the twist. Falls back to the raw basis only
|
||||
// if the view is (degenerately) near-vertical.
|
||||
// Derive the boresight from the AUTHORITATIVE view matrix
|
||||
// (eyeFwdW = the world look direction from the inverse view),
|
||||
|
||||
@@ -69,6 +69,9 @@ public:
|
||||
|
||||
virtual bool IsStatic() { return false; }
|
||||
|
||||
// task #58 diagnostics: expose the parent link so probes can walk the tree.
|
||||
HierarchicalDrawComponent *GetParentProbe() const { return m_parent; }
|
||||
|
||||
d3d_OBJECT *GetDrawObj() { return this->graphicalObject; }
|
||||
// Swap this component's drawable in place. Execute() re-reads graphicalObject
|
||||
// every frame, so changing it makes the segment draw a different mesh next
|
||||
|
||||
@@ -185,6 +185,17 @@ int BTAmmoBinFeeding(void *bin)
|
||||
return (bin != 0 && ((AmmoBin *)bin)->ammoAlarm.Level() == AmmoBin::Feeding) ? 1 : 0;
|
||||
}
|
||||
|
||||
// The round's GameModel resource ID (ammoModelFile @0x1E8, word 0x7A). In the
|
||||
// arcade the MissileLauncher seeds each spawned Missile's model from AmmoBin+0x1e8
|
||||
// (part_013.c:8778) and the Missile ctor reads SplashRadius from that model's
|
||||
// GameModel record +0x50 (part_013.c:10184). The port re-expresses missiles as
|
||||
// local flying rounds, so BTResolveSplashRadius (mech4.cpp) walks THIS path to
|
||||
// recover the authored blast radius. Complete-type read of the named member.
|
||||
int BTAmmoRoundModelResource(void *bin)
|
||||
{
|
||||
return (bin != 0) ? ((AmmoBin *)bin)->ammoModelFile : 0;
|
||||
}
|
||||
|
||||
int AmmoBin::FeedAmmo()
|
||||
{
|
||||
if (simulationState == 1) // this+0x40 == 1 (destroyed)
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
friend struct AmmoBinLayoutCheck; // compile-time offset locks (ammobin.cpp)
|
||||
friend int *BTAmmoBinCountPtr(void *bin); // panel ammo counter bridge (Streak fix)
|
||||
friend int BTAmmoBinFeeding(void *bin); // panel reload-state bridge
|
||||
friend int BTAmmoRoundModelResource(void *bin); // splash-radius resolve (round GameModel +0x50)
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Shared Data Support
|
||||
//
|
||||
|
||||
@@ -1411,6 +1411,21 @@ void SubsystemCluster::Execute()
|
||||
failedState = (BTSubsystemDamageLevelOf(subsystem) >= 1.0f);
|
||||
}
|
||||
|
||||
if (getenv("BT_TEMPBAR_LOG"))
|
||||
{
|
||||
Scalar *t = (Scalar *)AttributePointerOf(subsystem, "CurrentTemperature");
|
||||
Scalar *dg = (Scalar *)AttributePointerOf(subsystem, "DegradationTemperature");
|
||||
Scalar *fl = (Scalar *)AttributePointerOf(subsystem, "FailureTemperature");
|
||||
static int s_tbN = 0;
|
||||
if (t != 0 && ((++s_tbN) % 30) == 0)
|
||||
DEBUG_STREAM << "[tempbar] cluster EXECUTING "
|
||||
<< (subsystem ? ((Subsystem *)subsystem)->GetName() : "?")
|
||||
<< " T=" << *t
|
||||
<< " degT=" << (dg ? *dg : -1.0f)
|
||||
<< " failT=" << (fl ? *fl : -1.0f)
|
||||
<< " drawState=" << GetDrawState() << "\n" << std::flush;
|
||||
}
|
||||
|
||||
int state = GetDrawState();
|
||||
if (state != previousDrawState)
|
||||
{
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
// FUN_0044a5b4/5dc GraphicsViewRecord ctor/dtor
|
||||
// FUN_0044a650/630 GraphicsViewRecord Erase / Reset
|
||||
// DAT_00524e20 DebugStream (warning channel)
|
||||
// 0x50e3ec / 0x50e604 heat-subsystem class-derivation tables
|
||||
// 0x50e3ec / 0x50e604 heat class-derivation tags (HeatableSubsystem /
|
||||
// HEATWATCHER -- task #57 relabel; was "HeatSink")
|
||||
//
|
||||
// GraphicsView vtable slots used below (this+0x48):
|
||||
// +0x08 SetExtent +0x10 SetOrigin +0x18 SetColor +0x24 MoveTo
|
||||
@@ -363,9 +364,12 @@ void
|
||||
//
|
||||
// HeatConnection -- @004c3664 ctor / @004c3720 Transfer.
|
||||
// ctor: source = the heat subsystem; flag@0x14 records whether it is the
|
||||
// "primary" heat class (IsDerivedFrom 0x50e3ec) vs. the alternate (0x50e604);
|
||||
// destination@0x18. Transfer: no subsystem -> 100 ; subsystem operational
|
||||
// (+0x40 == 1) -> 100 ; else Round( Resolve(subsys+0x114 currentTemperature) ).
|
||||
// "primary" heat class (IsDerivedFrom 0x50e3ec HeatableSubsystem) vs. the
|
||||
// alternate (0x50e604 = HEATWATCHER, task #57 relabel); destination@0x18.
|
||||
// Transfer: no subsystem -> 100 ; subsystem operational (+0x40 == 1) -> 100 ;
|
||||
// else Round( Resolve(subsys+0x114) ) -- NB for a Heatable that offset is
|
||||
// currentTemperature, for a WATCHER it is watchedLink (the flag@0x14 likely
|
||||
// picks the read; the watcher path is unverified [T4]).
|
||||
// Used by ColorMapperHeat -- THIS is the heat gauge's data feed.
|
||||
//
|
||||
// All Transfer bodies are reproduced inside the owning gauge's notes; the
|
||||
@@ -925,8 +929,13 @@ ColorMapperHeat::ColorMapperHeat(
|
||||
// The subsystem MUST be heat-bearing (one of the two heat class
|
||||
// derivation tables) or the data feed is meaningless.
|
||||
//
|
||||
extern int BTIsHeatWatcher(Subsystem *sub); // heatfamily_reslice.cpp bridge
|
||||
if (!subsystem->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()) && // FUN_0041a1a4 (0x50e3ec)
|
||||
!subsystem->IsDerivedFrom(*HeatSink::GetClassDerivations())) // (0x50e604)
|
||||
!BTIsHeatWatcher(subsystem)) // (0x50e604 = HEATWATCHER -- task #57 relabel:
|
||||
// the old "HeatSink" reading was redundant after
|
||||
// the HeatableSubsystem test; the binary's
|
||||
// alternate branch is the disjoint WATCHER family
|
||||
// (ammo bins / torso carry a mirrored heatAlarm))
|
||||
{
|
||||
Verify(False); // FUN_0040385c -- "Bad subsystem type" BTL4GAUG.CPP:0x68a
|
||||
}
|
||||
|
||||
@@ -203,7 +203,8 @@
|
||||
// ColorMapperHeat -- the cockpit heat tint. ANCHOR @004c3f6c.
|
||||
// Connection: @004c3664 (reads HeatableSubsystem::currentTemperature@0x114
|
||||
// as a 0..100 percentage). The named subsystem must derive from a heat
|
||||
// class (IsDerivedFrom 0x50e3ec or 0x50e604) or the ctor asserts (line 0x68a).
|
||||
// class (IsDerivedFrom 0x50e3ec HeatableSubsystem or 0x50e604 HEATWATCHER
|
||||
// -- task #57 relabel) or the ctor asserts (line 0x68a).
|
||||
//
|
||||
class ColorMapperHeat :
|
||||
public ColorMapper
|
||||
|
||||
@@ -349,7 +349,12 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
//
|
||||
// (1) Full-throttle detent.
|
||||
//
|
||||
if (Abs(throttlePosition - 1.0f) <= 0.05f) // _DAT_004d1ac0 / _DAT_004d1ac4
|
||||
// NB: the Abs() macro (STYLE.H:118) is unparenthesized -- Abs(throttlePosition
|
||||
// - 1.0f) mis-expands to -(throttlePosition + 1.0f) on the false branch (always,
|
||||
// since throttle <= 1), which is <= 0.05 UNCONDITIONALLY -> the detent used to
|
||||
// snap throttle to full every frame. Diff into a temp so the macro sees one token.
|
||||
const Scalar _thrOff = throttlePosition - 1.0f;
|
||||
if (((_thrOff < 0.0f) ? -_thrOff : _thrOff) <= 0.05f) // _DAT_004d1ac0 / _DAT_004d1ac4
|
||||
{
|
||||
throttlePosition = 1.0f; // @0x11c
|
||||
}
|
||||
|
||||
@@ -697,9 +697,16 @@ void
|
||||
// Top-down: keep heading only. Extract the yaw, build a
|
||||
// yaw-only quaternion, SET the rotation block from it.
|
||||
//
|
||||
EulerAngles euler;
|
||||
euler = *currentAngularPointer; // FUN_0040954c
|
||||
Scalar heading = euler.yaw * HeadingHalf; // * 0.5f
|
||||
YawPitchRoll euler; // FIX: YawPitchRoll (yaw-first),
|
||||
euler = *currentAngularPointer; // not EulerAngles -- the latter folds
|
||||
Scalar heading = (Scalar)euler.yaw * HeadingHalf; // at +/-pi under yaw. * 0.5f half-angle
|
||||
if (getenv("BT_RADAR_LOG")) {
|
||||
EulerAngles oldE; oldE = *currentAngularPointer; // old buggy path, A/B
|
||||
static int s_rv = 0;
|
||||
if ((s_rv++ % 20) == 0)
|
||||
DEBUG_STREAM << "[radar-view] euler.yaw=" << (Scalar)oldE.yaw
|
||||
<< " ypr.yaw=" << (Scalar)euler.yaw << " (rad)\n" << std::flush;
|
||||
}
|
||||
SinCosPair sc;
|
||||
sc = Radian(heading); // FUN_00408328
|
||||
Quaternion yaw(0.0f, sc.sine, 0.0f, sc.cosine); // FUN_00409948
|
||||
@@ -965,12 +972,25 @@ void
|
||||
Scalar sinH = 0.0f, cosH = 1.0f;
|
||||
if (currentAngularPointer != NULL)
|
||||
{
|
||||
EulerAngles e;
|
||||
e = *currentAngularPointer; // Quaternion -> EulerAngles (operator=)
|
||||
// FIX (user-reported: radar rotation flops every 180deg): the MUNGA
|
||||
// EulerAngles(Quaternion) decomposition is AMBIGUOUS for a yawing mech --
|
||||
// as yaw sweeps past +/-pi the quaternion double-cover flips it onto a
|
||||
// pitch=roll=pi branch, so euler.yaw REVERSES (the radar spins back).
|
||||
// YawPitchRoll applies yaw FIRST -> a clean continuous 360deg heading.
|
||||
// Same fix the compass HeadingPointer already uses (btl4gaug.cpp:2185).
|
||||
YawPitchRoll ypr;
|
||||
ypr = *currentAngularPointer;
|
||||
SinCosPair scH;
|
||||
scH = Radian(e.yaw); // FUN_00408328
|
||||
scH = Radian((Scalar)ypr.yaw);
|
||||
sinH = scH.sine;
|
||||
cosH = scH.cosine;
|
||||
if (getenv("BT_RADAR_LOG")) {
|
||||
EulerAngles e; e = *currentAngularPointer; // old (buggy) path, for A/B
|
||||
static int s_rn = 0;
|
||||
if ((s_rn++ % 20) == 0)
|
||||
DEBUG_STREAM << "[radar] euler.yaw=" << (Scalar)e.yaw
|
||||
<< " ypr.yaw=" << (Scalar)ypr.yaw << " (rad)\n" << std::flush;
|
||||
}
|
||||
}
|
||||
Scalar rx = delta.x * cosH + delta.z * sinH;
|
||||
Scalar rz = -delta.x * sinH + delta.z * cosH;
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
#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
|
||||
@@ -224,9 +227,43 @@ void
|
||||
// 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
|
||||
&& entity->IsDerivedFrom(*Terrain::GetClassDerivations()))
|
||||
gBTTerrainEntity = entity;
|
||||
|| !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);
|
||||
@@ -368,6 +405,22 @@ HierarchicalDrawComponent*
|
||||
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.
|
||||
@@ -866,6 +919,116 @@ void BTRebuildMechModel(Entity *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)
|
||||
//#############################################################################
|
||||
|
||||
@@ -623,6 +623,26 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
||||
Logical
|
||||
TestInstance() const;
|
||||
|
||||
//
|
||||
// The per-zone EFFECT DISPATCHER (@004d097c, coverage-audit discovery
|
||||
// 2026-07-13) -- the missing target of the engine's whole authentic
|
||||
// StartEntityEffect chain (MechDeathHandler class-5 -> RendererManager
|
||||
// -> Renderer::StartEntityEffectMessageHandler [T0, applies the
|
||||
// ExplosionResourceTable graphic-state remap] -> THIS virtual).
|
||||
// Resolves the zone's SEGMENT socket, walks the effect resource's
|
||||
// VideoModel (type 10), and starts each >=1000 effect ATTACHED to the
|
||||
// segment (the smoke follows the walking mech); <1000 warns
|
||||
// (byte-faithful to the binary's gate). StopAll kills every effect
|
||||
// tagged to the entity (the respawn cleanup, @004d0c14 analog).
|
||||
//
|
||||
virtual void
|
||||
StartEntityEffectImplementation(
|
||||
Entity *entity,
|
||||
DamageZone *damage_zone,
|
||||
ResourceDescription::ResourceID resource_ID); // @004d097c
|
||||
virtual void
|
||||
StopAllEntityEffectsImplementation(Entity *entity); // @004d0c14
|
||||
|
||||
//
|
||||
// Material substitution (mirrors RPL4VideoRenderer).
|
||||
//
|
||||
|
||||
+111
-61
@@ -937,79 +937,111 @@ void
|
||||
if (enemy_res)
|
||||
{
|
||||
enemy_res->Lock();
|
||||
const ResourceDescription::ResourceID enemyResID = enemy_res->resourceID;
|
||||
|
||||
// Place it ahead of the player's drop ALONG THE SPAWN FACING (the mech
|
||||
// COUNT: BT_SPAWN_ENEMY is read as a count -- "1" (or any truthy value)
|
||||
// drops one dummy; "2"+ CLUSTERS extra dummies laterally so a bystander
|
||||
// sits within a missile's SplashRadius (30) of the primary -- the rig for
|
||||
// eyeballing splash damage (task #62): shoot ONE, watch the OTHER take
|
||||
// collateral. Clamped to a sane range.
|
||||
int enemyCount = atoi(getenv("BT_SPAWN_ENEMY"));
|
||||
if (enemyCount < 1) enemyCount = 1; // non-numeric truthy -> 1
|
||||
if (enemyCount > 8) enemyCount = 8;
|
||||
|
||||
// Base origin: 120 ahead of the player ALONG THE SPAWN FACING (the mech
|
||||
// faces local -Z; rotate that axis into world by the spawn orientation).
|
||||
// The old fixed "z -= 120" assumed the bring-up drive's forced heading=0;
|
||||
// the real-controls drive keeps the authentic spawn orientation, so the
|
||||
// dummy must follow the actual facing or the auto-walk leaves it behind.
|
||||
Origin enemy_origin = mech_location;
|
||||
Origin base_origin = mech_location;
|
||||
UnitVector spawnZ;
|
||||
{
|
||||
AffineMatrix facing;
|
||||
facing.BuildIdentity();
|
||||
facing = mech_location.angularPosition; // rotation from the spawn pose
|
||||
UnitVector spawnZ;
|
||||
facing.GetFromAxis(Z_Axis, &spawnZ); // local Z basis in world
|
||||
enemy_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z
|
||||
enemy_origin.linearPosition.y -= spawnZ.y * 120.0f;
|
||||
enemy_origin.linearPosition.z -= spawnZ.z * 120.0f;
|
||||
base_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z
|
||||
base_origin.linearPosition.y -= spawnZ.y * 120.0f;
|
||||
base_origin.linearPosition.z -= spawnZ.z * 120.0f;
|
||||
// FACE THE PLAYER: copying the spawn pose left BOTH mechs facing
|
||||
// the same way (the player stared at the enemy's back). Flip the
|
||||
// enemy's yaw 180 deg about Y so the two face each other. Engine
|
||||
// yaw convention (MATRIX.cpp:196-209): the Z basis = (sin y, 0,
|
||||
// cos y) -> yaw = atan2(z.x, z.z); enemy yaw = that + pi.
|
||||
Scalar spawnYaw = (Scalar)atan2((double)spawnZ.x, (double)spawnZ.z);
|
||||
enemy_origin.angularPosition =
|
||||
base_origin.angularPosition =
|
||||
EulerAngles(0.0f, spawnYaw + 3.14159265f, 0.0f);
|
||||
}
|
||||
|
||||
Mech::MakeMessage
|
||||
create_enemy(
|
||||
Mech::MakeMessageID,
|
||||
sizeof(Mech::MakeMessage),
|
||||
host_manager->MakeUniqueEntityID(),
|
||||
(Entity::ClassID)Mech::MechClassID,
|
||||
EntityID::Null,
|
||||
enemy_res->resourceID,
|
||||
Mech::DefaultFlags,
|
||||
enemy_origin,
|
||||
Motion::Identity,
|
||||
Motion::Identity,
|
||||
playerMission->GetBadgeName(),
|
||||
playerMission->GetColorName(),
|
||||
((BTMission *)playerMission)->GetPatchName());
|
||||
|
||||
enemy_res->Unlock();
|
||||
|
||||
Mech *enemy = Mech::Make(&create_enemy);
|
||||
if (enemy)
|
||||
// Right vector in the ground plane (perp to the forward XZ): cluster the
|
||||
// extra dummies sideways so a hit on the primary catches the bystander.
|
||||
Vector3D rightv(spawnZ.z, 0.0f, -spawnZ.x);
|
||||
{
|
||||
Register_Object(enemy);
|
||||
// Mark it a VALID master so Entity::Dispatch delivers messages
|
||||
// SYNCHRONOUSLY (Receiver::Receive) instead of posting them as deferred
|
||||
// "entity invalid" events that never fire -- otherwise TakeDamage never
|
||||
// lands. (The player gets validated via MakeViewpointEntity's CheckLoad
|
||||
// handshake; a manually-spawned entity must set it itself. Its resources
|
||||
// are already loaded -- it renders + its zones are built.) Also force
|
||||
// pre-run + interest so it ticks like a live entity.
|
||||
enemy->SetValidFlag();
|
||||
enemy->SetPreRunFlag();
|
||||
if (enemy->interestCount == 0) enemy->interestCount = 1;
|
||||
// GROUND MODEL (task #15): a MASTER mech needs a CollisionAssistant
|
||||
// for GetCurrentCollisions (the engine iterates it unchecked,
|
||||
// MOVER.cpp:894). The player gets one in MakeViewpointEntity
|
||||
// (btl4app.cpp:591); this dummy is a master too, so start its own
|
||||
// -- without it the authentic ground block skips its collision half.
|
||||
enemy->StartCollisionAssistant();
|
||||
gEnemyMech = enemy; // the player's targeting step locks onto this
|
||||
DEBUG_STREAM << "[enemy] spawned target mech at ("
|
||||
<< enemy_origin.linearPosition.x << ", "
|
||||
<< enemy_origin.linearPosition.y << ", "
|
||||
<< enemy_origin.linearPosition.z << ")\n" << std::flush;
|
||||
Scalar rl = (Scalar)sqrt((double)(rightv.x*rightv.x + rightv.z*rightv.z));
|
||||
if (rl < 1e-4f) { rightv.x = 1.0f; rightv.z = 0.0f; }
|
||||
else { rightv.x /= rl; rightv.z /= rl; }
|
||||
}
|
||||
else
|
||||
|
||||
for (int ei = 0; ei < enemyCount; ++ei)
|
||||
{
|
||||
DEBUG_STREAM << "[enemy] Mech::Make returned NULL\n" << std::flush;
|
||||
Origin enemy_origin = base_origin;
|
||||
// #0 at the primary spot; extras step +/-25u (inside SplashRadius 30).
|
||||
Scalar off = (ei == 0) ? 0.0f
|
||||
: 25.0f * (Scalar)(((ei + 1) / 2)) * ((ei & 1) ? 1.0f : -1.0f);
|
||||
enemy_origin.linearPosition.x += rightv.x * off;
|
||||
enemy_origin.linearPosition.z += rightv.z * off;
|
||||
|
||||
Mech::MakeMessage
|
||||
create_enemy(
|
||||
Mech::MakeMessageID,
|
||||
sizeof(Mech::MakeMessage),
|
||||
host_manager->MakeUniqueEntityID(),
|
||||
(Entity::ClassID)Mech::MechClassID,
|
||||
EntityID::Null,
|
||||
enemyResID,
|
||||
Mech::DefaultFlags,
|
||||
enemy_origin,
|
||||
Motion::Identity,
|
||||
Motion::Identity,
|
||||
playerMission->GetBadgeName(),
|
||||
playerMission->GetColorName(),
|
||||
((BTMission *)playerMission)->GetPatchName());
|
||||
|
||||
Mech *enemy = Mech::Make(&create_enemy);
|
||||
if (enemy)
|
||||
{
|
||||
Register_Object(enemy);
|
||||
// Mark it a VALID master so Entity::Dispatch delivers messages
|
||||
// SYNCHRONOUSLY (Receiver::Receive) instead of posting them as deferred
|
||||
// "entity invalid" events that never fire -- otherwise TakeDamage never
|
||||
// lands. (The player gets validated via MakeViewpointEntity's CheckLoad
|
||||
// handshake; a manually-spawned entity must set it itself. Its resources
|
||||
// are already loaded -- it renders + its zones are built.) Also force
|
||||
// pre-run + interest so it ticks like a live entity.
|
||||
enemy->SetValidFlag();
|
||||
enemy->SetPreRunFlag();
|
||||
if (enemy->interestCount == 0) enemy->interestCount = 1;
|
||||
// GROUND MODEL (task #15): a MASTER mech needs a CollisionAssistant
|
||||
// for GetCurrentCollisions (the engine iterates it unchecked,
|
||||
// MOVER.cpp:894). The player gets one in MakeViewpointEntity
|
||||
// (btl4app.cpp:591); this dummy is a master too, so start its own
|
||||
// -- without it the authentic ground block skips its collision half.
|
||||
enemy->StartCollisionAssistant();
|
||||
if (ei == 0)
|
||||
gEnemyMech = enemy; // the player's targeting step locks onto the primary
|
||||
DEBUG_STREAM << "[enemy] spawned dummy #" << ei
|
||||
<< " id=" << enemy->GetEntityID()
|
||||
<< (ei == 0 ? " (PRIMARY/direct-target)" : " (bystander/splash)")
|
||||
<< " at ("
|
||||
<< enemy_origin.linearPosition.x << ", "
|
||||
<< enemy_origin.linearPosition.y << ", "
|
||||
<< enemy_origin.linearPosition.z << ")\n" << std::flush;
|
||||
}
|
||||
else
|
||||
{
|
||||
DEBUG_STREAM << "[enemy] Mech::Make returned NULL (#" << ei << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -1494,12 +1526,31 @@ void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP deat
|
||||
{
|
||||
return;
|
||||
}
|
||||
BTPlayer *local_player = (BTPlayer *)application->GetMissionPlayer();
|
||||
if (local_player != 0)
|
||||
// OBSERVED-KILL credit (MP KILLS fix, 2026-07-13; the symmetric twin of
|
||||
// BTPlayerCountObservedDeath): every node maintains LOCAL score copies,
|
||||
// so the kill must land on the KILLER's player-link object -- the same
|
||||
// object the PilotList gauge reads (the roster resolves pilots via the
|
||||
// mechs' GetPlayerLink()). The old GetMissionPlayer() dispatch credited
|
||||
// a copy the scoreboard never displays (kills stuck at 0 while deaths --
|
||||
// which already flow through the player link -- counted; user-reported).
|
||||
// Resolving the killer from the victim's lastInflictingID also credits
|
||||
// the REMOTE player's local copy when THEY score ("target's kills
|
||||
// display 0") -- each node witnesses the death transition and tallies
|
||||
// its own copies self-consistently.
|
||||
BTPlayer *killer_player = 0;
|
||||
if (application->GetHostManager() != 0)
|
||||
{
|
||||
// KillScore (scoreType 2): senderMechID MUST be the VICTIM (!= our mech) so
|
||||
// the local player is credited via `this->killCount++` -- if it were our own
|
||||
// mech, ScoreMessageHandler takes the suicide branch (award negated, no kill).
|
||||
Entity *killer = application->GetHostManager()->GetEntityPointer(
|
||||
((Mech *)victim)->lastInflictingID);
|
||||
extern int BTIsRegisteredMech(Entity *e);
|
||||
if (killer != 0 && killer != victim && BTIsRegisteredMech(killer))
|
||||
killer_player = (BTPlayer *)((Mech *)killer)->GetPlayerLink();
|
||||
}
|
||||
if (killer_player != 0)
|
||||
{
|
||||
// KillScore (scoreType 2): senderMechID MUST be the VICTIM (!= the
|
||||
// receiver's mech) so the handler credits `killCount++` -- a suicide
|
||||
// (killer == victim) never reaches here.
|
||||
BTPlayer::ScoreMessage kill(
|
||||
Player::ScoreMessageID, // 0x12
|
||||
sizeof(BTPlayer::ScoreMessage),
|
||||
@@ -1507,13 +1558,12 @@ void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP deat
|
||||
0.0f, // scoreAward (killBonus; 0 for bring-up)
|
||||
damage, // damageAmount (killing-blow)
|
||||
victim->GetEntityID()); // senderMechID = victim
|
||||
local_player->Dispatch(&kill);
|
||||
killer_player->Dispatch(&kill);
|
||||
if (getenv("BT_SCORE_LOG"))
|
||||
DEBUG_STREAM << "[score] *** KILL *** localPlayer=" << (void *)local_player
|
||||
<< " killCount=" << local_player->GetKillCount()
|
||||
<< " deaths=" << local_player->GetDeaths()
|
||||
<< " score=" << (Scalar)local_player->GetScore()
|
||||
<< " rank=" << local_player->GetRanking() << "\n" << std::flush;
|
||||
DEBUG_STREAM << "[score] *** KILL *** killerPlayer=" << (void *)killer_player
|
||||
<< " killCount=" << killer_player->GetKillCount()
|
||||
<< " deaths=" << killer_player->GetDeaths()
|
||||
<< " score=" << (Scalar)killer_player->GetScore() << std::endl;
|
||||
}
|
||||
|
||||
// MP DEATH: credit a death to the VICTIM's own player. NULL for the solo
|
||||
|
||||
@@ -53,6 +53,7 @@ struct VgMesh
|
||||
{
|
||||
std::vector<VgTri> tris;
|
||||
float minx, maxx, minz, maxz; // model-local XZ bound
|
||||
float miny, maxy; // model-local Y bound (dome vs flat-ground test)
|
||||
};
|
||||
|
||||
struct VgInst
|
||||
@@ -89,8 +90,8 @@ int LoadMeshFor(const char *resource_name)
|
||||
if (LoadBgfFile(file, data) && data.ok && !data.indices.empty())
|
||||
{
|
||||
VgMesh m;
|
||||
m.minx = m.minz = 1e9f;
|
||||
m.maxx = m.maxz = -1e9f;
|
||||
m.minx = m.minz = m.miny = 1e9f;
|
||||
m.maxx = m.maxz = m.maxy = -1e9f;
|
||||
// The loader emits DOUBLE-SIDED triangles: emitTri pushes (a,b,c) then
|
||||
// (a,c,b) -- 6 indices per source triangle. Take the forward one.
|
||||
for (size_t i = 0; i + 5 < data.indices.size(); i += 6)
|
||||
@@ -100,17 +101,33 @@ int LoadMeshFor(const char *resource_name)
|
||||
const BgfVtx &C = data.verts[data.indices[i + 2]];
|
||||
VgTri t = { A.x, A.y, A.z, B.x, B.y, B.z, C.x, C.y, C.z };
|
||||
m.tris.push_back(t);
|
||||
float xs[3] = { A.x, B.x, C.x }, zs[3] = { A.z, B.z, C.z };
|
||||
float xs[3] = { A.x, B.x, C.x }, zs[3] = { A.z, B.z, C.z }, ys[3] = { A.y, B.y, C.y };
|
||||
for (int k = 0; k < 3; ++k)
|
||||
{
|
||||
if (xs[k] < m.minx) m.minx = xs[k];
|
||||
if (xs[k] > m.maxx) m.maxx = xs[k];
|
||||
if (zs[k] < m.minz) m.minz = zs[k];
|
||||
if (zs[k] > m.maxz) m.maxz = zs[k];
|
||||
if (ys[k] < m.miny) m.miny = ys[k];
|
||||
if (ys[k] > m.maxy) m.maxy = ys[k];
|
||||
}
|
||||
}
|
||||
if (!m.tris.empty())
|
||||
{
|
||||
if (getenv("BT_GROUND_LOG"))
|
||||
{
|
||||
float miny = 1e9f, maxy = -1e9f;
|
||||
for (size_t j = 0; j < m.tris.size(); ++j)
|
||||
{
|
||||
const VgTri &t = m.tris[j];
|
||||
const float ys[3] = { t.ay, t.by, t.cy };
|
||||
for (int k = 0; k < 3; ++k)
|
||||
{ if (ys[k] < miny) miny = ys[k]; if (ys[k] > maxy) maxy = ys[k]; }
|
||||
}
|
||||
DEBUG_STREAM << "[visgnd] mesh '" << key << "' tris=" << m.tris.size()
|
||||
<< " x[" << m.minx << "," << m.maxx << "] z[" << m.minz << "," << m.maxz
|
||||
<< "] y[" << miny << "," << maxy << "]\n" << std::flush;
|
||||
}
|
||||
gMeshes.push_back(m);
|
||||
idx = (int)gMeshes.size() - 1;
|
||||
}
|
||||
@@ -151,16 +168,44 @@ bool BuildTables()
|
||||
const float *pos = (const float *)(base + p + 48);
|
||||
p += (mlen + 3) & ~3u;
|
||||
|
||||
// CENSUS (BT_GROUND_LOG): log EVERY map entity's class + name, so we can see
|
||||
// what class the garages/structures are (the class-42 filter may drop them).
|
||||
if (getenv("BT_GROUND_LOG"))
|
||||
{
|
||||
ResourceDescription *crd = rf->FindResourceDescription(rid);
|
||||
DEBUG_STREAM << "[mapent] cls=" << cls << " mflags=" << mflags
|
||||
<< " name='" << (crd ? crd->resourceName : "?") << "'\n" << std::flush;
|
||||
}
|
||||
if (mflags & 0x2) continue; // include marker
|
||||
if (cls != 42) continue; // terrain/static cultural only
|
||||
|
||||
ResourceDescription *rd = rf->FindResourceDescription(rid);
|
||||
if (rd == 0) continue;
|
||||
if (SkippedName(rd->resourceName)) { ++skipped; continue; }
|
||||
|
||||
int mi = LoadMeshFor(rd->resourceName);
|
||||
if (mi < 0) { ++skipped; continue; }
|
||||
|
||||
// GEOMETRY-AWARE skip: the name filter (sky/backdrop) is right for a high
|
||||
// vertical DOME, but arena1's ground is a 10000x10000 FLAT plane at y=0
|
||||
// misnamed "sky" -- name-skipping it removed the only groundable geometry
|
||||
// (task #50: buildings/ground stopped moving the range axis). Keep a
|
||||
// name-skipped mesh IF it is a wide, near-flat, near-ground plane (the
|
||||
// arena floor); still skip true domes / tall vertical backdrops.
|
||||
if (SkippedName(rd->resourceName))
|
||||
{
|
||||
const VgMesh &gm = gMeshes[mi];
|
||||
const float yext = gm.maxy - gm.miny;
|
||||
const float xext = gm.maxx - gm.minx, zext = gm.maxz - gm.minz;
|
||||
const bool flatWideGround =
|
||||
(yext < 200.0f) && (xext > 500.0f) && (zext > 500.0f) && (gm.miny < 200.0f);
|
||||
if (getenv("BT_GROUND_LOG"))
|
||||
DEBUG_STREAM << "[visgnd] name-skip '" << rd->resourceName
|
||||
<< "' yext=" << yext << " xz=" << xext << "x" << zext
|
||||
<< " miny=" << gm.miny << " -> keepAsGround=" << (flatWideGround?1:0)
|
||||
<< "\n" << std::flush;
|
||||
if (!flatWideGround) { ++skipped; continue; }
|
||||
}
|
||||
|
||||
VgInst inst;
|
||||
inst.mesh = mi;
|
||||
inst.px = pos[0]; inst.py = pos[1]; inst.pz = pos[2];
|
||||
|
||||
@@ -271,6 +271,15 @@ void
|
||||
Scalar dist = (Scalar)Sqrt(delta.x*delta.x + delta.y*delta.y + delta.z*delta.z); // FUN_004dd138
|
||||
beamScale.z = dist / graphicLength; // 0x434 (== beamScale[2]) = dist / 0x438
|
||||
|
||||
if (getenv("BT_FIRE_LOG"))
|
||||
{
|
||||
Entity *st = (owner != 0) ? *(Entity **)((char *)owner + 0x388) : 0;
|
||||
DEBUG_STREAM << "[fireW] " << GetName() << " dist=" << (float)dist
|
||||
<< " effRange=" << (float)effectiveRange
|
||||
<< " inRange=" << (int)(dist <= effectiveRange)
|
||||
<< " explID=" << (long)GetExplosionResourceID()
|
||||
<< " tgt=" << (void *)st << "\n" << std::flush;
|
||||
}
|
||||
if (dist <= effectiveRange) // this+0x328
|
||||
{
|
||||
// THE AUTHENTIC DAMAGE SUBMISSION (task #8; binary @004bace8:7758-7764):
|
||||
@@ -349,6 +358,7 @@ void
|
||||
<< " pct=" << (float)rechargeLevel
|
||||
<< " tgt=" << (void *)((owner != 0)
|
||||
? *(Entity **)((char *)owner + 0x388) : 0)
|
||||
<< " explID=" << (long)GetExplosionResourceID()
|
||||
<< std::endl;
|
||||
}
|
||||
targetWithinRange = UpdateTargeting(); // @004b9bdc -> this+0x34c
|
||||
|
||||
@@ -818,6 +818,15 @@ void
|
||||
coolantDraw = 0.0f;
|
||||
}
|
||||
|
||||
// DIAG (BT_COOL_LOG): fire whenever THIS heat subsystem carries any damage --
|
||||
// answers "does damaging a mech drain its coolant?". Shows the damageLevel the
|
||||
// leak reads (@0xE0), the heat load, the resulting draw, and the live level.
|
||||
if (getenv("BT_COOL_LOG") && zoneDamage > 0.001f)
|
||||
DEBUG_STREAM << "[cool] " << GetName() << " dmg=" << zoneDamage
|
||||
<< " heatLoad=" << heatLoad << " draw=" << coolantDraw
|
||||
<< " coolantLevel=" << coolantLevel << "/" << thermalCapacity
|
||||
<< "\n" << std::flush;
|
||||
|
||||
Scalar amount = coolantDraw * time_slice;
|
||||
if (coolantLevel < amount)
|
||||
{
|
||||
|
||||
@@ -337,16 +337,14 @@ void
|
||||
{
|
||||
HeatableSubsystem *watched = (HeatableSubsystem *)watchedLink.Resolve(); // FUN_00417ab4(this+0x114)
|
||||
|
||||
// BRING-UP GUARD (marked deviation): in the shipped game watchedLink ALWAYS
|
||||
// resolves -- the HeatWatcher resource's "WatchedSubsystem" name binds to a
|
||||
// roster segment at load -- so the binary (FUN_004aeac4) derefs `watched`
|
||||
// unconditionally. Our port does not yet resolve the watch link, so Resolve()
|
||||
// returns null; the deref AVs the moment a HeatWatcher-derived subsystem's
|
||||
// Performance actually runs (exposed by the WAVE 4 un-stub: AmmoBinSimulation
|
||||
// calls this directly; PowerWatcher::Simulation for Searchlight/ThermalSight).
|
||||
// With no resolved source, hold the heat alarm at Normal (same established
|
||||
// pattern as Emitter::TrackSeekVoltage's null-source guard). FAITHFUL
|
||||
// FOLLOW-UP = resolve watchedLink so the alarm tracks the real temperature.
|
||||
// The watch link is now BOUND by the authentic factory connect pass (task
|
||||
// #57: the mech factory's post-roster loop calls the binary's vtable slot
|
||||
// +0x38 -- FUN_004aee2c / FUN_004b1a40 -> BTWatcherBindTarget below), so
|
||||
// Resolve() succeeds on every master-node watcher whose resource named a
|
||||
// WatchedSubsystem. The binary derefs `watched` unconditionally; the null
|
||||
// guard stays for replicant nodes (the binary never binds there either --
|
||||
// the +0x38 body is master-gated -- and their watcher Performances don't
|
||||
// run) and for bring-up safety.
|
||||
if (watched == 0)
|
||||
{
|
||||
heatAlarm.SetLevel(0); // NormalHeat (no watched source yet)
|
||||
@@ -369,6 +367,51 @@ void
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// @004aee2c (HeatWatcher vtable slot 14, offset +0x38; the PowerWatcher/Torso
|
||||
// override @004b1a40 is byte-identical) -- the factory post-roster CONNECT
|
||||
// pass, disassembled from the raw binary (Ghidra missed both function starts):
|
||||
//
|
||||
// owner = this->owner (+0xD0)
|
||||
// if ((owner->simulationFlags & 0xC) == 0 && (owner->simulationFlags & 0x100))
|
||||
// watchedLink(+0x114).Add( owner->roster(+0x128)[ watchedSubsystem(+0x128) ] )
|
||||
//
|
||||
// The mech factory loop tests IsDerivedFrom(HeatWatcher @0x50e604) then calls
|
||||
// the slot. Split across the TU boundary the same way as the other
|
||||
// cross-family helpers: the OWNER-side master gate + roster lookup live in the
|
||||
// mech family (complete Mech type); these two bridges are the family-side
|
||||
// derivation test and the link Add. [T1: bytes @004aee2c/@004b1a40]
|
||||
//
|
||||
int
|
||||
BTWatcherWatchedIndex(Subsystem *sub) // -1 = not a HeatWatcher
|
||||
{
|
||||
if (sub == 0 || !sub->IsDerivedFrom(*HeatWatcher::GetClassDerivations()))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
return ((HeatWatcher *)sub)->watchedSubsystem; // @0x128 (resource +0xE4, name index +2)
|
||||
}
|
||||
|
||||
void
|
||||
BTWatcherBindTarget(Subsystem *sub, Subsystem *target)
|
||||
{
|
||||
((HeatWatcher *)sub)->watchedLink.Add(target); // (**(link+4))(link, target) = FUN_00417a80
|
||||
}
|
||||
|
||||
//
|
||||
// Cross-family derivation probe: 0x50e604 is the HEATWATCHER derivation tag
|
||||
// (TestInstance @4aec38 checks its own class against it) -- NOT HeatSink, as
|
||||
// an old btl4gaug label claimed (HeatSink : HeatableSubsystem would make an
|
||||
// OR-test after HeatableSubsystem redundant; the binary's alternate branch is
|
||||
// the disjoint watcher family). Bridged so gauge TUs can test without
|
||||
// including this family's headers.
|
||||
//
|
||||
int
|
||||
BTIsHeatWatcher(Subsystem *sub)
|
||||
{
|
||||
return sub != 0 && sub->IsDerivedFrom(*HeatWatcher::GetClassDerivations());
|
||||
}
|
||||
|
||||
//
|
||||
// @4aec54 (468 bytes) -- parse the HeatWatcher resource. Stamps classID 0x0BBF
|
||||
// / size 0xF0; defaults the watched index to -1; requires DegradationTemperature,
|
||||
@@ -934,6 +977,37 @@ void BTRecomputeCondenserValves(Entity *owner)
|
||||
Derivation &condClass = *Condenser::GetClassDerivations();
|
||||
int count = owner->GetSubsystemCount();
|
||||
|
||||
// DIAG (BT_VALVE=<n>): force every condenser's valve detent so the coolant/jam
|
||||
// mechanic can be tested at a known coolant level (0=closed/uncooled .. 50=max).
|
||||
// DIAG (BT_VALVE_BOOST=<condenserNumber>): the authentic "coolant priority" test --
|
||||
// give ONE condenser the max detent (50) and starve the rest to the min (1), i.e.
|
||||
// route the coolant SHARE to that condenser (share ~0.9 vs the ~1/N baseline).
|
||||
{
|
||||
const char *fv = getenv("BT_VALVE");
|
||||
const char *fb = getenv("BT_VALVE_BOOST");
|
||||
if (fb && fb[0])
|
||||
{
|
||||
int boostNum = atoi(fb);
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
Subsystem *s = owner->GetSubsystem(i);
|
||||
if (s != 0 && s->IsDerivedFrom(condClass))
|
||||
((Condenser *)s)->valveState =
|
||||
(((Condenser *)s)->condenserNumber == boostNum) ? 50 : 1;
|
||||
}
|
||||
}
|
||||
else if (fv && fv[0])
|
||||
{
|
||||
int v = atoi(fv);
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
Subsystem *s = owner->GetSubsystem(i);
|
||||
if (s != 0 && s->IsDerivedFrom(condClass))
|
||||
((Condenser *)s)->valveState = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pass 1: total valve opening across all condensers
|
||||
int total = 0;
|
||||
for (int i = 0; i < count; ++i)
|
||||
|
||||
+306
-37
@@ -246,8 +246,9 @@ inline ReconRegistryT *ReconRegistry() { static ReconRegistryT r; return &r; }
|
||||
// segment/joint API -- it is no longer a free stand-in.
|
||||
template<class...A> inline Recon LookupVideoObject(A&&...) { return Recon(); }
|
||||
template<class...A> inline int ResourceFindByName(A&&...) { return 0; }
|
||||
template<class...A> inline void LoadLowDetailBody(A&&...) {}
|
||||
template<class...A> inline void LoadHighDetailBody(A&&...) {}
|
||||
// (LoadLowDetailBody / LoadHighDetailBody removed task #59 -- they were no-op
|
||||
// stubs mislabeling the FUN_004a80d4/86c8 GAIT-CLIP loaders as a body-LOD
|
||||
// pair; the authentic clip-set gate now calls the real loaders.)
|
||||
template<class...A> inline void FinishConstruction(A&&...) {}
|
||||
template<class...A> inline void ResetPose(A&&...) {}
|
||||
template<class...A> inline void SetStatusState(A&&...) {}
|
||||
@@ -538,6 +539,37 @@ Logical
|
||||
return True;
|
||||
}
|
||||
|
||||
//
|
||||
// World-STRUCTURE boresight pick. Ray-test the zone's STATIC collision solid
|
||||
// tree (the world geometry -- garages, walls, props -- that the mech's walk
|
||||
// already collides against) along the boresight, and hand back the entry point
|
||||
// of the closest structure the ray strikes. This is the authentic non-mech
|
||||
// world pick: Mover::FindStaticSolidHitBy is the "test against the static world"
|
||||
// tail of the engine's own FindBoxedSolidHitBy (MOVER.cpp), which is what the
|
||||
// mover-vs-world collision uses -- so a shot lands on EXACTLY the geometry that
|
||||
// blocks the walk (the user's "structures block the mech, but weapons don't").
|
||||
//
|
||||
// The ray is WORLD-space (the static tree is world-space, unlike a mech's local
|
||||
// collision template). FindBoundingBoxHitBy clips the Line's length to the
|
||||
// entry distance (BoundingBox::HitByBounded -> line->length = enter), so
|
||||
// line->FindEnd yields the world entry point on the struck solid.
|
||||
//
|
||||
Logical
|
||||
Mech::WorldStructurePick(const Point3D &start, const Vector3D &dir,
|
||||
Scalar max_range, Point3D *hit_world)
|
||||
{
|
||||
Scalar dlen = (Scalar)Sqrt(dir.x*dir.x + dir.y*dir.y + dir.z*dir.z);
|
||||
if (dlen < 1e-6f)
|
||||
return False;
|
||||
Line ray(start, UnitVector(dir.x/dlen, dir.y/dlen, dir.z/dlen), max_range);
|
||||
BoxedSolid *solid = FindStaticSolidHitBy(&ray); // static world only
|
||||
if (solid == 0)
|
||||
return False;
|
||||
if (hit_world != 0)
|
||||
ray.FindEnd(hit_world); // clipped length -> entry point
|
||||
return True;
|
||||
}
|
||||
|
||||
//
|
||||
// The roster torso (sinkSourceSubsystem @0x438, ClassID 0xBC5) -- parity with
|
||||
// the binary's *(mech+0x438).
|
||||
@@ -621,6 +653,10 @@ void
|
||||
// WRITTEN -- a reconstruction gap; the natural authentic write site is
|
||||
// this handler (every damage message carries the inflictor). [T3]
|
||||
lastInflictingID = message->inflictingEntity;
|
||||
// task #60: latch the killing-blow magnitude alongside the attacker id so the
|
||||
// kill score carries the REAL finishing-hit damage (mirrors the per-hit path
|
||||
// mech4.cpp:1207 that already passes p.damage), not the flat kShotDamage=12.
|
||||
lastInflictingDamage = message->damageData.damageAmount;
|
||||
|
||||
// THREAT feed (task #37, the recovered reticle Execute's ThreatVector
|
||||
// attr 0xC): when the PLAYER takes a hit, push the attack direction --
|
||||
@@ -865,6 +901,13 @@ Mech::Mech(
|
||||
// dispatch (task #56) gates on it every frame; garbage
|
||||
// nonzero silently disabled the writers until death anims ran
|
||||
legResetLatch = 0; // @0x654 -- same latch family, same fill hazard
|
||||
legAnimationState = 0; // @0x3b0 -- same hazard (found 2026-07-14, the real-clock
|
||||
// crash): the type-3 record writer re-dispatches
|
||||
// SetBodyAnimation(legAnimationState) on itself; a record
|
||||
// emitted before the leg SM's first tick passed the raw
|
||||
// 0xCDCDCDCD as a clip index -> AV in SetBodyAnimation.
|
||||
// (The stub call-counter clock had merely re-ordered record
|
||||
// emission so the window was never hit.) Standing = 0.
|
||||
attrPad = 0.0f; // shared read pad for un-populated gauge attributes
|
||||
linearSpeed = 0.0f; // live forward ground speed (LinearSpeed gauge); set per frame
|
||||
radarRange = 1000.0f; // radar display scale (SetTargetRange is stubbed;
|
||||
@@ -903,6 +946,12 @@ Mech::Mech(
|
||||
Wword(0xe6) = 0;
|
||||
poseSyncLatch = 0; // @0x77c (was absorbed Wword(0x1df))
|
||||
heatLevelSnapshot = 0; // @0x780 (type-7 deadband baseline)
|
||||
mirrorBodyAdv = 0.0f; // master send-mirror gait travel
|
||||
peerMirrorSpeed = -1.0f; // peer true-mirror cadence (disabled by default)
|
||||
angMirrorYaw = 0.0f; // scalar peer-yaw mirror (port addition)
|
||||
angMirrorRate = 0.0f;
|
||||
angMirrorValid = 0;
|
||||
angSyncLatch = 0; // peer heading re-anchor (type-4 receive)
|
||||
Wword(0xfe) = Wword(0xfc) = Wword(0x105) = 0;
|
||||
mechName.Copy(&DAT_004e0f8c); // FUN_00408e90(this+0xd8,"")
|
||||
Wword(0x160) = Wword(0x15f) = 0;
|
||||
@@ -1187,12 +1236,40 @@ Mech::Mech(
|
||||
//
|
||||
// Build the capability sub-rosters by IsDerivedFrom() class tests.
|
||||
//
|
||||
for (int id = 2; id < subsystemCount; ++id) // "start" each subsystem
|
||||
// Loop 1 = the watcher CONNECT pass (task #57). The binary calls vtable
|
||||
// slot +0x38 on every HeatWatcher-derived subsystem (@0x50e604 test);
|
||||
// the slot bodies (FUN_004aee2c, PowerWatcher/Torso override @004b1a40,
|
||||
// byte-identical -- Ghidra missed both starts, recovered from raw bytes)
|
||||
// bind watchedLink to roster[watchedSubsystem], gated on the owner being
|
||||
// the live master node ((flags & 0xC)==0 && (flags & 0x100)). The gate is
|
||||
// hoisted out of the loop unchanged (constant per mech). This replaces
|
||||
// the old SubProxy::Start() no-op that left every watchdogAlarm at 0 --
|
||||
// which held the Torso's ElectricalStateLevel() below Ready and zeroed the
|
||||
// twist rate forever.
|
||||
//
|
||||
if ((simulationFlags & 0xC) == 0 // SegmentCopyMask
|
||||
&& (simulationFlags & 0x100) != 0) // MasterHeatSinkFlag
|
||||
{
|
||||
SubProxy *s = (SubProxy *)subsystemArray[id];
|
||||
if (s != 0 && s->IsDerivedFrom(0x50e604)) // FUN_0041a1a4
|
||||
extern int BTWatcherWatchedIndex(Subsystem *sub); // heatfamily_reslice.cpp
|
||||
extern void BTWatcherBindTarget(Subsystem *sub, Subsystem *target);
|
||||
for (int id = 2; id < subsystemCount; ++id)
|
||||
{
|
||||
s->Start(); // (**(s+0x38))(s)
|
||||
int watched = BTWatcherWatchedIndex(subsystemArray[id]); // -1 = not a HeatWatcher
|
||||
if (watched < 0)
|
||||
continue;
|
||||
if (watched < subsystemCount && subsystemArray[watched] != 0)
|
||||
{
|
||||
BTWatcherBindTarget(subsystemArray[id], subsystemArray[watched]);
|
||||
DEBUG_STREAM << "[watch] subsystem " << id << " watches "
|
||||
<< watched << std::endl;
|
||||
}
|
||||
else
|
||||
{
|
||||
// The binary has no range check (CreateStreamed validated the
|
||||
// name); flag data drift honestly instead of a wild read.
|
||||
DEBUG_STREAM << "[watch] subsystem " << id
|
||||
<< " BAD WatchedSubsystem index " << watched << std::endl;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int id = 2; id < subsystemCount; ++id) // heatable roster
|
||||
@@ -1292,14 +1369,13 @@ Mech::Mech(
|
||||
<< " turnVel=" << updateTurnVelocityDeadband
|
||||
<< " turnAngle=" << updateTurnAngleDeadband << " (rad)" << std::endl;
|
||||
Wword(0x10c) = model->timeDelay; // rec+0xA4 -> mech+0x430
|
||||
// NOTE: the decomp writes word slots this[0x15d]/this[0x15e]. Routed to the
|
||||
// scratch bank (unnamed pose-angle fields) -- writing through (float*)(this +
|
||||
// 0x15d) would be typed-pointer arithmetic on Mech* (== this + 0x15d*sizeof(Mech)).
|
||||
// The AUTHENTIC per-mech turn rates (rec+0x60/0x64, deg->rad ->
|
||||
// mech+0x574/0x578) -- still absorbed pending the turn-code wiring
|
||||
// (locomotion polish item: the port turns at a bring-up constant rate).
|
||||
Wword(0x15d) = model->walkingTurnRate * DegreesToRadians;
|
||||
Wword(0x15e) = model->runningTurnRate * DegreesToRadians;
|
||||
// The AUTHENTIC per-mech turn rates (rec+0x60/0x64, deg->rad -> mech+0x574/0x578).
|
||||
// The decomp writes word slots this[0x15d]/this[0x15e] (== 0x574/0x578); stored in
|
||||
// real named members now (task #64b) -- read by the master-perf turn-rate lerp in
|
||||
// the drive (mech4.cpp) that yaws at lerp(walkingTurnRate, runningTurnRate) by speed
|
||||
// instead of the old bring-up constant kDriveTurnRate.
|
||||
walkingTurnRate = model->walkingTurnRate * DegreesToRadians;
|
||||
runningTurnRate = model->runningTurnRate * DegreesToRadians;
|
||||
|
||||
//
|
||||
// SHADOW JOINT resolve (task #20; CORRECTED -- the earlier draft misread
|
||||
@@ -1326,21 +1402,19 @@ Mech::Mech(
|
||||
}
|
||||
|
||||
//
|
||||
// Choose the LOD / view variant of the body model.
|
||||
// CLIP-SET / VIEW gate (@part_012.c:10308-10320). CORRECTION (task #59):
|
||||
// the two branch targets here are FUN_004a80d4 / FUN_004a86c8 -- which are
|
||||
// the GAIT-CLIP loaders (LoadLocomotionClips / LoadLocomotionClipsExt), NOT
|
||||
// a "body LOD" pair (the old LoadLowDetailBody/LoadHighDetailBody names were
|
||||
// no-op stubs, so this authentic gate did NOTHING and every mech silently
|
||||
// fell back to the unconditional exterior load below -> the local cockpit
|
||||
// mech played the EXTERIOR clips and leaned -8deg into every walk). The
|
||||
// only real side effect at THIS point is the L4VIEWEXT flag; the clip LOAD
|
||||
// itself is deferred to the gait-clip site below (after animationClips[] is
|
||||
// zeroed), where the same three-way selection is applied.
|
||||
//
|
||||
if (getenv("L4VIEWEXT") != 0) // FUN_004dee74
|
||||
{
|
||||
Wword(0x15f) = 1;
|
||||
LoadLowDetailBody(model); // FUN_004a80d4 [mech2]
|
||||
}
|
||||
else if ((instanceFlags & 0xc) == 4)
|
||||
{
|
||||
LoadLowDetailBody(model); // FUN_004a80d4 [mech2]
|
||||
}
|
||||
else
|
||||
{
|
||||
LoadHighDetailBody(model); // FUN_004a86c8 [mech2]
|
||||
}
|
||||
|
||||
//
|
||||
// Tell every heatable subsystem to attach to the body model.
|
||||
@@ -1392,6 +1466,7 @@ Mech::Mech(
|
||||
frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f); // collision-damage guard snapshot
|
||||
ramLastVictim = 0; // ram contact-edge state
|
||||
ramContactLinger = 0.0f;
|
||||
lastInflictingDamage = 0.0f; // task #60: killing-blow magnitude (set on hit)
|
||||
if (GroundReal() && GetCollisionVolumeCount() > 0
|
||||
&& collisionTemplate != 0 && collisionVolume != 0)
|
||||
{
|
||||
@@ -1421,12 +1496,49 @@ Mech::Mech(
|
||||
animationClips[ci] = 0;
|
||||
|
||||
// P3 STEP 7 (state-machine path): load the full gait clip set + speed caps into
|
||||
// animationClips[5..26] via LoadLocomotionClips (mech3.cpp @004a80d4 -- real). GATED
|
||||
// (BT_GAIT_CUTOVER) so the default STEP-1/2 path is untouched while the AdvanceBody
|
||||
// Animation state machine is brought up (it derefs ResolveAnimationClip unconditionally
|
||||
// -> will fault on any clip this mech's content lacks; observe under the gate first).
|
||||
// animationClips[5..26]. GATED (BT_GAIT_CUTOVER) so the default STEP-1/2 path is
|
||||
// untouched while the AdvanceBodyAnimation state machine is brought up (it derefs
|
||||
// ResolveAnimationClip unconditionally -> will fault on any clip this mech's content
|
||||
// lacks; observe under the gate first).
|
||||
//
|
||||
// AUTHENTIC clip-set gate (@part_012.c:10308-10320, task #59): the local cockpit
|
||||
// MASTER gets the INTERIOR clip set (LoadLocomotionClipsExt @004a86c8, 4-char 'i'
|
||||
// suffix) whose clips shake `jointshakey` and OMIT the `jointhip` walk lean; network
|
||||
// REPLICANTS and the forced L4VIEWEXT external view get the EXTERIOR set
|
||||
// (LoadLocomotionClips @004a80d4, 3-char) carrying the authored -8deg lean. This is
|
||||
// the SINGLE loader call the decomp ctor makes (the earlier no-op body-LOD gate was
|
||||
// this same decomp branch mis-reconstructed -- see the L4VIEWEXT block above).
|
||||
//
|
||||
// PORT NOTE (task #59): the authentic discriminator is the replicant COPY bit
|
||||
// (instanceFlags & 0xC == 4), which the 1995 game sets when it constructs a
|
||||
// network replicant. The port does NOT set that bit at construction (all MP
|
||||
// mechs are built as local masters; replication rides the mech registry +
|
||||
// update-records, not the copy-performance path) -- so BOTH the local mech
|
||||
// and the peer's replica currently take the ELSE (interior) branch. That is
|
||||
// correct for the LOCAL cockpit (level, the reported bug) but means the peer's
|
||||
// mech does not carry the exterior -8deg lean in the OTHER pod's view yet;
|
||||
// that lights up automatically once the replicant copy bit is set at
|
||||
// construction (tracked in open-questions -- do NOT fake it with a viewpoint
|
||||
// test here: the clips load once at ctor, before the viewpoint is assigned).
|
||||
int loadedInterior = 0;
|
||||
if (BTEnvOn("BT_GAIT_CUTOVER", 1)) // default ON (=0 to disable)
|
||||
LoadLocomotionClips(model);
|
||||
{
|
||||
if (getenv("L4VIEWEXT") != 0 || (instanceFlags & 0xc) == 4)
|
||||
{
|
||||
LoadLocomotionClips(model); // exterior (3-char): replicant / forced-ext view
|
||||
}
|
||||
else
|
||||
{
|
||||
LoadLocomotionClipsExt(model); // interior (4-char 'i'): the local cockpit master
|
||||
loadedInterior = 1;
|
||||
}
|
||||
}
|
||||
// Register this mech's model + which clip set it currently holds, so the
|
||||
// per-frame MaintainViewClipSet() (mech4 PerformAndWatch) can flip it to the
|
||||
// authentic set once the viewpoint is assigned (the copy bit is never set in
|
||||
// the port, so the ctor gate lands everyone on interior -- see the note above).
|
||||
extern void BTStashClipState(const Mech *m, Mech__ModelResource *model, int interior);
|
||||
BTStashClipState(this, model, loadedInterior);
|
||||
|
||||
Wword(0xe5) = 0;
|
||||
Wword(0xdd) = Wword(0xde) = 1;
|
||||
@@ -1594,6 +1706,8 @@ Mech::~Mech()
|
||||
|
||||
extern void BTDeregisterMech(Entity *m); // task #46 live-mech registry
|
||||
BTDeregisterMech((Entity *)this);
|
||||
extern void BTUnstashClipState(const Mech *m); // task #59 clip-set registry
|
||||
BTUnstashClipState(this);
|
||||
|
||||
vtable = &PTR_FUN_0050cfa8;
|
||||
|
||||
@@ -1707,6 +1821,33 @@ Logical
|
||||
void
|
||||
Mech::ReadUpdateRecord(Simulation::UpdateRecord *message)
|
||||
{
|
||||
// RECORD-CADENCE probe (BT_RXJIT): quantify how EVENLY records arrive (wall-clock
|
||||
// ms between arrivals) -- the jitter that makes the peer's corrections random.
|
||||
if (getenv("BT_RXJIT") && GetInstance() == ReplicantInstance)
|
||||
{
|
||||
LARGE_INTEGER _now, _freq;
|
||||
QueryPerformanceCounter(&_now); QueryPerformanceFrequency(&_freq);
|
||||
static double s_lastMs = 0.0, s_min = 1e9, s_max = 0.0, s_acc = 0.0;
|
||||
static int s_n = 0, s_rep = 0;
|
||||
const double nowMs = (double)_now.QuadPart * 1000.0 / (double)_freq.QuadPart;
|
||||
if (s_lastMs > 0.0)
|
||||
{
|
||||
const double gap = nowMs - s_lastMs;
|
||||
s_n++; s_acc += gap;
|
||||
if (gap > s_max) s_max = gap;
|
||||
if (gap < s_min) s_min = gap;
|
||||
}
|
||||
s_lastMs = nowMs;
|
||||
if (++s_rep >= 120 && s_n > 0)
|
||||
{
|
||||
DEBUG_STREAM << "[rxjit] records/win=" << s_n
|
||||
<< " gapMs min=" << s_min << " avg=" << (s_acc / s_n)
|
||||
<< " max=" << s_max
|
||||
<< " burstiness(max/avg)=" << (s_max / (s_acc / s_n))
|
||||
<< "\n" << std::flush;
|
||||
s_rep = 0; s_n = 0; s_acc = 0.0; s_min = 1e9; s_max = 0.0;
|
||||
}
|
||||
}
|
||||
static const int s_mrecLog = getenv("BT_REPL_LOG") ? 1 : 0;
|
||||
if (s_mrecLog && message->recordID != 0)
|
||||
DEBUG_STREAM << "[mrec-rx] type=" << (int)message->recordID
|
||||
@@ -1733,6 +1874,13 @@ void
|
||||
updateVelocity.angularMotion = savedAngV; // FUN_00408440
|
||||
poseSyncLatch = 1; // @0x77c -- arm the dead-reckon re-base
|
||||
|
||||
if (getenv("BT_WIRE"))
|
||||
DEBUG_STREAM << "[rx0] ent=" << GetEntityID()
|
||||
<< " uvLin=(" << updateVelocity.linearMotion.x << ","
|
||||
<< updateVelocity.linearMotion.z << ")"
|
||||
<< " uPos=(" << updateOrigin.linearPosition.x << ","
|
||||
<< updateOrigin.linearPosition.z << ")\n" << std::flush;
|
||||
|
||||
if (bodyAnimationState == 2 || bodyAnimationState == 3) // @0x728
|
||||
{
|
||||
projectedVelocity.linearMotion = ZeroVector; // FUN_0040a7f4(0x298, zeroMotion)
|
||||
@@ -1760,7 +1908,23 @@ void
|
||||
case 2: // commanded-speed update
|
||||
{
|
||||
Mech__SpeedUpdateRecord *record = (Mech__SpeedUpdateRecord *)message;
|
||||
Simulation::ReadUpdateRecord(message); // FUN_0041bd34
|
||||
{
|
||||
// CLOCK GUARD (keyboard-skip fix): the base reader stamps lastUpdate=Now()
|
||||
// on EVERY record ('HACK' per SIMULATE.cpp:276). Non-pose records carry no
|
||||
// pose, but that stamp shrinks the dead-reckoner's projection span
|
||||
// (nextUpdate - lastUpdate) WITHOUT refreshing updateOrigin -- so the
|
||||
// peer's position target jumps backward toward the stale origin, then the
|
||||
// next pose record yanks it forward: target oscillation every frame while
|
||||
// records churn. Keyboard driving churns them CONSTANTLY (the throttle
|
||||
// lever sweeps every frame of a key-hold -> a type-2 speed record per
|
||||
// frame), which autodrive's pinned throttle never does -- the 'skips when
|
||||
// I drive, smooth when you drive' report. Preserve the clock; keep the
|
||||
// real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B).
|
||||
static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0;
|
||||
const Time savedLastUpdate = lastUpdate;
|
||||
Simulation::ReadUpdateRecord(message);
|
||||
if (!s_t2clock) lastUpdate = savedLastUpdate;
|
||||
} // FUN_0041bd34
|
||||
bodyTargetSpeed = record->speedDemand; // @0x6b4 <- rec+0x10
|
||||
}
|
||||
break;
|
||||
@@ -1768,7 +1932,29 @@ void
|
||||
case 3: // leg/body state + stability
|
||||
{
|
||||
Mech__StateUpdateRecord *record = (Mech__StateUpdateRecord *)message;
|
||||
Simulation::ReadUpdateRecord(message);
|
||||
if (getenv("BT_GAITEV"))
|
||||
DEBUG_STREAM << "[t3rx] legState=" << record->legState
|
||||
<< " reset=" << record->legResetLatch
|
||||
<< " myLegState=" << (int)legStateAlarm.GetLevel()
|
||||
<< " myLegFrm=" << legAnimation.currentFrame
|
||||
<< " spd=" << record->speedDemand << "\n" << std::flush;
|
||||
{
|
||||
// CLOCK GUARD (keyboard-skip fix): the base reader stamps lastUpdate=Now()
|
||||
// on EVERY record ('HACK' per SIMULATE.cpp:276). Non-pose records carry no
|
||||
// pose, but that stamp shrinks the dead-reckoner's projection span
|
||||
// (nextUpdate - lastUpdate) WITHOUT refreshing updateOrigin -- so the
|
||||
// peer's position target jumps backward toward the stale origin, then the
|
||||
// next pose record yanks it forward: target oscillation every frame while
|
||||
// records churn. Keyboard driving churns them CONSTANTLY (the throttle
|
||||
// lever sweeps every frame of a key-hold -> a type-2 speed record per
|
||||
// frame), which autodrive's pinned throttle never does -- the 'skips when
|
||||
// I drive, smooth when you drive' report. Preserve the clock; keep the
|
||||
// real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B).
|
||||
static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0;
|
||||
const Time savedLastUpdate = lastUpdate;
|
||||
Simulation::ReadUpdateRecord(message);
|
||||
if (!s_t2clock) lastUpdate = savedLastUpdate;
|
||||
}
|
||||
bodyResetLatch = record->legResetLatch; // @0x658 <- rec+0x10
|
||||
stabilityAlarm.SetLevel(record->stability); // @0x4c4 <- rec+0x18
|
||||
if (record->legState == 0)
|
||||
@@ -1809,13 +1995,30 @@ void
|
||||
}
|
||||
}
|
||||
bodyTargetSpeed = record->speedDemand; // rec+0x28
|
||||
angSyncLatch = 1; // arm the peer heading re-anchor
|
||||
}
|
||||
break;
|
||||
|
||||
case 5: // knockdown
|
||||
{
|
||||
Mech__FallUpdateRecord *record = (Mech__FallUpdateRecord *)message;
|
||||
Simulation::ReadUpdateRecord(message);
|
||||
{
|
||||
// CLOCK GUARD (keyboard-skip fix): the base reader stamps lastUpdate=Now()
|
||||
// on EVERY record ('HACK' per SIMULATE.cpp:276). Non-pose records carry no
|
||||
// pose, but that stamp shrinks the dead-reckoner's projection span
|
||||
// (nextUpdate - lastUpdate) WITHOUT refreshing updateOrigin -- so the
|
||||
// peer's position target jumps backward toward the stale origin, then the
|
||||
// next pose record yanks it forward: target oscillation every frame while
|
||||
// records churn. Keyboard driving churns them CONSTANTLY (the throttle
|
||||
// lever sweeps every frame of a key-hold -> a type-2 speed record per
|
||||
// frame), which autodrive's pinned throttle never does -- the 'skips when
|
||||
// I drive, smooth when you drive' report. Preserve the clock; keep the
|
||||
// real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B).
|
||||
static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0;
|
||||
const Time savedLastUpdate = lastUpdate;
|
||||
Simulation::ReadUpdateRecord(message);
|
||||
if (!s_t2clock) lastUpdate = savedLastUpdate;
|
||||
}
|
||||
heatAlarm.SetLevel(record->heatLevel); // @0x450 <- rec+0x10
|
||||
throttleState = record->throttleState; // @0x4a4 <- rec+0x14
|
||||
fallDirection = record->fallDirection; // @0x4a8 <- rec+0x18
|
||||
@@ -1830,7 +2033,23 @@ void
|
||||
case 6: // death
|
||||
{
|
||||
Mech__FallUpdateRecord *record = (Mech__FallUpdateRecord *)message;
|
||||
Simulation::ReadUpdateRecord(message);
|
||||
{
|
||||
// CLOCK GUARD (keyboard-skip fix): the base reader stamps lastUpdate=Now()
|
||||
// on EVERY record ('HACK' per SIMULATE.cpp:276). Non-pose records carry no
|
||||
// pose, but that stamp shrinks the dead-reckoner's projection span
|
||||
// (nextUpdate - lastUpdate) WITHOUT refreshing updateOrigin -- so the
|
||||
// peer's position target jumps backward toward the stale origin, then the
|
||||
// next pose record yanks it forward: target oscillation every frame while
|
||||
// records churn. Keyboard driving churns them CONSTANTLY (the throttle
|
||||
// lever sweeps every frame of a key-hold -> a type-2 speed record per
|
||||
// frame), which autodrive's pinned throttle never does -- the 'skips when
|
||||
// I drive, smooth when you drive' report. Preserve the clock; keep the
|
||||
// real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B).
|
||||
static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0;
|
||||
const Time savedLastUpdate = lastUpdate;
|
||||
Simulation::ReadUpdateRecord(message);
|
||||
if (!s_t2clock) lastUpdate = savedLastUpdate;
|
||||
}
|
||||
heatAlarm.SetLevel(record->heatLevel);
|
||||
throttleState = record->throttleState;
|
||||
fallDirection = record->fallDirection;
|
||||
@@ -1847,7 +2066,23 @@ void
|
||||
case 7: // impact (fields only, no side effects)
|
||||
{
|
||||
Mech__FallUpdateRecord *record = (Mech__FallUpdateRecord *)message;
|
||||
Simulation::ReadUpdateRecord(message);
|
||||
{
|
||||
// CLOCK GUARD (keyboard-skip fix): the base reader stamps lastUpdate=Now()
|
||||
// on EVERY record ('HACK' per SIMULATE.cpp:276). Non-pose records carry no
|
||||
// pose, but that stamp shrinks the dead-reckoner's projection span
|
||||
// (nextUpdate - lastUpdate) WITHOUT refreshing updateOrigin -- so the
|
||||
// peer's position target jumps backward toward the stale origin, then the
|
||||
// next pose record yanks it forward: target oscillation every frame while
|
||||
// records churn. Keyboard driving churns them CONSTANTLY (the throttle
|
||||
// lever sweeps every frame of a key-hold -> a type-2 speed record per
|
||||
// frame), which autodrive's pinned throttle never does -- the 'skips when
|
||||
// I drive, smooth when you drive' report. Preserve the clock; keep the
|
||||
// real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B).
|
||||
static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0;
|
||||
const Time savedLastUpdate = lastUpdate;
|
||||
Simulation::ReadUpdateRecord(message);
|
||||
if (!s_t2clock) lastUpdate = savedLastUpdate;
|
||||
}
|
||||
heatAlarm.SetLevel(record->heatLevel);
|
||||
throttleState = record->throttleState;
|
||||
fallDirection = record->fallDirection;
|
||||
@@ -1859,7 +2094,23 @@ void
|
||||
case 8: // airborne/reverse cycle-rate selector
|
||||
{
|
||||
Mech__AirborneUpdateRecord *record = (Mech__AirborneUpdateRecord *)message;
|
||||
Simulation::ReadUpdateRecord(message);
|
||||
{
|
||||
// CLOCK GUARD (keyboard-skip fix): the base reader stamps lastUpdate=Now()
|
||||
// on EVERY record ('HACK' per SIMULATE.cpp:276). Non-pose records carry no
|
||||
// pose, but that stamp shrinks the dead-reckoner's projection span
|
||||
// (nextUpdate - lastUpdate) WITHOUT refreshing updateOrigin -- so the
|
||||
// peer's position target jumps backward toward the stale origin, then the
|
||||
// next pose record yanks it forward: target oscillation every frame while
|
||||
// records churn. Keyboard driving churns them CONSTANTLY (the throttle
|
||||
// lever sweeps every frame of a key-hold -> a type-2 speed record per
|
||||
// frame), which autodrive's pinned throttle never does -- the 'skips when
|
||||
// I drive, smooth when you drive' report. Preserve the clock; keep the
|
||||
// real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B).
|
||||
static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0;
|
||||
const Time savedLastUpdate = lastUpdate;
|
||||
Simulation::ReadUpdateRecord(message);
|
||||
if (!s_t2clock) lastUpdate = savedLastUpdate;
|
||||
}
|
||||
airborneSelect = record->airborne; // @0x3f4 <- rec+0x10
|
||||
bodyTargetSpeed = record->speedDemand; // rec+0x14
|
||||
}
|
||||
@@ -2021,6 +2272,24 @@ void
|
||||
}
|
||||
updateOrigin.angularPosition = localOrigin.angularPosition; // FUN_00409968(0x138 <- 0x10c)
|
||||
updateVelocity.angularMotion = localVelocity.angularMotion; // FUN_00408440(0x2d4 <- 0x1d0)
|
||||
// RE-BASE the master's peer-estimate mirror (2026-07-14, replicant-chop
|
||||
// fix): projectedOrigin/projectedVelocity model what the PEER will now
|
||||
// extrapolate from this record. The send gate compares local vs projected
|
||||
// (the peer's drift); without the re-base the mirror went stale, the gate
|
||||
// fired EVERY frame, and the per-frame records re-based the replicant into
|
||||
// the stall/snap chop. (The binary maintains this in the un-decompiled
|
||||
// master perf; the writer-side re-base is the coherent reconstruction.)
|
||||
projectedOrigin.angularPosition = localOrigin.angularPosition;
|
||||
projectedVelocity.angularMotion = localVelocity.angularMotion;
|
||||
// SCALAR peer-yaw mirror re-base (see mech.hpp): what the peer will now
|
||||
// extrapolate -- this yaw, at this rate, from this moment.
|
||||
{
|
||||
YawPitchRoll _my; _my = localOrigin.angularPosition;
|
||||
angMirrorYaw = (Scalar)_my.yaw;
|
||||
angMirrorRate = (Scalar)localVelocity.angularMotion.y;
|
||||
angMirrorTime = lastPerformance;
|
||||
angMirrorValid = 1;
|
||||
}
|
||||
record->speedDemand = speedDemand; // rec+0x28
|
||||
bodyTargetSpeed = speedDemand;
|
||||
}
|
||||
|
||||
@@ -618,6 +618,32 @@ public:
|
||||
Scalar updatePositionDeadband; // binary @0x768 -- type-0 pose trigger
|
||||
Scalar updateTurnVelocityDeadband; // binary @0x76c -- type-4 yaw-rate trigger
|
||||
Scalar updateTurnAngleDeadband; // binary @0x770 -- type-4 orientation trigger (rad)
|
||||
// SCALAR peer-yaw mirror (port addition, replicant-spin work): the master's
|
||||
// estimate of the yaw the PEER is currently rendering -- re-based on every
|
||||
// type-4 send (writer case 4), advanced analytically as base + rate*elapsed.
|
||||
// Replaces the quaternion projectedOrigin mirror for the ANGLE deadband: that
|
||||
// one is recomputed by the master's own reckoner each frame from lastUpdate
|
||||
// timing it does not control, and its false pi-drift waves FLOODED type-4
|
||||
// resyncs (measured maxAng~=pi bursts). Scalars, wrap-safe, self-timed.
|
||||
// Master send-mirror gait travel (BT_MASTER_GAITMIRROR): the invisible mj=0
|
||||
// body channel's per-frame cycleDistance, stashed in the drive block and
|
||||
// consumed by the send gate to advance projectedOrigin the way the PEER
|
||||
// predicts (gait), instead of the constant-velocity deadReckoner. This is the
|
||||
// authentic FUN_004a9b5c mirror = IntegrateMotion(mj=0) on projectedOrigin.
|
||||
Scalar mirrorBodyAdv; // body cycleDistance this frame (master)
|
||||
// Peer true-mirror cadence: the replicant sets this to the actual replicated
|
||||
// ground speed (|updateVelocity|) each frame so the body clip advances at the
|
||||
// master's real rate instead of the locally-slewed bodyCycleSpeed (which drifts
|
||||
// and makes the animation phase wander -> the accel/decel hesitation). -1 =
|
||||
// disabled (master/single-player always -1, so the authentic slew runs).
|
||||
Scalar peerMirrorSpeed;
|
||||
Scalar angMirrorYaw; // yaw at the last type-4 send (rad)
|
||||
Scalar angMirrorRate; // yaw rate sent with it (rad/s)
|
||||
Time angMirrorTime; // lastPerformance at that send
|
||||
int angMirrorValid; // 0 until the first type-4 send
|
||||
int angSyncLatch; // peer: type-4 arrived -> re-anchor heading (the
|
||||
// angular analog of poseSyncLatch; consumed by
|
||||
// the replicant's incremental heading integrator)
|
||||
// AUTHENTIC GROUND MODEL ctor products (task #15, ground-model-decode;
|
||||
// binary part_012.c:9938-9940 + 9974-9975). By-name access only; declared
|
||||
// after the layout-locked fields so nothing shifts.
|
||||
@@ -667,6 +693,14 @@ public:
|
||||
Logical PickRayHit(const Point3D &start, const Vector3D &dir,
|
||||
Scalar max_range, Point3D *hit_world);
|
||||
|
||||
// World-STRUCTURE boresight pick: ray-test the zone's STATIC collision solid
|
||||
// tree (garages/walls/props -- the geometry that blocks the walk) along the
|
||||
// boresight and return the entry point of the closest structure hit. The
|
||||
// authentic non-mech world pick (Mover::FindStaticSolidHitBy, the static-world
|
||||
// half of the engine's FindBoxedSolidHitBy). Defined in mech.cpp.
|
||||
Logical WorldStructurePick(const Point3D &start, const Vector3D &dir,
|
||||
Scalar max_range, Point3D *hit_world);
|
||||
|
||||
// Per-weapon beam render walk (task #33; extracted task #51 so it runs for
|
||||
// EVERY mech -- player, dummy masters, and MP replicants whose emitters
|
||||
// carry replicated discharge state). Defined in mech4.cpp.
|
||||
@@ -848,6 +882,8 @@ protected:
|
||||
Scalar jumpWalkSpeedMax; // @0x540
|
||||
Scalar jumpRunStrideLength; // @0x544
|
||||
Scalar jumpWalkStrideLength; // @0x548
|
||||
Scalar walkingTurnRate; // @0x574 (WalkingTurnRate, rad/s) -- the master
|
||||
Scalar runningTurnRate; // @0x578 (RunningTurnRate, rad/s) perf turn-rate lerp
|
||||
Scalar groundCycleRate; // @0x5b8
|
||||
Scalar airborneCycleRate; // @0x5bc
|
||||
// Forward-throttle scale read by the controls mapper (@004afd10:
|
||||
@@ -930,6 +966,7 @@ protected:
|
||||
void MeasureClipStride(int slot, Scalar *total, Scalar *lastKey); // @004a8054
|
||||
void LoadLocomotionClips(Mech__ModelResource *model); // @004a80d4
|
||||
void LoadLocomotionClipsExt(Mech__ModelResource *model); // @004a86c8
|
||||
void MaintainViewClipSet(); // task #59 port: interior(viewpoint)/exterior(others)
|
||||
|
||||
static int
|
||||
CreateSubsystemStream(
|
||||
@@ -1008,7 +1045,8 @@ protected:
|
||||
// Defined in mech4.cpp (Mech__DamageZone::vitalDamageZone is protected; Mech has access).
|
||||
int FirstVitalZone() const;
|
||||
int flags; // entity flags word (this+0x28 region)
|
||||
int stance; // posture state
|
||||
Scalar lastInflictingDamage; // task #60: killing-blow magnitude (port-only; reuses
|
||||
// the retired phantom `stance` slot -- feeds the kill score)
|
||||
int ammoState; // @0x44c 0 none / 1 leaking / 2 dry
|
||||
AlarmIndicator graphicAlarm; // body graphic-state alarm
|
||||
EntityID lastInflictingID; // @0x43c last attacker (read by DamageZone routing)
|
||||
|
||||
+196
-26
@@ -570,6 +570,20 @@ Scalar
|
||||
switch (legAnimationState) // this+0x3b0
|
||||
{
|
||||
case StandingAnimation: // 0
|
||||
// STANDING ZEROES THE CYCLE (reverse-stop desync, live-diagnosed
|
||||
// 2026-07-13): a REVERSE cadence is NEGATIVE, so the walk-family stop
|
||||
// gate (cycleSpeed <= ZeroSpeed) passes while still cycling at full
|
||||
// reverse speed, and several stand-entry paths (turn exit, terminal
|
||||
// poses) never touch the cycle -- Standing could be entered with a
|
||||
// stale legCycleSpeed = -2.507 ([gaitSM] state=0 evidence). The master
|
||||
// LOOKS still (case 0 never advances the clip) but the stale cycle
|
||||
// REPLICATES and the peer's replicant marches in place. A standing
|
||||
// mech's cycle is 0 (the clean forward-stop log: legSum ~3e-8).
|
||||
if (legCycleSpeed != 0.0f)
|
||||
{
|
||||
legCycleSpeed = 0.0f;
|
||||
ForceUpdate(8); // type-3 record: legs stopped
|
||||
}
|
||||
// RAW (part_012.c FUN_004a5028 case 0): standSpeed < commandedSpeed ->
|
||||
// begin WALKING (state 5); 0 <= commanded < standSpeed -> stay standing;
|
||||
// commanded < 0 -> stand-to-reverse (0x10). (The earlier draft had the
|
||||
@@ -581,18 +595,58 @@ Scalar
|
||||
else
|
||||
{
|
||||
distance = 0.0f;
|
||||
// TURN-IN-PLACE entry (bring-up TRIGGER, real machinery): the trn
|
||||
// clip/state-4 advance/exit are binary-verified, but the authentic
|
||||
// entry DISPATCHER isn't yet located in the decomp -- enter from
|
||||
// Standing on a live turn demand with no speed demand. The clip's
|
||||
// finished-callback drops back to stand; while the demand persists
|
||||
// this trigger re-arms it (a looping pivot step).
|
||||
// TURN-IN-PLACE entry -- AUTHENTIC, decoded from the master-perf disasm
|
||||
// (0x4aa505-0x4aa588, task #64b). The dispatcher lives in the master perf
|
||||
// FUN_004a9b5c (Ghidra never decompiled it -> objdump); it arms trn from
|
||||
// Standing when: the mech is TURNING (|angularVelocity| > 1e-4, ds:0x4ab16c
|
||||
// -- angularVelocity = turnDemand * turnRate, so turnDemand outside a tiny
|
||||
// deadband) AND 0 <= speedDemand <= standSpeed (the FULL sub-walk range,
|
||||
// ds:0x4ab178=0 .. mech[0x530]) AND turnCapable(mech[0x588]) AND the
|
||||
// legResetLatch(mech[0x654]) debounce is clear. CORRECTS the task-#64a
|
||||
// stand-in `commandedSpeed < 0.25*standSpeed` (that near-zero gate was a
|
||||
// guess; the binary allows the full [0,standSpeed] range -- trn is gated on
|
||||
// TURNING, not on being slow). [T1 from disasm] The turnDemand deadband
|
||||
// (0.05) is the port proxy for the |angVel|>1e-4 test. NOTE: the disasm's
|
||||
// legResetLatch gate is a master-perf ONE-FRAME debounce (cleared every
|
||||
// frame at 0x4a9bff, set on wind-down/turn-stop) -- it does NOT map onto
|
||||
// the port's split leg-SM (wind-down + trn-entry run in different frames/
|
||||
// cases and the port never per-frame-clears the latch), so gating entry on
|
||||
// it here would wrongly block trn after every walk. Omitted by design;
|
||||
// see locomotion.md "turn-in-place dispatcher".
|
||||
// REPLICANT accommodation (regression fix, 2026-07-14): the dispatcher
|
||||
// above is MASTER-perf logic; a replicant feeds this SM DERIVED signals
|
||||
// (speed/turn from the dead-reckon stream, mech4.cpp:1948) and runs ONLY
|
||||
// the leg channel. Two master rules break it:
|
||||
// (1) the body weld -- a replicant never runs the body SM, so arming the
|
||||
// body to 4 sticks bodyAnimationState there forever and BLOCKS every
|
||||
// later trn entry (the peer "rotates as a statue");
|
||||
// (2) the full [0,standSpeed] entry -- the derived speed sweeps that band
|
||||
// on every dead-reckoned start/stop with turnDemand pinned +-1, so
|
||||
// trn keeps arming mid-locomotion and speed-exiting (jerky walking).
|
||||
// Replicants: no body weld / no body arm (channel is inert, mj=0) and the
|
||||
// narrow near-zero entry gate (the pre-#64b accommodation). [T3]
|
||||
const int trnIsRepl = (GetInstance() == ReplicantInstance);
|
||||
const Scalar trnEntryMax = trnIsRepl ? standSpeed * 0.25f : standSpeed;
|
||||
if (hasCrashSet != 0 && mppr != 0
|
||||
&& ZeroSpeed <= commandedSpeed
|
||||
&& commandedSpeed >= ZeroSpeed && commandedSpeed <= trnEntryMax
|
||||
&& (mppr->turnDemand > 0.05f
|
||||
|| mppr->turnDemand < -0.05f))
|
||||
|| mppr->turnDemand < -0.05f)
|
||||
&& (trnIsRepl || bodyAnimationState == StandingAnimation)) // weld: masters only
|
||||
{
|
||||
SetLegAnimation(4); // turn-in-place (trn)
|
||||
// LOCKSTEP (task #64): arm BOTH channels on the same frame. Arming
|
||||
// only the leg let the body enter walk on its own schedule ~7-20
|
||||
// frames apart -> the two walk cycles ran permanently out of phase
|
||||
// and the body's pose flashed through on leg clip-boundary frames
|
||||
// (the rhythmic gait skip + reduced bob). Both SequenceControllers
|
||||
// bind the same trn clip at the same frame + advance at the same
|
||||
// rate (case 4 twins), so they complete + re-enter walk together --
|
||||
// the same phase-weld a standstill start gets for free. This is the
|
||||
// workflow plan's "master perf arms both channels" reconstruction
|
||||
// (the authentic dispatcher in the un-decompiled 0x4a9b5c gap arms
|
||||
// both -- the body's case-4/finish machinery is dead code otherwise).
|
||||
SetLegAnimation(4); // turn-in-place (trn), channel A
|
||||
if (!trnIsRepl)
|
||||
SetBodyAnimation(4); // channel B, same frame [lockstep, masters]
|
||||
goto advance_normally;
|
||||
}
|
||||
if (ZeroSpeed <= commandedSpeed)
|
||||
@@ -628,26 +682,51 @@ Scalar
|
||||
break;
|
||||
|
||||
case 4: // TURN-IN-PLACE (trn clip)
|
||||
// BINARY-VERIFIED (raw FUN_004a5028 case 4, part_012.c:12013): state 4 is
|
||||
// the turn-in-place animation (animationClips[4] = the "trn" clip, loaded
|
||||
// under turnCapable@0x588). It EXITS to stand when a real speed is
|
||||
// commanded (standSpeed < demand -- the earlier draft had this comparison
|
||||
// INVERTED, the same bug class as the Standing case) or when reverse is
|
||||
// commanded; otherwise it ADVANCES the turn clip while the mech pivots.
|
||||
if (standSpeed < commandedSpeed)
|
||||
// state 4 is the turn-in-place animation (animationClips[4] = the "trn" clip,
|
||||
// loaded under turnCapable@0x588). AUTHENTIC exits, restored VERBATIM from the
|
||||
// decompiled leg SM (part_012.c:12013 == FUN_004a5028 case 4 [T1]):
|
||||
// * standSpeed < commandedSpeed -> SetLevel(0)+ForceUpdate(8): walk takes over
|
||||
// * commandedSpeed < ZeroSpeed -> SetLevel(0)+ForceUpdate(8): reverse
|
||||
// * else -> goto advance_normally: advance the pivot at idleStrideScale.
|
||||
// PLUS the master-perf turn-STOP exit (disasm 0x4aa5c6-0x4aa5e6 [T1], which the
|
||||
// leg SM does NOT contain -- it lives in the un-decompiled master perf that the
|
||||
// port has no separate frame for, so it folds in here): when the turn stops
|
||||
// (|angularVelocity| <= 1e-4, i.e. turnDemand back inside the deadband) ->
|
||||
// SetLevel(0) + legResetLatch(mech[0x654])=1. Without it the trn clip would
|
||||
// shuffle in place forever after the stick re-centers.
|
||||
//
|
||||
// The task-#64 fast-forward (4x) + 0.25*standSpeed early-release were STAND-INS
|
||||
// invented to dodge a turn->walk stutter that was ACTUALLY the body channel
|
||||
// leaking joints into the rendered skeleton (mj=1); root-caused + fixed by the
|
||||
// body-channel mj=0 change (AdvanceBodyAnimation, mech4.cpp). With the body no
|
||||
// longer writing joints the authentic mid-clip cut renders cleanly, so the
|
||||
// inventions are removed. trn has zero root translation -> distance stays 0.
|
||||
if (standSpeed < commandedSpeed || commandedSpeed < ZeroSpeed)
|
||||
{
|
||||
legStateAlarm.SetLevel(0);
|
||||
ForceUpdate(8); // FUN_004a4c54(this,8): type-3 record
|
||||
break;
|
||||
}
|
||||
distance = 0.0f;
|
||||
if (commandedSpeed < ZeroSpeed)
|
||||
{
|
||||
legStateAlarm.SetLevel(0);
|
||||
legStateAlarm.SetLevel(0); // -> Standing (leg-SM exit)
|
||||
ForceUpdate(8); // type-3 record
|
||||
distance = 0.0f;
|
||||
break;
|
||||
}
|
||||
goto advance_normally;
|
||||
// MASTER ONLY (regression fix): the master-perf turn-stop exit is master-perf
|
||||
// logic (FUN_004a9b5c runs on MasterInstance mechs, NOT replicants). A replicant
|
||||
// derives turnDemand from the REPLICATED yaw rate (mech4.cpp:1968) -- a noisy
|
||||
// proxy that dips into the deadband between dead-reckon updates; running this
|
||||
// exit on it kicked the peer out of trn every few frames -> the peer "rotated as
|
||||
// a statue" + jerky (user-reported). Gate to masters; a replicant leaves trn via
|
||||
// the speed exits + its own turnDemand-driven re-entry, exactly as it did before
|
||||
// this exit was added.
|
||||
if (GetInstance() != ReplicantInstance
|
||||
&& (mppr == 0
|
||||
|| (mppr->turnDemand <= 0.05f && mppr->turnDemand >= -0.05f)))
|
||||
{
|
||||
legStateAlarm.SetLevel(0); // turn stopped -> Standing (master-perf exit)
|
||||
ForceUpdate(8);
|
||||
legResetLatch = 1; // mech[0x654] (master perf sets it here, 0x4aa5e1)
|
||||
distance = 0.0f;
|
||||
break;
|
||||
}
|
||||
goto advance_normally; // still turning, sub-walk -> advance the pivot
|
||||
|
||||
case 6: case 7: // WalkToRun
|
||||
//
|
||||
@@ -823,6 +902,20 @@ Scalar
|
||||
switch (bodyAnimationState) // this+0x728
|
||||
{
|
||||
case StandingAnimation: // 0
|
||||
// STANDING ZEROES THE CYCLE (reverse-stop desync, live-diagnosed
|
||||
// 2026-07-13): a REVERSE cadence is NEGATIVE, so the walk-family stop
|
||||
// gate (cycleSpeed <= ZeroSpeed) passes while still cycling at full
|
||||
// reverse speed, and several stand-entry paths (turn exit, terminal
|
||||
// poses) never touch the cycle -- Standing could be entered with a
|
||||
// stale bodyCycleSpeed = -2.507 ([gaitSM] state=0 evidence). The master
|
||||
// LOOKS still (case 0 never advances the clip) but the stale cycle
|
||||
// REPLICATES and the peer's replicant marches in place. A standing
|
||||
// mech's cycle is 0 (the clean forward-stop log: legSum ~3e-8).
|
||||
if (bodyCycleSpeed != 0.0f)
|
||||
{
|
||||
bodyCycleSpeed = 0.0f;
|
||||
ForceUpdate(8); // type-3 record: legs stopped
|
||||
}
|
||||
// RAW (FUN_004a5678 case 0): standSpeed < bodyTargetSpeed -> begin WALKING
|
||||
// (5); 0 <= target < standSpeed -> stay standing; target < 0 -> reverse
|
||||
// (0x10). (The earlier draft had the comparison INVERTED.)
|
||||
@@ -841,7 +934,44 @@ Scalar
|
||||
}
|
||||
// FALLTHROUGH
|
||||
|
||||
case 2: case 3: case 4: case 5: case 8: case 9: case 10: case 0x0b:
|
||||
case 4: // TURN-IN-PLACE, LOCKSTEP twin (task #64)
|
||||
// The body channel runs trn in LOCKSTEP with the leg: armed together at
|
||||
// entry (leg Standing cross-arms both), advanced at the SAME rate keyed on
|
||||
// the SAME live speedDemand, so both clips complete on the same frame and
|
||||
// both channels re-enter walk on the same frame. Without this the body
|
||||
// entered walk ~7-20 frames apart from the leg and the two walk cycles ran
|
||||
// permanently out of phase. Exits mirror the leg twin VERBATIM
|
||||
// (part_012.c:12013 [T1]): speed exits + the master-perf turn-stop exit.
|
||||
// The body does NOT set legResetLatch (leg-channel/master-perf state, set
|
||||
// once by the leg twin). Since the body no longer writes joints (mj=0,
|
||||
// AdvanceBodyAnimation) this channel only tracks state so both channels
|
||||
// re-enter walk on the same frame.
|
||||
{
|
||||
MechControlsMapper *bm = MappingMapper();
|
||||
const Scalar bspd = (bm != 0) ? bm->speedDemand : 0.0f;
|
||||
if (standSpeed < bspd || bspd < ZeroSpeed) // walk / reverse (leg-symmetric)
|
||||
{
|
||||
bodyStateAlarm.SetLevel(0);
|
||||
ForceUpdate(8);
|
||||
distance = 0.0f;
|
||||
break;
|
||||
}
|
||||
if (GetInstance() != ReplicantInstance // MASTER only (see leg twin)
|
||||
&& (bm == 0
|
||||
|| (bm->turnDemand <= 0.05f && bm->turnDemand >= -0.05f)))
|
||||
{
|
||||
bodyStateAlarm.SetLevel(0); // turn stopped (leg-symmetric)
|
||||
ForceUpdate(8);
|
||||
distance = 0.0f;
|
||||
break;
|
||||
}
|
||||
distance = bodyAnimation.Advance( // still turning -> advance the pivot
|
||||
time_slice * globalTimeScale * idleStrideScale, loop);
|
||||
bodyCycleSpeed = distance / time_slice;
|
||||
}
|
||||
break;
|
||||
|
||||
case 2: case 3: case 5: case 8: case 9: case 10: case 0x0b:
|
||||
case 0x0e: case 0x0f: case 0x10: case 0x11: case 0x14: case 0x15:
|
||||
case 0x1c: case 0x1d: case 0x1e: case 0x1f: case 0x20:
|
||||
distance = bodyAnimation.Advance( // FUN_0042790c(this+0x6bc, ...)
|
||||
@@ -869,6 +999,9 @@ Scalar
|
||||
if (bodyCycleSpeed > bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
||||
if (bodyCycleSpeed > walkStrideLength) bodyCycleSpeed = walkStrideLength;
|
||||
}
|
||||
if (peerMirrorSpeed >= 0.0f) // peer: cadence == actual mirrored ground speed
|
||||
bodyCycleSpeed = (peerMirrorSpeed < standSpeed) ? standSpeed
|
||||
: ((peerMirrorSpeed > walkStrideLength) ? walkStrideLength : peerMirrorSpeed);
|
||||
distance = bodyAnimation.Advance(
|
||||
time_slice * (bodyCycleSpeed / walkStrideLength) * globalTimeScale, loop);
|
||||
break;
|
||||
@@ -889,6 +1022,9 @@ Scalar
|
||||
if (bodyCycleSpeed > bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
||||
if (bodyCycleSpeed > reverseSpeedMax2) bodyCycleSpeed = reverseSpeedMax2;
|
||||
}
|
||||
if (peerMirrorSpeed >= 0.0f) // peer: cadence == actual mirrored ground speed
|
||||
bodyCycleSpeed = (peerMirrorSpeed < reverseSpeedMax) ? reverseSpeedMax
|
||||
: ((peerMirrorSpeed > reverseSpeedMax2) ? reverseSpeedMax2 : peerMirrorSpeed);
|
||||
distance = bodyAnimation.Advance(
|
||||
time_slice * (bodyCycleSpeed / reverseStrideLength) * globalTimeScale, loop);
|
||||
break;
|
||||
@@ -979,6 +1115,20 @@ Scalar
|
||||
switch (bodyAnimationState)
|
||||
{
|
||||
case StandingAnimation: // 0
|
||||
// STANDING ZEROES THE CYCLE (reverse-stop desync, live-diagnosed
|
||||
// 2026-07-13): a REVERSE cadence is NEGATIVE, so the walk-family stop
|
||||
// gate (cycleSpeed <= ZeroSpeed) passes while still cycling at full
|
||||
// reverse speed, and several stand-entry paths (turn exit, terminal
|
||||
// poses) never touch the cycle -- Standing could be entered with a
|
||||
// stale bodyCycleSpeed = -2.507 ([gaitSM] state=0 evidence). The master
|
||||
// LOOKS still (case 0 never advances the clip) but the stale cycle
|
||||
// REPLICATES and the peer's replicant marches in place. A standing
|
||||
// mech's cycle is 0 (the clean forward-stop log: legSum ~3e-8).
|
||||
if (bodyCycleSpeed != 0.0f)
|
||||
{
|
||||
bodyCycleSpeed = 0.0f;
|
||||
ForceUpdate(8); // type-3 record: legs stopped
|
||||
}
|
||||
if (bodyTargetSpeed <= standSpeed) // 0x6b4 <= 0x530
|
||||
{
|
||||
distance = 0.0f;
|
||||
@@ -1015,6 +1165,9 @@ Scalar
|
||||
if (bodyCycleSpeed > bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
||||
if (bodyCycleSpeed > walkStrideLength) bodyCycleSpeed = walkStrideLength;
|
||||
}
|
||||
if (peerMirrorSpeed >= 0.0f) // peer: cadence == actual mirrored ground speed
|
||||
bodyCycleSpeed = (peerMirrorSpeed < standSpeed) ? standSpeed
|
||||
: ((peerMirrorSpeed > walkStrideLength) ? walkStrideLength : peerMirrorSpeed);
|
||||
distance = bodyAnimation.Advance(
|
||||
time_slice * (bodyCycleSpeed / walkStrideLength) * globalTimeScale, loop);
|
||||
break;
|
||||
@@ -1035,6 +1188,9 @@ Scalar
|
||||
if (bodyCycleSpeed > bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
||||
if (bodyCycleSpeed > reverseSpeedMax2) bodyCycleSpeed = reverseSpeedMax2;
|
||||
}
|
||||
if (peerMirrorSpeed >= 0.0f) // peer: cadence == actual mirrored ground speed
|
||||
bodyCycleSpeed = (peerMirrorSpeed < reverseSpeedMax) ? reverseSpeedMax
|
||||
: ((peerMirrorSpeed > reverseSpeedMax2) ? reverseSpeedMax2 : peerMirrorSpeed);
|
||||
distance = bodyAnimation.Advance(
|
||||
time_slice * (bodyCycleSpeed / reverseStrideLength) * globalTimeScale, loop);
|
||||
break;
|
||||
@@ -1166,6 +1322,20 @@ Scalar
|
||||
switch (legAnimationState)
|
||||
{
|
||||
case StandingAnimation: // 0
|
||||
// STANDING ZEROES THE CYCLE (reverse-stop desync, live-diagnosed
|
||||
// 2026-07-13): a REVERSE cadence is NEGATIVE, so the walk-family stop
|
||||
// gate (cycleSpeed <= ZeroSpeed) passes while still cycling at full
|
||||
// reverse speed, and several stand-entry paths (turn exit, terminal
|
||||
// poses) never touch the cycle -- Standing could be entered with a
|
||||
// stale legCycleSpeed = -2.507 ([gaitSM] state=0 evidence). The master
|
||||
// LOOKS still (case 0 never advances the clip) but the stale cycle
|
||||
// REPLICATES and the peer's replicant marches in place. A standing
|
||||
// mech's cycle is 0 (the clean forward-stop log: legSum ~3e-8).
|
||||
if (legCycleSpeed != 0.0f)
|
||||
{
|
||||
legCycleSpeed = 0.0f;
|
||||
ForceUpdate(8); // type-3 record: legs stopped
|
||||
}
|
||||
if (motionSource->commandedSpeed <= standSpeed) // +0x128 <= 0x530
|
||||
{
|
||||
distance = 0.0f;
|
||||
|
||||
+204
-17
@@ -470,31 +470,218 @@ void
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
// LoadLocomotionClipsExt
|
||||
// LoadLocomotionClipsExt (the INTERIOR clip set)
|
||||
//
|
||||
// @004a86c8
|
||||
//
|
||||
// Structurally identical to LoadLocomotionClips, but reads from the *second*
|
||||
// gait-clip suffix table at 0050d94d -- these are 4-character codes (note the
|
||||
// extra uStack_19 byte the decomp materialises in the name buffer) -- and the
|
||||
// optional-set markers also carry the extra character. Used for the mech
|
||||
// configuration whose clip names do not fit the 3-letter scheme. The cached
|
||||
// targets (0x530/0x534/0x538/0x34c/0x52c/0x350, the optional sets, and the
|
||||
// capability flags) are exactly the same as the base loader.
|
||||
//
|
||||
// (Body omitted here for brevity; it differs from LoadLocomotionClips only in
|
||||
// the suffix-string operands and the 4-char name length. See @004a86c8 for
|
||||
// the byte-exact form -- the call/measure/store sequence is line-for-line
|
||||
// identical.)
|
||||
// The 4-character suffix table @0050d94d is the 3-letter codes with a trailing
|
||||
// 'i' (swri, wwri, wwli, ... -- byte-verified from BTL4OPT.EXE @file 0x10d74d).
|
||||
// These are the INTERIOR ('i') gait clips, bound to the LOCAL cockpit MASTER
|
||||
// (the ctor gate @part_012.c:10308-10320): they animate `jointshakey` (the
|
||||
// cockpit shake node) and OMIT the `jointhip` walk-lean channel the exterior
|
||||
// clips carry -- so the pilot's own view stays level while a walking mech
|
||||
// leans into its stride for everyone ELSE (the exterior clips, network
|
||||
// replicants + the L4VIEWEXT external view). The call/measure/store sequence
|
||||
// and the cached targets (0x530/0x534/0x538/0x34c/0x52c/0x350, the optional
|
||||
// sets, the capability flags) are LINE-FOR-LINE identical to the 3-char
|
||||
// loader; only the suffix operands differ (each gains the 'i'). [T1: byte-
|
||||
// exact against @004a86c8 + the suffix-table dump.]
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
void
|
||||
Mech::LoadLocomotionClipsExt(Mech__ModelResource *model)
|
||||
{
|
||||
// identical control flow to LoadLocomotionClips with the 0050d94d table;
|
||||
// see banner. Reconstructed as a thin alias to keep the cached-field
|
||||
// contract documented in one place.
|
||||
LoadLocomotionClips(model); // PLACEHOLDER for the 4-char variant @004a86c8
|
||||
const char *prefix = model->animationPrefix; // param_2 + 0x40
|
||||
|
||||
gyroRumbleTimer = 0.0f; // this+0x5c4
|
||||
|
||||
// --- stand -> walk : standSpeed is that clip's final-keyframe stride ----
|
||||
namedClip[ 0] = *ResolveAnimationClip(prefix, "swri"); // 0x5e0
|
||||
legAnimation.SelectSequence(namedClip[0], PTR_LAB_0050d714, DAT_0050d718, DAT_0050d71c);
|
||||
standSpeed = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x530
|
||||
|
||||
// --- forward walk/run : stride = (s6+s7)/(d6+d7) ------------------------
|
||||
Scalar s0, d0, s1, d1;
|
||||
namedClip[ 1] = *ResolveAnimationClip(prefix, "wwri"); // 0x5e4
|
||||
MeasureClipStride(6, &s0, &d0);
|
||||
namedClip[ 2] = *ResolveAnimationClip(prefix, "wwli"); // 0x5e8
|
||||
MeasureClipStride(7, &s1, &d1);
|
||||
walkStrideLength = (s0 + s1) / (d0 + d1); // 0x534
|
||||
|
||||
namedClip[ 3] = *ResolveAnimationClip(prefix, "wsri"); // 0x5ec walk->stand R
|
||||
namedClip[ 4] = *ResolveAnimationClip(prefix, "wsli"); // 0x5f0 walk->stand L
|
||||
|
||||
// --- reverse base : reverseSpeedMax is that clip's final-keyframe stride -
|
||||
namedClip[ 5] = *ResolveAnimationClip(prefix, "wrri"); // 0x5f4
|
||||
legAnimation.SelectSequence(namedClip[5], PTR_LAB_0050d720, DAT_0050d724, DAT_0050d728);
|
||||
reverseSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x538
|
||||
|
||||
namedClip[ 6] = *ResolveAnimationClip(prefix, "wrli"); // 0x5f8
|
||||
|
||||
// --- reverse cycle : stride = (s_c+s_d)/(d_c+d_d) -----------------------
|
||||
namedClip[ 7] = *ResolveAnimationClip(prefix, "rrri"); // 0x5fc
|
||||
MeasureClipStride(0x0c, &s0, &d0);
|
||||
namedClip[ 8] = *ResolveAnimationClip(prefix, "rrli"); // 0x600
|
||||
MeasureClipStride(0x0d, &s1, &d1);
|
||||
reverseStrideLength = (s0 + s1) / (d0 + d1); // 0x34c
|
||||
|
||||
// REVERSE + BUMP set (same slot map as the 3-char loader; see its banner) --
|
||||
animationClips[14] = *ResolveAnimationClip(prefix, "rwri"); // 0x604 state 0xE run->walk R
|
||||
animationClips[15] = *ResolveAnimationClip(prefix, "rwli"); // 0x608 state 0xF run->walk L
|
||||
animationClips[0x20] = *ResolveAnimationClip(prefix, "bmpi");// 0x64c bump/stagger clip
|
||||
gimpBaseClip = animationClips[0x20];
|
||||
|
||||
animationClips[16] = *ResolveAnimationClip(prefix, "sbri"); // 0x60c state 0x10 stand->back R
|
||||
legAnimation.SelectSequence(animationClips[16], PTR_LAB_0050d72c, DAT_0050d730, DAT_0050d734);
|
||||
gimpSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x52c
|
||||
|
||||
animationClips[17] = *ResolveAnimationClip(prefix, "sbli"); // 0x610 state 0x11 stand->back L
|
||||
animationClips[20] = *ResolveAnimationClip(prefix, "bsri"); // 0x61c state 0x14 back->stand R
|
||||
animationClips[21] = *ResolveAnimationClip(prefix, "bsli"); // 0x620 state 0x15 back->stand L
|
||||
|
||||
// --- reverse cycle : stride = -(s+s)/(d+d), stored negative (carried s1/d1)
|
||||
animationClips[18] = *ResolveAnimationClip(prefix, "bbri"); // 0x614 state 0x12 back cycle R
|
||||
MeasureClipStride(0x12, &s0, &d0);
|
||||
animationClips[19] = *ResolveAnimationClip(prefix, "bbli"); // 0x618 state 0x13 back cycle L
|
||||
MeasureClipStride(0x13, &s0, &d0);
|
||||
gimpStrideLength = -((s0 + s1) / (d0 + d1)); // 0x350
|
||||
|
||||
//
|
||||
// ---- OPTIONAL clip set 1: JUMP JETS (probe "wgli") ----
|
||||
//
|
||||
jumpCapable = 0; // this+0x580
|
||||
if (ResolveAnimationClip(prefix, "wgli") != 0)
|
||||
{
|
||||
jumpCapable = 1;
|
||||
animationClips[22] = *ResolveAnimationClip(prefix, "wgli"); // 0x624 state 0x16
|
||||
legAnimation.SelectSequence(animationClips[22], PTR_LAB_0050d738, DAT_0050d73c, DAT_0050d740);
|
||||
jumpRunSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x53c
|
||||
|
||||
animationClips[23] = *ResolveAnimationClip(prefix, "wgri"); // 0x628 state 0x17
|
||||
legAnimation.SelectSequence(animationClips[23], PTR_LAB_0050d744, DAT_0050d748, DAT_0050d74c);
|
||||
jumpWalkSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x540
|
||||
|
||||
animationClips[24] = *ResolveAnimationClip(prefix, "ggri"); // 0x62c state 0x18
|
||||
MeasureClipStride(0x18, &s0, &d0);
|
||||
jumpRunStrideLength = s0 / d0; // 0x544
|
||||
|
||||
animationClips[25] = *ResolveAnimationClip(prefix, "ggli"); // 0x630 state 0x19
|
||||
MeasureClipStride(0x19, &s0, &d0);
|
||||
jumpWalkStrideLength = s0 / d0; // 0x548
|
||||
|
||||
animationClips[26] = *ResolveAnimationClip(prefix, "gsli"); // 0x634 state 0x1A
|
||||
animationClips[27] = *ResolveAnimationClip(prefix, "gsri"); // 0x638 state 0x1B
|
||||
}
|
||||
|
||||
//
|
||||
// ---- OPTIONAL clip set 2: SQUAT (probe "squi") ----
|
||||
//
|
||||
hasReverseGimpSet = 0; // this+0x584 (squatCapable)
|
||||
if (ResolveAnimationClip(prefix, "squi") != 0)
|
||||
{
|
||||
hasReverseGimpSet = 1;
|
||||
animationClips[3] = *ResolveAnimationClip(prefix, "squi"); // 0x5d8
|
||||
animationClips[2] = *ResolveAnimationClip(prefix, "sqdi"); // 0x5d4
|
||||
}
|
||||
|
||||
//
|
||||
// ---- OPTIONAL clip set 3: TURN-IN-PLACE (probe "trni") ----
|
||||
//
|
||||
hasCrashSet = 0; // this+0x588 (turnCapable)
|
||||
if (ResolveAnimationClip(prefix, "trni") != 0)
|
||||
{
|
||||
hasCrashSet = 1;
|
||||
animationClips[4] = *ResolveAnimationClip(prefix, "trni"); // 0x5dc
|
||||
}
|
||||
DEBUG_STREAM << "[loadclips] INTERIOR ('i') set: fScale=" << forwardThrottleScale
|
||||
<< " walkStride=" << walkStrideLength << " jumpCapable=" << jumpCapable
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
// MaintainViewClipSet (port adaptation, task #59)
|
||||
//
|
||||
// The authentic clip-set gate (@part_012.c:10308-10320) keys on the replicant
|
||||
// COPY bit (instanceFlags & 0xC == 4), which the port never sets -- ALL MP mechs
|
||||
// are built as local masters (replication rides the mech registry + update
|
||||
// records, not the copy-performance path; the heat sim / scoring / torso
|
||||
// watcher-connect all run on both, by design). So the ctor gate lands every
|
||||
// mech on the INTERIOR set. We restore the authentic RESULT by selecting the
|
||||
// set by VIEWPOINT instead, maintained per frame: the mech you PILOT (== the
|
||||
// viewpoint entity) keeps the INTERIOR set (level cockpit -- you never see your
|
||||
// own mech externally in the real pod); every OTHER mech gets the EXTERIOR set
|
||||
// (the authored -8deg jointhip walk lean, which you DO see from your cockpit /
|
||||
// the chase cam). Reloads only on a viewpoint-status CHANGE (no per-frame
|
||||
// thrash), and the two sets share identical stride data so the swap is seamless.
|
||||
// The model pointer is stashed at ctor (the GameModel resource persists for the
|
||||
// mission). Fixed array -- mech counts are tiny, no allocator in the tick path.
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
namespace {
|
||||
struct BTClipEntry { const Mech *mech; Mech__ModelResource *model; int interior; };
|
||||
BTClipEntry s_clipReg[32];
|
||||
int s_clipRegCount = 0;
|
||||
}
|
||||
|
||||
void BTStashClipState(const Mech *m, Mech__ModelResource *model, int interior)
|
||||
{
|
||||
for (int i = 0; i < s_clipRegCount; ++i)
|
||||
if (s_clipReg[i].mech == m)
|
||||
{
|
||||
s_clipReg[i].model = model;
|
||||
s_clipReg[i].interior = interior;
|
||||
return;
|
||||
}
|
||||
if (s_clipRegCount < 32)
|
||||
{
|
||||
s_clipReg[s_clipRegCount].mech = m;
|
||||
s_clipReg[s_clipRegCount].model = model;
|
||||
s_clipReg[s_clipRegCount].interior = interior;
|
||||
++s_clipRegCount;
|
||||
}
|
||||
}
|
||||
|
||||
void BTUnstashClipState(const Mech *m)
|
||||
{
|
||||
for (int i = 0; i < s_clipRegCount; ++i)
|
||||
if (s_clipReg[i].mech == m)
|
||||
{
|
||||
s_clipReg[i] = s_clipReg[--s_clipRegCount];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
Mech::MaintainViewClipSet()
|
||||
{
|
||||
if (application == 0 || !BTEnvOn("BT_GAIT_CUTOVER", 1))
|
||||
return;
|
||||
if (getenv("L4VIEWEXT") != 0) // forced-external debug view: leave EXTERIOR everywhere
|
||||
return;
|
||||
|
||||
Entity *vp = application->GetViewpointEntity();
|
||||
if (vp == 0) // viewpoint not assigned yet -- keep the current set
|
||||
return;
|
||||
|
||||
BTClipEntry *e = 0;
|
||||
for (int i = 0; i < s_clipRegCount; ++i)
|
||||
if (s_clipReg[i].mech == this) { e = &s_clipReg[i]; break; }
|
||||
if (e == 0 || e->model == 0) // not registered / no model -- nothing to flip
|
||||
return;
|
||||
|
||||
const int wantInterior = ((Entity *)this == vp) ? 1 : 0;
|
||||
if (wantInterior == e->interior) // already holding the right set
|
||||
return;
|
||||
|
||||
if (wantInterior)
|
||||
LoadLocomotionClipsExt(e->model); // interior: level cockpit (your own mech)
|
||||
else
|
||||
LoadLocomotionClips(e->model); // exterior: the authored walk lean (every other mech)
|
||||
e->interior = wantInterior;
|
||||
DEBUG_STREAM << "[clipfix] mech " << (void *)this << " -> "
|
||||
<< (wantInterior ? "INTERIOR (level)" : "EXTERIOR (lean)") << "\n" << std::flush;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1477
-75
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@
|
||||
// FUN_00408050 (Scalar)Random -> [0,1) FUN_0040807c(rng,n) Random(n)
|
||||
// FUN_004ac07c Subsystem::ApplyDamage(damage) -> Scalar
|
||||
// FUN_004b11bc Generator::ForceShortRecovery (class derivations @0050f4bc)
|
||||
// FUN_0049fb54 Mech::<query torso/leg destroyed state>
|
||||
// FUN_0049fb54 Mech::IsDisabled() (movementMode 2||9; NOT a torso/leg query)
|
||||
// FUN_0040485c/00404720/00404088/00404190/004040d8 NotationFile readers
|
||||
// FUN_00402298/004022b0/004022d0 operator new / array new / delete
|
||||
// FUN_004dbb24/004db92c/004d9c38/004dbd4c DebugStream << / endl
|
||||
@@ -424,7 +424,14 @@ void
|
||||
}
|
||||
|
||||
// ---- real zone: apply to armour/structure ----
|
||||
Scalar preLvl = damageLevel;
|
||||
DamageZone::TakeDamage(damage); // FUN_0041e4e0
|
||||
if (getenv("BT_DMG_LOG"))
|
||||
DEBUG_STREAM << "[dmghit] mech=" << mech->GetEntityID()
|
||||
<< " zone=" << damageZoneIndex << " vital=" << (int)vitalDamageZone
|
||||
<< " type=" << (int)damage.damageType << " amt=" << damage.damageAmount
|
||||
<< " burst=" << damage.burstCount
|
||||
<< " lvl " << preLvl << "->" << damageLevel << "\n" << std::flush;
|
||||
|
||||
Mech__DamageZone *parent = parentArtifactZone.GetCurrent(); // FUN_00417ab4(this+0x62)
|
||||
if (parent != 0)
|
||||
@@ -448,18 +455,26 @@ void
|
||||
if (damageLevel >= StructureMax)
|
||||
RecurseSegmentTable(mech); // FUN_0049cad4
|
||||
}
|
||||
else if ((unsigned)(mech->stance - 3) < 2) // standing/walking
|
||||
else if ((unsigned)(mech->MovementMode() - 3) < 2) // live sim state 3||4 (mech+0x40)
|
||||
{
|
||||
// task #60: the gate reads the LIVE MovementMode (mech+0x40),
|
||||
// @part_012.c:6910 `*(int*)(iVar1+0x40) - 3U < 2` -- NOT the phantom
|
||||
// `stance` member (no binary offset, zero writers -> perma-0, so
|
||||
// this whole leg-shot-out -> fall/death branch was DEAD).
|
||||
if (rightLeg != 0 && damageLevel >= StructureMax)
|
||||
mech->graphicAlarm.SetLevel(9); // fall / leg gone
|
||||
else if (leftLeg != 0 && damageLevel >= StructureMax)
|
||||
mech->graphicAlarm.SetLevel(9);
|
||||
}
|
||||
else if (mech->IsAirborne() == 0) // FUN_0049fb54
|
||||
else if (mech->IsDisabled() == 0) // FUN_0049fb54 (NOT disabled)
|
||||
{
|
||||
// Non-standing stance, still grounded: a half-destroyed leg zone
|
||||
// trips the partial-failure graphic (right -> 4, left -> 3). The
|
||||
// oracle compares against _DAT_0049c9a0 (0.5), NOT StructureMax.
|
||||
// A live (non-disabled) mech: a half-destroyed leg zone trips the
|
||||
// partial-failure graphic (right -> 4, left -> 3). The oracle
|
||||
// compares against _DAT_0049c9a0 (0.5), NOT StructureMax. task #60:
|
||||
// the guard is IsDisabled (@0049fb54 = movementMode 2||9), NOT the
|
||||
// always-0 IsAirborne stub -- on a WRECK the binary SUPPRESSES this
|
||||
// write; the stub let it corrupt graphicAlarm 9->4/3 (the task-#52
|
||||
// wreck-graphic bug, now fixed at source not just masked).
|
||||
if (rightLeg != 0 && damageLevel >= LegHalfStructure)
|
||||
mech->graphicAlarm.SetLevel(4);
|
||||
else if (leftLeg != 0 && damageLevel >= LegHalfStructure)
|
||||
@@ -1092,8 +1107,21 @@ void
|
||||
}
|
||||
if (d != 0)
|
||||
{
|
||||
BTSpawnDamageEffect(owner, d->effectResource, // explosion AT the zone's
|
||||
zone->segmentIndex); // segment (world position)
|
||||
// THE AUTHENTIC CHAIN (@004d097c dispatcher, landed 2026-07-13):
|
||||
// RendererManager::StartEntityEffect -> the Renderer message
|
||||
// handler [T0: GameModel resolve + ExplosionResourceTable
|
||||
// graphic-state remap] -> BTL4VideoRenderer::
|
||||
// StartEntityEffectImplementation -> the effect ATTACHED to the
|
||||
// zone's segment (smoke follows the walking mech) -- and the
|
||||
// AudioRenderer's implementation hears the same message (zone
|
||||
// sounds ride free). Explosion::Make fallback only when the
|
||||
// manager isn't up (early bring-up).
|
||||
extern int BTStartZoneEffect(Mech *mech, void *zone, int resource);
|
||||
if (!BTStartZoneEffect(owner, zone, d->effectResource))
|
||||
{
|
||||
BTSpawnDamageEffect(owner, d->effectResource, // explosion AT the zone's
|
||||
zone->segmentIndex); // segment (world position)
|
||||
}
|
||||
zone->ApplyDamageGraphicState(d->graphicState); // destroyed skin (graphic state)
|
||||
// A graphic-state change means the segment's MODEL changed (intact ->
|
||||
// destroyed variant, keyed by GetVideoObjectName(skl, gstate)). Fire
|
||||
|
||||
@@ -211,6 +211,12 @@
|
||||
int
|
||||
segmentIndex; // @0x194 GetSegmentIndex() result
|
||||
|
||||
public:
|
||||
// The zone's segment slot -- consumed by the @004d097c per-zone effect
|
||||
// dispatcher (BTL4VideoRenderer::StartEntityEffectImplementation).
|
||||
int EffectSegmentIndex() const { return segmentIndex; }
|
||||
protected:
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Damage Support
|
||||
//
|
||||
|
||||
+105
-16
@@ -440,6 +440,18 @@ void
|
||||
Check(message);
|
||||
|
||||
if (message->dataContents > 0)
|
||||
{
|
||||
CycleControlModeNow();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// The mode-cycle body, shared by the console-button message handler above and
|
||||
// the desktop 'M' key (mech4 key poll -> gBTModeCycle).
|
||||
//
|
||||
void
|
||||
MechControlsMapper::CycleControlModeNow()
|
||||
{
|
||||
{
|
||||
controlMode = (ControlMode)(controlMode + 1);
|
||||
if (controlMode > VeteranMode)
|
||||
@@ -448,23 +460,28 @@ void
|
||||
}
|
||||
NotifyOfControlModeChange(controlMode); // vtable+0x48
|
||||
|
||||
Mech *mech = GetMech();
|
||||
int torso = *(int *)((int)mech + 0x438);
|
||||
int cockpit = *(int *)((int)mech + 0x5b4);
|
||||
|
||||
if (controlMode == BasicMode)
|
||||
// TYPED torso reconfiguration (2026-07-13): the raw block this
|
||||
// replaces wrote the BINARY's offsets (torso+0x1f0/0x274/0x220...)
|
||||
// straight onto OUR compiled Torso -- the databinding trap: garbage
|
||||
// writes into whatever members live there in this build. The
|
||||
// observable semantics via named members: Basic clears the analog
|
||||
// axes and recenters (the sim's centerCommand -> Recenter); the
|
||||
// assisted modes just free the torso (the sim clamps to the authored
|
||||
// limits on its own).
|
||||
Mech *mech = GetMech();
|
||||
Torso *torso = (mech != 0) ? (Torso *)mech->GetTorsoSubsystem() : 0;
|
||||
if (torso != 0)
|
||||
{
|
||||
*(LWord *)(torso + 0x1f0) = 0; // no free aim
|
||||
*(LWord *)(torso + 0x274) = 1; // auto-center on
|
||||
*(LWord *)(torso + 0x220) = *(LWord *)(torso + 0x228); // recenter yaw
|
||||
*(LWord *)(torso + 0x224) = *(LWord *)(torso + 0x22c); // recenter pitch
|
||||
*(LWord *)(cockpit + 0x2a0) = 1;
|
||||
}
|
||||
else if ((unsigned)(controlMode - 1) < 2) // Standard / Veteran
|
||||
{
|
||||
*(LWord *)(torso + 0x220) = *(LWord *)(torso + 0x230); // yaw -> limit
|
||||
*(LWord *)(torso + 0x224) = *(LWord *)(torso + 0x234); // pitch -> limit
|
||||
if (controlMode == BasicMode)
|
||||
{
|
||||
torso->SetAnalogTwistAxis(0.0f);
|
||||
torso->SetAnalogElevationAxis(0.0f);
|
||||
torso->CommandRecenter();
|
||||
}
|
||||
// Standard/Veteran: nothing to force -- the sim's limits govern.
|
||||
}
|
||||
DEBUG_STREAM << "[mode] control mode -> " << (int)controlMode
|
||||
<< " (0=Basic 1=Standard 2=Veteran)" << std::endl;
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
@@ -632,6 +649,31 @@ void
|
||||
key_throttle = 0.08f; // near-idle half-cycle
|
||||
}
|
||||
}
|
||||
// BT_FORCE_SWEEP=<period>: triangle-sweep the forced throttle 0.2..0.9
|
||||
// continuously -- mimics a keyboard key-hold sweeping the lever every
|
||||
// frame (a type-2 speed record per frame), the keyboard-skip repro.
|
||||
{
|
||||
static float s_swpP = -1.0f, s_swpClock = 0.0f;
|
||||
if (s_swpP < 0.0f)
|
||||
{
|
||||
const char *fw = getenv("BT_FORCE_SWEEP");
|
||||
s_swpP = fw ? (float)atof(fw) : 0.0f;
|
||||
}
|
||||
if (s_swpP > 0.0f)
|
||||
{
|
||||
s_swpClock += time_slice;
|
||||
float ph = fmodf(s_swpClock, 2.0f * s_swpP) / s_swpP; // 0..2
|
||||
float tri = (ph < 1.0f) ? ph : (2.0f - ph); // 0..1..0
|
||||
key_throttle = 0.2f + 0.7f * tri;
|
||||
// BT_FORCE_STEP=1: quantize the swept throttle to the RIO
|
||||
// Ranger 0.05 grid (the authentic ADC/deadband step) -- the
|
||||
// stepped-vs-continuous A/B for the speed-change glitch.
|
||||
static int s_swpStep = -1;
|
||||
if (s_swpStep < 0) s_swpStep = getenv("BT_FORCE_STEP") ? 1 : 0;
|
||||
if (s_swpStep)
|
||||
key_throttle = floorf(key_throttle / 0.05f + 0.5f) * 0.05f;
|
||||
}
|
||||
}
|
||||
// BT_FORCE_FLIP=<t>: invert the forced throttle after t sim-seconds
|
||||
// (the mid-stride direction-change repro).
|
||||
static float s_hFlip = -1.0f;
|
||||
@@ -655,7 +697,54 @@ void
|
||||
}
|
||||
throttlePosition = (key_throttle >= 0.0f) ? key_throttle : -key_throttle;
|
||||
reverseThrust = (key_throttle < 0.0f) ? 1 : 0;
|
||||
stickPosition.x = key_turn;
|
||||
// CONTROL-MODE AXIS ROUTING (2026-07-13, the pod mapping): in
|
||||
// BASIC the stick steers the legs (turn) and the torso auto-
|
||||
// centers; in STANDARD/VETERAN the stick is the TORSO (free aim /
|
||||
// twist) and the PEDALS steer -- so the keyboard's A/D feed the
|
||||
// pedals and Q/E ('the stick') feed the twist. 'M' cycles modes
|
||||
// (the same body the pod console button message drives, so the
|
||||
// CONTROL MODE gauge tracks).
|
||||
{
|
||||
extern int gBTModeCycle;
|
||||
extern float gBTTwistAxis;
|
||||
extern int gBTTorsoRecenter;
|
||||
if (gBTModeCycle)
|
||||
{
|
||||
gBTModeCycle = 0;
|
||||
CycleControlModeNow();
|
||||
}
|
||||
if (controlMode == BasicMode)
|
||||
{
|
||||
stickPosition.x = key_turn;
|
||||
pedalsPosition = 0.0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
stickPosition.x = gBTTwistAxis; // the torso axis
|
||||
pedalsPosition = key_turn; // A/D = the pedals
|
||||
// 'X' recenter pulse (2026-07-13): one-frame centerCommand
|
||||
// -> the sim arms recenterActive and Recenter (@004b6918)
|
||||
// slews the torso home; any Q/E input cancels it sim-side.
|
||||
// centerCommand is a pod BUTTON state, so the writer clears
|
||||
// it while unpressed (Basic's own path re-asserts every
|
||||
// frame; this branch owns it in Standard/Veteran).
|
||||
{
|
||||
Torso *rcTorso = (Torso *)mech->GetTorsoSubsystem();
|
||||
if (rcTorso != 0)
|
||||
{
|
||||
if (gBTTorsoRecenter)
|
||||
{
|
||||
gBTTorsoRecenter = 0;
|
||||
rcTorso->CommandRecenter();
|
||||
}
|
||||
else
|
||||
{
|
||||
rcTorso->ClearRecenterCommand();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stickPosition.y = 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,11 @@ class Pilot;
|
||||
CycleControlModeMessageHandler( // @004afbe0
|
||||
ReceiverDataMessageOf<ControlsButton> *message
|
||||
);
|
||||
|
||||
// The shared mode-cycle body (the console-button handler above + the
|
||||
// desktop 'M' key both drive it).
|
||||
void
|
||||
CycleControlModeNow();
|
||||
void
|
||||
CycleDisplayModeMessageHandler( // @004afcac
|
||||
ReceiverDataMessageOf<ControlsButton> *message
|
||||
|
||||
@@ -435,11 +435,33 @@ namespace Vector
|
||||
//
|
||||
// P3 GAIT CUTOVER: backed by the real engine Quaternion (were no-op stubs).
|
||||
// ReconQuatIdentity(q, &id) -> q = Quaternion::Identity
|
||||
// ReconQuatIntegrate(out,in,d) -> out = in integrated by angular delta d
|
||||
// (engine Quaternion::Add(source, Vector3D) == FUN_00409f58)
|
||||
// ReconQuatIntegrate(out,in,d) -> out = in composed with an EXACT rotation by
|
||||
// angular delta d (= the real FUN_00409f58).
|
||||
// All callers pass (Quaternion*,Quaternion*) / (Quaternion*,Quaternion*,Vector3D*).
|
||||
inline void ReconQuatIdentity(Quaternion *q, const Quaternion * /*id*/) { *q = Quaternion::Identity; }
|
||||
inline void ReconQuatIntegrate(Quaternion *out, const Quaternion *in, const Vector3D *delta) { out->Add(*in, *delta); }
|
||||
// EXACT axis-angle quaternion integration -- the real FUN_00409f58 (part_000.c:9359),
|
||||
// verified from the decomp. A prior stand-in wrongly used `out->Add(*in,*delta)` (a
|
||||
// small-angle VECTOR add): correct every-frame on the master (tiny angle) but it
|
||||
// DIVERGES over a long dead-reckon gap -- the spinning-peer heading drifting to ~180deg
|
||||
// then snapping. The binary builds the unit rotation quaternion from the rotation vector
|
||||
// `delta` (angle=|delta|, axis=delta/angle) as { axis*sin(angle/2), cos(angle/2) } and
|
||||
// Hamilton-multiplies it onto `in` (FUN_00409d9c: out = in (X) deltaQuat).
|
||||
inline void ReconQuatIntegrate(Quaternion *out, const Quaternion *in, const Vector3D *delta)
|
||||
{
|
||||
const Scalar ang = delta->Length();
|
||||
if (ang > 1.0e-6f)
|
||||
{
|
||||
const Scalar half = (Scalar)(fmodf((float)ang, 6.2831853f) * 0.5f); // reduce2pi, then halve
|
||||
const Scalar s = (Scalar)(sinf((float)half) / ang); // sin(angle/2)/angle
|
||||
const Quaternion dq((Scalar)(delta->x * s), (Scalar)(delta->y * s),
|
||||
(Scalar)(delta->z * s), (Scalar)cosf((float)half));
|
||||
out->Multiply(*in, dq); // in (X) dq
|
||||
}
|
||||
else
|
||||
{
|
||||
*out = *in;
|
||||
}
|
||||
}
|
||||
template<class...A> inline Recon ReconQuatSlerp(A&&...) { return Recon(); }
|
||||
template<class...A> inline Recon ComputeImpactDamage(A&&...) { return Recon(); }
|
||||
|
||||
|
||||
@@ -500,8 +500,21 @@ Logical
|
||||
rangeToTarget = (Scalar)Sqrt( // FUN_004dd138
|
||||
delta.x * delta.x + delta.y * delta.y + delta.z * delta.z);
|
||||
|
||||
// 1.0f == _DAT_004b9c98; heatLoad is the inherited HeatableSubsystem load.
|
||||
effectiveRange = (1.0f - heatLoad) * weaponRange; // *0x32c
|
||||
// AUTHENTIC (decomp @004b9bdc:6983): effectiveRange = (1 - hostZoneDamage) *
|
||||
// weaponRange, where hostZoneDamage is this weapon's OWN DamageZone.damageLevel
|
||||
// (the QUALIFIED Subsystem::damageZone @0xE0 -> damageLevel +0x158), NOT
|
||||
// heatLoad. The old `heatLoad` read was the SAME @0xE0-vs-heat misattribution
|
||||
// already corrected in HeatSink::UpdateCoolant (heat.cpp:803): for a charge/
|
||||
// discharge weapon (ER laser) the weapon's OWN heatLoad swings 0..1 every cycle,
|
||||
// so effectiveRange collapsed toward 0 and the weapon was perpetually "out of
|
||||
// range" -- no damage submission, hence no impact explosion (user report:
|
||||
// lackluster/absent laser hits on mechs AND buildings, esp. the ER medium).
|
||||
// An UNDAMAGED weapon has damageLevel 0 -> full, STABLE weaponRange. Use the
|
||||
// QUALIFIED engine zone -- MechSubsystem::damageZone is a shim SHADOW (heat.cpp:812).
|
||||
// 1.0f == _DAT_004b9c98.
|
||||
::DamageZone *hostZone = this->Subsystem::damageZone; // @0xE0
|
||||
Scalar hostZoneDamage = (hostZone != 0) ? hostZone->damageLevel : 0.0f; // +0x158
|
||||
effectiveRange = (1.0f - hostZoneDamage) * weaponRange; // *0x32c
|
||||
|
||||
targetWithinRange = (effectiveRange > rangeToTarget) ? True : False;
|
||||
}
|
||||
@@ -610,11 +623,12 @@ void
|
||||
// -- the weapon-fire damage submission into the owning mech's
|
||||
// SubsystemMessageManager (mech+0x434 -> AddDamageMessage @0049b6d8), NOT a
|
||||
// HUD pip. The "HUD element record" reading below was the same 0x434
|
||||
// misattribution the mapper suffered. The authentic weapon-side submission
|
||||
// (per-weapon damageData + inflictingSubsystemID) is pending the
|
||||
// damage-economy reconciliation (authored 0.25-scale amounts vs the
|
||||
// bring-up kShotDamage=12); the interim submitter is the mech4 fire block
|
||||
// routing through AddDamageMessage. This body stays dormant meanwhile.
|
||||
// misattribution the mapper suffered. This body is the LIVE per-shot
|
||||
// beam-damage path (called from Emitter::FireWeapon, emitter.cpp:289; decomp
|
||||
// part_013.c:7758-7764) carrying the authentic per-weapon damageData +
|
||||
// inflictingSubsystemID -- the damage-economy reconciliation (task #60)
|
||||
// CONFIRMED the authored-DamageAmount economy; kShotDamage is retired to the
|
||||
// kill-score and env-gated diag hooks (task #60).
|
||||
//
|
||||
void
|
||||
MechWeapon::SendDamageMessage(Entity *target)
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
|
||||
const Point3D &targetPos, Scalar speed, Scalar damage,
|
||||
const Vector3D *launch_velocity = 0, int guided = 1,
|
||||
int weapon_subsys = -1);
|
||||
int weapon_subsys = -1, int splash_burst = 0);
|
||||
|
||||
//###########################################################################
|
||||
// Port-side salvo replication state (missile-visibility wave).
|
||||
@@ -305,9 +305,19 @@ void MissileLauncher::FireWeapon()
|
||||
// on the authored up-tilt, then the seeker loft + steering arc it onto the target.
|
||||
int nmiss = (missileCount > 0 && missileCount < 40) ? missileCount : 1;
|
||||
for (int i = 0; i < nmiss; ++i)
|
||||
BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount,
|
||||
// DAMAGE + SPLASH ONCE PER SALVO (task #62 fix): the arcade fires ONE
|
||||
// cluster Missile per trigger, and its zone damage is applied EXACTLY ONCE
|
||||
// -- DamageZone::TakeDamage (@0041e4e0) is `damageLevel += amount*scale`
|
||||
// and IGNORES burstCount. The port draws that cluster as N visual rounds;
|
||||
// damaging on EVERY round applied the hit N times = ~missileCount-x too
|
||||
// lethal (the "2-shot kill" regression). Only the LEAD round (i==0)
|
||||
// carries the missile's damageAmount + the cluster splash; the rest are
|
||||
// VISUAL (damage 0) -- the ripple of tracers, no extra damage.
|
||||
BTPushProjectile(muzzle, o, target, targetPos, speed,
|
||||
(i == 0) ? damageData.damageAmount : 0.0f,
|
||||
&launchVelocity, 1,
|
||||
subsystemID /*messmgr explosion bundling at impact (task #7)*/);
|
||||
subsystemID /*messmgr explosion bundling at impact (task #7)*/,
|
||||
(i == 0) ? nmiss : 0 /*salvo-lead: cluster splash baseBurst*/);
|
||||
|
||||
// Salvo replication (missile-visibility wave): bump the fire counter + stamp
|
||||
// the aim point; the extended update record carries both to peer nodes.
|
||||
|
||||
@@ -169,9 +169,12 @@ class Missile;
|
||||
protected:
|
||||
|
||||
// @004bcc60 (ProjectileWeapon spawn path) -- builds the streamed-entity
|
||||
// spawn descriptor for a Missile (entity ClassID 0xBD0), seeds it with
|
||||
// muzzleVelocity + the per-missile Damage, and hands it to the entity
|
||||
// manager. Invoked missileCount times per trigger pull.
|
||||
// spawn descriptor for ONE Missile (entity ClassID 0xBD0), seeds it with
|
||||
// muzzleVelocity + the per-missile Damage (authored/missileCount) and the
|
||||
// burstCount cluster-hit roll, and hands it to the entity manager.
|
||||
// (task #60 correction: ONE spawn per trigger pull, NOT missileCount
|
||||
// spawns -- burstCount carries the cluster count to the single missile;
|
||||
// the PORT re-expresses this as N flying rounds, see mislanch.cpp.)
|
||||
void
|
||||
FireWeapon();
|
||||
|
||||
|
||||
@@ -75,7 +75,12 @@ static const Scalar MaxOutputVoltage = 1.0f; // _DAT_004b2154 / _DAT_004b2258
|
||||
static const Scalar RatedVoltageRef = 1.0f; // _DAT_004b1d10 (myomer rated reference)
|
||||
static const Scalar DegradedDriveScale = 0.5f; // _DAT_004b1d14 (heat-degradation throttle)
|
||||
static const Scalar ResourceUnset = -1.0f; // _DAT_004b177c / _DAT_004b1a3c / _DAT_004b2580
|
||||
static const Scalar MinVoltageScale = 1.0f; // _DAT_004b1924 (PowerWatcher threshold scale)
|
||||
static const Scalar MinVoltageScale = 0.01f; // _DAT_004b1924 -- a 10-byte x87 literal
|
||||
// (0a d7 a3 70 3d 0a d7 a3 f8 3f = 0.01,
|
||||
// byte-read from the exe, task #57): converts
|
||||
// the authored MinVoltagePercent to a fraction.
|
||||
// Was 1.0f, which made minVoltage 100x too big
|
||||
// and latched the watchdog brownout (level 1).
|
||||
|
||||
|
||||
//###########################################################################
|
||||
@@ -1191,8 +1196,12 @@ PowerWatcher::SharedData
|
||||
Derivation*
|
||||
PowerWatcher::GetClassDerivations()
|
||||
{
|
||||
// Real base ctor is @004aeb40 (HeatWatcher); HeatableSubsystem stands in.
|
||||
static Derivation classDerivations(HeatableSubsystem::GetClassDerivations(), "PowerWatcher");
|
||||
// Chains to the REAL base HeatWatcher (@004aeb40) -- task #57: the old
|
||||
// HeatableSubsystem stand-in predated the base-chain re-base and broke
|
||||
// IsDerivedFrom(HeatWatcher) for every PowerWatcher descendant, which
|
||||
// silently skipped the Torso/Searchlight/ThermalSight in the factory
|
||||
// watcher-CONNECT pass (their watchdogs stayed at 0 = never Ready).
|
||||
static Derivation classDerivations(HeatWatcher::GetClassDerivations(), "PowerWatcher");
|
||||
return &classDerivations;
|
||||
}
|
||||
|
||||
@@ -1254,13 +1263,68 @@ Logical
|
||||
}
|
||||
|
||||
//
|
||||
// @004b1804 -- delegate to the base simulation.
|
||||
// @004b1804 (slot 10) -- ResetToInitialState: chain to the HeatWatcher base.
|
||||
// (This body was previously MISLABELED as the Simulation. The vtable
|
||||
// @0050f99c slot 10 (offset 0x28) is 004b1804, exactly where every other
|
||||
// subsystem carries ResetToInitialState; the registered Performance PTR
|
||||
// @0050f5fc points at 004b181c -- UpdateWatch below.)
|
||||
//
|
||||
void
|
||||
PowerWatcher::Simulation(Scalar time_slice)
|
||||
PowerWatcher::ResetToInitialState(Logical /*powered*/)
|
||||
{
|
||||
HeatWatcher::ResetToInitialState(True); // FUN_004aea9c = HeatWatcher::ResetToInitialState
|
||||
(void)time_slice;
|
||||
HeatWatcher::ResetToInitialState(True); // FUN_004aea9c
|
||||
}
|
||||
|
||||
//
|
||||
// @004b181c -- the per-tick watch update: THE registered Performance
|
||||
// (PTR @0050f5fc -> 004b181c), and called directly by the Torso master/copy
|
||||
// simulations (@004b5cf0/@004b65f8 first line). Ghidra missed the function
|
||||
// start; recovered from the raw pseudocode at part_013.c:1645:
|
||||
//
|
||||
// FUN_004aeac4(this); // heat mirror
|
||||
// watched = watchedLink.Resolve(); // +0x114
|
||||
// gen = watched->voltageSource.Resolve(); // +0x1D0
|
||||
// watchdogAlarm.SetLevel(watched->elecLevel); // mirror +0x278
|
||||
// if (elecLevel == 4 && gen && gen->outputVoltage(+0x1DC)
|
||||
// <= minVoltage(+0x180) * gen->ratedVoltage(+0x1D8))
|
||||
// watchdogAlarm.SetLevel(1); // brownout
|
||||
//
|
||||
// The watchdog MIRRORS the watched subsystem's electrical state -- this is
|
||||
// what drives ElectricalStateLevel()==Ready on the Torso (the twist-rate
|
||||
// power gate) and WatchedVoltageLevel on Searchlight/ThermalSight.
|
||||
//
|
||||
void
|
||||
PowerWatcher::UpdateWatch()
|
||||
{
|
||||
WatchSimulation(0.0f); // FUN_004aeac4 -- heat-alarm mirror
|
||||
|
||||
PoweredSubsystem *watched = (PoweredSubsystem *)watchedLink.Resolve(); // FUN_00417ab4(this+0x114)
|
||||
if (watched == 0)
|
||||
{
|
||||
// The binary derefs unconditionally (the link always binds on the
|
||||
// master node). Null only on replicants / bring-up: hold Not-Ready.
|
||||
watchdogAlarm.SetLevel(0);
|
||||
return;
|
||||
}
|
||||
|
||||
int level = watched->electricalStateAlarm.GetLevel(); // *(watched+0x278)
|
||||
watchdogAlarm.SetLevel(level); // FUN_0041bbd8(this+0x184, level)
|
||||
|
||||
Generator *source = (Generator *)watched->ResolveVoltageSource(); // FUN_00417ab4(watched+0x1d0)
|
||||
if (level == PoweredSubsystem::Ready && source != 0
|
||||
&& source->MeasuredVoltage() <= minVoltage * source->RatedVoltageOf())
|
||||
{
|
||||
watchdogAlarm.SetLevel(1); // brownout: Ready but sagging source
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// The Performance wrapper (PTR @0050f5fc -> @004b181c takes no dt).
|
||||
//
|
||||
void
|
||||
PowerWatcher::Simulation(Scalar /*time_slice*/)
|
||||
{
|
||||
UpdateWatch();
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -553,7 +553,14 @@ class Generator;
|
||||
Logical
|
||||
HandleMessage(int message); // @004b179c (chains to base @004aea84)
|
||||
void
|
||||
Simulation(Scalar time_slice); // @004b1804 (chains to base @004aea9c)
|
||||
ResetToInitialState(Logical powered); // @004b1804 (slot 10; chains to base @004aea9c)
|
||||
void
|
||||
UpdateWatch(); // @004b181c -- watchdog mirror of the watched
|
||||
// subsystem's electrical state (+ brownout);
|
||||
// the Torso sims call this directly
|
||||
void
|
||||
Simulation(Scalar time_slice); // the registered Performance (PTR @0050f5fc
|
||||
// -> @004b181c): forwards to UpdateWatch()
|
||||
|
||||
public:
|
||||
// BASE-CHAIN RE-BASE: the shadow watchedLink was DELETED -- the binary has a
|
||||
|
||||
@@ -95,9 +95,45 @@
|
||||
extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
|
||||
const Point3D &targetPos, Scalar speed, Scalar damage,
|
||||
const Vector3D *launch_velocity = 0, int guided = 1,
|
||||
int weapon_subsys = -1);
|
||||
int weapon_subsys = -1, int splash_burst = 0); // AC: no cluster splash (default 0)
|
||||
|
||||
|
||||
//#############################################################################
|
||||
// FIRE-VISUAL REPLICATION STATE (task #61) -- the AC twin of MissileLauncher's
|
||||
// BTSalvoState (mislanch.cpp). Port-side table keyed by the weapon pointer
|
||||
// (the 0x1D0 object is byte-locked): the master counts its shots + remembers
|
||||
// the aim; the replicant edge-detects the counter and mirrors ONE visual round
|
||||
// + DAFC muzzle flash so the enemy's cannon is visible on the peer.
|
||||
//#############################################################################
|
||||
namespace {
|
||||
struct BTAcFireState
|
||||
{
|
||||
const void *owner;
|
||||
int fired; // master: shots fired
|
||||
int seen; // replicant: last counter applied (-1 = unsynced)
|
||||
Point3D target; // master: the last shot's aim point
|
||||
};
|
||||
BTAcFireState gAcFireTable[64];
|
||||
|
||||
BTAcFireState &BTAcFireOf(const void *weapon)
|
||||
{
|
||||
int freeSlot = -1;
|
||||
for (int i = 0; i < 64; ++i)
|
||||
{
|
||||
if (gAcFireTable[i].owner == weapon)
|
||||
return gAcFireTable[i];
|
||||
if (gAcFireTable[i].owner == 0 && freeSlot < 0)
|
||||
freeSlot = i;
|
||||
}
|
||||
BTAcFireState &s = gAcFireTable[(freeSlot >= 0) ? freeSlot : 0];
|
||||
s.owner = weapon;
|
||||
s.fired = 0;
|
||||
s.seen = -1;
|
||||
s.target = Point3D(0.0f, 0.0f, 0.0f);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// Resolved read-only constants + unrecovered-helper stand-ins (see banner).
|
||||
//
|
||||
@@ -481,6 +517,16 @@ void
|
||||
Logical
|
||||
ProjectileWeapon::CheckForJam()
|
||||
{
|
||||
if (getenv("BT_JAM_LOG"))
|
||||
DEBUG_STREAM << "[jam] " << GetName()
|
||||
<< " T=" << currentTemperature
|
||||
<< " degT=" << degradationTemperature
|
||||
<< " failT=" << failureTemperature
|
||||
<< " alarm=" << (int)heatAlarm.GetLevel()
|
||||
<< " liveFire=" << (int)LiveFireEnabled()
|
||||
<< " p=" << ((_DAT_004bc064 * currentTemperature) / failureTemperature)
|
||||
<< "\n" << std::flush;
|
||||
|
||||
if (!LiveFireEnabled()) // *(*(this+0xd0)+400)+0x25c
|
||||
return False;
|
||||
|
||||
@@ -681,6 +727,20 @@ void
|
||||
Point3D muzzle;
|
||||
GetMuzzlePoint(muzzle); // MechWeapon @004b9948
|
||||
|
||||
// GENUINE MUZZLE FLASH (task #61): the shipped DAFC fire-smoke blast at the
|
||||
// gun-port -- BTDPL.INI psfx 6/14 "the effect used on all projectile guns".
|
||||
// Fires on every AC shot (all ACs author TracerInterval=1); one ~0.2s
|
||||
// auto-expiring burst. Default ON (BT_MUZZLE=0 disables for diagnostics).
|
||||
{
|
||||
const char *mz = getenv("BT_MUZZLE"); // default ON; =0 disables
|
||||
if (mz == 0 || mz[0] != '0')
|
||||
{
|
||||
extern void BTFlashMuzzle(void *ownerMech, int seg_index, float mx, float my, float mz);
|
||||
BTFlashMuzzle(owner, GetSegmentIndex(),
|
||||
(float)muzzle.x, (float)muzzle.y, (float)muzzle.z);
|
||||
}
|
||||
}
|
||||
|
||||
// WAVE 7 Phase B: launch one flying ballistic round toward the owner's target (port
|
||||
// reconstruction; the byte-exact world-entity Projectile is blocked by the 2007 engine
|
||||
// Entity base mismatch -- see BTPushProjectile / mech4.cpp). Speed = |launchVelocity|.
|
||||
@@ -701,11 +761,93 @@ void
|
||||
0 /*aim straight at the pick*/, 0 /*BALLISTIC: no seeker on a shell*/,
|
||||
subsystemID /*messmgr explosion bundling at impact (task #7)*/);
|
||||
|
||||
simulationFlags |= 0x1; // replication-dirty
|
||||
// task #61: mark this shot for REPLICATION so the peer sees the enemy's
|
||||
// cannon. ++fireCounter + ForceUpdate() -- IDENTICAL to the missile path
|
||||
// (mislanch.cpp:316/326) and the emitter beam: the master's per-frame
|
||||
// subsystem serialize writes this weapon's record (WriteUpdateRecord),
|
||||
// Entity::UpdateMessageHandler routes it to the peer's replicant, and the
|
||||
// replicant's ReadUpdateRecord mirrors the shot. (The old code set only
|
||||
// `simulationFlags |= 0x1` -- the +0x28 instance flag, NOT the updateModel
|
||||
// bit WriteSimulationUpdate walks -- so the AC record never serialized: the
|
||||
// root cause of the invisible enemy autocannon.)
|
||||
{
|
||||
BTAcFireState &fs = BTAcFireOf(this);
|
||||
++fs.fired;
|
||||
fs.target = targetPos;
|
||||
}
|
||||
simulationFlags |= 0x1; // replication-dirty (state flag)
|
||||
ForceUpdate(); // set the updateModel bit -> serialize the record
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
|
||||
//#############################################################################
|
||||
// FIRE-VISUAL REPLICATION (task #61) -- the AC twin of MissileLauncher's salvo
|
||||
// mirror. Master serializes the fire counter + aim after the MechWeapon base
|
||||
// fields; the replicant edge-detects the counter and mirrors ONE visual round
|
||||
// + DAFC muzzle flash from its OWN resolved muzzle -- so the enemy's autocannon
|
||||
// is visible on the peer (the port's local flying round otherwise never left
|
||||
// the firing node).
|
||||
//#############################################################################
|
||||
void
|
||||
ProjectileWeapon::WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model)
|
||||
{
|
||||
MechWeapon::WriteUpdateRecord(message, update_model); // @004b9690 (alarm fields + subsystemID)
|
||||
|
||||
ProjectileWeapon__UpdateRecord *rec = (ProjectileWeapon__UpdateRecord *)message;
|
||||
BTAcFireState &s = BTAcFireOf(this);
|
||||
rec->recordLength = sizeof(ProjectileWeapon__UpdateRecord);
|
||||
rec->fireCounter = s.fired;
|
||||
rec->fireTarget = s.target;
|
||||
}
|
||||
|
||||
void
|
||||
ProjectileWeapon::ReadUpdateRecord(Simulation__UpdateRecord *message)
|
||||
{
|
||||
MechWeapon::ReadUpdateRecord(message); // @004b964c (alarm apply)
|
||||
|
||||
ProjectileWeapon__UpdateRecord *rec = (ProjectileWeapon__UpdateRecord *)message;
|
||||
BTAcFireState &s = BTAcFireOf(this);
|
||||
if (s.seen < 0)
|
||||
{
|
||||
s.seen = rec->fireCounter; // first sync: adopt silently
|
||||
return;
|
||||
}
|
||||
if (rec->fireCounter == s.seen)
|
||||
return;
|
||||
s.seen = rec->fireCounter;
|
||||
|
||||
// Mirror ONE shot (a missed record collapses to one -- no phantom replays):
|
||||
// a VISUAL-ONLY round (damage 0; the master's round delivers cross-pod
|
||||
// damage) from this replicant's own muzzle toward the master's aim point,
|
||||
// plus the DAFC muzzle flash.
|
||||
Point3D mz;
|
||||
GetMuzzlePoint(mz); // @004b9948
|
||||
Scalar spd = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
|
||||
+ launchVelocity.y*launchVelocity.y
|
||||
+ launchVelocity.z*launchVelocity.z);
|
||||
// Fly toward the master's aim if it replicated a real pick; else straight
|
||||
// out the barrel (the shooter-frame launchVelocity, as the missile mirror
|
||||
// does) so an untargeted shot still streaks forward, not toward the origin.
|
||||
const int haveAim = (rec->fireTarget.x != 0.0f
|
||||
|| rec->fireTarget.y != 0.0f
|
||||
|| rec->fireTarget.z != 0.0f);
|
||||
BTPushProjectile(mz, owner, 0 /*aim point only, no entity*/, rec->fireTarget,
|
||||
spd, 0.0f /*VISUAL*/,
|
||||
haveAim ? 0 : &launchVelocity, 0 /*ballistic*/, subsystemID);
|
||||
|
||||
const char *mzf = getenv("BT_MUZZLE"); // default ON; =0 disables
|
||||
if (mzf == 0 || mzf[0] != '0')
|
||||
{
|
||||
extern void BTFlashMuzzle(void *ownerMech, int seg_index, float, float, float);
|
||||
BTFlashMuzzle(owner, GetSegmentIndex(), (float)mz.x, (float)mz.y, (float)mz.z);
|
||||
}
|
||||
if (getenv("BT_PROJ_LOG"))
|
||||
DEBUG_STREAM << "[projectile] REPLICANT AC shot at(" << rec->fireTarget.x
|
||||
<< "," << rec->fireTarget.y << "," << rec->fireTarget.z << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
|
||||
//#############################################################################
|
||||
// CreateStreamedSubsystem
|
||||
//
|
||||
|
||||
@@ -195,6 +195,28 @@ class NotationFile;
|
||||
void
|
||||
PrintState(); // slot 13, @004bc6c8
|
||||
|
||||
// FIRE-VISUAL REPLICATION (task #61 -- the AC twin of MissileLauncher's
|
||||
// salvo mirror, mislanch.hpp). The port's AC is a LOCAL flying round,
|
||||
// so the enemy's cannon fire never reached the peer. Same proven path
|
||||
// the missile + emitter use: FireWeapon calls ForceUpdate() (sets the
|
||||
// updateModel bit), so the mech's per-frame subsystem serialize
|
||||
// (mech4.cpp:3893 subsystem->PerformAndWatch(update_stream)) writes this
|
||||
// record; Entity::UpdateMessageHandler routes it to the peer's replicant
|
||||
// by subsystemID. The master serializes a monotonic fire counter + aim;
|
||||
// the replicant edge-detects it and mirrors ONE visual round + DAFC
|
||||
// muzzle flash. Fire STATE lives in a port-side table (projweap.cpp);
|
||||
// the 0x1D0 object layout is byte-locked and must not grow.
|
||||
struct ProjectileWeapon__UpdateRecord:
|
||||
public MechWeapon::UpdateRecord
|
||||
{
|
||||
int fireCounter; // AC shots since spawn (edge-detected by peers)
|
||||
Point3D fireTarget; // the aim point the round flew at
|
||||
};
|
||||
typedef ProjectileWeapon__UpdateRecord UpdateRecord;
|
||||
|
||||
void WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model); // slot 7
|
||||
void ReadUpdateRecord(Simulation__UpdateRecord *message); // slot 6
|
||||
|
||||
protected:
|
||||
// TASK #51 RENAME: slot 9 is TakeDamage (the old ReadUpdateRecord
|
||||
// attribution was swapped; the record read is INHERITED from
|
||||
|
||||
@@ -198,6 +198,22 @@ Scalar
|
||||
int ty = jt ? (int)jt->GetJointType() : 0;
|
||||
if (ty < 3) // hinge
|
||||
{
|
||||
// task #59 discriminator (BT_HIP_LOG): log hinge writes so the
|
||||
// jointhip walk-lean can be seen. EXTERIOR clips ramp jointhip
|
||||
// to ~-8deg on stand->walk and hold it; INTERIOR ('i') clips
|
||||
// never bind jointhip, so it stays silent. Sampled by joint
|
||||
// slot so the stream stays readable.
|
||||
if (jt && move_joints)
|
||||
{
|
||||
static const int s_hipLog = getenv("BT_HIP_LOG") ? 1 : 0;
|
||||
if (s_hipLog)
|
||||
{
|
||||
const Radian ang = ((const Hinge *)cursor)->rotationAmount;
|
||||
if (fabsf((float)ang) > 0.02f) // only the leaning ones
|
||||
DEBUG_STREAM << "[hip] jointIdx=" << jointIndices[j]
|
||||
<< " deg=" << ((float)ang * 57.2958f) << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
if (jt && move_joints) jt->SetHinge(*(const Hinge *)cursor); // FUN_0041cfc8
|
||||
cursor += 8;
|
||||
}
|
||||
|
||||
+101
-14
@@ -94,6 +94,9 @@ namespace {
|
||||
inline Scalar
|
||||
RecordField(Simulation::UpdateRecord *record, int byte_offset)
|
||||
{ return *(const Scalar*)((const char*)record + byte_offset); }
|
||||
inline void
|
||||
WriteRecordField(Simulation::UpdateRecord *record, int byte_offset, Scalar value)
|
||||
{ *(Scalar*)((char*)record + byte_offset) = value; }
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
@@ -243,19 +246,29 @@ Torso::Torso(
|
||||
// rate. TorsoSimulation drives the sweep (below). Remove after verification.
|
||||
if (isDamagedCopy == 0 && getenv("BT_FORCE_TORSO"))
|
||||
{
|
||||
// ⚠ HARNESS TRAP (task #58 post-mortem): this hook used to override the
|
||||
// joint nodes UNCONDITIONALLY with the BLH demo names -- on a mech whose
|
||||
// resource already resolved REAL torso joints (the MadCat's jointtorso)
|
||||
// that silently redirected the whole sweep into the SHADOW hinge, and a
|
||||
// probe run "proved" the cockpit camera ignored the twist (it doesn't;
|
||||
// the joint chain delivers it). Now: only fill joints that did NOT
|
||||
// resolve from the resource; a twist-capable mech sweeps its real ones.
|
||||
horizontalEnabled = True; // @0x250
|
||||
const char *mj = getenv("BT_FORCE_TORSO_JOINT");
|
||||
if (mj == 0 || *mj == '\0') mj = "jointshakey2";
|
||||
horizontalJointNode = ResolveJoint(mj); // torso body (ball)
|
||||
horizontalShadowJointNode = ResolveJoint("jointtshadow"); // shadow twist (hingey)
|
||||
horizontalLimitLeft = 0.7f; // @0x1E0 ~40 deg
|
||||
horizontalLimitRight = -0.7f; // @0x1DC
|
||||
if (horizontalJointNode == 0)
|
||||
{
|
||||
const char *mj = getenv("BT_FORCE_TORSO_JOINT");
|
||||
if (mj == 0 || *mj == '\0') mj = "jointshakey2";
|
||||
horizontalJointNode = ResolveJoint(mj); // torso body (ball)
|
||||
horizontalShadowJointNode = ResolveJoint("jointtshadow"); // shadow twist (hingey)
|
||||
horizontalLimitLeft = 0.7f; // @0x1E0 ~40 deg
|
||||
horizontalLimitRight = -0.7f; // @0x1DC
|
||||
}
|
||||
baseTwistRate = 1.0f; // @0x23C rad/s slew
|
||||
if (getenv("BT_TORSO_LOG"))
|
||||
{
|
||||
DEBUG_STREAM << "[torso] FORCE-ENABLE '" << mj << "' -> " << (void*)horizontalJointNode;
|
||||
DEBUG_STREAM << "[torso] FORCE-ENABLE node=" << (void*)horizontalJointNode;
|
||||
if (horizontalJointNode) DEBUG_STREAM << " type=" << (int)horizontalJointNode->GetJointType();
|
||||
DEBUG_STREAM << " ; shadow 'jointtshadow' -> " << (void*)horizontalShadowJointNode;
|
||||
DEBUG_STREAM << " ; shadow node=" << (void*)horizontalShadowJointNode;
|
||||
if (horizontalShadowJointNode) DEBUG_STREAM << " type=" << (int)horizontalShadowJointNode->GetJointType();
|
||||
DEBUG_STREAM << "\n" << std::flush;
|
||||
}
|
||||
@@ -387,14 +400,17 @@ Logical
|
||||
}
|
||||
|
||||
//
|
||||
// @004b6a78 (slot 6) [CONFIDENT] -- network/replay update record. Samples the
|
||||
// @004b6a78 (slot 6) [CONFIDENT] -- the REPLICANT-side apply (engine semantics
|
||||
// [T0]: ReadUpdateRecord = write the object FROM the record). Samples the
|
||||
// clock (FUN_00414b60) into lastUpdateTime, biasing it forward by one interval
|
||||
// when the elapsed window is below MinSlewMs, chains to Subsystem::WriteUpdateRecord
|
||||
// (FUN_0041bd34), then writes twistAtUpdate / twistVelocity / twistRate from the
|
||||
// record fields (record +0x10 / +0x14 / +0x18).
|
||||
// when the elapsed window is below MinSlewMs, chains to the base apply
|
||||
// (FUN_0041bd34 = ReadUpdateRecord, 2 args), then applies twistAtUpdate /
|
||||
// twistVelocity / twistRate from the record extras (+0x10 / +0x14 / +0x18).
|
||||
// (Task #57: previously mislabeled as WriteUpdateRecord -- the direction flip
|
||||
// made the MASTER consume its own blank record.)
|
||||
//
|
||||
void
|
||||
Torso::WriteUpdateRecord(UpdateRecord *message, int update_model)
|
||||
Torso::ReadUpdateRecord(UpdateRecord *message)
|
||||
{
|
||||
lastUpdateTime = GetCurrentTime(); // @0x254
|
||||
if ((Scalar)(lastUpdateTime - GetCreationTime()) / MsPerSecond < MinSlewMs)
|
||||
@@ -402,13 +418,41 @@ void
|
||||
lastUpdateTime += (lastUpdateTime - GetCreationTime()); // stretch tiny windows
|
||||
}
|
||||
|
||||
Subsystem::WriteUpdateRecord(message, update_model); // FUN_0041bd34
|
||||
Subsystem::ReadUpdateRecord(message); // FUN_0041bd34
|
||||
|
||||
twistAtUpdate = RecordField(message, 0x10); // @0x21C
|
||||
twistVelocity = RecordField(message, 0x14); // @0x1E8
|
||||
twistRate = RecordField(message, 0x18); // @0x238
|
||||
}
|
||||
|
||||
//
|
||||
// @004b6a1c (slot 7) [CONFIDENT, raw-disasm recovery -- Ghidra missed the
|
||||
// function start] -- the MASTER-side serialize (engine semantics [T0]:
|
||||
// WriteUpdateRecord = fill the record FROM the object). Chains the base
|
||||
// producer (FUN_0041c500(this, record, model), 3 args), extends the record to
|
||||
// 0x1C bytes and appends currentTwist / twistVelocity / twistRate at
|
||||
// +0x10/+0x14/+0x18 (exactly what the replicant's @004b6a78 applies), then
|
||||
// snapshots twistAtUpdate = currentTwist:
|
||||
//
|
||||
// 004b6a2e call 0041c500 ; base WriteUpdateRecord
|
||||
// 004b6a38 mov [record], 0x1C ; recordLength = 0x1C
|
||||
// 004b6a44+ record+0x10 = this+0x1D8 ; currentTwist
|
||||
// 004b6a51 record+0x14 = this+0x1E8 ; twistVelocity
|
||||
// 004b6a5a record+0x18 = this+0x238 ; twistRate
|
||||
// 004b6a63+ this+0x21C = this+0x1D8 ; twistAtUpdate = currentTwist
|
||||
//
|
||||
void
|
||||
Torso::WriteUpdateRecord(UpdateRecord *message, int update_model)
|
||||
{
|
||||
Subsystem::WriteUpdateRecord(message, update_model); // FUN_0041c500
|
||||
|
||||
message->recordLength = 0x1C; // base header 0x10 + 3 Scalars
|
||||
WriteRecordField(message, 0x10, currentTwist); // @0x1D8
|
||||
WriteRecordField(message, 0x14, twistVelocity); // @0x1E8
|
||||
WriteRecordField(message, 0x18, twistRate); // @0x238
|
||||
twistAtUpdate = currentTwist; // @0x21C snapshot at send
|
||||
}
|
||||
|
||||
|
||||
//#############################################################################
|
||||
// Per-frame simulation
|
||||
@@ -464,6 +508,34 @@ void
|
||||
analogTwistAxis = ((++s_sweep / 90) & 1) ? -1.0f : 1.0f; // +/- every ~90 frames
|
||||
}
|
||||
|
||||
// TORSO GATE PROBE (BT_TORSO_LOG): why is the twist rate zero?
|
||||
if (getenv("BT_TORSO_LOG"))
|
||||
{
|
||||
static int s_tl = 0;
|
||||
if ((s_tl++ % 120) == 0)
|
||||
{
|
||||
PoweredSubsystem *w = (PoweredSubsystem *)watchedLink.Resolve();
|
||||
DEBUG_STREAM << "[torso] hmOff=" << (int)HeatModelOff()
|
||||
<< " elec=" << (int)ElectricalStateLevel()
|
||||
<< " heatState=" << (int)HeatStateLevel()
|
||||
<< " rate=" << effectiveTwistRate
|
||||
<< " base=" << baseTwistRate
|
||||
<< " hEn=" << (int)horizontalEnabled
|
||||
<< " limits=(" << horizontalLimitRight << ".." << horizontalLimitLeft << ")"
|
||||
<< " axis=" << analogTwistAxis
|
||||
<< " twist=" << currentTwist
|
||||
<< " wIdx=" << watchedSubsystem
|
||||
<< " w=" << (void*)w
|
||||
<< " wElec=" << (w ? w->electricalStateAlarm.GetLevel() : -1)
|
||||
<< " wSrc=" << (void*)(w ? w->ResolveVoltageSource() : 0);
|
||||
Generator *g = (w ? (Generator *)w->ResolveVoltageSource() : 0);
|
||||
if (g)
|
||||
DEBUG_STREAM << " gOut=" << g->MeasuredVoltage()
|
||||
<< " gRated=" << g->RatedVoltageOf()
|
||||
<< " minV=" << minVoltage;
|
||||
DEBUG_STREAM << std::endl;
|
||||
}
|
||||
}
|
||||
Scalar twistStep = effectiveTwistRate * time_slice;
|
||||
Scalar elevStep = effectiveElevationRate * time_slice;
|
||||
|
||||
@@ -616,6 +688,21 @@ void
|
||||
// external joint pass as the master path (see TorsoSimulation for the @004b67ec
|
||||
// note). Harmless in single-player bring-up (no copies); correct for MP.
|
||||
UpdateJoints(); // FUN_004b67ec
|
||||
|
||||
// COPY-SIDE PROBE (BT_TORSO_LOG): where does the replicant's twist come from?
|
||||
if (getenv("BT_TORSO_LOG"))
|
||||
{
|
||||
static int s_cl = 0;
|
||||
if ((s_cl++ % 120) == 0)
|
||||
DEBUG_STREAM << "[torso-copy] cur=" << currentTwist
|
||||
<< " target=" << targetTwist
|
||||
<< " atUpd=" << twistAtUpdate
|
||||
<< " rate=" << twistRate
|
||||
<< " vel=" << twistVelocity
|
||||
<< " lastUpd=" << lastUpdateTime
|
||||
<< " now=" << GetCurrentTime()
|
||||
<< " copy=" << (int)isDamagedCopy << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -156,11 +156,13 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
|
||||
void SetMovedFlag() { ForceUpdate(); }
|
||||
// Local helper: "is value pinned at this software limit?".
|
||||
Logical AtLimit(Scalar value, Scalar limit) const { return fabsf(value - limit) <= 0.0001f; }
|
||||
// CROSS-FAMILY delegate shims -- still no-ops (no layout impact; STEP-4 will
|
||||
// chain them to the real PowerWatcher/HeatWatcher base once the Torso is live).
|
||||
// CROSS-FAMILY delegate shims (STEP-4): UpdateWatch now chains to the real
|
||||
// PowerWatcher per-tick watch body (@004b181c, task #57) -- this is what
|
||||
// raises watchdogAlarm to Ready and un-gates the twist rate. The other
|
||||
// two remain no-ops (no layout impact).
|
||||
void WatcherResetToInitialState() {}
|
||||
Logical WatcherHandleDeathMessage(Message & /*m*/) { return False; }
|
||||
void WatcherUpdateWatch() {}
|
||||
void WatcherUpdateWatch() { UpdateWatch(); } // FUN_004b181c (body: powersub.cpp)
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Test Class Support
|
||||
@@ -189,6 +191,8 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
|
||||
// Controls (@0x1F0 twist, @0x1F4 elevation); proportional, no button ramp.
|
||||
void SetAnalogTwistAxis(Scalar v) { analogTwistAxis = v; }
|
||||
void SetAnalogElevationAxis(Scalar v) { analogElevationAxis = v; }
|
||||
void CommandRecenter() { centerCommand = 1; } // @0x208 (Basic-mode re-center)
|
||||
void ClearRecenterCommand() { centerCommand = 0; } // button released (writer-owned state)
|
||||
Logical GetHorizontalEnabled() const { return horizontalEnabled; } // @0x250 (mapper free-aim gate @004afd10)
|
||||
|
||||
// Reachable horizontal (yaw) half-arc the guns can be brought to bear by
|
||||
@@ -224,7 +228,17 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
|
||||
// Subsystem virtual overrides (slots on vtable @0051103c)
|
||||
//
|
||||
public:
|
||||
void WriteUpdateRecord(UpdateRecord *message, int update_model); // slot 6, @004b6a78
|
||||
// Update-record pair (task #57 direction fix): the ENGINE's semantics
|
||||
// [T0 SIMULATE.cpp] are Write = PRODUCE the outgoing record (master),
|
||||
// Read = APPLY the incoming record (replicant). @004b6a78 (slot 6,
|
||||
// base chain 41bd34 = ReadUpdateRecord) CONSUMES record+0x10/14/18 --
|
||||
// it is the READ side, previously mislabeled Write; the real WRITE
|
||||
// side is @004b6a1c (slot 7, base chain 41c500, 3 args), which Ghidra
|
||||
// missed -- recovered from raw disasm. With the old flip the master
|
||||
// never serialized twist and clobbered its own fields from the
|
||||
// uninitialized stream buffer (the replicant's 0xCDCDCDCD twist pin).
|
||||
void ReadUpdateRecord(UpdateRecord *message); // slot 6, @004b6a78 (apply)
|
||||
void WriteUpdateRecord(UpdateRecord *message, int update_model); // slot 7, @004b6a1c (serialize)
|
||||
Logical HandleDeathMessage(Message &message); // slot 9, @004b5be0 -> @004b179c
|
||||
void ResetToInitialState(); // slot 10, @004b5bf8
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ the BLH (3 lasers + 2 PPCs + 2 MissileLaunchers), AddWeapon @004cdac0 store map
|
||||
(part_014.c:4827-4837; both state attrs are named "SimulationState", strings @51d526/51d577).
|
||||
Draw hook = `BTDrawReticle(device)` after the 3D scene, cockpit view only. The binary Execute
|
||||
@004cdcf0 is an UN-EXPORTED gap → Draw dynamics are best-effort [T3] (caret slide from live
|
||||
target range; pip A lit on TargetWithinRange else ring B). Step 6 (blx_cop canopy shell) and the
|
||||
PNAME1-8.bgf 3D pip meshes remain DEFERRED — tracked in context/open-questions.md.
|
||||
target range; pip A lit on TargetWithinRange else ring B). **[SUPERSEDED: Execute @004cdcf0 was
|
||||
RECOVERED task #37 (capstone disasm → reference/decomp/reticle_execute_004cdcf0.disasm.txt) —
|
||||
Draw dynamics are now [T1]; pip lighting is FIRE-CYCLE state, not TargetWithinRange; crosshair
|
||||
positioning re-corrected task #58 — see context/gauges-hud.md.]** Step 6 (blx_cop canopy shell)
|
||||
and the PNAME1-8.bgf 3D pip meshes remain DEFERRED — tracked in context/open-questions.md.
|
||||
|
||||
## The recovered dpl2d recorder API (libDPL 2D display lists) [T1]
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import struct
|
||||
data = open(r'C:/git/bt411/content/BTL4.RES','rb').read()
|
||||
n = len(data)
|
||||
def f32(o): return struct.unpack_from('<f', data, o)[0]
|
||||
def i32(o): return struct.unpack_from('<i', data, o)[0]
|
||||
def cstr(o, m=32):
|
||||
s = data[o:o+m].split(b'\0')[0]
|
||||
try: return s.decode('ascii')
|
||||
except: return None
|
||||
|
||||
# find AFC100 subsystem records (name @+0, classID @+0x20)
|
||||
hits=[]
|
||||
i=0
|
||||
while i < n-0x28:
|
||||
if data[i:i+7]==b'AFC100\0':
|
||||
cid=struct.unpack_from('<I',data,i+0x20)[0]
|
||||
sz=struct.unpack_from('<I',data,i+0x24)[0]
|
||||
if 0xBB0<=cid<=0xBE5 and 0xE0<=sz<=0x300:
|
||||
hits.append((i,cid,sz))
|
||||
i+=1
|
||||
print("AFC100 records:", [(hex(p),hex(c),hex(s)) for p,c,s in hits])
|
||||
if hits:
|
||||
p,cid,sz=hits[0]
|
||||
print(f"\n=== AFC100 @ {hex(p)} classID={hex(cid)} size={hex(sz)} ===")
|
||||
# dump the offsets the loader reads (per heat.cpp / mechweap.hpp comments)
|
||||
fields = {
|
||||
0xE4:"startingTemp", 0xE8:"degradationTemp", 0xEC:"failureTemp",
|
||||
0xF0:"thermalConductance", 0xF4:"thermalMass",
|
||||
0x1A4:"heatCostToFire", 0x1CC:"minJamChance",
|
||||
}
|
||||
for off,name in sorted(fields.items()):
|
||||
print(f" +{hex(off):>6} {name:20} f32={f32(p+off):<16g} i32={i32(p+off)}")
|
||||
print("\n --- full float scan (offset: value) for plausible fields 0xE0..0x1E0 ---")
|
||||
for off in range(0xE0, 0x1E0, 4):
|
||||
v=f32(p+off)
|
||||
if abs(v)>1e-3 and abs(v)<1e12:
|
||||
print(f" +{hex(off):>6}: {v:g}")
|
||||
+9
-1
@@ -122,7 +122,7 @@ def serve_pod(hostport, egg_bytes):
|
||||
try:
|
||||
data = s.recv(4096)
|
||||
if not data:
|
||||
print(f"[{name}] pod closed the console socket")
|
||||
print(f"[{name}] pod closed the console socket", flush=True)
|
||||
return
|
||||
# Expect the AcknowledgeEggFile (and possibly other console traffic).
|
||||
if len(data) >= 24:
|
||||
@@ -134,6 +134,14 @@ def serve_pod(hostport, egg_bytes):
|
||||
print(f"[{name}] pod->console {len(data)} bytes")
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError as e:
|
||||
# Previously UNHANDLED: a reset/abort here killed the thread silently
|
||||
# (buffered stdout lost) and the console process exited -- leaving the
|
||||
# pods holding dead console sockets. Log it and keep the thread alive
|
||||
# (idle) so the process + diagnosis survive.
|
||||
print(f"[{name}] console socket error: {e!r} -- idling", flush=True)
|
||||
while True:
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# mp_launch.sh -- launch a 2-node local multiplayer test (FOGDAY.EGG) with the two
|
||||
# game nodes PINNED TO DISJOINT CPU CORES + the console relay.
|
||||
#
|
||||
# WHY THE AFFINITY MATTERS (task #50, 2026-07-15): two Debug btl4 nodes on one box
|
||||
# fight for the same cores, and Windows punishes the contention by BATCHING their
|
||||
# TCP packet delivery -- update records that should arrive every ~17ms instead
|
||||
# arrive in bursts (measured gaps up to 226ms, burstiness 3-7x, BT_RXJIT). A peer
|
||||
# dead-reckons across those gaps and then snaps when a record finally lands -> the
|
||||
# "random" shaky peer motion. Pinning each node to its own core set restores even
|
||||
# ~17ms delivery (burstiness ~1.0) and the shakiness vanishes. This is a SINGLE-BOX
|
||||
# TEST-RIG artifact -- real pods are dedicated machines and never see it; do NOT
|
||||
# "fix" it in the game with an interpolation buffer.
|
||||
#
|
||||
# Usage: tools/mp_launch.sh [extra env for BOTH nodes...]
|
||||
# e.g. tools/mp_launch.sh BT_SLIDE=1
|
||||
# Run from the repo root; content/ must hold FOGDAY.EGG and the build in build/Debug.
|
||||
set -u
|
||||
cd "$(dirname "$0")/../content" || exit 1
|
||||
EXE=../build/Debug/btl4.exe
|
||||
EXTRA="$*"
|
||||
|
||||
powershell -NoProfile -Command "Get-Process btl4,python -ErrorAction SilentlyContinue | Stop-Process -Force" 2>/dev/null
|
||||
sleep 2
|
||||
rm -f fa.log fb.log console.log
|
||||
|
||||
env $EXTRA BT_DEV_GAUGES=1 BT_LOG=fb.log BT_START_INSIDE=1 BT_SPAWN_AT="40 -150" "$EXE" -egg FOGDAY.EGG -net 1601 &
|
||||
sleep 2
|
||||
env $EXTRA BT_DEV_GAUGES=1 BT_LOG=fa.log BT_START_INSIDE=1 BT_SPAWN_AT="-40 -150" "$EXE" -egg FOGDAY.EGG -net 1501 &
|
||||
sleep 3
|
||||
|
||||
# Pin the two nodes to disjoint core sets (0x00F = cores 0-3, 0x3C0 = cores 6-9).
|
||||
powershell -NoProfile -Command "\$ps=@(Get-Process btl4 | Sort-Object Id); if(\$ps.Count -ge 2){ \$ps[0].ProcessorAffinity=[IntPtr]0x00F; \$ps[1].ProcessorAffinity=[IntPtr]0x3C0; Write-Output ('PINNED '+\$ps[0].Id+'->cores0-3, '+\$ps[1].Id+'->cores6-9') } else { Write-Output ('WARN only '+\$ps.Count+' btl4 up') }"
|
||||
|
||||
sleep 6
|
||||
python -u ../tools/btconsole.py FOGDAY.EGG 127.0.0.1:1501 127.0.0.1:1601 > console.log 2>&1 &
|
||||
echo "console streaming; nodes on 1501/1601. Logs: content/fa.log content/fb.log"
|
||||
wait
|
||||
Reference in New Issue
Block a user