MP live-play wave: collision economy, missiles, radar transform, panel polarity, comm ticker

The interactive 2-node playtest wave -- every fix decomp-grounded and live-verified:

COLLISION ECONOMY (the ram one-shot): StaticBounce mutates worldLinearVelocity
per contact and ProcessCollisionList walks EVERY touched solid per frame; with
2007 terrain-as-solids the reflections compounded x4-x40 within one frame and a
walking bump one-shot a pristine mech for 112,375 pts (62-pt authentic economy).
Fix: frameEntryWorldVelocity restore per contact (damage always priced at the
real approach speed -- all the binary's physics ever saw); Mech::Reset zeroes
the mover motion (respawn = teleport); [collide-tx]/[mp-hdlr] telemetry.
Gotcha #16 (engine-facility drift class).

MISSILES: peer-visible salvos (the launcher record extension carries a salvo
counter + aim point; ForceUpdate actually enqueues it -- the dirty flag alone
never serialized), the authentic arc (authored MuzzleVelocity vector + the
Seeker's 200m/0.1/300 loft + gain-4 steering, decoded from @004beae4/@004bef78),
world-impact bursts (rounds detonate on cave geometry instead of phasing
through), contact-only damage (flight-cap expiry = fizzle, no more teleport
damage), live re-lead, and ballistic (unguided) shells for autocannons.
projweap's stale BTPushProjectile extern (the /FORCE signature trap, gotcha #6
corollary) crashed the Avatar's first AFC100 shot -- fixed + sweep rule recorded.

RADAR: two transcription bugs made the scope permanently empty -- FUN_0040b244
is the affine INVERSE (not a copy) and FUN_0040adec writes ONLY the 3x3 rotation
(never the translation); worldToView now Invert(view) built rotation-first.
CulturalIcons sorted out of the moving grid (the phantom red pips were map
props), visible-radius culls on all three draw passes, live pip verified at
|delta| x ppm px.  Gotcha #17 (verify the FUN_ body, not its call shape).

WEAPON PANELS (the frozen-dial hunt): the binary's *(subsystem+0x40) means
FAILED -- the recon's 'operating' name was backwards, inverting the destroyed-X
lamps, the panel look, the children enable and the ready-lamp gate (which had
NEVER executed).  Polarity chain corrected end-to-end (failedState, fed by real
damage saturation).  Root cause of the freezes: MFD page-mode gating -- the dev
composite shows ALL pages at once, so off-page dials legitimately stopped; under
BT_DEV_GAUGES the 15 page-plane bits stay active (the exclusive secondary trio
untouched).  The SEH gauge guard now names its kills; repaint-heal resets the
incremental arc after panel repaints; [panel]/[arc] probes added.

COMM/SCORE: MessageBoard LIVE (the engine already shipped the whole
Player__StatusMessage queue; wired the binary's one producer -- the kill branch,
victim's name, 6s -- plus the consumer bridge and a lazy source bind); MP DEATHS
counted via the observed-death tally (each node scores every pilot from locally
observed events, the same model as the KILLS credit) and the -2/-1 engine seed
clamped for display.

DEV UX: node-tagged window titles (-net port), gauge panel reworked (1320x480,
true 4:3 MFD cells, the portrait secondary UNROTATED upright, linear filtering,
BT_GAUGE_SCALE), fixed close spawns via BT_SPAWN_XZ, Boreas flies an Avatar
(first second-chassis live outing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-12 17:24:15 -05:00
co-authored by Claude Fable 5
parent dd27238ceb
commit bb795e2805
30 changed files with 1006 additions and 97 deletions
+20 -4
View File
@@ -1,8 +1,8 @@
[mission] [mission]
adventure=BattleTech adventure=BattleTech
map=grass map=cavern
scenario=freeforall scenario=freeforall
time=day time=night
weather=clear weather=clear
temperature=27 temperature=27
length=600 length=600
@@ -156,8 +156,9 @@ x=128
y=32 y=32
width=8 width=8
[pilots] [pilots]
pilot=200.0.0.96 pilot=127.0.0.1:1502
[200.0.0.96] pilot=127.0.0.1:1602
[127.0.0.1:1502]
hostType=0 hostType=0
advancedDamage=1 advancedDamage=1
loadzones=1 loadzones=1
@@ -171,6 +172,20 @@ dropzone=one
vehicle=bhk1 vehicle=bhk1
vehicleValue=1000 vehicleValue=1000
color=White 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] [largebitmap]
bitmap=BitMap::Large::Aeolus bitmap=BitMap::Large::Aeolus
[BitMap::Large::Aeolus] [BitMap::Large::Aeolus]
@@ -227,3 +242,4 @@ width=4
model=dfltrole model=dfltrole
[Role::NoReturn] [Role::NoReturn]
model=noretun model=noretun
+1 -1
View File
@@ -183,7 +183,7 @@ badge=VGL
patch=Yellow patch=Yellow
role=Role::Default role=Role::Default
dropzone=one dropzone=one
vehicle=bhk1 vehicle=ava1
vehicleValue=1000 vehicleValue=1000
color=Red color=Red
[largebitmap] [largebitmap]
+18
View File
@@ -233,6 +233,24 @@ Two non-field fixes were needed: **message-handler chaining** (an unchained set
silently) + **entity validity** (`SetValidFlag()` on a manually-spawned entity) — see silently) + **entity validity** (`SetValidFlag()` on a manually-spawned entity) — see
[[reconstruction-gotchas]] §9. [T2] [[reconstruction-gotchas]] §9. [T2]
## Ram (mech-vs-mech collision) damage — the economy (2026-07-12)
The binary's `Mech::ProcessCollision` (part_012.c:15280-15413) computes contact damage with the
engine `Mover::StaticBounce` [T0 MOVER.cpp]: **damage = 0.0005 × (1e²) × impact² × moverMass**
(KE loss; `impact` = own `worldLinearVelocity·normal`; mass/elasticity/friction stream from the
model gamedata `MoverMass` etc.). On a Mover owner it gates on separating-contact (`normal·rel <
1e-4 → return`), then on `ClassID==0xbb9` dispatches a `DamageMessage{id=0x64, zone=1,
CollisionDamageType}` at the OTHER mech (the receiver's cylinder table resolves the zone). Ported
in `BTDispatchCollisionDamage` (mech4.cpp) [T1]. **Observed live economy [T2]:** weapons 3.4-11.8
per hit; a walking/running ram = 13-62 per contact frame; a sustained ram killed a pristine mech
at 102.5 total (4 contacts) via (presumably) a vital torso zone — per-zone HP = the authored
`CollisionDamagePoints` (fallback `WeaponDamagePoints`), scale = 1/points, replayed from the
type-0x14 stream. Whether ~100-pt ram lethality matches the pod is unverified (the authored
collision points for the torso zones haven't been dumped) [T3].
**⚠ The one-shot bug (fixed 2026-07-12):** multi-solid frames amplified the velocity StaticBounce
priced damage from (62 → 112,375; killed a pristine mech through a single bump) — gotcha #16 in
[[reconstruction-gotchas]]; fix = `frameEntryWorldVelocity` restore per contact + `Mech::Reset`
motion zeroing. `[collide-tx]` (BT_MP_NET) logs every dispatched ram with the pricing velocity.
## Death — the wreck STAYS (P5 CLOSED) ## Death — the wreck STAYS (P5 CLOSED)
A killed mech does NOT disappear — BT death is a STATE transition A killed mech does NOT disappear — BT death is a STATE transition
(`SetGraphicState(DestroyedGraphicState)` + death anim + effect/splash), the mech becomes a persistent (`SetGraphicState(DestroyedGraphicState)` + death anim + effect/splash), the mech becomes a persistent
+33 -1
View File
@@ -6,8 +6,10 @@ source_sections: "docs/GAUGE_COMPOSITE.md (full map); CLAUDE.md §8"
related_topics: [reconstruction-gotchas, decomp-reference, subsystems, pod-hardware] related_topics: [reconstruction-gotchas, decomp-reference, subsystems, pod-hardware]
key_terms: [gauge, methodDescription, attribute-pointer, GaugeRenderer, MFD, PCC] key_terms: [gauge, methodDescription, attribute-pointer, GaugeRenderer, MFD, PCC]
open_questions: open_questions:
- "Status-message queue (StatusMessagePool) is a NULL stub -> MessageBoard is empty" - "MessageBoard LIVE 2026-07-12 (kill ticker); remaining: only strip 0 (kill) is produced -- survey the other btsmsgs.pcx strips for authentic producers"
- "HUD binary attr table @005110c0 offsets conflict with the port HUD layout on 3 slots (0x1D8/0x1EC/0x1F8) -- re-dump before publishing" - "HUD binary attr table @005110c0 offsets conflict with the port HUD layout on 3 slots (0x1D8/0x1EC/0x1F8) -- re-dump before publishing"
- "Secondary-view cycling (Damage/Critical/Heat) unreachable from the desktop keyboard: the mapper's 0x13d/0x13e cases are dead DOS F3/F4 codes (VK_F3/F4 = 0x72/0x73 collide with 'r'/'s') -- wire a free key to CycleControlMode"
- "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)"
--- ---
# Cockpit Gauges / MFD HUD # Cockpit Gauges / MFD HUD
@@ -68,6 +70,36 @@ over one shared `SVGA16` pixelBuffer; `SVGA16::DrawDevSurface`). On the POD they
intact). The `overlay` port (SectorDisplay lives there) shares the `sec` physical surface via a intact). The `overlay` port (SectorDisplay lives there) shares the `sec` physical surface via a
different bit-plane (0x00C0). [T2] different bit-plane (0x00C0). [T2]
## The secondary screen's THREE views (Damage / Critical / Heat) — mode-gated [T0/T1]
The `sec` port stacks three mode-gated mech-schematic layers at offset (50,0) over the
always-on radar/heading/speed/messageBoard (`Secondary1`): **Damage** (`ModeSecondaryDamage`,
`<mech>dama.pcc` + `colorMapArmor`/`colorMapperMultiArmor` — 4 silhouettes front/left/right/back,
pixel-plane ids 60-63, per-DAMAGE-ZONE `dz_*` tint through the adpal→adpal2 ramp), **Critical**
(`ModeSecondaryCritical`, `<mech>crit.pcc` + the cmCrit per-SUBSYSTEM list), **Heat**
(`ModeSecondaryHeat` + cmHeat). Mode bits (BTL4MODE.HPP, `nextModeBit`=0 [T0]): Mapping=0x8000,
NonMapping=0x10000, Intercom=0x20000, **SecondaryDamage=0x40000, SecondaryCritical=0x80000,
SecondaryHeat=0x100000** (bits 18-20). `ModeInitial` includes **SecondaryDamage** → the ARMOR
view is the default-on layer (our port creates `BTL4ModeManager(ModeInitial)`, btl4app.cpp:303).
`L4MechControlsMapper::SetControlMode` @004d1ae4 is the SELECTOR — its mask table
{0x40000,0x80000,0x100000} clears bits 18-20 and sets one — despite the "control mode" name it
switches the secondary VIEW. ⚠ Desktop gap: the Keypress cases `0x13d`/`0x13e`
(CycleControlMode/CycleDisplayMode) are the DOS Tesla extended F3/F4 codes and never fire under
the WinTesla VK map (VK_F3=0x72 collides with 'r', VK_F4=0x73 with 's' — the same collision
class as the documented 'p'/VK_F1 drop), so the desktop is PINNED on the Damage view. The
schematic shows the pilot's OWN mech only — there is no target-damage readout in the cockpit.
## pilotList (Comm KILLS/DEATHS) row semantics + the 1 [T1/T2]
One ROW PER PILOT in the mission (2-player MP = 2 rows — not duplicate displays). KILLS =
`killCount` (the victim's ScoreMessageHandler credits the shooter cross-player, works for both
rows); DEATHS = `Player::deathCount`: engine-inits to **2** (PLAYER.cpp:759), the LOCAL
vehicle-acquire branch zeroes it (btplayer.cpp:1118), then VehicleDead(-1) ++s per death. A
REMOTE player's Player object never runs the local acquire → 2 +1 spawn increment = **1
locked**. **RESOLVED 2026-07-12 [T2]:** deaths now tally per node from LOCALLY OBSERVED events
(the same model as the cross-pod KILLS credit) — a replicant's once-per-death transition calls
`BTPlayerCountObservedDeath` on its owning player's local copy (replicant-gated: the master's
own VehicleDead path counts its node), and `BTPilotDeaths` clamps the 2/1 pre-acquire seed
to 0 for display. Own row counts correctly as before.
## ConfigMapGauge (the weapon panel's regroup lamp column) — authentically DORMANT ## ConfigMapGauge (the weapon panel's regroup lamp column) — authentically DORMANT
The per-weapon btjoy.pcc joystick image + 4 cm_* state lamps (off/other/only/both) showing, The per-weapon btjoy.pcc joystick image + 4 cm_* state lamps (off/other/only/both) showing,
for each mappable fire button (Pinky/ThumbLow/Trigger/ThumbHigh), whether THIS panel's weapon for each mappable fire button (Pinky/ThumbLow/Trigger/ThumbHigh), whether THIS panel's weapon
+19 -4
View File
@@ -92,10 +92,17 @@ authentic path scoped.
chain** ([0x31c]/[0x320] dpl objects + the PNAME1-8.bgf player-name meshes over the locked chain** ([0x31c]/[0x320] dpl objects + the PNAME1-8.bgf player-name meshes over the locked
target — matters for MP). (c) the compass rotation SIGN is derived-not-verified [T3] (turn target — matters for MP). (c) the compass rotation SIGN is derived-not-verified [T3] (turn
left → the stem should swing right; flip the sin sign in Draw if footage disagrees). left → the stem should swing right; flip the sin sign in Draw if footage disagrees).
- **StatusMessagePool (NULL stub, btstubs.cpp:62)** — the per-player status-message queue. - **StatusMessagePool / MessageBoard — LIVE (2026-07-12) [T1/T2].** Decoded: FUN_0042e580 =
BTPlayer+0x1dc is never populated → MessageBoard is empty (authentic for bring-up). What the engine `Player::AddStatusMessage` (chain add at +0x1e4); FUN_0042e5a0 = `StatusMessageUpdate`
resolves it: wire the pool + decode `AddStatusMessage @0042e580` + the `Player__StatusMessage` (+0x1dc = the displayed message; +0x14 displayTime countdown, expiry-deleted) — ALL already in
record ({messageId@+0xc, nameEntity@+0x10}). the engine [T0 PLAYER.cpp]. The ONE binary producer = BTPlayer::ScoreMessageHandler's KILL
branch (@004c02e4 type-2): pool-alloc @00512f6c + ctor(victim's player, strip 0, 6.0s) +
AddStatusMessage. Port: engine `new Player__StatusMessage` + Register_Object (the engine
reclaims with delete — the binary pool is an allocation detail); consumer =
BTResolveMessageBoard reads the mission player's statusMessagePointer + resolves the name via
Mission::GetSmallNameBitmap; the board's SetSource binder (no recovered caller) is replaced by
a lazy viewpoint-entity bind in Execute. Ticker pops on BOTH nodes (each node's death
transition posts its local KillScore).
- **Gyroscope (0xBC4) — RE-ENABLED LIVE (task #56, 2026-07-10).** The NaN revert's root causes - **Gyroscope (0xBC4) — RE-ENABLED LIVE (task #56, 2026-07-10).** The NaN revert's root causes
are fixed byte-exact (ctor field map @004b3778 — springConstant@0x1E8/dampingConstant@0x1F4 are fixed byte-exact (ctor field map @004b3778 — springConstant@0x1E8/dampingConstant@0x1F4
were mislabelled, the 0x254-0x2B3 block was missing, clamps/accumulators uninitialised; the were mislabelled, the 0x254-0x2B3 block was missing, clamps/accumulators uninitialised; the
@@ -292,6 +299,14 @@ authentic path scoped.
under reconstruction: config-mode regrouping (task #6), generator reassignment (ids 4-8), under reconstruction: config-mode regrouping (task #6), generator reassignment (ids 4-8),
coolant valves. coolant valves.
- **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
the panel ONLINE (GetDrawState()==1) because the old raw reads were layout NOISE that flapped
the state and black-filled the panel over the recharge arc (the frozen-PPC-dial bug; the
WeaponCluster repaint-heal also landed). Identify both fields to restore the authentic
destroyed-subsystem dark-panel look.
## Locomotion / combat polish (non-gating) ## Locomotion / combat polish (non-gating)
- Authentic per-mech TURN-RATE constant (currently a bring-up constant rate). - Authentic per-mech TURN-RATE constant (currently a bring-up constant rate).
- Body-callback gimp handlers (states 16-19, targets 0x70b2/0x7161 undecoded → fall back to stand); - Body-callback gimp handlers (states 16-19, targets 0x70b2/0x7161 undecoded → fall back to stand);
+52
View File
@@ -113,6 +113,14 @@ near the image base, grep the link log for "unresolved external" — the "succes
lying.** Corollary: a bridge fn / a `.data` fn-ptr callback MUST have a real (stub) definition. lying.** Corollary: a bridge fn / a `.data` fn-ptr callback MUST have a real (stub) definition.
A `SetVideoPathPriority` defined in an anonymous namespace → internal linkage → unresolved in A `SetVideoPathPriority` defined in an anonymous namespace → internal linkage → unresolved in
another TU → stubbed by /FORCE → AV in `LoadMissionImplementation`. [T2] another TU → stubbed by /FORCE → AV in `LoadMissionImplementation`. [T2]
**Signature-change corollary (user-hit crash 2026-07-12):** changing a shared free-function
bridge's SIGNATURE changes its mangled name — every OTHER TU's local `extern` decl now
references a symbol that no longer exists, /FORCE tolerates it, and the crash lands on the
first call from the un-updated TU (the missile-arc wave updated `BTPushProjectile` +
mislanch.cpp's extern but not projweap.cpp's → first AUTOCANNON shot AV'd). **Rule: after any
bridge-signature change, `grep -rn "extern .*<name>"` and update every declaration; then grep
the fresh link output for the symbol name** — the pre-existing LNK2019 wall camouflages new
entries if you only eyeball it.
## 7. Dtor-epilogue rule — do not reconstruct compiler glue ## 7. Dtor-epilogue rule — do not reconstruct compiler glue
@@ -293,6 +301,50 @@ corrupts the metric.
canopies are open lattices. Namespace edge keys by patch identity (and remember l/r patches are canopies are open lattices. Namespace edge keys by patch identity (and remember l/r patches are
MIRRORED — winding handedness flips, so no global winding choice can be right; orient per-face). MIRRORED — winding handedness flips, so no global winding choice can be right; orient per-face).
## 16. Engine-facility drift: 2007 terrain-solids amplify 1995 per-contact physics (the MP ram one-shot)
`Mover::StaticBounce` [T0] MUTATES `worldLinearVelocity` (`+= delta_v`, a ×(1+e) reflection) on
every call, and `ProcessCollisionList` calls it once PER CONTACTED SOLID in the frame. In the 1995
binary the ground was a heightfield probe (FUN_0040e5f0 lineage) — never a collision-list entry —
so a mech's list held ~one solid and the mutation was harmless. The 2007 WinTesla engine models
TERRAIN AS COLLISION SOLIDS: a mech touching ground + rock + another mech reflects 2-4× in ONE
frame, compounding velocity ×4-×40, and the mech-vs-mech damage dispatch later in the list priced
ram damage off the amplified value — a 62-point bump economy produced 1,074- and 112,375-point
one-shots (mp_a.log:32651, 2026-07-12: a pristine mech killed by a walking bump). [T2]
- **Fix pattern:** snapshot the TRUE frame-entry motion (`frameEntryWorldVelocity`, set beside the
`ProcessCollisionList` call sites) and restore it at the top of every `Mech::ProcessCollision`
each contact prices damage at the mech's real approach speed, which is all the binary's
StaticBounce ever saw. The post-list velocity is discarded anyway (frame-rejection response /
next frame's position-delta derive). Also: `Mech::Reset` must zero `worldLinearVelocity` +
`localVelocity` (respawn is a TELEPORT; stale death-frame motion must not survive it).
- **Tell:** damage amounts orders of magnitude outside the weapon economy (weapons 3-12/hit, rams
~13-62), CONSTANT repeated values (a stable grind oscillation), or spikes scaling with how many
solids surround the contact. Damage = `0.0005 × (1e²) × impact² × moverMass` [T0 MOVER.cpp] —
invert it to read the implied impact speed; >100 m/s means amplified/garbage velocity, not motion.
- **Class rule:** when a 1995 per-event computation reads MUTABLE engine state, audit what ELSE the
2007 engine feeds that state within the same event batch. (Family of gotcha #12's frame-pacing
trap: the binary's physics assumed its own engine's event granularity.)
## 17. Engine-helper identity: verify the FUN_ body, not its call shape (the empty-radar bug)
Two adjacent matrix helpers in the radar's DrawDisplay were transcribed by CALL SHAPE and both
were wrong — producing a plausible-looking but broken world→view transform that drew every pip
hundreds of pixels off-scope (the radar looked simply "empty"; nothing crashed, nothing warned):
- `FUN_0040b244(dst, src)` read as a COPY — it is the full affine INVERSE (cofactor expansion +
determinant divide, part_001.c:172). → `worldToView.Invert(view)`.
- `FUN_0040adec(matrix, quat)` read as a COMPOSE (`view *= yaw`) — it writes ONLY the 3×3
rotation elements and NEVER touches [3]/[7]/[11] (the translation row). The engine's
`operator*=(Quaternion)` composes fully (rotates the translation too) — the pre-set center got
corrupted BEFORE the invert. → rotation-only assignment first, `SetFromAxis(W_Axis, center)`
LAST. [T1 both, verified live: blip at exactly |delta|·ppm px after the fix]
- **Tell:** a transform chain whose output is self-inconsistent — check whether the matrix maps
its own reference point where it must (here: the viewer's position → the scope origin; it
mapped to (54, 599)). One logged matrix dump falsifies the whole chain in one frame.
- **Rule:** for ANY engine-helper FUN_ in a reconstruction, read its BODY once (a 30-line
decompile) before assigning it an engine method — a wrong-but-plausible identity survives
every compile and every "it runs" test.
--- ---
## Diagnostic recipe (the standard loop) ## Diagnostic recipe (the standard loop)
+7 -1
View File
@@ -613,7 +613,13 @@ is a cluster-child (not config-called) — its 4 Seek* attributes are the only r
VehicleDead(deathCount=-2) timer, and our handler ++deathCount + **re-posted it unconditionally** → an VehicleDead(deathCount=-2) timer, and our handler ++deathCount + **re-posted it unconditionally** → an
infinite loop (the drop-zone handshake never completes in bring-up). Fix: if the player already has a infinite loop (the drop-zone handshake never completes in bring-up). Fix: if the player already has a
vehicle (drop zone acquired), the retry is moot → return without counting/re-posting → DEATHS=0, loop gone. vehicle (drop zone acquired), the retry is moot → return without counting/re-posting → DEATHS=0, loop gone.
MP real-death DEATHS (destroyed vehicle via @004c05c4) remains deferred. MP real-death DEATHS: RESOLVED 2026-07-12 (the observed-death tally). Each node scores every
pilot from LOCALLY OBSERVED events (the same model as the cross-pod KILLS credit): a REPLICANT
mech's once-per-death transition tallies onto its owning player's local copy
(BTPlayerCountObservedDeath, btplayer.cpp; call site mech4.cpp death transition, replicant-gated
against double-counting the master's own VehicleDead path). The display bridge BTPilotDeaths
clamps the engine's -2/-1 pre-acquire seed to 0 (a remote player's copy never runs the local
acquire that zeroes it -- the raw negative was the user-visible "DEATHS -1").
- **duckState** writer (button-4 crouch lamp; only ctor-zeroed today). - **duckState** writer (button-4 crouch lamp; only ctor-zeroed today).
### Phase 4 — DEEP/DEFERRED ### Phase 4 — DEEP/DEFERRED
+10
View File
@@ -579,8 +579,18 @@ Logical
if (s_devGaugesUpd) if (s_devGaugesUpd)
{ {
if (!GuardedExecute()) if (!GuardedExecute())
{
// LOUD KILL (2026-07-12): the silent version made a faulted
// gauge simply FREEZE on screen (user-hit: a weapon panel's
// dial + lamp died mid-session with no trace). Name the
// casualty so the log convicts the faulting Execute.
DEBUG_STREAM << "[gauge-fault] '"
<< (identificationString ? identificationString : "?")
<< "' Execute FAULTED -> gauge DISABLED (this="
<< (void *)this << ")\n" << std::flush;
Disable(True); Disable(True);
} }
}
else else
{ {
Execute(); Execute();
+30 -2
View File
@@ -3628,15 +3628,24 @@ void
// Radar contacts are the DYNAMIC vehicles (mechs), not the props/terrain/ // Radar contacts are the DYNAMIC vehicles (mechs), not the props/terrain/
// projectiles that AllEntityIterator would flood the grid with -- iterate the // projectiles that AllEntityIterator would flood the grid with -- iterate the
// dynamic masters (the local + AI/dummy vehicles) plus the dynamic replicants // dynamic masters (the local + AI/dummy vehicles) plus the dynamic replicants
// (peer vehicles in multiplayer). Static beacons/props (a separate // (peer vehicles in multiplayer).
// classification) are left for a follow-up. // CLASSIFICATION (the phantom-red-pip fix, 2026-07-12): the dynamic
// iterators ALSO surface non-Mover world entities (the map's CulturalIcon
// props, class 0x5E, register dynamic on this port) -- unsorted, they drew
// through the radar's MOVING loop as RED contacts ("multiple pips with one
// player"). Sort by Mover derivation: vehicles into the moving grid (red
// pips), everything else into the static grid (the dim silhouette layer
// DrawStatic draws) -- the follow-up the old note deferred.
{ {
HostManager::DynamicMasterEntityIterator master_iterator(host_manager); HostManager::DynamicMasterEntityIterator master_iterator(host_manager);
master_iterator.First(); master_iterator.First();
Entity *entity; Entity *entity;
while ((entity = master_iterator.ReadAndNext()) != NULL) while ((entity = master_iterator.ReadAndNext()) != NULL)
{ {
if (entity->IsDerivedFrom(*Mover::GetClassDerivations()))
movingEntities.Add(entity); movingEntities.Add(entity);
else
staticEntities.Add(entity);
} }
} }
{ {
@@ -3645,7 +3654,10 @@ void
Entity *entity; Entity *entity;
while ((entity = replicant_iterator.ReadAndNext()) != NULL) while ((entity = replicant_iterator.ReadAndNext()) != NULL)
{ {
if (entity->IsDerivedFrom(*Mover::GetClassDerivations()))
movingEntities.Add(entity); movingEntities.Add(entity);
else
staticEntities.Add(entity);
} }
} }
Check_Fpu(); Check_Fpu();
@@ -3665,6 +3677,22 @@ void
Check(application->GetModeManager()); Check(application->GetModeManager());
ModeMask ModeMask
current_mode_mask = application->GetModeManager()->GetModeMask(); current_mode_mask = application->GetModeManager()->GetModeMask();
// DEV-COMPOSITE (2026-07-12, the frozen-dial fix): page-gated gauges (mode
// = their MFD page bit) freeze when their page isn't mode-active -- on the
// pod you FLIP pages, but the dev window shows every page surface at once,
// so six of seven weapon dials sat frozen on screen (user-hit; the arcs
// executed exactly 8 times at startup then never again). Under
// BT_DEV_GAUGES force the 15 MFD page-plane bits (BTL4 mode bits 0..14 =
// MFD1/2/3 Quad+Eng1-4) active so every visible page runs. The secondary
// trio (bits 18-20) stays authentic -- those views SHARE pixels and are
// exclusive by design.
{
static int s_devAllPages = -1;
if (s_devAllPages < 0)
s_devAllPages = (getenv("BT_DEV_GAUGES") != NULL) ? 1 : 0;
if (s_devAllPages)
current_mode_mask |= (ModeMask)0x7FFF;
}
ModeMask ModeMask
previous_mode_mask = application->GetModeManager()->GetPreviousModeMask(); previous_mode_mask = application->GetModeManager()->GetPreviousModeMask();
ModeMask ModeMask
+13
View File
@@ -912,6 +912,19 @@ void
break; break;
} }
} }
if (getenv("BT_MAP_LOG"))
{
static int s_gi = 0;
if ((s_gi++ % 30) == 0)
{
DEBUG_STREAM << "[gauima] lodVal=" << LOD_value
<< " lodCount=" << LODCount << " chosen=" << lod_index
<< " scales={";
for (int gi = 0; gi < LODCount && gi < 4; ++gi)
DEBUG_STREAM << LODScales[gi] << (gi + 1 < LODCount ? "," : "");
DEBUG_STREAM << "} verts=" << vertexCount << "\n" << std::flush;
}
}
//------------------------------------------------------------- //-------------------------------------------------------------
// Continue if the index is within range // Continue if the index is within range
//------------------------------------------------------------- //-------------------------------------------------------------
+123 -19
View File
@@ -127,7 +127,8 @@ static bool DevGaugeDocked()
// render-state save/restore is needed (the next frame resets device state). // render-state save/restore is needed (the next frame resets device state).
//===========================================================================// //===========================================================================//
void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int paletteID, void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int paletteID,
int monoTint, float dstX, float dstY, float dstW, float dstH) int monoTint, float dstX, float dstY, float dstW, float dstH,
int rotateCCW)
{ {
int w = pixelBuffer.Data.Size.x; // 640 int w = pixelBuffer.Data.Size.x; // 640
int h = pixelBuffer.Data.Size.y; // 480 int h = pixelBuffer.Data.Size.y; // 480
@@ -181,6 +182,10 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
} }
// Draw the surface quad at (dstX,dstY,dstW,dstH) in the current render target. // Draw the surface quad at (dstX,dstY,dstW,dstH) in the current render target.
// rotateCCW: the pod's SECONDARY screen was a physically ROTATED portrait
// CRT -- the game authors its content sideways in the landscape buffer and
// the cabinet monitor unrotated it. On the dev panel WE unrotate: display
// the texture turned 90 degrees so the radar/secondary reads upright.
struct InsetVert { float x, y, z, rhw, u, v; }; struct InsetVert { float x, y, z, rhw, u, v; };
InsetVert quad[4] = InsetVert quad[4] =
{ {
@@ -189,8 +194,26 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
{ dstX + dstW, dstY + dstH, 0.0f, 1.0f, 1.0f, 1.0f }, { dstX + dstW, dstY + dstH, 0.0f, 1.0f, 1.0f, 1.0f },
{ dstX, dstY + dstH, 0.0f, 1.0f, 0.0f, 1.0f }, { dstX, dstY + dstH, 0.0f, 1.0f, 0.0f, 1.0f },
}; };
if (rotateCCW == 1) // screen corner <- texture corner, turned CCW
{
quad[0].u = 1.0f; quad[0].v = 0.0f; // TL <- TR
quad[1].u = 1.0f; quad[1].v = 1.0f; // TR <- BR
quad[2].u = 0.0f; quad[2].v = 1.0f; // BR <- BL
quad[3].u = 0.0f; quad[3].v = 0.0f; // BL <- TL
}
else if (rotateCCW == 3) // the other direction (CW), env-selectable
{
quad[0].u = 0.0f; quad[0].v = 1.0f; // TL <- BL
quad[1].u = 0.0f; quad[1].v = 0.0f; // TR <- TL
quad[2].u = 1.0f; quad[2].v = 0.0f; // BR <- TR
quad[3].u = 1.0f; quad[3].v = 1.0f; // BL <- BR
}
device->SetTexture(0, tex); device->SetTexture(0, tex);
device->SetFVF(D3DFVF_XYZRHW | D3DFVF_TEX1); device->SetFVF(D3DFVF_XYZRHW | D3DFVF_TEX1);
// LINEAR sampling: the cells scale the 8-bit surfaces (sec runs 0.75x at
// scale 1.0); point sampling drops the radar's 1px strokes entirely.
device->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_LINEAR);
device->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR);
device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1); device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
device->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1); device->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1);
@@ -218,18 +241,27 @@ struct BTGaugeSurfaceDesc
int monoTint; // -1 = palette (sec); else R5G6B5 tint int monoTint; // -1 = palette (sec); else R5G6B5 tint
float cellX, cellY; // normalized cell origin within the panel (y down) float cellX, cellY; // normalized cell origin within the panel (y down)
float cellW, cellH; // normalized cell size float cellW, cellH; // normalized cell size
int rotateCCW; // 1 = unrotate (portrait secondary CRT)
}; };
// PANEL LAYOUT (2026-07-12 rework): the old 3x2 equal grid DOWNSCALED every
// 640x480 surface into a 320x192 cell (blurry) and showed the SECONDARY
// (radar) sideways -- on the pod that screen was a physically ROTATED
// portrait CRT. New layout, logical 1320x480: a 2x2 block of mono MFDs on
// the left at 320x240 (correct 4:3), the secondary UNROTATED in a portrait
// 360x480 cell in the middle, Comm top-right. Scale the whole window with
// BT_GAUGE_SCALE (e.g. 1.5) for more pixels -- content is a 1995 8-bit
// buffer, so past ~1.5x you magnify, not sharpen.
static const float kBTPanelW = 1320.0f;
static const float kBTPanelH = 480.0f;
static const BTGaugeSurfaceDesc kBTGaugeSurfaces[6] = static const BTGaugeSurfaceDesc kBTGaugeSurfaces[6] =
{ {
// top row: Heat (UL) | Mfd2 (UC) | Comm (UR) { "Heat", 0xFFFF, 0.0f / kBTPanelW, 0.0f, 320.0f / kBTPanelW, 0.5f, 0 },
{ "Heat", 0xFFFF, 0.0f / 3, 0.0f, 1.0f / 3, 0.5f }, { "Mfd2", 0xFFFF, 320.0f / kBTPanelW, 0.0f, 320.0f / kBTPanelW, 0.5f, 0 },
{ "Mfd2", 0xFFFF, 1.0f / 3, 0.0f, 1.0f / 3, 0.5f }, { "Mfd1", 0xFFFF, 0.0f / kBTPanelW, 0.5f, 320.0f / kBTPanelW, 0.5f, 0 },
{ "Comm", 0xFFFF, 2.0f / 3, 0.0f, 1.0f / 3, 0.5f }, { "Mfd3", 0xFFFF, 320.0f / kBTPanelW, 0.5f, 320.0f / kBTPanelW, 0.5f, 0 },
// bottom row: Mfd1 (LL) | sec/radar (center) | Mfd3 (LR) { "sec", -1, 640.0f / kBTPanelW, 0.0f, 360.0f / kBTPanelW, 1.0f, 1 },
{ "Mfd1", 0xFFFF, 0.0f / 3, 0.5f, 1.0f / 3, 0.5f }, { "Comm", 0xFFFF, 1000.0f / kBTPanelW, 0.0f, 320.0f / kBTPanelW, 0.5f, 0 },
{ "sec", -1, 1.0f / 3, 0.5f, 1.0f / 3, 0.5f },
{ "Mfd3", 0xFFFF, 2.0f / 3, 0.5f, 1.0f / 3, 0.5f },
}; };
// //
@@ -243,6 +275,16 @@ void BTDrawGaugeSurfaces(LPDIRECT3DDEVICE9 device, float px, float py, float pw,
GaugeRenderer *gr = application ? application->GetGaugeRenderer() : NULL; GaugeRenderer *gr = application ? application->GetGaugeRenderer() : NULL;
if (gr == NULL) return; if (gr == NULL) return;
// DEVICE-STATE ISOLATION (the "floating cavern rocks" bug): the per-surface
// quad draw disables Z (D3DRS_ZENABLE=FALSE), culling and lighting, binds a
// pre-transformed FVF and rewrites texture stage 0 -- and the world pass
// does NOT re-assert these each frame, so the NEXT frame's world rendered
// WITHOUT DEPTH TESTING (geometry resolved in submission order; rock
// columns drew "floating / unconnected"). Snapshot the whole device state
// and restore it after the composite -- dev-only path, cost irrelevant.
IDirect3DStateBlock9 *stateBlock = NULL;
device->CreateStateBlock(D3DSBT_ALL, &stateBlock);
// Reach the shared display once (any present port shares the same SVGA16/pixelBuffer). // Reach the shared display once (any present port shares the same SVGA16/pixelBuffer).
SVGA16 *svga = NULL; SVGA16 *svga = NULL;
for (int i = 0; i < 6 && svga == NULL; i++) for (int i = 0; i < 6 && svga == NULL; i++)
@@ -257,9 +299,30 @@ void BTDrawGaugeSurfaces(LPDIRECT3DDEVICE9 device, float px, float py, float pw,
const BTGaugeSurfaceDesc &d = kBTGaugeSurfaces[i]; const BTGaugeSurfaceDesc &d = kBTGaugeSurfaces[i];
L4GraphicsPort *port = static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(d.portName)); L4GraphicsPort *port = static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(d.portName));
if (port == NULL) continue; // this surface's port isn't configured -> skip if (port == NULL) continue; // this surface's port isn't configured -> skip
// Secondary-CRT unrotation direction is env-flippable (BT_GAUGE_SEC_ROT=3
// for the other way) in case the assumed pod mounting is mirrored.
int rot = d.rotateCCW;
if (rot != 0)
{
static int s_secRot = -1;
if (s_secRot < 0)
{
// Default 3 (CW): user-verified upright (the first guess, 1,
// showed the secondary upside down). Env still overrides.
const char *rv = getenv("BT_GAUGE_SEC_ROT");
s_secRot = (rv != NULL && rv[0] == '1') ? 1 : 3;
}
rot = s_secRot;
}
svga->DrawDevSurface( svga->DrawDevSurface(
device, i, port->GetBitMask(), port->paletteID, d.monoTint, device, i, port->GetBitMask(), port->paletteID, d.monoTint,
px + d.cellX * pw, py + d.cellY * ph, d.cellW * pw, d.cellH * ph); px + d.cellX * pw, py + d.cellY * ph, d.cellW * pw, d.cellH * ph, rot);
}
if (stateBlock != NULL)
{
stateBlock->Apply();
stateBlock->Release();
} }
} }
@@ -273,7 +336,8 @@ void BTDrawGaugeInset(LPDIRECT3DDEVICE9 device)
{ {
if (!DevGaugeComposite() || !DevGaugeDocked() || device == NULL) return; if (!DevGaugeComposite() || !DevGaugeDocked() || device == NULL) return;
// DOCKED mode only: draw into the main backbuffer (this runs before EndScene). // DOCKED mode only: draw into the main backbuffer (this runs before EndScene).
BTDrawGaugeSurfaces(device, 2.0f, 300.0f, 540.0f, 296.0f); // Keep the new panel's 1320:480 aspect (2.75) so nothing distorts.
BTDrawGaugeSurfaces(device, 2.0f, 400.0f, 540.0f, 540.0f / 2.75f);
} }
//===========================================================================// //===========================================================================//
@@ -286,13 +350,32 @@ void BTDrawGaugeInset(LPDIRECT3DDEVICE9 device)
//===========================================================================// //===========================================================================//
static HWND s_gaugeHwnd = NULL; static HWND s_gaugeHwnd = NULL;
static IDirect3DSwapChain9 *s_gaugeSwap = NULL; static IDirect3DSwapChain9 *s_gaugeSwap = NULL;
static const int s_gaugeW = 960; // Window = the logical 1320x480 panel (see kBTGaugeSurfaces) x BT_GAUGE_SCALE.
static const int s_gaugeH = 384; // Resolved once at first ensure; default 1.0 (surfaces at/near native pixels).
static int s_gaugeW = 1320;
static int s_gaugeH = 480;
static bool s_gaugeSized = false;
static void BTGaugeResolveSize()
{
if (s_gaugeSized) return;
s_gaugeSized = true;
const char *sv = getenv("BT_GAUGE_SCALE");
float scale = (sv != NULL) ? (float)atof(sv) : 1.0f;
if (scale < 0.5f || scale > 3.0f) scale = 1.0f;
s_gaugeW = (int)(1320.0f * scale);
s_gaugeH = (int)(480.0f * scale);
}
static LRESULT CALLBACK BTGaugeWndProc(HWND h, UINT m, WPARAM w, LPARAM l) static LRESULT CALLBACK BTGaugeWndProc(HWND h, UINT m, WPARAM w, LPARAM l)
{ {
if (m == WM_CLOSE) { ShowWindow(h, SW_HIDE); return 0; } // hide, never quit the app if (m == WM_CLOSE) { ShowWindow(h, SW_HIDE); return 0; } // hide, never quit the app
return DefWindowProc(h, m, w, l); // Fully-WIDE window (class + create + proc all W; the class/create below
// are RegisterClassW/CreateWindowExW): mixed A/W pairings mangled the
// title -- A-class + W-proc painted the ANSI bytes as UTF-16 (CJK-looking
// mojibake), W-class + A-proc truncated at the first thunked NUL ("B").
// One consistent character set ends the thunk roulette.
return DefWindowProcW(h, m, w, l);
} }
// Lazily create the window + its additional swap chain (on the MAIN device). // Lazily create the window + its additional swap chain (on the MAIN device).
@@ -301,26 +384,47 @@ static bool BTGaugeWindowEnsure(LPDIRECT3DDEVICE9 device)
if (s_gaugeSwap != NULL) return true; if (s_gaugeSwap != NULL) return true;
if (device == NULL) return false; if (device == NULL) return false;
BTGaugeResolveSize(); // BT_GAUGE_SCALE -> window dims
HINSTANCE hInst = GetModuleHandle(NULL); HINSTANCE hInst = GetModuleHandle(NULL);
static bool s_classReg = false; static bool s_classReg = false;
if (!s_classReg) if (!s_classReg)
{ {
WNDCLASSA wc; // WIDE APIs throughout: this TU compiles UNICODE, so BTGaugeWndProc's
// DefWindowProc resolves to the W variant -- registering the class
// with RegisterClassA made Windows reinterpret the ANSI title bytes
// as UTF-16 (the "Korean garbage title" bug: byte-pairs of
// "BattleTech - Cockpit MFDs" land in the CJK plane, only the
// trailing 's'+NUL survives readable).
WNDCLASSW wc;
memset(&wc, 0, sizeof(wc)); memset(&wc, 0, sizeof(wc));
wc.lpfnWndProc = BTGaugeWndProc; wc.lpfnWndProc = BTGaugeWndProc;
wc.hInstance = hInst; wc.hInstance = hInst;
wc.hCursor = LoadCursor(NULL, IDC_ARROW); wc.hCursor = LoadCursor(NULL, IDC_ARROW);
wc.hbrBackground = (HBRUSH) GetStockObject(BLACK_BRUSH); wc.hbrBackground = (HBRUSH) GetStockObject(BLACK_BRUSH);
wc.lpszClassName = "BTGaugeWnd"; wc.lpszClassName = L"BTGaugeWnd";
RegisterClassA(&wc); RegisterClassW(&wc);
s_classReg = true; s_classReg = true;
} }
if (s_gaugeHwnd == NULL) if (s_gaugeHwnd == NULL)
{ {
RECT r = { 0, 0, s_gaugeW, s_gaugeH }; RECT r = { 0, 0, s_gaugeW, s_gaugeH };
AdjustWindowRect(&r, WS_OVERLAPPEDWINDOW, FALSE); AdjustWindowRect(&r, WS_OVERLAPPEDWINDOW, FALSE);
s_gaugeHwnd = CreateWindowExA( // Window identity (MP dev): tag with the -net port so two side-by-side
0, "BTGaugeWnd", "BattleTech - Cockpit MFDs", WS_OVERLAPPEDWINDOW, // nodes' MFD windows are distinguishable (matches the main window tag).
wchar_t gaugeTitle[64];
{
int netPort = 0;
const char *cmd = GetCommandLineA();
const char *np = (cmd != NULL) ? strstr(cmd, "-net") : NULL;
if (np != NULL)
sscanf(np + 4, " %d", &netPort);
if (netPort > 0)
swprintf(gaugeTitle, 64, L"BattleTech MFDs \x2014 node %d", netPort);
else
wcscpy(gaugeTitle, L"BattleTech - Cockpit MFDs");
}
s_gaugeHwnd = CreateWindowExW(
0, L"BTGaugeWnd", gaugeTitle, WS_OVERLAPPEDWINDOW,
40, 40, r.right - r.left, r.bottom - r.top, NULL, NULL, hInst, NULL); 40, 40, r.right - r.left, r.bottom - r.top, NULL, NULL, hInst, NULL);
if (s_gaugeHwnd == NULL) return false; if (s_gaugeHwnd == NULL) return false;
ShowWindow(s_gaugeHwnd, SW_SHOWNOACTIVATE); ShowWindow(s_gaugeHwnd, SW_SHOWNOACTIVATE);
+4 -1
View File
@@ -300,8 +300,11 @@ public:
// dstW,dstH). monoTint < 0 -> palette-expand (sec/radar, case-0 LUT); monoTint >= 0 // dstW,dstH). monoTint < 0 -> palette-expand (sec/radar, case-0 LUT); monoTint >= 0
// -> mono bit-plane expand ((word & mask) ? tint : 0) for an MFD plane. See // -> mono bit-plane expand ((word & mask) ? tint : 0) for an MFD plane. See
// BTDrawGaugeSurfaces (L4VB16.cpp). // BTDrawGaugeSurfaces (L4VB16.cpp).
// rotateCCW: 0 = as-is; 1/3 = display the texture turned 90 degrees (the
// pod's portrait-mounted secondary CRT; the dev panel unrotates it).
void DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int paletteID, void DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int paletteID,
int monoTint, float dstX, float dstY, float dstW, float dstH); int monoTint, float dstX, float dstY, float dstW, float dstH,
int rotateCCW = 0);
}; };
//######################################################################## //########################################################################
+18 -1
View File
@@ -391,7 +391,24 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
} }
RECT wr = { 0, 0, winW, winH }; RECT wr = { 0, 0, winW, winH };
AdjustWindowRect(&wr, WS_OVERLAPPEDWINDOW, FALSE); AdjustWindowRect(&wr, WS_OVERLAPPEDWINDOW, FALSE);
hWnd = CreateWindowEx(0, L"MainWndClass", L"BattleTech", WS_OVERLAPPEDWINDOW,
//
// Window identity (MP dev): tag the title with the -net port so a player
// running two nodes side-by-side can tell the windows apart when
// reporting ("node 1501" = A / player 1, "node 1601" = B / player 2).
//
wchar_t winTitle[64];
{
int netPort = 0;
const char *np = lpCmdLine ? strstr(lpCmdLine, "-net") : 0;
if (np != 0)
sscanf(np + 4, " %d", &netPort);
if (netPort > 0)
swprintf(winTitle, 64, L"BattleTech \x2014 node %d", netPort);
else
wcscpy(winTitle, L"BattleTech");
}
hWnd = CreateWindowEx(0, L"MainWndClass", winTitle, WS_OVERLAPPEDWINDOW,
0, 0, wr.right - wr.left, wr.bottom - wr.top, 0, 0, wr.right - wr.left, wr.bottom - wr.top,
(HWND)NULL, (HMENU)NULL, hInstance, (LPVOID)NULL); (HWND)NULL, (HMENU)NULL, hInstance, (LPVOID)NULL);
if (!hWnd) return FALSE; if (!hWnd) return FALSE;
+60 -13
View File
@@ -1310,7 +1310,7 @@ SubsystemCluster::SubsystemCluster(
*(int *)((char *)subsystem_in + 0x150) /*third*/, 3 /*frames*/, *(int *)((char *)subsystem_in + 0x150) /*third*/, 3 /*frames*/,
(Scalar *)coolantLeak, "LeakGauge"); // BEST-EFFORT raw (subsys+0x150) (Scalar *)coolantLeak, "LeakGauge"); // BEST-EFFORT raw (subsys+0x150)
operating = False; // @0xC4 this[0x31] failedState = False; // @0xC4 this[0x31] (1 = destroyed)
subsystem = subsystem_in; // @0xC0 this[0x30] subsystem = subsystem_in; // @0xC0 this[0x30]
previousDrawState = 0; // @0xC8 this[0x32] previousDrawState = 0; // @0xC8 this[0x32]
} }
@@ -1362,10 +1362,12 @@ void SubsystemCluster::DrawPanelFrame(int color)
// //
void SubsystemCluster::SetChildrenEnable(int enable) void SubsystemCluster::SetChildrenEnable(int enable)
{ {
// FUN_00444650 forwards visibility; enable==0 (offline) => Disable(True). // POLARITY (PPC-dial fix, take 3): the passed value is the DRAW STATE --
if (temperatureBar) temperatureBar->Disable(enable == 0); // 0 = alive (children run), nonzero = dead panel (children off). The old
if (coolingLoopA) coolingLoopA->Disable(enable == 0); // reading (`enable==0 => Disable`) had it backwards.
if (powerSourceA) powerSourceA->Disable(enable == 0); if (temperatureBar) temperatureBar->Disable(enable != 0);
if (coolingLoopA) coolingLoopA->Disable(enable != 0);
if (powerSourceA) powerSourceA->Disable(enable != 0);
} }
// //
@@ -1374,11 +1376,13 @@ void SubsystemCluster::SetChildrenEnable(int enable)
// //
int SubsystemCluster::GetDrawState() int SubsystemCluster::GetDrawState()
{ {
if (operating != 0) // POLARITY (PPC-dial fix, take 3): state 1 = the DEAD-panel presentation
return 1; // (black fill + bright frame + X lamps lit); state 0 = ALIVE (frame 0 +
if (*(int *)((char *)subsystem + 0x278) != 4) // BEST-EFFORT raw offset // background art; the WeaponCluster lamp gate runs ONLY in state 0). The
return 1; // binary's secondary condition (*(subsystem+0x278) != 4 -> 1) remains
return 0; // unidentified [T4] -- a healthy subsystem reads ALIVE here until that
// field is decoded (open-questions).
return failedState ? 1 : 0;
} }
Logical SubsystemCluster::TestInstance() const Logical SubsystemCluster::TestInstance() const
@@ -1394,7 +1398,18 @@ Logical SubsystemCluster::TestInstance() const
// //
void SubsystemCluster::Execute() void SubsystemCluster::Execute()
{ {
operating = (*(int *)((char *)subsystem + 0x40) == 1); // BEST-EFFORT raw offset // PPC-dial fix (2026-07-12, take 3 -- POLARITY): the binary's
// *(subsystem+0x40)==1 means FAILED (it lights the X TwoStates, which the
// engine draws bright on NONZERO, and selects the black-fill dead-panel
// look). The recon's old name `operating` was backwards, which inverted
// the X lamps, the panel look, the children enable AND the weapon lamp
// gate (which runs only in draw-state 0 = alive). Truth source: the
// subsystem's own damage saturation (DistributeCriticalHit destroys at
// >= 1.0, mechdmg.cpp).
{
extern Scalar BTSubsystemDamageLevelOf(void *subsystem_in);
failedState = (BTSubsystemDamageLevelOf(subsystem) >= 1.0f);
}
int state = GetDrawState(); int state = GetDrawState();
if (state != previousDrawState) if (state != previousDrawState)
@@ -1450,7 +1465,7 @@ HeatSinkCluster::HeatSinkCluster(
int engPort = renderer_in->FindGraphicsPort(eng_port_name); int engPort = renderer_in->FindGraphicsPort(eng_port_name);
heatFailLamp = new TwoState(ChildRate(), eng_mode, renderer_in, owner_ID, // @004739d8 heatFailLamp = new TwoState(ChildRate(), eng_mode, renderer_in, owner_ID, // @004739d8
engPort, 200, 0x110, fail_lamp_image, 0, 0xff, (int *)&operating, "TwoState"); engPort, 200, 0x110, fail_lamp_image, 0, 0xff, (int *)&failedState, "TwoState"); // bright when FAILED
failSubsystem = hud; // @0x33 failSubsystem = hud; // @0x33
failFlag = 0; // @0x34 failFlag = 0; // @0x34
@@ -1587,6 +1602,12 @@ WeaponCluster::WeaponCluster(
warningState = 0; // @0x39 warningState = 0; // @0x39
AddConnection(new GaugeConnectionDirectOf<Scalar>(0, &percentDone, // @00474855 AddConnection(new GaugeConnectionDirectOf<Scalar>(0, &percentDone, // @00474855
(Scalar *)percentDoneAttr)); (Scalar *)percentDoneAttr));
// PANEL DIAGNOSTIC (PPC-dial investigation 2026-07-12): remember the
// weapon's name so the Execute probe can attribute its readings; tag the
// recharge arc with the same name for its own [arc] probe.
diagWeaponName = (subsystem_in != 0) ? subsystem_in->GetName() : "?";
((SegmentArc270 *)rechargeArc)->diagName = diagWeaponName;
} }
WeaponCluster::~WeaponCluster() WeaponCluster::~WeaponCluster()
@@ -1630,7 +1651,33 @@ void WeaponCluster::BecameActive()
// //
void WeaponCluster::Execute() void WeaponCluster::Execute()
{ {
// PPC-dial fix (2026-07-12): the base Execute BLACK-FILLS + repaints the
// panel on every draw-state flip -- wiping the recharge arc's pixels while
// the arc's INCREMENTAL draw memory (previousSegment) still says "lit".
// The stale arc then draws nothing until the value crosses new segments =
// "tick marks frozen (at the backdrop's painted ring), dot still toggles"
// (user-reported, intermittent). Detect the repaint and reset the arc's
// draw memory so it fully redraws its true fill.
int preDrawState = previousDrawState;
SubsystemCluster::Execute(); SubsystemCluster::Execute();
if (previousDrawState != preDrawState)
{
if (rechargeArc) rechargeArc->BecameActive();
if (configMap) configMap->BecameActive();
warningState = -2; // lamp: force redraw too
}
// PANEL DIAGNOSTIC (PPC-dial investigation): 1 Hz per-panel dump of the
// exact value the recharge arc + warning lamp read. Answers whether a
// frozen dial is a DATA fault (percentDone pegged) or a DRAW fault
// (value cycles, segments don't).
if (getenv("BT_PANEL_LOG"))
{
static int s_pl = 0;
if ((s_pl++ % 60) == 0)
DEBUG_STREAM << "[panel] " << (diagWeaponName ? diagWeaponName : "?")
<< " percentDone=" << percentDone
<< " warn=" << warningState << "\n" << std::flush;
}
if (previousDrawState == 0) if (previousDrawState == 0)
{ {
int warn = (WeaponWarnThreshold < percentDone) ? 1 : 0; int warn = (WeaponWarnThreshold < percentDone) ? 1 : 0;
@@ -1736,7 +1783,7 @@ BallisticWeaponCluster::BallisticWeaponCluster(
"NumericDisplayScalarTwoState"); "NumericDisplayScalarTwoState");
destroyedLamp = new TwoState(ChildRate(), eng_mode, renderer_in, owner_ID, destroyedLamp = new TwoState(ChildRate(), eng_mode, renderer_in, owner_ID,
engPort, 0xc2, 0x85, "edestryd.pcc", 0, 0xff, (int *)&operating, "TwoState"); engPort, 0xc2, 0x85, "edestryd.pcc", 0, 0xff, (int *)&failedState, "TwoState"); // the destroyed X: bright when FAILED
// ejectWipe (BitMapInverseWipeScalar @004c61c8, eject-timer wipe reading // ejectWipe (BitMapInverseWipeScalar @004c61c8, eject-timer wipe reading
// subsys+0x3f8). BRING-UP: the BitMapInverseWipeScalar class is not yet // subsys+0x3f8). BRING-UP: the BitMapInverseWipeScalar class is not yet
+7 -1
View File
@@ -390,7 +390,12 @@
*stateLamp, // @0xB8 this[0x2E] OneOfSeveralStates *stateLamp, // @0xB8 this[0x2E] OneOfSeveralStates
*leakGauge; // @0xBC this[0x2F] LeakGauge *leakGauge; // @0xBC this[0x2F] LeakGauge
Subsystem *subsystem; // @0xC0 this[0x30] Subsystem *subsystem; // @0xC0 this[0x30]
Logical operating; // @0xC4 this[0x31] // POLARITY CORRECTED (PPC-dial fix, 2026-07-12): the binary's
// *(subsystem+0x40)==1 means the subsystem FAILED -- it lights the
// destroyed-X TwoStates (engine TwoState draws bright on NONZERO) and
// selects the dead-panel look (GetDrawState 1 = black fill). The old
// name `operating` had it backwards, inverting every consumer.
Logical failedState; // @0xC4 this[0x31] 1 = subsystem destroyed
int previousDrawState; // @0xC8 this[0x32] (Execute repaint key) int previousDrawState; // @0xC8 this[0x32] (Execute repaint key)
// internal helpers (non-virtual; @004c89c4 / @004c8820 / @004c8a28) // internal helpers (non-virtual; @004c89c4 / @004c8820 / @004c8a28)
@@ -472,6 +477,7 @@
int warningCenterX; // @0xDC this[0x37] int warningCenterX; // @0xDC this[0x37]
int warningCenterY; // @0xE0 this[0x38] int warningCenterY; // @0xE0 this[0x38]
int warningState; // @0xE4 this[0x39] int warningState; // @0xE4 this[0x39]
const char *diagWeaponName; // PORT diagnostic (appended; clusters are plain-new)
}; };
class EnergyWeaponCluster : // @004c93b0 class EnergyWeaponCluster : // @004c93b0
+12 -5
View File
@@ -1001,6 +1001,13 @@ void
void void
MessageBoard::Execute() MessageBoard::Execute()
{ {
// FEED LIVE (2026-07-12): the binary's SetSource binder has no recovered
// caller, so trackedMech stayed NULL and the board never drew. The board
// is the LOCAL cockpit's ticker -- bind the viewpoint mech lazily (the
// same fallback the subsystem cluster uses, :226). The message resolve
// itself reads the mission player's queue regardless.
if (trackedMech == NULL && application != NULL)
trackedMech = (Entity *)application->GetViewpointEntity();
if (trackedMech == NULL) // binary gate :3949 if (trackedMech == NULL) // binary gate :3949
return; return;
@@ -1030,11 +1037,11 @@ void
} }
} }
// --- sender name cell (:3983-4009). STRUCTURAL SIMPLIFICATION (marked): the binary // --- sender name cell (:3983-4009). The binary tracks the previous name
// tracks the previous name ENTITY pointer at 0x9c and erases it via +0x1e0->App+0xc8; // ENTITY pointer at 0x9c; with the feed LIVE (2026-07-12) we track the
// here previousNameId is a bool (0/1). Functionally identical while DEFERRED (name // resolved BITMAP pointer -- same change-detection granularity (a new
// always NULL); RESTORE the entity-pointer tracking + erase branch when the feed goes live. // victim = a different prebuilt raster), no entity re-deref.
int nameState = (nameBitmap != NULL) ? 1 : 0; int nameState = (int)(size_t)nameBitmap;
if (nameState != previousNameId) if (nameState != previousNameId)
{ {
localView.MoveToAbsolute(0x80, 0); // vtbl+0x24 (name cell x=128) localView.MoveToAbsolute(0x80, 0); // vtbl+0x24 (name cell x=128)
+20
View File
@@ -2404,8 +2404,28 @@ SegmentArc270::SegmentArc270(
AddConnection(new GaugeConnectionDirectOf<Scalar>(0, &currentValue, value_pointer)); AddConnection(new GaugeConnectionDirectOf<Scalar>(0, &currentValue, value_pointer));
int n = (number_of_segs < 0) ? -number_of_segs : number_of_segs; int n = (number_of_segs < 0) ? -number_of_segs : number_of_segs;
segmentSpan = (Scalar)(n / (n - 1)) * 0.75f; // _DAT_004c62d4 = 0.75 segmentSpan = (Scalar)(n / (n - 1)) * 0.75f; // _DAT_004c62d4 = 0.75
diagName = 0;
} }
SegmentArc270::~SegmentArc270() {} // @004c66b3 (base chain) SegmentArc270::~SegmentArc270() {} // @004c66b3 (base chain)
//
// PORT diagnostic wrapper (frozen-dial hunt, 2026-07-12): log the exact value
// and segment memory the arc runs with, then chain the engine draw untouched.
//
void
SegmentArc270::Execute()
{
if (getenv("BT_PANEL_LOG") && diagName != 0)
{
static int s_al = 0;
if ((s_al++ % 60) == 0)
DEBUG_STREAM << "[arc] " << diagName
<< " val=" << (float)currentValue
<< " prevSeg=" << previousSegment
<< " segs=" << numberOfSegments << "\n" << std::flush;
}
SegmentArc::Execute();
}
// === btl4gau2.cpp begins at @004c6798 (SeekVoltage gauge) -- not part of this TU. // === btl4gau2.cpp begins at @004c6798 (SeekVoltage gauge) -- not part of this TU.
+2
View File
@@ -546,6 +546,8 @@
Scalar *value_pointer, Logical use_thick_lines, Scalar *value_pointer, Logical use_thick_lines,
const char *identification_string); const char *identification_string);
~SegmentArc270(); // dtor thunk @004c66b3 ~SegmentArc270(); // dtor thunk @004c66b3
void Execute(); // PORT diag wrapper (chains SegmentArc::Execute)
const char *diagName; // PORT diagnostic (frozen-dial hunt)
protected: protected:
Scalar segmentSpan; // @0xC0 this[0x30] Scalar segmentSpan; // @0xC0 this[0x30]
}; };
+64 -8
View File
@@ -679,15 +679,23 @@ void
if (currentPositionPointer != NULL) if (currentPositionPointer != NULL)
{ {
Point3D center = *currentPositionPointer; // FUN_00408440(local_5c, ...) Point3D center = *currentPositionPointer; // FUN_00408440(local_5c, ...)
view.SetFromAxis(W_Axis, center); // FUN_0040b0ac(view,3,center)
// TRANSCRIPTION FIX #2 (the invisible-pip bug): FUN_0040adec writes
// ONLY the 3x3 rotation elements ([0..2],[4..6],[8..10]) -- it never
// touches the translation row. The binary therefore ends with
// view = [R(q) | center]: rotation SET, center PRESERVED. Our old
// `view *= yaw` was the engine's full COMPOSE, which also rotated the
// translation (view = [R | center x R]) -- the subsequent Invert then
// mapped center x R to the origin instead of the viewer, so every
// blip landed hundreds of pixels off-scope. Rotation first
// (rotation-only assignment), translation row LAST.
if (currentAngularPointer != NULL) if (currentAngularPointer != NULL)
{ {
if (!rockAndRoll) if (!rockAndRoll)
{ {
// //
// Top-down: keep heading only. Extract the yaw, build a // Top-down: keep heading only. Extract the yaw, build a
// yaw-only quaternion and concatenate it. // yaw-only quaternion, SET the rotation block from it.
// //
EulerAngles euler; EulerAngles euler;
euler = *currentAngularPointer; // FUN_0040954c euler = *currentAngularPointer; // FUN_0040954c
@@ -695,21 +703,27 @@ void
SinCosPair sc; SinCosPair sc;
sc = Radian(heading); // FUN_00408328 sc = Radian(heading); // FUN_00408328
Quaternion yaw(0.0f, sc.sine, 0.0f, sc.cosine); // FUN_00409948 Quaternion yaw(0.0f, sc.sine, 0.0f, sc.cosine); // FUN_00409948
view *= yaw; // FUN_0040adec view = yaw; // rotation-only (FUN_0040adec semantics)
} }
else else
{ {
// //
// "Rock and roll": use the full orientation matrix. // "Rock and roll": use the full orientation.
// //
AffineMatrix full; view = *currentAngularPointer; // rotation-only build
full = *currentAngularPointer; // FUN_00409968
view *= full; // FUN_0040adec
} }
} }
view.SetFromAxis(W_Axis, center); // FUN_0040b0ac(view,3,center) -- translation LAST
} }
worldToView = view; // FUN_0040b244(this+0x324, view) // TRANSCRIPTION FIX (the invisible-pip bug, 2026-07-12): FUN_0040b244 is
// the full affine INVERSE (cofactor expansion + determinant divide,
// part_001.c:172), not a copy. `view` above is the OPERATOR's local->
// world pose (translate to the mech + yaw); the radar needs its inverse
// -- world->scope, center subtracted, heading unrotated. Read as a copy,
// every pip/name transformed to (world + center) x ppm = thousands of
// pixels off-scope: the scope drew empty at all times.
worldToView.Invert(view); // FUN_0040b244(this+0x324, view)
// //
// Bake the metres->pixels scale into the matrix. // Bake the metres->pixels scale into the matrix.
@@ -717,6 +731,18 @@ void
Vector3D scale; Vector3D scale;
scale.x = scale.y = scale.z = pixelsPerMeter; scale.x = scale.y = scale.z = pixelsPerMeter;
worldToView.Multiply(worldToView, scale); // FUN_0040b374 worldToView.Multiply(worldToView, scale); // FUN_0040b374
if (getenv("BT_MAP_LOG"))
{
static int s_m = 0;
if ((s_m++ % 30) == 0)
DEBUG_STREAM << "[map] w2v ppm=" << pixelsPerMeter
<< " halfW=" << halfWidth << " scale=" << currentScale
<< " m=[" << worldToView(0,0) << "," << worldToView(0,1) << "," << worldToView(0,2)
<< " | " << worldToView(1,0) << "," << worldToView(1,1) << "," << worldToView(1,2)
<< " | " << worldToView(2,0) << "," << worldToView(2,1) << "," << worldToView(2,2)
<< " | t " << worldToView(3,0) << "," << worldToView(3,1) << "," << worldToView(3,2)
<< "]\n" << std::flush;
}
BuildRadarShadow(worldToView); // FUN_004c228c BuildRadarShadow(worldToView); // FUN_004c228c
@@ -803,6 +829,14 @@ void
{ {
continue; continue;
} }
// VISIBLE-RADIUS cull (port): the capability radius above (RadarPercent
// x 4000 m) exceeds the SCOPE radius (currentScale/2 at the current
// zoom); an out-of-scope draw lands beyond the port rect on the shared
// gauge surface. Skip what the scope can't show.
if (delta.x*delta.x + delta.z*delta.z > currentScale * currentScale * 0.25f)
{
continue;
}
Pip *pip = (pips != NULL) Pip *pip = (pips != NULL)
? pips->LookUpPip(entity->GetResourceID()) // FUN_004688ce(pips+0x58,entity+0x1bc,0) ? pips->LookUpPip(entity->GetResourceID()) // FUN_004688ce(pips+0x58,entity+0x1bc,0)
@@ -888,10 +922,23 @@ void
{ {
continue; continue;
} }
// VISIBLE-RADIUS cull (port; see DrawStatic): don't draw past the scope.
if (delta.x*delta.x + delta.z*delta.z > currentScale * currentScale * 0.25f)
{
continue;
}
Pip *pip = (pips != NULL) Pip *pip = (pips != NULL)
? pips->LookUpPip(entity->GetResourceID()) ? pips->LookUpPip(entity->GetResourceID())
: NULL; : NULL;
if (mlog)
{
Point3D sp;
sp.Multiply(EntityPosition(entity), worldToView);
DEBUG_STREAM << "[map] pip=" << (void*)pip
<< " screen=(" << sp.x << "," << sp.y << "," << sp.z << ")"
<< " lod=" << LODIndex << " mpp=" << metersPerPixel << "\n" << std::flush;
}
if (pip != NULL) if (pip != NULL)
{ {
AffineMatrix blipXform; AffineMatrix blipXform;
@@ -987,6 +1034,15 @@ void
// "MapDisplay::DrawNames -- GetSize() / iterator mismatch" // "MapDisplay::DrawNames -- GetSize() / iterator mismatch"
// "d:\\tesla\\bt\\bt_l4\\BTL4RDR.CPP", 0x408 // "d:\\tesla\\bt\\bt_l4\\BTL4RDR.CPP", 0x408
} }
else
{
// VISIBLE-RADIUS cull (port; see DrawStatic): a label for a
// contact beyond the scope would blit outside the port rect.
Vector3D d;
d.Subtract(EntityPosition(entity), viewingPosition);
if (d.x*d.x + d.z*d.z > currentScale * currentScale * 0.25f)
entity = NULL; // extract guards NULL -> no label
}
nameArray[i].ExtractFromEntity(entity, worldToView, owner, target); // FUN_004c19fc nameArray[i].ExtractFromEntity(entity, worldToView, owner, target); // FUN_004c19fc
} }
+74 -13
View File
@@ -669,24 +669,25 @@ void
} }
// //
// Pop a "destroyed by <killer>" status message onto the pilot HUD. // Pop a "destroyed" status message naming the victim onto the comm
// Gated on a real attacker player (+ the status pool, a NULL stub in // ticker (MessageBoard feed, LIVE 2026-07-12). ALLOCATION NOTE: the
// bring-up), so the ownerless-dummy solo path skips it (no player to name). // binary drew from the MemoryBlock pool @00512f6c and spliced the BT
// vtable; our engine's StatusMessageUpdate reclaims each expired
// message with Unregister_Object + plain delete (PLAYER.cpp:864), so
// the matching allocation here is the engine ctor + Register_Object
// -- same record {playerInvolved, messageType 0, 6.0s}, same queue.
// Gated on a real named player (the ownerless dummy has none).
// //
if (sender_owner && StatusMessagePool) if (sender_owner)
{ {
StatusMessage *status = (StatusMessage *)StatusMessagePool->New(); // FUN_00402f74(0x512f6c) Player__StatusMessage *status = new Player__StatusMessage(
if (status) sender_owner, // *(sender+0x190) -- the victim's player
{ 0, // message strip 0 (the kill cell)
new (status) Player__StatusMessage(
sender_owner, // *(sender+0x190)
0,
STATUS_DISPLAY_TIME // 0x40c00000 == 6.0f STATUS_DISPLAY_TIME // 0x40c00000 == 6.0f
); );
*(int *)((char *)status + 0x18) = 0; // status[6] = 0 Register_Object(status);
AddStatusMessage(status); // FUN_0042e580 AddStatusMessage(status); // FUN_0042e580
} }
}
// //
// If we just killed our designated objective mech, notify it. // If we just killed our designated objective mech, notify it.
@@ -1359,7 +1360,41 @@ int BTPilotKills(void *pilot)
DEBUG_STREAM << "[score] gauge read: pilot=" << pilot << " kills=" << k << "\n" << std::flush; DEBUG_STREAM << "[score] gauge read: pilot=" << pilot << " kills=" << k << "\n" << std::flush;
return k; return k;
} }
int BTPilotDeaths(void *pilot) { return pilot ? ((BTPlayer *)pilot)->GetDeaths() : 0; } // DEATHS display (MP DEATHS fix): the engine seeds Player::deathCount at -2
// (PLAYER.cpp:759) and only the LOCAL vehicle-acquire path zeroes it -- a REMOTE
// player's copy on this node keeps the raw pre-acquire value (-2/-1) forever,
// which the scoreboard showed verbatim ("DEATHS -1", user-reported). Those
// negatives are the engine's internal spawn-cycle convention, not real deaths;
// clamp them to the observable truth: no deaths yet = 0.
int BTPilotDeaths(void *pilot)
{
if (pilot == 0)
return 0;
int deaths = ((BTPlayer *)pilot)->GetDeaths();
return (deaths < 0) ? 0 : deaths;
}
// OBSERVED-DEATH tally (MP DEATHS fix): each node maintains every player's
// score from LOCALLY OBSERVED events -- exactly how the KILLS credit already
// works (the victim's ScoreMessage handler ++s the SHOOTER's local Player
// copy). This is the symmetric half: when a REPLICANT mech dies here (the
// once-per-death transition runs on every node), tally the death onto its
// owning player's LOCAL copy. The owner's own node counts through its real
// VehicleDead(-1) path, so the caller gates this to replicant mechs -- each
// node then counts every pilot self-consistently. Normalize the -2/-1
// pre-acquire seed first so death #1 reads 1.
void BTPlayerCountObservedDeath(void *player)
{
if (player == 0)
return;
BTPlayer *p = (BTPlayer *)player;
if (p->deathCount < 0)
p->deathCount = 0;
++p->deathCount;
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] observed death -> pilot " << player
<< " deaths=" << p->deathCount << "\n" << std::flush;
}
// Is `pilot` the owner of `local_player`'s current target? (the SELECT-TARGET row // Is `pilot` the owner of `local_player`'s current target? (the SELECT-TARGET row
// highlight). Computed here via accessors -- the PilotList's raw-offset version // highlight). Computed here via accessors -- the PilotList's raw-offset version
@@ -1388,12 +1423,38 @@ int BTPilotIsSelected(void *pilot, void *local_player)
// compiled BTPlayer/Player__StatusMessage members (decode AddStatusMessage @0042e580). // compiled BTPlayer/Player__StatusMessage members (decode AddStatusMessage @0042e580).
// A real (stub) definition is REQUIRED or the /FORCE link AVs MessageBoard::Execute's call. // A real (stub) definition is REQUIRED or the /FORCE link AVs MessageBoard::Execute's call.
class BitMap; class BitMap;
// MessageBoard feed (LIVE 2026-07-12): the board is the LOCAL cockpit's comm
// ticker -- read the mission player's engine status queue. statusMessagePointer
// is maintained per frame by Player::StatusMessageUpdate [T0 PLAYER.cpp:520/822]:
// it holds the newest queued Player__StatusMessage until its 6s displayTime runs
// out. The name bitmap resolves through the SAME small-raster chain the radar
// labels use (Mission::GetSmallNameBitmap keyed by the involved player's egg
// bitmapindex).
Logical BTResolveMessageBoard(Entity * /*tracked_mech*/, int *messageId, BitMap **nameBitmap) Logical BTResolveMessageBoard(Entity * /*tracked_mech*/, int *messageId, BitMap **nameBitmap)
{ {
*messageId = -1; *messageId = -1;
*nameBitmap = 0; *nameBitmap = 0;
BTPlayer *local_player = (application != 0)
? (BTPlayer *)application->GetMissionPlayer() : 0;
if (local_player == 0)
{
return False; return False;
} }
Player::StatusMessage *m = local_player->statusMessagePointer;
if (m == 0)
{
return False;
}
*messageId = m->messageType;
Player *involved = m->playerInvolved;
if (involved != 0)
{
Mission *mission = application->GetCurrentMission();
if (mission != 0)
*nameBitmap = mission->GetSmallNameBitmap(involved->playerBitmapIndex);
}
return True;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// gauge scoring wave: PRODUCERS -- posted by the combat path (mech4.cpp) to feed // gauge scoring wave: PRODUCERS -- posted by the combat path (mech4.cpp) to feed
+1
View File
@@ -374,6 +374,7 @@ class DropZone__ReplyMessage;
int int
roleClassIndex; // @0x274 (role resource +0xe4) roleClassIndex; // @0x274 (role resource +0xe4)
friend int BTPlayerRoleLocksAdvanced(void *); // the FUN_004ac9c8 bridge (task #12) friend int BTPlayerRoleLocksAdvanced(void *); // the FUN_004ac9c8 bridge (task #12)
friend void BTPlayerCountObservedDeath(void *); // observed-death tally (MP DEATHS fix)
int int
killCount; // @0x27c kills credited to this player killCount; // @0x27c kills credited to this player
+1
View File
@@ -346,6 +346,7 @@ void
<< " edge=" << (int)fireEdge << " edge=" << (int)fireEdge
<< " alarm=" << (int)weaponAlarm.GetState() << " alarm=" << (int)weaponAlarm.GetState()
<< " level=" << (float)currentLevel << " level=" << (float)currentLevel
<< " pct=" << (float)rechargeLevel
<< " tgt=" << (void *)((owner != 0) << " tgt=" << (void *)((owner != 0)
? *(Entity **)((char *)owner + 0x388) : 0) ? *(Entity **)((char *)owner + 0x388) : 0)
<< std::endl; << std::endl;
+6
View File
@@ -597,6 +597,11 @@ void
<< " inst=" << (int)GetInstance() << " inst=" << (int)GetInstance()
<< " invalidZone=" << (int)message->invalidDamageZone << " invalidZone=" << (int)message->invalidDamageZone
<< " amount=" << message->damageData.damageAmount << " amount=" << message->damageData.damageAmount
<< " type=" << (int)message->damageData.damageType
<< " from=" << message->inflictingEntity
<< " at(" << message->damageData.impactPoint.x
<< "," << message->damageData.impactPoint.y
<< "," << message->damageData.impactPoint.z << ")"
<< " table=" << (void*)damageLookupTable << " table=" << (void*)damageLookupTable
<< " zones=" << damageZoneCount << "\n" << std::flush; << " zones=" << damageZoneCount << "\n" << std::flush;
@@ -1384,6 +1389,7 @@ Mech::Mech(
standingTemplateMaxY = 0.0f; standingTemplateMaxY = 0.0f;
duckedTemplateMaxY = 0.0f; duckedTemplateMaxY = 0.0f;
templateBottomLift = 0.0f; templateBottomLift = 0.0f;
frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f); // collision-damage guard snapshot
if (GroundReal() && GetCollisionVolumeCount() > 0 if (GroundReal() && GetCollisionVolumeCount() > 0
&& collisionTemplate != 0 && collisionVolume != 0) && collisionTemplate != 0 && collisionVolume != 0)
{ {
+15
View File
@@ -625,6 +625,21 @@ public:
Scalar duckedTemplateMaxY; // binary @0x51c 0.6 x standing (duck preset) Scalar duckedTemplateMaxY; // binary @0x51c 0.6 x standing (duck preset)
Scalar templateBottomLift; // binary @0x4b8 0.05 x (volume maxX-minX) Scalar templateBottomLift; // binary @0x4b8 0.05 x (volume maxX-minX)
// COLLISION-DAMAGE ECONOMY GUARD (the MP ram one-shot, 2026-07-12): the
// mech's TRUE motion at frame entry, snapshotted just before the frame's
// ProcessCollisionList walk. Each StaticBounce in that walk MUTATES
// worldLinearVelocity (the += delta_v reflection); with several solids in
// one frame's list (2007 WinTesla puts TERRAIN in the collision tree; the
// 1995 ground was a heightfield probe, never a list entry) the reflections
// COMPOUND x(1+e) per contact, and a mech-vs-mech contact later in the
// list priced its dispatched damage off a velocity amplified 4x-40x
// (62-pt bump economy -> 1074/112375-pt one-shots; mp_a.log:32651).
// Mech::ProcessCollision restores worldLinearVelocity from this snapshot
// per contact, so contact damage is always priced at the mech's REAL
// approach speed -- which is all the binary's StaticBounce ever saw.
// By-name access only; declared after the layout-locked fields.
Vector3D frameEntryWorldVelocity;
// AUTHENTIC TARGETING (task #36): the engine Reticle struct (MUNGA/RETICLE.h // AUTHENTIC TARGETING (task #36): the engine Reticle struct (MUNGA/RETICLE.h
// [T0]) -- the mech's "TargetReticle" attribute (id 0x1d) binds to it, per the // [T0]) -- the mech's "TargetReticle" attribute (id 0x1d) binds to it, per the
// RP VTV analog (VTV.h: `Reticle targetReticle` + TargetReticleAttributeID). // RP VTV analog (VTV.h: `Reticle targetReticle` + TargetReticleAttributeID).
+209 -11
View File
@@ -764,13 +764,15 @@ int
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
struct BTProjectile { struct BTProjectile {
Point3D pos; Point3D pos;
Vector3D dir; Vector3D vel; // world velocity (authored MuzzleVelocity, steered per frame)
Scalar speed; Scalar speed; // |vel| held constant through the steer
Scalar traveled; Scalar traveled;
Scalar range; Scalar range;
Entity *target; Entity *target;
Point3D targetPos; Point3D targetPos;
Scalar damage; Scalar aimOffsetY; // vertical aim offset vs the target's origin (live re-lead)
Scalar damage; // <= 0 -> VISUAL-ONLY round (replicant-side salvo mirror)
int guided; // 1 = missile (seeker loft + steering); 0 = ballistic (straight)
int active; int active;
}; };
static BTProjectile gProjectiles[64]; static BTProjectile gProjectiles[64];
@@ -782,7 +784,7 @@ extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, fl
// locked target entity + point, the launch speed (|muzzleVelocity|), and per-shot damage. // locked target entity + point, the launch speed (|muzzleVelocity|), and per-shot damage.
void void
BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, const Point3D &targetPos, BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, const Point3D &targetPos,
Scalar speed, Scalar damage) Scalar speed, Scalar damage, const Vector3D *launch_velocity, int guided)
{ {
// The weapon's GetMuzzlePoint falls back to the mech ORIGIN (feet) when its mount // The weapon's GetMuzzlePoint falls back to the mech ORIGIN (feet) when its mount
// segment doesn't resolve, so a projectile appeared to launch from the mech's feet. // segment doesn't resolve, so a projectile appeared to launch from the mech's feet.
@@ -824,7 +826,9 @@ void
// like the energy weapons. (The old gEnemyMech fallback pre-dated the // like the energy weapons. (The old gEnemyMech fallback pre-dated the
// acquisition and let missiles bypass it.) // acquisition and let missiles bypass it.)
Point3D tpos = targetPos; Point3D tpos = targetPos;
if (target == 0) // A VISUAL round (damage <= 0, the replicant-side salvo mirror) flies on the
// replicated aim POINT alone; a live round still requires the designation.
if (target == 0 && damage > 0.0f)
return; return;
Vector3D d; Vector3D d;
d.x = tpos.x - mz.x; d.y = tpos.y - mz.y; d.z = tpos.z - mz.z; d.x = tpos.x - mz.x; d.y = tpos.y - mz.y; d.z = tpos.z - mz.z;
@@ -838,13 +842,45 @@ void
if (gProjectiles[i].active) continue; if (gProjectiles[i].active) continue;
BTProjectile &p = gProjectiles[i]; BTProjectile &p = gProjectiles[i];
p.pos = mz; // resolved launch port p.pos = mz; // resolved launch port
p.dir.x = d.x/len; p.dir.y = d.y/len; p.dir.z = d.z/len;
p.speed = (speed > 1.0f) ? speed : 120.0f; // |launchVelocity|; sane fallback p.speed = (speed > 1.0f) ? speed : 120.0f; // |launchVelocity|; sane fallback
// AUTHENTIC LAUNCH (missile-arc wave): the launcher's authored
// MuzzleVelocity is a VECTOR in the shooter's frame (typically
// up-tilted); rotate it into world by the shooter's pose so the round
// leaves the rack climbing -- the front half of the pod's missile arc.
// (The old code collapsed it to |v| along the straight line to the
// target: dead-flat flight.) Fall back to the straight line when no
// authored vector reaches us (non-missile callers).
int haveLaunch = 0;
if (launch_velocity != 0 && shooter != 0)
{
Mech *sm = (Mech *)shooter;
UnitVector ax, ay, az;
sm->localToWorld.GetFromAxis(X_Axis, &ax);
sm->localToWorld.GetFromAxis(Y_Axis, &ay);
sm->localToWorld.GetFromAxis(Z_Axis, &az);
p.vel.x = ax.x*launch_velocity->x + ay.x*launch_velocity->y + az.x*launch_velocity->z;
p.vel.y = ax.y*launch_velocity->x + ay.y*launch_velocity->y + az.y*launch_velocity->z;
p.vel.z = ax.z*launch_velocity->x + ay.z*launch_velocity->y + az.z*launch_velocity->z;
Scalar lv = (Scalar)sqrtf(p.vel.x*p.vel.x + p.vel.y*p.vel.y + p.vel.z*p.vel.z);
if (lv > 1.0f) { p.speed = lv; haveLaunch = 1; }
}
if (!haveLaunch)
{
p.vel.x = d.x/len*p.speed; p.vel.y = d.y/len*p.speed; p.vel.z = d.z/len*p.speed;
}
p.traveled = 0.0f; p.traveled = 0.0f;
p.range = len + 40.0f; // impact by the target, else expire just past it p.range = len * 1.3f + 60.0f; // arc margin; expire past the target
p.target = (Entity *)target; p.target = (Entity *)target;
p.targetPos = tpos; // resolved target position (fallback-aware) p.targetPos = tpos; // resolved target position (fallback-aware)
// live re-lead (authentic: the Seeker re-leads the MOVING target every
// slice): remember the aim's height above the target's origin so the
// per-frame refresh keeps striking the same body height.
extern int BTIsRegisteredMech(Entity *e);
p.aimOffsetY = (target != 0 && BTIsRegisteredMech((Entity *)target))
? (tpos.y - ((Mech *)target)->localOrigin.linearPosition.y) : 0.0f;
p.damage = damage; p.damage = damage;
p.guided = guided; // autocannon shells fly straight (no seeker in the binary's plain Projectile)
p.active = 1; p.active = 1;
return; return;
} }
@@ -859,23 +895,117 @@ static void
BTProjectile &p = gProjectiles[i]; BTProjectile &p = gProjectiles[i];
if (!p.active) continue; if (!p.active) continue;
Point3D prev = p.pos; Point3D prev = p.pos;
// AUTHENTIC GUIDANCE (missile-arc wave; Seeker::LeadTarget @004beae4 +
// the @004bef78 steering [T1]). The seeker LOFTS the aim point while
// the round is far out -- beyond 200 m the aim rises by
// 0.1 x clamp(range-200, 0, 300) (up to +30 m above the target) -- so
// the missile climbs at range and dives as it closes: the pod's
// "gravity arc". Steering is the port shape of the binary's
// squared-error guidance: rotate the velocity toward the lofted aim at
// the decomp turn gain (MissileTurnGain = 4.0, _DAT_004bf5a4), speed
// held at the authored launch speed.
if (p.guided)
{
// LIVE RE-LEAD (authentic: Seeker::LeadTarget runs every slice on
// the MOVING target): refresh the aim from the target's current
// position, preserving the launch aim's body height.
extern int BTIsRegisteredMech(Entity *e);
if (p.target != 0 && BTIsRegisteredMech(p.target)
&& !((Mech *)p.target)->IsMechDestroyed())
{
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition;
p.targetPos.y += p.aimOffsetY;
}
Point3D aim = p.targetPos;
Scalar rx = aim.x - p.pos.x, ry = aim.y - p.pos.y, rz = aim.z - p.pos.z;
Scalar range = (Scalar)sqrtf(rx*rx + ry*ry + rz*rz);
if (range > 200.0f) // SeekerLeadMinRange @004bec18
{
Scalar lead = range - 200.0f;
if (lead > 300.0f) lead = 300.0f; // SeekerLeadMaxClamp @004bec24
aim.y += 0.1f * lead; // SeekerLeadCoef @004bec28
ry = aim.y - p.pos.y;
}
if (range > 0.001f)
{
Scalar inv = 1.0f / range;
Scalar cx = p.vel.x / p.speed, cy = p.vel.y / p.speed, cz = p.vel.z / p.speed;
// MissileTurnGain (4.0, _DAT_004bf5a4) far out; DOUBLED inside
// the no-loft radius so the dive converges onto the contact
// sphere (the port shape of the binary's squared-error terminal
// aggressiveness -- error^2 steering climbs steeply near boresight).
Scalar k = ((range < 200.0f) ? 8.0f : 4.0f) * dt;
cx += (rx*inv - cx) * k; cy += (ry*inv - cy) * k; cz += (rz*inv - cz) * k;
Scalar cl = (Scalar)sqrtf(cx*cx + cy*cy + cz*cz);
if (cl > 0.001f)
{
p.vel.x = cx/cl*p.speed; p.vel.y = cy/cl*p.speed; p.vel.z = cz/cl*p.speed;
}
}
}
p.pos.x += p.vel.x*dt; p.pos.y += p.vel.y*dt; p.pos.z += p.vel.z*dt;
p.traveled += p.speed * dt;
// WORLD IMPACT (authentic: the binary missile runs a world collision
// query every frame, FUN_0042291c, and DETONATES on geometry). Ray the
// flight step against the terrain/cave solids -- a lofted round in a
// low cavern bursts on the CEILING instead of punching through it.
{
extern bool BTGroundRayHit(float,float,float, float,float,float,
float, float*,float*,float*);
extern Entity *gBTTerrainEntity; // captured by MakeEntityRenderables
Scalar step = p.speed * dt; Scalar step = p.speed * dt;
p.pos.x += p.dir.x*step; p.pos.y += p.dir.y*step; p.pos.z += p.dir.z*step; if (gBTTerrainEntity != 0 && step > 0.0001f)
p.traveled += step; {
Vector3D rd;
rd.x = p.vel.x/p.speed; rd.y = p.vel.y/p.speed; rd.z = p.vel.z/p.speed;
float hx, hy, hz;
if (BTGroundRayHit(prev.x, prev.y, prev.z, rd.x, rd.y, rd.z,
step + 1.0f, &hx, &hy, &hz))
{
// burst on the rock: a tight puff cluster at the hit, no damage
extern void BTPfxTrailPuff(int, float, float, float, float, float, float, int);
for (int pf = 0; pf < 4; ++pf)
BTPfxTrailPuff(0, hx, hy, hz, -rd.x, -rd.y, -rd.z, 2);
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] WORLD burst at(" << hx << ","
<< hy << "," << hz << ")\n" << std::flush;
p.active = 0;
continue;
}
}
}
// AUTHENTIC missile look: the round is a short hot streak, and the trail // AUTHENTIC missile look: the round is a short hot streak, and the trail
// is the real dsrm smoke-trail effect (psfx 0, "the lrm smoke trail") // is the real dsrm smoke-trail effect (psfx 0, "the lrm smoke trail")
// puffed along the flight path each frame with local +Z = backward (the // puffed along the flight path each frame with local +Z = backward (the
// .PFX's velocities stream the smoke behind the round). This replaces // .PFX's velocities stream the smoke behind the round). This replaces
// the old 3-segment white tracer lines (a bring-up placeholder). // the old 3-segment white tracer lines (a bring-up placeholder).
Vector3D bd = p.dir; Vector3D bd;
bd.x = p.vel.x/p.speed; bd.y = p.vel.y/p.speed; bd.z = p.vel.z/p.speed;
BTPushBeam(prev.x, prev.y, prev.z, p.pos.x, p.pos.y, p.pos.z, 0x00FFB040u, 0.10f, 0.9f); // the round (hot streak) BTPushBeam(prev.x, prev.y, prev.z, p.pos.x, p.pos.y, p.pos.z, 0x00FFB040u, 0.10f, 0.9f); // the round (hot streak)
{ {
extern void BTPfxTrailPuff(int, float, float, float, float, float, float, int); extern void BTPfxTrailPuff(int, float, float, float, float, float, float, int);
BTPfxTrailPuff(0, prev.x, prev.y, prev.z, -bd.x, -bd.y, -bd.z, 2); BTPfxTrailPuff(0, prev.x, prev.y, prev.z, -bd.x, -bd.y, -bd.z, 2);
} }
// CONTACT-ONLY damage (user-verified bug: an arcing round that ran out
// its flight cap while still descending "applied damage without making
// contact"). Proximity = the hit; the flight-cap expiry is a FIZZLE --
// no damage, matching the binary (a missile that dies mid-air detonates
// nothing; only the world-collision hit spawns the Damage entity).
Scalar dx = p.targetPos.x - p.pos.x, dy = p.targetPos.y - p.pos.y, dz = p.targetPos.z - p.pos.z; Scalar dx = p.targetPos.x - p.pos.x, dy = p.targetPos.y - p.pos.y, dz = p.targetPos.z - p.pos.z;
if (dx*dx + dy*dy + dz*dz < (10.0f*10.0f) || p.traveled >= p.range) const int contact = (dx*dx + dy*dy + dz*dz < (10.0f*10.0f));
if (!contact && p.traveled >= p.range)
{
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] FIZZLE (flight cap, no contact)\n" << std::flush;
p.active = 0;
continue;
}
if (contact)
{ {
Entity *tgt = p.target; Entity *tgt = p.target;
// Deliver to the projectile's target mech -- the launcher set p.target // Deliver to the projectile's target mech -- the launcher set p.target
@@ -1053,6 +1183,12 @@ void
projectedVelocity = Motion::Identity; projectedVelocity = Motion::Identity;
updateVelocity = Motion::Identity; updateVelocity = Motion::Identity;
updateAcceleration = Motion::Identity; updateAcceleration = Motion::Identity;
// The Mover motion scalars too (the binary Reset zeroes the whole motion
// block): a respawn is a TELEPORT -- stale death-frame velocity must never
// survive into the first post-respawn collision/publish.
worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f);
localVelocity = Motion::Identity;
frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f);
// --- our RELOCATED gait/motion accumulators -> identity (the 1995 offsets // --- our RELOCATED gait/motion accumulators -> identity (the 1995 offsets
// the decomp zeroes map to these named members in our layout) --- // the decomp zeroes map to these named members in our layout) ---
@@ -1281,6 +1417,21 @@ void
DEBUG_STREAM << "[damage] *** " << GetEntityID() DEBUG_STREAM << "[damage] *** " << GetEntityID()
<< " DESTROYED (death effects dispatched from the death transition) ***\n" << " DESTROYED (death effects dispatched from the death transition) ***\n"
<< std::flush; << std::flush;
// OBSERVED-DEATH tally (MP DEATHS fix): a REPLICANT's death observed
// here counts onto its owning player's LOCAL score copy -- the
// symmetric half of the cross-pod KILLS credit (see btplayer.cpp
// BTPlayerCountObservedDeath). Masters count through their own
// VehicleDead(-1) path; counting them here too would double-tally.
if (GetInstance() == ReplicantInstance)
{
Player *owning_player = GetOwningPlayer(); // entity+0x190
if (owning_player != 0)
{
extern void BTPlayerCountObservedDeath(void *);
BTPlayerCountObservedDeath(owning_player);
}
}
} }
// //
@@ -1398,6 +1549,20 @@ void
const float fdot = -((float)wv.x * (float)zAxR.x const float fdot = -((float)wv.x * (float)zAxR.x
+ (float)wv.z * (float)zAxR.z); // mech faces -Z + (float)wv.z * (float)zAxR.z); // mech faces -Z
replMppr->speedDemand = (fdot < 0.0f) ? -spd : spd; replMppr->speedDemand = (fdot < 0.0f) ? -spd : spd;
// REPLICANT TURN-STEP (user-reported: a turning peer
// statue-rotates while the local mech steps): the leg SM's
// Standing case arms the turn-in-place "trn" clip (state 4,
// mech2.cpp:590) from mppr->turnDemand -- which nothing fed on
// a replicant, so pivots never stepped. Feed it from the
// REPLICATED yaw rate (updateVelocity.angularMotion.y, stored
// by Mover::ReadUpdateRecord from the master's localVelocity
// -- the same stream DeadReckon rotates the body with). Only
// the +-0.05 threshold matters to the SM (the trn clip
// advances at fixed cadence), so a signed unit demand
// suffices; the deadband ignores dead-reckon jitter.
const float yawRate = (float)updateVelocity.angularMotion.y;
replMppr->turnDemand = (yawRate > 0.02f) ? 1.0f
: (yawRate < -0.02f) ? -1.0f : 0.0f;
// Prime the same clip-advance scalars the master's gait block sets // Prime the same clip-advance scalars the master's gait block sets
// each frame -- uninitialized on a replicant they read 0, freezing // each frame -- uninitialized on a replicant they read 0, freezing
// the clip at advance-time dt*0 (observed: legState engaged at 11, // the clip at advance-time dt*0 (observed: legState engaged at 11,
@@ -2406,6 +2571,7 @@ void
if (cols) if (cols)
{ {
Point3D before = localOrigin.linearPosition; Point3D before = localOrigin.linearPosition;
frameEntryWorldVelocity = worldLinearVelocity; // collision-damage guard (see mech.hpp)
Damage collisionDamage; // filled by ProcessCollisionList (unused) Damage collisionDamage; // filled by ProcessCollisionList (unused)
ProcessCollisionList(cols, dt, collisionOldPos, &collisionDamage); // pushes localOrigin out ProcessCollisionList(cols, dt, collisionOldPos, &collisionDamage); // pushes localOrigin out
Scalar dx = localOrigin.linearPosition.x - before.x; Scalar dx = localOrigin.linearPosition.x - before.x;
@@ -3621,6 +3787,12 @@ void
if (isPlayer && gBlockCooldown > 0.0f) if (isPlayer && gBlockCooldown > 0.0f)
gBlockCooldown -= dt; // decay the out-of-contact window gBlockCooldown -= dt; // decay the out-of-contact window
// BINARY-TAIL-DEFERRED: collisionTemporaryState = 0 here (@4aa741). // BINARY-TAIL-DEFERRED: collisionTemporaryState = 0 here (@4aa741).
// COLLISION-DAMAGE ECONOMY GUARD: snapshot the TRUE frame motion for the
// per-contact velocity restore in Mech::ProcessCollision (see mech.hpp) --
// the list walk below reflects worldLinearVelocity at every StaticBounce,
// and a multi-solid frame compounds those reflections into a garbage
// velocity that priced mech-vs-mech damage 4x-40x too high (mp_a.log:32651).
frameEntryWorldVelocity = savedWorldVel;
Damage dmg; // ctor zeroes damageAmount Damage dmg; // ctor zeroes damageAmount
if (cols != 0) if (cols != 0)
ProcessCollisionList(cols, dt, old_position, &dmg); ProcessCollisionList(cols, dt, old_position, &dmg);
@@ -3823,6 +3995,20 @@ static void
DEBUG_STREAM << "[collide] dmg=" << resolved->damageAmount DEBUG_STREAM << "[collide] dmg=" << resolved->damageAmount
<< " -> victim class=" << (int)victim->GetClassID() << " -> victim class=" << (int)victim->GetClassID()
<< " (zone==-1, resolved by receiver)\n" << std::flush; << " (zone==-1, resolved by receiver)\n" << std::flush;
// RAM ECONOMY TELEMETRY (the MP ram one-shot investigation): every
// dispatched ram, with the exact velocity StaticBounce priced it from --
// the decisive evidence channel for any future out-of-economy amount.
if (getenv("BT_MP_NET"))
{
const Vector3D &v = inflictor->GetWorldLinearVelocity();
DEBUG_STREAM << "[collide-tx] " << inflictor->GetEntityID()
<< " rams " << victim->GetEntityID()
<< " dmg=" << resolved->damageAmount
<< " |v|=" << (Scalar)sqrtf(v.x*v.x + v.y*v.y + v.z*v.z)
<< " at(" << dmg.impactPoint.x << "," << dmg.impactPoint.y
<< "," << dmg.impactPoint.z << ")\n" << std::flush;
}
} }
void void
@@ -3838,6 +4024,18 @@ void
return; return;
} }
// COLLISION-DAMAGE ECONOMY GUARD (see mech.hpp frameEntryWorldVelocity):
// this runs once PER CONTACT in the frame's ProcessCollisionList walk, and
// the StaticBounce below reflects worldLinearVelocity (+= delta_v) every
// time. Restore the TRUE frame-entry motion first so a multi-solid frame
// (mech + terrain solids + debris) cannot compound the reflections into an
// amplified velocity: every contact -- and every dispatched mech-vs-mech
// damage amount -- is priced at the mech's real approach speed, which is
// all the 1995 binary's StaticBounce ever saw (its ground was a heightfield
// probe, never a collision-list entry). The post-list velocity is
// overwritten by the response policy / next frame's derive regardless.
worldLinearVelocity = frameEntryWorldVelocity;
// --- the BoxedSolid resolver (:15302-15304); miss => nothing runs ------- // --- the BoxedSolid resolver (:15302-15304); miss => nothing runs -------
Scalar penetration = 0.0f; Scalar penetration = 0.0f;
if (!collisionVolume->ProcessCollision(collision, worldLinearVelocity, if (!collisionVolume->ProcessCollision(collision, worldLinearVelocity,
+15
View File
@@ -511,3 +511,18 @@ void
*message_id_out = sub->controlMessageID; // @0xEC *message_id_out = sub->controlMessageID; // @0xEC
*receiver_out = (Receiver *)sub; *receiver_out = (Receiver *)sub;
} }
//
// BTSubsystemDamageLevelOf -- complete-type bridge (PPC-dial fix, 2026-07-12):
// feeds the gauge cluster's OPERATING flag (the "destroyed X" lamps). The
// observable truth: a subsystem is out of action when its own damage level
// saturates (DistributeCriticalHit destroys at >= 1.0, mechdmg.cpp) -- read
// that instead of the unidentified binary +0x40 field the old raw read
// guessed at (layout noise -> X's over healthy panels).
//
Scalar
BTSubsystemDamageLevelOf(void *subsystem_in)
{
return (subsystem_in != 0)
? ((MechSubsystem *)subsystem_in)->GetSubsystemDamageLevel() : 0.0f;
}
+8
View File
@@ -384,6 +384,10 @@ void
? owner->GetSubsystem(info->subsystemID) : 0; ? owner->GetSubsystem(info->subsystemID) : 0;
ResourceDescription::ResourceID explosionID = ResourceDescription::ResourceID explosionID =
ResolveExplosionID(firingWeapon); // weapon+0x3E4 ResolveExplosionID(firingWeapon); // weapon+0x3E4
if (getenv("BT_FIRE_LOG"))
DEBUG_STREAM << "[boom-q] subsysID=" << info->subsystemID
<< " weapon=" << (firingWeapon ? firingWeapon->GetName() : "<none>")
<< " explosionID=" << explosionID << std::endl;
if (explosionID != ResourceDescription::NullResourceID if (explosionID != ResourceDescription::NullResourceID
&& !weaponExplosions.Find(explosionID)) // chain+0xFC, slot 0xC && !weaponExplosions.Find(explosionID)) // chain+0xFC, slot 0xC
{ {
@@ -541,6 +545,10 @@ void
explosion_id, Explosion::DefaultFlags, o, explosion_id, Explosion::DefaultFlags, o,
hitID, owner->GetEntityID()); hitID, owner->GetEntityID());
Explosion *e = Explosion::Make(&m); Explosion *e = Explosion::Make(&m);
if (getenv("BT_FIRE_LOG"))
DEBUG_STREAM << "[boom] id=" << explosion_id
<< " at(" << o.linearPosition.x << "," << o.linearPosition.y
<< "," << o.linearPosition.z << ") made=" << (void *)e << std::endl;
if (e != 0) if (e != 0)
{ {
Register_Object(e); Register_Object(e);
+120 -3
View File
@@ -68,8 +68,49 @@
// WAVE 7 Phase B -- the port-side flying-projectile service (defined in mech4.cpp, a // WAVE 7 Phase B -- the port-side flying-projectile service (defined in mech4.cpp, a
// complete-Mech TU with the render + damage path). A fired missile becomes a tracked // complete-Mech TU with the render + damage path). A fired missile becomes a tracked
// projectile that flies to the target + delivers this weapon's damage on impact. // projectile that flies to the target + delivers this weapon's damage on impact.
// Missile-arc wave: launch_velocity = the authored MuzzleVelocity VECTOR in the
// shooter's frame (up-tilted rack launch); damage <= 0 = a VISUAL-ONLY round.
extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
const Point3D &targetPos, Scalar speed, Scalar damage); const Point3D &targetPos, Scalar speed, Scalar damage,
const Vector3D *launch_velocity = 0, int guided = 1);
//###########################################################################
// Port-side salvo replication state (missile-visibility wave).
//
// The launcher OBJECT is byte-locked at 0x44C (factory alloc) -- salvo state
// cannot live on it. Keyed by launcher identity: on a MASTER, `fired` counts
// FireWeapon salvos (serialized in the extended update record); on a
// REPLICANT, `seen` tracks the last counter applied (-1 = unsynced, so a
// freshly-joined peer does not replay the master's historical count).
//###########################################################################
namespace {
struct BTSalvoState
{
const void *owner;
int fired; // master: salvos launched
int seen; // replicant: last counter applied (-1 = unsynced)
Point3D target; // master: the last salvo's aim point
};
BTSalvoState gSalvoTable[64];
BTSalvoState &BTSalvoOf(const void *launcher)
{
int freeSlot = -1;
for (int i = 0; i < 64; ++i)
{
if (gSalvoTable[i].owner == launcher)
return gSalvoTable[i];
if (gSalvoTable[i].owner == 0 && freeSlot < 0)
freeSlot = i;
}
BTSalvoState &s = gSalvoTable[(freeSlot >= 0) ? freeSlot : 0];
s.owner = launcher;
s.fired = 0;
s.seen = -1;
s.target = Point3D(0.0f, 0.0f, 0.0f);
return s;
}
}
//########################################################################### //###########################################################################
//########################################################################### //###########################################################################
@@ -258,15 +299,91 @@ void MissileLauncher::FireWeapon()
+ launchVelocity.y*launchVelocity.y + launchVelocity.y*launchVelocity.y
+ launchVelocity.z*launchVelocity.z); + launchVelocity.z*launchVelocity.z);
// Always launch -- BTPushProjectile falls back to gEnemyMech when the owner's target // Always launch -- BTPushProjectile falls back to gEnemyMech when the owner's target
// slot isn't populated (bring-up); missileCount missiles per salvo. // slot isn't populated (bring-up); missileCount missiles per salvo. The authored
// MuzzleVelocity VECTOR rides along (missile-arc wave): the round leaves the rack
// on the authored up-tilt, then the seeker loft + steering arc it onto the target.
int nmiss = (missileCount > 0 && missileCount < 40) ? missileCount : 1; int nmiss = (missileCount > 0 && missileCount < 40) ? missileCount : 1;
for (int i = 0; i < nmiss; ++i) for (int i = 0; i < nmiss; ++i)
BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount); BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount,
&launchVelocity);
// Salvo replication (missile-visibility wave): bump the fire counter + stamp
// the aim point; the extended update record carries both to peer nodes.
{
BTSalvoState &s = BTSalvoOf(this);
++s.fired;
s.target = targetPos;
}
simulationFlags |= 0x1; // replication-dirty simulationFlags |= 0x1; // replication-dirty
// ENQUEUE the record (visibility fix): the dirty flag alone never
// serializes -- ForceUpdate sets the updateModel bit that makes the next
// PerformAndWatch write THIS subsystem's record into the mech's update
// stream (the exact mechanism the Emitter's beam replication uses,
// emitter.cpp:512). Without it the peer never saw a salvo record at all.
ForceUpdate();
Check_Fpu(); Check_Fpu();
} }
//#############################################################################
// WriteUpdateRecord / ReadUpdateRecord (PORT record extension -- missile-
// visibility wave; see the mislanch.hpp banner). The master serializes the
// salvo counter + aim point after the MechWeapon base fields; the replicant
// edge-detects the counter and mirrors the salvo as VISUAL-ONLY rounds
// (damage 0 -- the master's own rounds deliver the damage cross-pod).
//
void
MissileLauncher::WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model)
{
MechWeapon::WriteUpdateRecord(message, update_model); // @004b9690 (alarm fields)
MissileLauncher__UpdateRecord *rec = (MissileLauncher__UpdateRecord *)message;
BTSalvoState &s = BTSalvoOf(this);
rec->recordLength = sizeof(MissileLauncher__UpdateRecord);
rec->salvoCounter = s.fired;
rec->salvoRounds = missileCount;
rec->salvoTarget = s.target;
}
void
MissileLauncher::ReadUpdateRecord(Simulation__UpdateRecord *message)
{
MechWeapon::ReadUpdateRecord(message); // @004b964c (alarm apply)
MissileLauncher__UpdateRecord *rec = (MissileLauncher__UpdateRecord *)message;
BTSalvoState &s = BTSalvoOf(this);
if (s.seen < 0)
{
// first sync after spawn/join: adopt the master's count silently --
// no replay of salvos fired before we could see them.
s.seen = rec->salvoCounter;
}
else if (rec->salvoCounter != s.seen)
{
s.seen = rec->salvoCounter;
// Mirror ONE salvo (a missed record collapses to one -- catch-up
// replays would draw stale phantom volleys). Rounds fly the same
// authored launch vector + seeker-loft arc as the master's, from
// this replicant's own resolved rack ports, damage 0.
Point3D mz;
GetMuzzlePoint(mz); // @004b9948
Scalar spd = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
+ launchVelocity.y*launchVelocity.y
+ launchVelocity.z*launchVelocity.z);
int n = rec->salvoRounds;
if (n < 1 || n > 40)
n = (missileCount > 0 && missileCount < 40) ? missileCount : 1;
for (int i = 0; i < n; ++i)
BTPushProjectile(mz, owner, 0 /*no entity: aim point only*/,
rec->salvoTarget, spd, 0.0f /*VISUAL*/, &launchVelocity);
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] REPLICANT salvo x" << n
<< " at(" << rec->salvoTarget.x << "," << rec->salvoTarget.y
<< "," << rec->salvoTarget.z << ")\n" << std::flush;
}
}
//############################################################################# //#############################################################################
// TakeDamage / DeathReset // TakeDamage / DeathReset
// //
+29
View File
@@ -90,6 +90,35 @@ class Missile;
void TakeDamage(Damage &damage); // inherits MechWeapon::TakeDamage (vtable slot 6, @004b964c) void TakeDamage(Damage &damage); // inherits MechWeapon::TakeDamage (vtable slot 6, @004b964c)
void DeathReset(Logical full_reset); // best-effort -- chains ProjectileWeapon reset @004bbaf8 void DeathReset(Logical full_reset); // best-effort -- chains ProjectileWeapon reset @004bbaf8
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Replication Support (PORT record extension -- missile-visibility wave)
//
// In the 1995 binary a fired Missile was a REPLICATED WORLD ENTITY (its
// own update records, tag 0x78), so the peer pod rendered the round from
// entity replication and the launcher's record stayed the MechWeapon
// base. The port's flying round is a LOCAL tracked projectile
// (BTPushProjectile) -- invisible to peers -- so the launcher's record
// carries the SALVO EVENT instead: a fire counter + the aim point + the
// round count. The replicant-side reader edge-detects the counter and
// launches a VISUAL-ONLY mirror salvo (damage 0; the master's own rounds
// deliver damage cross-pod through TakeDamage). Both nodes run this
// port, so the extended wire format is self-consistent. Salvo STATE
// lives in a port-side table (mislanch.cpp) -- the object layout is
// byte-locked to the 0x44C factory alloc and must not grow.
//
public:
struct MissileLauncher__UpdateRecord:
public MechWeapon::UpdateRecord
{
int salvoCounter; // fires since spawn (edge-detected by peers)
int salvoRounds; // missiles in the salvo (authored missileCount)
Point3D salvoTarget; // the aim point the salvo flew at
};
typedef MissileLauncher__UpdateRecord UpdateRecord;
void WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model); // base-typed param (task #51 override rule)
void ReadUpdateRecord(Simulation__UpdateRecord *message);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Model Support // Model Support
// //
+8 -2
View File
@@ -88,8 +88,13 @@
#include <math.h> #include <math.h>
// WAVE 7 Phase B -- the port-side flying-projectile service (defined in mech4.cpp). // WAVE 7 Phase B -- the port-side flying-projectile service (defined in mech4.cpp).
// SIGNATURE MUST MATCH mech4.cpp EXACTLY (the missile-arc wave added
// launch_velocity): a stale extern here mangles to a nonexistent symbol, /FORCE
// fills the call with garbage, and the first autocannon shot AVs (user-hit
// crash, 2026-07-12 -- gotcha #3).
extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
const Point3D &targetPos, Scalar speed, Scalar damage); const Point3D &targetPos, Scalar speed, Scalar damage,
const Vector3D *launch_velocity = 0, int guided = 1);
//############################################################################# //#############################################################################
@@ -679,7 +684,8 @@ void
Scalar speed = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x Scalar speed = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
+ launchVelocity.y*launchVelocity.y + launchVelocity.y*launchVelocity.y
+ launchVelocity.z*launchVelocity.z); + launchVelocity.z*launchVelocity.z);
BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount); BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount,
0 /*aim straight at the pick*/, 0 /*BALLISTIC: no seeker on a shell*/);
simulationFlags |= 0x1; // replication-dirty simulationFlags |= 0x1; // replication-dirty
Check_Fpu(); Check_Fpu();