From 8a99972f22983325a1185e1a2d0828745a19866d Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Wed, 29 Jul 2026 13:04:11 -0500 Subject: [PATCH] aimed fire strikes the part under the crosshair: the per-segment pick (#73) The port's target pick was a whole-mech bounding-box slab test, and every hit -- aimed or not -- dispatched zone -1 into the victim's cylinder lottery. The recovered 1995 model (the division-card scene intersection) struck a SEGMENT and credited that segment's own damage zone. This is the port's equivalent. At render-tree build, each segment's draw object and its PrimaryDamageZone -- authored per segment in the skeleton stream, read by JMOVER.cpp:290 -- are recorded in MechRenderTree::segPick. The pick (BTL4VideoRenderer:: MechSegmentPick) ray-tests the per-segment bounding spheres on the live posed skeleton, using the draw-cached mLocalToWorld (at most one frame stale, fine for aiming). Selection is SPECIFICITY-FIRST: among the spheres the ray pierces, the smallest radius wins, normalized-distance tie-break. Both obvious rules were measured failing the same way before this one: the torso mesh's sphere (r~4.1 on the MadCat, vs shoulders at r~1.0) envelops nearly the whole mech, so its front face is nearest for any aim AND any near-body ray normalizes to ~0 against it. Limb spheres nest inside the envelope; smallest-pierced picks the most specific part on the aim line, and the torso wins only when no limb is threaded -- the per-part semantic the pod's mesh test produced. mech4.cpp tries the segment pick per candidate; the box PickRayHit survives only as the fallback (no tree yet, wrecked, spectator), still carrying zone -1 into the lottery, and a structure occlusion clears the zone. The winner's zone rides MECH_TARGET_SUBIDX + targetReticle.targetDamageZone into SendDamageMessage, so aimed hits now dispatch a real zone; the victim's handler applies it directly (bursts 2+ still re-lottery, authentic per the recovered @0x4a0230 loop). Bench, the same L/C/R sweep that exposed the bug: aiming left now lands 36/41 hits on zone 2 = jointlshoulder -- the left arm -- with a 0.30 thread score, where the same aim was a 6-way lottery spray before. The MadCat's authored segment->zone map is rich (shoulders 2/9, guns 6/17, hip 1, six leg zones, torso 0). Known approximations, flagged for field verification: sphere bounds rather than triangles, and a torso-envelope graze credits the torso where the pod's exact mesh test would have missed into air. The field protocol is the one the testers already ran on night 6: stationary mechs, short range, fire only at one arm -- the paper doll should now damage THAT arm. Diag: BT_PICK_LOG ([segpick] map at build, [pickwin] per pick). Co-Authored-By: Claude Fable 5 --- context/combat-damage.md | 25 +++++-- context/decomp-reference.md | 1 + game/reconstructed/btl4vid.cpp | 131 +++++++++++++++++++++++++++++++++ game/reconstructed/btl4vid.hpp | 21 ++++++ game/reconstructed/mech4.cpp | 37 ++++++++-- 5 files changed, 205 insertions(+), 10 deletions(-) diff --git a/context/combat-damage.md b/context/combat-damage.md index a73b404..bb32e3f 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -102,11 +102,26 @@ the DCS = the struck SEGMENT → its `dzone` (the SKL segment→zone map, `GetSe `targetDamageZone`. The cylinder lottery (STEP 6) was only ever the UNAIMED path. The port's whole-mech box pick + "STEP-6 zone under the boresight" funnels AIMED fire through the unaimed lottery — which is exactly the night-6 report (#73): aim at the arm, get the spray. -**Fix design**: per-SEGMENT ray test on the shooter side — for each visible segment of the -candidate mech, `inverse(segmentWorld) · ray` vs the segment BGF's local extent box (the draw -traversal already owns the per-segment world matrices); nearest struck segment → its `dzone` → -dispatch with that zone (aimed), beam converges to the segment hit point. Falls back to the -existing box+lottery when no segment resolves. +**IMPLEMENTED (2026-07-29/30) [T2 bench]: the aimed PER-PART pick.** +`BTL4VideoRenderer::MechSegmentPick` (btl4vid.cpp): at tree build, each segment's draw object + +`GetPrimaryDamageZone()` (SEGMENT.h — carried per segment in the skeleton STREAM, read by +JMOVER.cpp:290) are recorded in `MechRenderTree::segPick`; the pick ray-tests the per-segment +bounding spheres (`mCullCenter/mCullRadius`, world via the draw-cached `mLocalToWorld` — ≤1 frame +stale). **Selection is SPECIFICITY-FIRST: among pierced spheres the SMALLEST radius wins** +(normalized `d²/r²` tie-break). Both simpler rules were measured failing identically: the torso +mesh's sphere (r≈4.1 on the MadCat vs shoulders r≈1.0) envelops the mech, so nearest-entry always +faces you AND normalized distance rewards the giant sphere (a 0.45u-off-axis ray scores 0.012 vs +it). Limbs nest inside the envelope; smallest-pierced picks the most specific part on the aim line +— the per-part semantic the 1995 mesh intersection produced. `mech4.cpp` tries the segment pick +per candidate (box `PickRayHit` + zone −1 → the unaimed lottery survives only as fallback: +no tree / wrecked / structure occlusion), and the winner's zone rides `MECH_TARGET_SUBIDX` + +`targetReticle.targetDamageZone` into `SendDamageMessage` — aimed hits now dispatch a REAL zone. +Bench (L/C/R sweep, dummy at 8u): aim-left = **36/41 hits on zone 2 = `jointlshoulder`** with +thread score 0.30 (was a 6-way lottery spray); envelope grazes log score ≈0.99 and credit the +torso. The MadCat segment→zone map is authored and rich (shoulders 2/9, guns 6/17, hip 1, legs +3/5/8/10/16/19, torso 0). Known approximations for field verification: sphere bounds (not +per-triangle), the torso-envelope graze credits the torso where the pod's mesh test would miss +into air. Diag: `BT_PICK_LOG` (`[segpick]` the map at build, `[pickwin]` zone/score/t per pick). `mech+0x37c` = target world Point3D; `mech+0x388` = target `Entity*` (the `HasActiveTarget()` gate); `mech+0x38c` = targeted sub-zone (−1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint, refreshed each frame. [T1] diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 4b720af..1956bcf 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -473,6 +473,7 @@ default-ON (`'0'` disables). | `BT_SELF_DAMAGE=` | dispatch an unaimed `TakeDamage` at your OWN mech once a second, through the real `Entity::Dispatch` path, so the whole RESPAWN family is bench-testable solo (nothing else can kill the local pilot: `BT_MP_FORCE_DMG` only targets replicants). **Latches off at first death** so everything after the respawn is the respawn's doing, not the harness still shooting you | | `BT_POWER_DETACH_TEST=` | drop a subsystem's voltage link + force Auto, so the auto-hunt must recover it. `1` = first powered subsystem to tick; a NAME (`PPC_1`, `Myomers`) targets one, which is what proves FAILOVER to a different generator rather than a same-generator re-attach | | `BT_AUDIO_SOURCES=` | request `n` OpenAL mono sources instead of the driver default (~256). **Opt-in on purpose** — the cap doubles as a governor, and with EFX reverb live a higher ceiling means more simultaneous voices mixing during heavy combat. Measure frame time. See [[wintesla-port]] | +| `BT_PICK_LOG` | #73 aimed-pick diagnostics: `[segpick]` the segment→zone map at tree build (index, name, zone, sphere), `[pickwin]` the winning zone/score/t per pick (score ~0 = threading the part core, ~1 = envelope graze) | | `BT_CRIT_LOG` | #80 crit diagnostics: `[subarmor]` per-subsystem armour/scales/critBonus at ctor (proves the resource keys parsed + the zone got REAL scales), `[critroll]` per landed crit (zone, subsystem, its resulting own-zone level). NB the type-0x1e loader's `[crit]` tag is a different, older log | | `BT_DEVICELOST_TEST=[,crashrepro]` | #35 bench hook. `` forces the D3D9 DEVICELOST branch at that render frame (+600/+1200 = 3 cycles), driving the REAL `BTResetLostDevice` recovery. `,crashrepro` runs the field null-teardown shape (double `ParticleEngine::Destroy`) — pre-fix this reproduced the field crash byte-for-byte (`Destroy +0x11`, `target=0x0`); post-fix it must log `SURVIVED`. See [[wintesla-port]] §Device-loss | diff --git a/game/reconstructed/btl4vid.cpp b/game/reconstructed/btl4vid.cpp index 5bd354e..bccd496 100644 --- a/game/reconstructed/btl4vid.cpp +++ b/game/reconstructed/btl4vid.cpp @@ -537,6 +537,25 @@ HierarchicalDrawComponent* for (int op = 0; op < this_object->GetDrawOpCount(); ++op) this_object->GetDrawOp(op)->alphaTest = true; } + + // #73: record the pickable segment geometry + its zone for the + // aimed per-part pick (MechSegmentPick). The shadow proxy is not + // a target; a rebuild resets the map with the tree. + if (this_object != NULL && this_object->GetIsShadow() == 0) + { + MechRenderTree::SegPick sp; + sp.obj = this_object; + sp.zone = segment->GetPrimaryDamageZone(); // SEGMENT.h:107 + render_tree.segPick[segment->GetIndex()] = sp; + if (getenv("BT_PICK_LOG")) + DEBUG_STREAM << "[segpick] seg=" << segment->GetIndex() + << " '" << (const char *)segment->GetName() + << "' zone=" << sp.zone + << " r=" << this_object->mCullRadius + << " c=(" << this_object->mCullCenter.x << "," + << this_object->mCullCenter.y << "," + << this_object->mCullCenter.z << ")\n" << std::flush; + } } // @@ -1315,6 +1334,118 @@ int BTWreckSinkTick(Entity *victim, float dt) } +// +// #73 -- the aimed PER-PART pick (see the header note). Ray-vs-sphere over +// the per-segment draw objects recorded at tree build; world centers come +// through the draw-cached mLocalToWorld (updated every drawn frame -- the +// target being aimed at is on screen, so at most one frame stale). +// +int + BTL4VideoRenderer::MechSegmentPick( + Entity *mech, + const float ray_start[3], + const float ray_dir[3], + float max_range, + float hit_out[3], + int *zone_out) +{ + std::map::iterator it = mMechRenderTrees.find(mech); + if (it == mMechRenderTrees.end() || it->second.wrecked) + return 0; + + // Selection is SPECIFICITY-FIRST: among the spheres the ray pierces, the + // SMALLEST radius wins (normalized-distance tie-break). Neither nearest- + // entry nor pure normalized distance works here, and both were measured + // failing the same way: the torso mesh's sphere (r~4.1 on the MadCat) + // envelops nearly the whole mech, so its front face is nearest for any aim + // AND any near-body ray scores ~0 against it (d/r rewards giant spheres). + // The limb spheres (shoulders r~1.0, guns r~2.2) nest INSIDE the torso + // envelope; smallest-pierced picks the most specific part on the aim line, + // and the torso wins only when no limb is threaded -- which is the per-part + // semantic the 1995 mesh intersection produced. + float bestR = 1e30f; // primary key: sphere radius (ascending) + float bestScore = 1.0f; // tie-break: normalized perpendicular d2/r2 + float bestT = max_range; + int bestZone = -1; + int hitAny = 0; + + std::map::iterator sp; + for (sp = it->second.segPick.begin(); sp != it->second.segPick.end(); ++sp) + { + d3d_OBJECT *obj = sp->second.obj; + if (obj == NULL || obj->mCullRadius <= 0.0f) + continue; + + D3DXMATRIX l2w = obj->GetLocalToWorld(); + D3DXVECTOR3 cw; + D3DXVec3TransformCoord(&cw, &obj->mCullCenter, &l2w); + + float ocx = cw.x - ray_start[0]; + float ocy = cw.y - ray_start[1]; + float ocz = cw.z - ray_start[2]; + float tca = ocx*ray_dir[0] + ocy*ray_dir[1] + ocz*ray_dir[2]; + float r = obj->mCullRadius; + float oc2 = ocx*ocx + ocy*ocy + ocz*ocz; + if (tca < 0.0f && oc2 > r*r) + continue; // wholly behind the ray + float d2 = oc2 - tca*tca; + float r2 = r*r; + if (d2 > r2) + continue; // ray passes outside the sphere + float thc = sqrtf(r2 - d2); + float t = tca - thc; + if (t < 0.0f) + t = tca + thc; // ray starts inside: exit point + if (t < 0.0f || t >= max_range) + continue; + + float score = d2 / r2; // 0 = dead-center thread + if (r > bestR + || (r == bestR && score >= bestScore)) + continue; + + bestR = r; + bestScore = score; + bestT = t; + bestZone = sp->second.zone; + hitAny = 1; + } + + if (!hitAny) + return 0; + + if (getenv("BT_PICK_LOG")) + { + static int s_pl = 0; + if ((s_pl++ % 60) == 0) + DEBUG_STREAM << "[pickwin] zone=" << bestZone + << " score=" << bestScore << " t=" << bestT << "\n" << std::flush; + } + + hit_out[0] = ray_start[0] + bestT * ray_dir[0]; + hit_out[1] = ray_start[1] + bestT * ray_dir[1]; + hit_out[2] = ray_start[2] + bestT * ray_dir[2]; + *zone_out = bestZone; // -1 = zone-less segment (lottery downstream) + return 1; +} + +// +// Game-side bridge (mech4.cpp's per-frame target pick; same access pattern as +// the wreck swap below). +// +int BTMechSegmentPick(void *mech, const float ray_start[3], const float ray_dir[3], + float max_range, float hit_out[3], int *zone_out) +{ + if (mech == NULL || application == NULL) + return 0; + BTL4VideoRenderer *renderer = + (BTL4VideoRenderer *)application->GetVideoRenderer(); + if (renderer == NULL) + return 0; + return renderer->MechSegmentPick((Entity *)mech, ray_start, ray_dir, + max_range, hit_out, zone_out); +} + // // Engine-side bridge (the ExplosionClassID dispatch calls this on effect 104). // diff --git a/game/reconstructed/btl4vid.hpp b/game/reconstructed/btl4vid.hpp index c7eb114..195a3cd 100644 --- a/game/reconstructed/btl4vid.hpp +++ b/game/reconstructed/btl4vid.hpp @@ -706,6 +706,12 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device); d3d_OBJECT *wreckDebrisObj; d3d_OBJECT *wreckFlamesObj; std::map segRenderable; // slot -> joint renderable + // #73 (aimed per-part pick): each segment's drawn geometry + its + // damage zone. The 1995 pick was a dpl SCENE intersection against + // the rendered meshes; this map is the port's equivalent target set + // (per-segment bounding spheres on the live posed skeleton). + struct SegPick { d3d_OBJECT *obj; int zone; }; + std::map segPick; // segment idx -> pickable std::map segGState; // slot -> last applied graphic state char paintSerno; // the %serno% this mech was BUILT with // (0 = none) -- ApplyViewSkeleton re-parses @@ -756,6 +762,21 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device); void SwapToWreck(Entity *victim); + // #73 -- the aimed PER-PART pick. The 1995 target pick was a dpl scene + // intersection run by the division card against the drawn geometry + // (dpl_isect_mode_obj per renderable; results at Instance/DCS/Geometry + // granularity), so aimed fire struck a SEGMENT and credited that + // segment's damage zone. This is the port's equivalent: ray-vs-sphere + // over the mech's per-segment draw objects (mCullCenter/mCullRadius, + // world via the draw-cached mLocalToWorld -- one frame stale, fine for + // aiming), nearest struck segment wins, returning its hit point and + // PrimaryDamageZone. Returns 0 when the tree is absent/wrecked (the + // caller falls back to the whole-mech box + the unaimed lottery). + int + MechSegmentPick(Entity *mech, const float ray_start[3], + const float ray_dir[3], float max_range, + float hit_out[3], int *zone_out); + protected: // // Renderer-manager overrides diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 5ce8db9..08f89db 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -4796,6 +4796,7 @@ void // CODE -- written indirectly, never a manual player lock). Entity *hotTarget = 0; // the enemy mech under the boresight Point3D hotPoint; // picked world point on its hull + int hotZone = -1; // #73: the struck SEGMENT's zone (-1 = box fallback -> lottery) Entity *pickTarget = 0; // what the boresight ray hit (mech/terrain) Point3D pickPoint; // where it hit // BT_RANGE_LOG (Gitea #4 VERDICT instrumentation -- uncommitted diag): @@ -4973,13 +4974,33 @@ void // pick the CLOSEST one the boresight ray strikes -- generalising // the solo gEnemyMech. float bestDist = 1e30f; + hotZone = -1; // #73: reset per frame (declared with hotTarget) for (int ci = 0; ci < nc; ++ci) { Mech *m = (Mech *)cand[ci]; if (m == 0 || m->IsMechDestroyed()) continue; + // #73 -- PER-PART pick first. The 1995 pick was a dpl scene + // intersection against the DRAWN geometry (the division card + // cast from the view; results at DCS/segment granularity), so + // aimed fire struck a SEGMENT and credited that segment's own + // damage zone. The renderer-side equivalent ray-tests the + // per-segment bounding spheres on the live posed skeleton and + // returns the nearest segment's hit point + PrimaryDamageZone. + // The whole-mech box (PickRayHit) survives only as the + // fallback when no tree/segment resolves -- its zone stays -1, + // which routes through the unaimed cylinder lottery as before. + extern int BTMechSegmentPick(void *mech, const float s[3], + const float dir[3], float max_range, + float hit_out[3], int *zone_out); Point3D hp; - if (!m->PickRayHit(rayStart, rayDir, 4000.0f, &hp)) + int zone = -1; + float segHit[3]; + if (BTMechSegmentPick(m, rs, rd, 4000.0f, segHit, &zone)) + { + hp.x = segHit[0]; hp.y = segHit[1]; hp.z = segHit[2]; + } + else if (!m->PickRayHit(rayStart, rayDir, 4000.0f, &hp)) continue; float dx = hp.x - rs[0], dy = hp.y - rs[1], dz = hp.z - rs[2]; float d = dx*dx + dy*dy + dz*dz; @@ -4988,6 +5009,7 @@ void bestDist = d; hotTarget = cand[ci]; hotPoint = hp; + hotZone = zone; } } // WORLD-STRUCTURE pick (task #50): ray-test the STATIC collision @@ -5054,6 +5076,7 @@ void // HudSim part_013.c:5620). pickTarget = (structOwner != 0) ? structOwner : gBTTerrainEntity; pickPoint = structPoint; + hotZone = -1; // #73: a structure has no mech zone rlPickClass = 2; // BT_RANGE_LOG (issue #4) ++gAimGround; } @@ -5078,12 +5101,16 @@ void } // The Reticle struct (the mech's TargetReticle attribute): position, - // pick result. targetDamageZone stays -1 -- the zone ROLL happens at - // damage delivery (the authentic percent-table roll, STEP 6). + // pick result. #73: targetDamageZone now carries the struck + // SEGMENT's zone from the per-part pick -- the authentic aimed-fire + // model ("For BattleTech, damage zones are only valid via reticle + // based weapons", ENTITY3.h:131). It stays -1 only on the box + // fallback, which routes through the unaimed cylinder lottery + // (STEP 6) exactly as before. targetReticle.reticlePosition.x = gBTAimX; targetReticle.reticlePosition.y = gBTAimY; targetReticle.targetEntity = pickTarget; - targetReticle.targetDamageZone = -1; + targetReticle.targetDamageZone = (pickTarget != 0) ? hotZone : -1; if (pickTarget != 0) targetReticle.rayIntersection = pickPoint; @@ -5091,7 +5118,7 @@ void if (pickTarget != 0) { MECH_TARGET_ENTITY(this) = pickTarget; - MECH_TARGET_SUBIDX(this) = -1; + MECH_TARGET_SUBIDX(this) = hotZone; // #73: aimed zone (or -1) MECH_TARGET_POS(this) = pickPoint; // beam endpoint = the pick } else if (gBTTerrainEntity != 0)