diff --git a/content/LAST.EGG b/content/LAST.EGG index 61255bd..72e7c5a 100644 --- a/content/LAST.EGG +++ b/content/LAST.EGG @@ -1,8 +1,8 @@ [mission] adventure=BattleTech -map=grass +map=cavern scenario=freeforall -time=day +time=night weather=clear temperature=27 length=600 @@ -156,8 +156,9 @@ x=128 y=32 width=8 [pilots] -pilot=200.0.0.96 -[200.0.0.96] +pilot=127.0.0.1:1502 +pilot=127.0.0.1:1602 +[127.0.0.1:1502] hostType=0 advancedDamage=1 loadzones=1 @@ -171,6 +172,20 @@ dropzone=one vehicle=bhk1 vehicleValue=1000 color=White +[127.0.0.1:1602] +hostType=0 +advancedDamage=1 +loadzones=1 +name=Boreas +bitmapindex=2 +experience=expert +badge=VGL +patch=Yellow +role=Role::Default +dropzone=one +vehicle=ava1 +vehicleValue=1000 +color=Red [largebitmap] bitmap=BitMap::Large::Aeolus [BitMap::Large::Aeolus] @@ -227,3 +242,4 @@ width=4 model=dfltrole [Role::NoReturn] model=noretun + diff --git a/content/MP.EGG b/content/MP.EGG index 2d585da..f062d5e 100644 --- a/content/MP.EGG +++ b/content/MP.EGG @@ -183,7 +183,7 @@ badge=VGL patch=Yellow role=Role::Default dropzone=one -vehicle=bhk1 +vehicle=ava1 vehicleValue=1000 color=Red [largebitmap] diff --git a/context/combat-damage.md b/context/combat-damage.md index 6ac5efc..82395b5 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -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 [[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 × (1−e²) × 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) A killed mech does NOT disappear — BT death is a STATE transition (`SetGraphicState(DestroyedGraphicState)` + death anim + effect/splash), the mech becomes a persistent diff --git a/context/gauges-hud.md b/context/gauges-hud.md index 5a63104..c7f6fff 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -6,8 +6,10 @@ source_sections: "docs/GAUGE_COMPOSITE.md (full map); CLAUDE.md §8" related_topics: [reconstruction-gotchas, decomp-reference, subsystems, pod-hardware] key_terms: [gauge, methodDescription, attribute-pointer, GaugeRenderer, MFD, PCC] 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" + - "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 @@ -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 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`, +`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`, `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 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 diff --git a/context/open-questions.md b/context/open-questions.md index c86fff0..cb6ea3a 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -92,10 +92,17 @@ authentic path scoped. 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 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. - BTPlayer+0x1dc is never populated → MessageBoard is empty (authentic for bring-up). What - resolves it: wire the pool + decode `AddStatusMessage @0042e580` + the `Player__StatusMessage` - record ({messageId@+0xc, nameEntity@+0x10}). +- **✅ StatusMessagePool / MessageBoard — LIVE (2026-07-12) [T1/T2].** Decoded: FUN_0042e580 = + the engine `Player::AddStatusMessage` (chain add at +0x1e4); FUN_0042e5a0 = `StatusMessageUpdate` + (+0x1dc = the displayed message; +0x14 displayTime countdown, expiry-deleted) — ALL already in + 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 are fixed byte-exact (ctor field map @004b3778 — springConstant@0x1E8/dampingConstant@0x1F4 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), 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) - 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); diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index 2576078..100345c 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -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. A `SetVideoPathPriority` defined in an anonymous namespace → internal linkage → unresolved in 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 .*"` 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 @@ -293,6 +301,50 @@ corrupts the metric. 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). +## 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 × (1−e²) × 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) diff --git a/docs/GAUGE_COMPOSITE.md b/docs/GAUGE_COMPOSITE.md index 0af93ac..c88504c 100644 --- a/docs/GAUGE_COMPOSITE.md +++ b/docs/GAUGE_COMPOSITE.md @@ -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 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. - 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). ### Phase 4 — DEEP/DEFERRED diff --git a/engine/MUNGA/GAUGE.cpp b/engine/MUNGA/GAUGE.cpp index 309b194..ed5b682 100644 --- a/engine/MUNGA/GAUGE.cpp +++ b/engine/MUNGA/GAUGE.cpp @@ -579,7 +579,17 @@ Logical if (s_devGaugesUpd) { 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); + } } else { diff --git a/engine/MUNGA/GAUGREND.cpp b/engine/MUNGA/GAUGREND.cpp index dc55a9a..61978fe 100644 --- a/engine/MUNGA/GAUGREND.cpp +++ b/engine/MUNGA/GAUGREND.cpp @@ -3628,15 +3628,24 @@ void // Radar contacts are the DYNAMIC vehicles (mechs), not the props/terrain/ // projectiles that AllEntityIterator would flood the grid with -- iterate the // dynamic masters (the local + AI/dummy vehicles) plus the dynamic replicants - // (peer vehicles in multiplayer). Static beacons/props (a separate - // classification) are left for a follow-up. + // (peer vehicles in multiplayer). + // 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); master_iterator.First(); Entity *entity; while ((entity = master_iterator.ReadAndNext()) != NULL) { - movingEntities.Add(entity); + if (entity->IsDerivedFrom(*Mover::GetClassDerivations())) + movingEntities.Add(entity); + else + staticEntities.Add(entity); } } { @@ -3645,7 +3654,10 @@ void Entity *entity; while ((entity = replicant_iterator.ReadAndNext()) != NULL) { - movingEntities.Add(entity); + if (entity->IsDerivedFrom(*Mover::GetClassDerivations())) + movingEntities.Add(entity); + else + staticEntities.Add(entity); } } Check_Fpu(); @@ -3665,6 +3677,22 @@ void Check(application->GetModeManager()); ModeMask 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 previous_mode_mask = application->GetModeManager()->GetPreviousModeMask(); ModeMask diff --git a/engine/MUNGA_L4/L4GAUIMA.cpp b/engine/MUNGA_L4/L4GAUIMA.cpp index 6660110..1b362a4 100644 --- a/engine/MUNGA_L4/L4GAUIMA.cpp +++ b/engine/MUNGA_L4/L4GAUIMA.cpp @@ -912,6 +912,19 @@ void 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 //------------------------------------------------------------- diff --git a/engine/MUNGA_L4/L4VB16.cpp b/engine/MUNGA_L4/L4VB16.cpp index 9fa2294..3ac60f4 100644 --- a/engine/MUNGA_L4/L4VB16.cpp +++ b/engine/MUNGA_L4/L4VB16.cpp @@ -127,7 +127,8 @@ static bool DevGaugeDocked() // render-state save/restore is needed (the next frame resets device state). //===========================================================================// 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 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. + // 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; }; 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, 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->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_COLORARG1, D3DTA_TEXTURE); device->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1); @@ -218,18 +241,27 @@ struct BTGaugeSurfaceDesc int monoTint; // -1 = palette (sec); else R5G6B5 tint float cellX, cellY; // normalized cell origin within the panel (y down) 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] = { - // top row: Heat (UL) | Mfd2 (UC) | Comm (UR) - { "Heat", 0xFFFF, 0.0f / 3, 0.0f, 1.0f / 3, 0.5f }, - { "Mfd2", 0xFFFF, 1.0f / 3, 0.0f, 1.0f / 3, 0.5f }, - { "Comm", 0xFFFF, 2.0f / 3, 0.0f, 1.0f / 3, 0.5f }, - // bottom row: Mfd1 (LL) | sec/radar (center) | Mfd3 (LR) - { "Mfd1", 0xFFFF, 0.0f / 3, 0.5f, 1.0f / 3, 0.5f }, - { "sec", -1, 1.0f / 3, 0.5f, 1.0f / 3, 0.5f }, - { "Mfd3", 0xFFFF, 2.0f / 3, 0.5f, 1.0f / 3, 0.5f }, + { "Heat", 0xFFFF, 0.0f / kBTPanelW, 0.0f, 320.0f / kBTPanelW, 0.5f, 0 }, + { "Mfd2", 0xFFFF, 320.0f / kBTPanelW, 0.0f, 320.0f / kBTPanelW, 0.5f, 0 }, + { "Mfd1", 0xFFFF, 0.0f / kBTPanelW, 0.5f, 320.0f / kBTPanelW, 0.5f, 0 }, + { "Mfd3", 0xFFFF, 320.0f / kBTPanelW, 0.5f, 320.0f / kBTPanelW, 0.5f, 0 }, + { "sec", -1, 640.0f / kBTPanelW, 0.0f, 360.0f / kBTPanelW, 1.0f, 1 }, + { "Comm", 0xFFFF, 1000.0f / kBTPanelW, 0.0f, 320.0f / kBTPanelW, 0.5f, 0 }, }; // @@ -243,6 +275,16 @@ void BTDrawGaugeSurfaces(LPDIRECT3DDEVICE9 device, float px, float py, float pw, GaugeRenderer *gr = application ? application->GetGaugeRenderer() : NULL; 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). SVGA16 *svga = NULL; 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]; L4GraphicsPort *port = static_cast(gr->GetGraphicsPort(d.portName)); 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( 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; // 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 IDirect3DSwapChain9 *s_gaugeSwap = NULL; -static const int s_gaugeW = 960; -static const int s_gaugeH = 384; +// Window = the logical 1320x480 panel (see kBTGaugeSurfaces) x BT_GAUGE_SCALE. +// 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) { 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). @@ -301,26 +384,47 @@ static bool BTGaugeWindowEnsure(LPDIRECT3DDEVICE9 device) if (s_gaugeSwap != NULL) return true; if (device == NULL) return false; + BTGaugeResolveSize(); // BT_GAUGE_SCALE -> window dims HINSTANCE hInst = GetModuleHandle(NULL); static bool s_classReg = false; 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)); wc.lpfnWndProc = BTGaugeWndProc; wc.hInstance = hInst; wc.hCursor = LoadCursor(NULL, IDC_ARROW); wc.hbrBackground = (HBRUSH) GetStockObject(BLACK_BRUSH); - wc.lpszClassName = "BTGaugeWnd"; - RegisterClassA(&wc); + wc.lpszClassName = L"BTGaugeWnd"; + RegisterClassW(&wc); s_classReg = true; } if (s_gaugeHwnd == NULL) { RECT r = { 0, 0, s_gaugeW, s_gaugeH }; AdjustWindowRect(&r, WS_OVERLAPPEDWINDOW, FALSE); - s_gaugeHwnd = CreateWindowExA( - 0, "BTGaugeWnd", "BattleTech - Cockpit MFDs", WS_OVERLAPPEDWINDOW, + // Window identity (MP dev): tag with the -net port so two side-by-side + // 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); if (s_gaugeHwnd == NULL) return false; ShowWindow(s_gaugeHwnd, SW_SHOWNOACTIVATE); diff --git a/engine/MUNGA_L4/L4VB16.h b/engine/MUNGA_L4/L4VB16.h index a7ee22b..140c7e6 100644 --- a/engine/MUNGA_L4/L4VB16.h +++ b/engine/MUNGA_L4/L4VB16.h @@ -300,8 +300,11 @@ public: // 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 // 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, - int monoTint, float dstX, float dstY, float dstW, float dstH); + int monoTint, float dstX, float dstY, float dstW, float dstH, + int rotateCCW = 0); }; //######################################################################## diff --git a/game/btl4main.cpp b/game/btl4main.cpp index ead2ff9..11a29a5 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -391,7 +391,24 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine } RECT wr = { 0, 0, winW, winH }; 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, (HWND)NULL, (HMENU)NULL, hInstance, (LPVOID)NULL); if (!hWnd) return FALSE; diff --git a/game/reconstructed/btl4gau2.cpp b/game/reconstructed/btl4gau2.cpp index d943ac0..27db968 100644 --- a/game/reconstructed/btl4gau2.cpp +++ b/game/reconstructed/btl4gau2.cpp @@ -1310,7 +1310,7 @@ SubsystemCluster::SubsystemCluster( *(int *)((char *)subsystem_in + 0x150) /*third*/, 3 /*frames*/, (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] previousDrawState = 0; // @0xC8 this[0x32] } @@ -1362,10 +1362,12 @@ void SubsystemCluster::DrawPanelFrame(int color) // void SubsystemCluster::SetChildrenEnable(int enable) { - // FUN_00444650 forwards visibility; enable==0 (offline) => Disable(True). - if (temperatureBar) temperatureBar->Disable(enable == 0); - if (coolingLoopA) coolingLoopA->Disable(enable == 0); - if (powerSourceA) powerSourceA->Disable(enable == 0); + // POLARITY (PPC-dial fix, take 3): the passed value is the DRAW STATE -- + // 0 = alive (children run), nonzero = dead panel (children off). The old + // reading (`enable==0 => Disable`) had it backwards. + 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() { - if (operating != 0) - return 1; - if (*(int *)((char *)subsystem + 0x278) != 4) // BEST-EFFORT raw offset - return 1; - return 0; + // POLARITY (PPC-dial fix, take 3): state 1 = the DEAD-panel presentation + // (black fill + bright frame + X lamps lit); state 0 = ALIVE (frame 0 + + // background art; the WeaponCluster lamp gate runs ONLY in state 0). The + // binary's secondary condition (*(subsystem+0x278) != 4 -> 1) remains + // unidentified [T4] -- a healthy subsystem reads ALIVE here until that + // field is decoded (open-questions). + return failedState ? 1 : 0; } Logical SubsystemCluster::TestInstance() const @@ -1394,7 +1398,18 @@ Logical SubsystemCluster::TestInstance() const // 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(); if (state != previousDrawState) @@ -1450,7 +1465,7 @@ HeatSinkCluster::HeatSinkCluster( int engPort = renderer_in->FindGraphicsPort(eng_port_name); 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 failFlag = 0; // @0x34 @@ -1587,6 +1602,12 @@ WeaponCluster::WeaponCluster( warningState = 0; // @0x39 AddConnection(new GaugeConnectionDirectOf(0, &percentDone, // @00474855 (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() @@ -1630,7 +1651,33 @@ void WeaponCluster::BecameActive() // 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(); + 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) { int warn = (WeaponWarnThreshold < percentDone) ? 1 : 0; @@ -1736,7 +1783,7 @@ BallisticWeaponCluster::BallisticWeaponCluster( "NumericDisplayScalarTwoState"); 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 // subsys+0x3f8). BRING-UP: the BitMapInverseWipeScalar class is not yet diff --git a/game/reconstructed/btl4gau2.hpp b/game/reconstructed/btl4gau2.hpp index 8bf1fa7..ac26752 100644 --- a/game/reconstructed/btl4gau2.hpp +++ b/game/reconstructed/btl4gau2.hpp @@ -390,7 +390,12 @@ *stateLamp, // @0xB8 this[0x2E] OneOfSeveralStates *leakGauge; // @0xBC this[0x2F] LeakGauge 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) // internal helpers (non-virtual; @004c89c4 / @004c8820 / @004c8a28) @@ -472,6 +477,7 @@ int warningCenterX; // @0xDC this[0x37] int warningCenterY; // @0xE0 this[0x38] int warningState; // @0xE4 this[0x39] + const char *diagWeaponName; // PORT diagnostic (appended; clusters are plain-new) }; class EnergyWeaponCluster : // @004c93b0 diff --git a/game/reconstructed/btl4gau3.cpp b/game/reconstructed/btl4gau3.cpp index 6708921..c1ac965 100644 --- a/game/reconstructed/btl4gau3.cpp +++ b/game/reconstructed/btl4gau3.cpp @@ -1001,6 +1001,13 @@ void void 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 return; @@ -1030,11 +1037,11 @@ void } } - // --- sender name cell (:3983-4009). STRUCTURAL SIMPLIFICATION (marked): the binary - // tracks the previous name ENTITY pointer at 0x9c and erases it via +0x1e0->App+0xc8; - // here previousNameId is a bool (0/1). Functionally identical while DEFERRED (name - // always NULL); RESTORE the entity-pointer tracking + erase branch when the feed goes live. - int nameState = (nameBitmap != NULL) ? 1 : 0; + // --- sender name cell (:3983-4009). The binary tracks the previous name + // ENTITY pointer at 0x9c; with the feed LIVE (2026-07-12) we track the + // resolved BITMAP pointer -- same change-detection granularity (a new + // victim = a different prebuilt raster), no entity re-deref. + int nameState = (int)(size_t)nameBitmap; if (nameState != previousNameId) { localView.MoveToAbsolute(0x80, 0); // vtbl+0x24 (name cell x=128) diff --git a/game/reconstructed/btl4gaug.cpp b/game/reconstructed/btl4gaug.cpp index ace49e2..8b57f71 100644 --- a/game/reconstructed/btl4gaug.cpp +++ b/game/reconstructed/btl4gaug.cpp @@ -2404,8 +2404,28 @@ SegmentArc270::SegmentArc270( AddConnection(new GaugeConnectionDirectOf(0, ¤tValue, value_pointer)); int n = (number_of_segs < 0) ? -number_of_segs : number_of_segs; segmentSpan = (Scalar)(n / (n - 1)) * 0.75f; // _DAT_004c62d4 = 0.75 + diagName = 0; } 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. diff --git a/game/reconstructed/btl4gaug.hpp b/game/reconstructed/btl4gaug.hpp index c8bd482..799dd94 100644 --- a/game/reconstructed/btl4gaug.hpp +++ b/game/reconstructed/btl4gaug.hpp @@ -546,6 +546,8 @@ Scalar *value_pointer, Logical use_thick_lines, const char *identification_string); ~SegmentArc270(); // dtor thunk @004c66b3 + void Execute(); // PORT diag wrapper (chains SegmentArc::Execute) + const char *diagName; // PORT diagnostic (frozen-dial hunt) protected: Scalar segmentSpan; // @0xC0 this[0x30] }; diff --git a/game/reconstructed/btl4rdr.cpp b/game/reconstructed/btl4rdr.cpp index 99c68e1..f6d4faf 100644 --- a/game/reconstructed/btl4rdr.cpp +++ b/game/reconstructed/btl4rdr.cpp @@ -679,15 +679,23 @@ void if (currentPositionPointer != NULL) { 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 (!rockAndRoll) { // // 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; euler = *currentAngularPointer; // FUN_0040954c @@ -695,21 +703,27 @@ void SinCosPair sc; sc = Radian(heading); // FUN_00408328 Quaternion yaw(0.0f, sc.sine, 0.0f, sc.cosine); // FUN_00409948 - view *= yaw; // FUN_0040adec + view = yaw; // rotation-only (FUN_0040adec semantics) } else { // - // "Rock and roll": use the full orientation matrix. + // "Rock and roll": use the full orientation. // - AffineMatrix full; - full = *currentAngularPointer; // FUN_00409968 - view *= full; // FUN_0040adec + view = *currentAngularPointer; // rotation-only build } } + 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. @@ -717,6 +731,18 @@ void Vector3D scale; scale.x = scale.y = scale.z = pixelsPerMeter; 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 @@ -803,6 +829,14 @@ void { 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) ? pips->LookUpPip(entity->GetResourceID()) // FUN_004688ce(pips+0x58,entity+0x1bc,0) @@ -888,10 +922,23 @@ void { 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) ? pips->LookUpPip(entity->GetResourceID()) : 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) { AffineMatrix blipXform; @@ -987,6 +1034,15 @@ void // "MapDisplay::DrawNames -- GetSize() / iterator mismatch" // "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 } diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index 22bcea6..54395f9 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -669,23 +669,24 @@ void } // - // Pop a "destroyed by " status message onto the pilot HUD. - // Gated on a real attacker player (+ the status pool, a NULL stub in - // bring-up), so the ownerless-dummy solo path skips it (no player to name). + // Pop a "destroyed" status message naming the victim onto the comm + // ticker (MessageBoard feed, LIVE 2026-07-12). ALLOCATION NOTE: the + // 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) - if (status) - { - new (status) Player__StatusMessage( - sender_owner, // *(sender+0x190) - 0, - STATUS_DISPLAY_TIME // 0x40c00000 == 6.0f - ); - *(int *)((char *)status + 0x18) = 0; // status[6] = 0 - AddStatusMessage(status); // FUN_0042e580 - } + Player__StatusMessage *status = new Player__StatusMessage( + sender_owner, // *(sender+0x190) -- the victim's player + 0, // message strip 0 (the kill cell) + STATUS_DISPLAY_TIME // 0x40c00000 == 6.0f + ); + Register_Object(status); + AddStatusMessage(status); // FUN_0042e580 } // @@ -1359,7 +1360,41 @@ int BTPilotKills(void *pilot) DEBUG_STREAM << "[score] gauge read: pilot=" << pilot << " kills=" << k << "\n" << std::flush; 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 // highlight). Computed here via accessors -- the PilotList's raw-offset version @@ -1388,11 +1423,37 @@ int BTPilotIsSelected(void *pilot, void *local_player) // compiled BTPlayer/Player__StatusMessage members (decode AddStatusMessage @0042e580). // A real (stub) definition is REQUIRED or the /FORCE link AVs MessageBoard::Execute's call. 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) { *messageId = -1; *nameBitmap = 0; - return False; + BTPlayer *local_player = (application != 0) + ? (BTPlayer *)application->GetMissionPlayer() : 0; + if (local_player == 0) + { + 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; } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/game/reconstructed/btplayer.hpp b/game/reconstructed/btplayer.hpp index 8add2a6..1f2a7d0 100644 --- a/game/reconstructed/btplayer.hpp +++ b/game/reconstructed/btplayer.hpp @@ -374,6 +374,7 @@ class DropZone__ReplyMessage; int roleClassIndex; // @0x274 (role resource +0xe4) friend int BTPlayerRoleLocksAdvanced(void *); // the FUN_004ac9c8 bridge (task #12) + friend void BTPlayerCountObservedDeath(void *); // observed-death tally (MP DEATHS fix) int killCount; // @0x27c kills credited to this player diff --git a/game/reconstructed/emitter.cpp b/game/reconstructed/emitter.cpp index 3ff6c12..de496e9 100644 --- a/game/reconstructed/emitter.cpp +++ b/game/reconstructed/emitter.cpp @@ -346,6 +346,7 @@ void << " edge=" << (int)fireEdge << " alarm=" << (int)weaponAlarm.GetState() << " level=" << (float)currentLevel + << " pct=" << (float)rechargeLevel << " tgt=" << (void *)((owner != 0) ? *(Entity **)((char *)owner + 0x388) : 0) << std::endl; diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 5479383..9b0f1a5 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -597,6 +597,11 @@ void << " inst=" << (int)GetInstance() << " invalidZone=" << (int)message->invalidDamageZone << " 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 << " zones=" << damageZoneCount << "\n" << std::flush; @@ -1384,6 +1389,7 @@ Mech::Mech( standingTemplateMaxY = 0.0f; duckedTemplateMaxY = 0.0f; templateBottomLift = 0.0f; + frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f); // collision-damage guard snapshot if (GroundReal() && GetCollisionVolumeCount() > 0 && collisionTemplate != 0 && collisionVolume != 0) { diff --git a/game/reconstructed/mech.hpp b/game/reconstructed/mech.hpp index 9b13e0c..17f92fc 100644 --- a/game/reconstructed/mech.hpp +++ b/game/reconstructed/mech.hpp @@ -625,6 +625,21 @@ public: Scalar duckedTemplateMaxY; // binary @0x51c 0.6 x standing (duck preset) 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 // [T0]) -- the mech's "TargetReticle" attribute (id 0x1d) binds to it, per the // RP VTV analog (VTV.h: `Reticle targetReticle` + TargetReticleAttributeID). diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index afb9eb4..118ef2c 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -764,13 +764,15 @@ int //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ struct BTProjectile { Point3D pos; - Vector3D dir; - Scalar speed; + Vector3D vel; // world velocity (authored MuzzleVelocity, steered per frame) + Scalar speed; // |vel| held constant through the steer Scalar traveled; Scalar range; Entity *target; 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; }; 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. void 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 // 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 // acquisition and let missiles bypass it.) 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; Vector3D d; 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; BTProjectile &p = gProjectiles[i]; 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 + + // 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.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.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.guided = guided; // autocannon shells fly straight (no seeker in the binary's plain Projectile) p.active = 1; return; } @@ -859,23 +895,117 @@ static void BTProjectile &p = gProjectiles[i]; if (!p.active) continue; Point3D prev = p.pos; - 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; - p.traveled += step; + + // 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; + if (gBTTerrainEntity != 0 && step > 0.0001f) + { + 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 // 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 // .PFX's velocities stream the smoke behind the round). This replaces // 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) { 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); } + // 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; - 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; // Deliver to the projectile's target mech -- the launcher set p.target @@ -1053,6 +1183,12 @@ void projectedVelocity = Motion::Identity; updateVelocity = 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 // the decomp zeroes map to these named members in our layout) --- @@ -1281,6 +1417,21 @@ void DEBUG_STREAM << "[damage] *** " << GetEntityID() << " DESTROYED (death effects dispatched from the death transition) ***\n" << 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 + (float)wv.z * (float)zAxR.z); // mech faces -Z 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 // each frame -- uninitialized on a replicant they read 0, freezing // the clip at advance-time dt*0 (observed: legState engaged at 11, @@ -2406,6 +2571,7 @@ void if (cols) { Point3D before = localOrigin.linearPosition; + frameEntryWorldVelocity = worldLinearVelocity; // collision-damage guard (see mech.hpp) Damage collisionDamage; // filled by ProcessCollisionList (unused) ProcessCollisionList(cols, dt, collisionOldPos, &collisionDamage); // pushes localOrigin out Scalar dx = localOrigin.linearPosition.x - before.x; @@ -3621,6 +3787,12 @@ void if (isPlayer && gBlockCooldown > 0.0f) gBlockCooldown -= dt; // decay the out-of-contact window // 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 if (cols != 0) ProcessCollisionList(cols, dt, old_position, &dmg); @@ -3823,6 +3995,20 @@ static void DEBUG_STREAM << "[collide] dmg=" << resolved->damageAmount << " -> victim class=" << (int)victim->GetClassID() << " (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 @@ -3838,6 +4024,18 @@ void 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 ------- Scalar penetration = 0.0f; if (!collisionVolume->ProcessCollision(collision, worldLinearVelocity, diff --git a/game/reconstructed/mechsub.cpp b/game/reconstructed/mechsub.cpp index c5dfbe5..cbb452d 100644 --- a/game/reconstructed/mechsub.cpp +++ b/game/reconstructed/mechsub.cpp @@ -511,3 +511,18 @@ void *message_id_out = sub->controlMessageID; // @0xEC *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; +} diff --git a/game/reconstructed/messmgr.cpp b/game/reconstructed/messmgr.cpp index a1f1e93..ac932be 100644 --- a/game/reconstructed/messmgr.cpp +++ b/game/reconstructed/messmgr.cpp @@ -384,6 +384,10 @@ void ? owner->GetSubsystem(info->subsystemID) : 0; ResourceDescription::ResourceID explosionID = ResolveExplosionID(firingWeapon); // weapon+0x3E4 + if (getenv("BT_FIRE_LOG")) + DEBUG_STREAM << "[boom-q] subsysID=" << info->subsystemID + << " weapon=" << (firingWeapon ? firingWeapon->GetName() : "") + << " explosionID=" << explosionID << std::endl; if (explosionID != ResourceDescription::NullResourceID && !weaponExplosions.Find(explosionID)) // chain+0xFC, slot 0xC { @@ -541,6 +545,10 @@ void explosion_id, Explosion::DefaultFlags, o, hitID, owner->GetEntityID()); 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) { Register_Object(e); diff --git a/game/reconstructed/mislanch.cpp b/game/reconstructed/mislanch.cpp index ad25f6c..1f0731a 100644 --- a/game/reconstructed/mislanch.cpp +++ b/game/reconstructed/mislanch.cpp @@ -68,8 +68,49 @@ // 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 // 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, - 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.z*launchVelocity.z); // 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; 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 + // 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(); } +//############################################################################# +// 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 // diff --git a/game/reconstructed/mislanch.hpp b/game/reconstructed/mislanch.hpp index 3456ba5..807437a 100644 --- a/game/reconstructed/mislanch.hpp +++ b/game/reconstructed/mislanch.hpp @@ -90,6 +90,35 @@ class Missile; void TakeDamage(Damage &damage); // inherits MechWeapon::TakeDamage (vtable slot 6, @004b964c) 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 // diff --git a/game/reconstructed/projweap.cpp b/game/reconstructed/projweap.cpp index 96860d3..a92b85b 100644 --- a/game/reconstructed/projweap.cpp +++ b/game/reconstructed/projweap.cpp @@ -88,8 +88,13 @@ #include // 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, - 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 + launchVelocity.y*launchVelocity.y + 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 Check_Fpu();