From de8f6d02c1665f1f8c0e4e9c9d4cb80aefe02c5a Mon Sep 17 00:00:00 2001 From: arcattack Date: Wed, 15 Jul 2026 10:08:45 -0500 Subject: [PATCH] MP: world structures (garages/walls) TARGETABLE -- boresight ray-tests the static collision tree (task #50) The boresight's non-mech pick only sampled the VISUAL heightfield (BTGroundRayHit), which on arena1 is a single flat 'sky'-named ground mesh (and class-42 BuildTables likewise holds only 'sky') -- so shots passed THROUGH the garages/walls and only mechs moved the HUD range axis (user-reported regression: "buildings used to move the range axis; now only mechs do, and you can only fire at a peer"). Real fix: the boresight now ALSO ray-tests the ZONE'S STATIC COLLISION SOLID TREE -- the same geometry that already blocks the mech's walk. Authentic engine mechanism: Mover::FindBoxedSolidHitBy already tests the static world via zone->GetCollisionRoot()->FindBoundingBoxHitBy(line). Factored its "static world" tail into Mover::FindStaticSolidHitBy(Line*) (static solids only, no movers), wrapped by Mech::WorldStructurePick(start,dir,range,&hit) (builds the world-space Line, reads the entry point back via line->FindEnd since HitByBounded clips line->length=enter). Boresight pick order is now: closest MECH (PickRayHit, damage zones + lock) -> closest STRUCTURE (WorldStructurePick; occludes a mech BEHIND it; designates the gBTTerrainEntity sentinel + entry point, so the range caret reads the structure distance and NO lock ring draws, mech4.cpp:4529) -> flat ground (BTGroundRayHit) -> sky (fire-at-nothing). Also un-skips arena1's misnamed-'sky' flat ground so the ground tier works (btvisgnd geometry-aware skip + [mapent]/[rendent] census). Verified headless: a BT_WSWEEP horizontal ray-fan on arena1 tracks position (3/24 hits near the boundary -> 17-21/24 inside the interior garage cluster -> 3/24 past it = DISCRETE interior solids, not an enclosing box), no crash/assert/AV. Interactive aim (BTGetAimRay) can't run headless (no window -> noRay), so the sweep is the headless proof; interactive aim is user-verified. Note: FindBoundingBoxUnder (the ground/containedByNode BoundingBoxTree) is DOWNWARD-only (gravity/ground-snap: *height = FindDistanceBelowBounded), useless for a horizontal boresight; the static SOLID tree's FindBoundingBoxHitBy is the only ray-vs-world query. Co-Authored-By: Claude Opus 4.8 (1M context) --- context/combat-damage.md | 26 ++++++++++ context/open-questions.md | 11 +++++ engine/MUNGA/MOVER.cpp | 28 +++++++++++ engine/MUNGA/MOVER.h | 10 ++++ game/reconstructed/btl4vid.cpp | 17 +++++++ game/reconstructed/btvisgnd.cpp | 8 +++ game/reconstructed/mech.cpp | 31 ++++++++++++ game/reconstructed/mech.hpp | 8 +++ game/reconstructed/mech4.cpp | 87 +++++++++++++++++++++++++++++++-- 9 files changed, 222 insertions(+), 4 deletions(-) diff --git a/context/combat-damage.md b/context/combat-damage.md index 372dae1..e3b484d 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -104,6 +104,32 @@ bug was fixed en route: every renderable rebuild stomped `mCamera` back to the chase eye (btl4vid `mViewInside` now persists the chosen view — the aim camera feed and the V toggle both depend on it). +**WORLD STRUCTURES ARE TARGETABLE via the STATIC COLLISION TREE (task #50, 2026-07-15) [T2].** +The `BTGroundRayHit` "terrain" tier only samples the VISUAL heightfield, which on arena1 is a +single flat `'sky'`-named ground mesh (`[visgnd] sampler ready: 1 terrain instances`) — and the +class-42 map-instance stream (`BuildTables`) likewise holds only `'sky'`. So the garages/walls +were invisible to the pick → shots passed through them, and only mechs moved the range axis (the +user-reported regression). The FIX: the boresight now also ray-tests the **zone's static collision +solid tree** — the SAME geometry that blocks the mech's walk. Mechanism is authentic engine code: +`Mover::FindBoxedSolidHitBy` (MOVER.cpp) already tests movers, doors, AND the static world +(`application->GetInterestManager()->GetInterestZone(interestZoneID)->GetCollisionRoot()->FindBoundingBoxHitBy(line)`). +We factored its "test against the static world" tail into **`Mover::FindStaticSolidHitBy(Line*)`** +(static solids only, no movers), wrapped by **`Mech::WorldStructurePick(start,dir,range,&hit)`** +(mech.cpp — builds the world-space `Line`, reads the entry point back via `line->FindEnd` since +`FindBoundingBoxHitBy`→`HitByBounded` clips `line->length = enter`). The pick order in mech4.cpp is +now: closest MECH (`PickRayHit`, damage zones + lock) → closest STRUCTURE (`WorldStructurePick`, +occludes a mech BEHIND it) → flat ground (`BTGroundRayHit`) → sky (fire-at-nothing). A structure +hit designates `mech+0x388 = gBTTerrainEntity` (the no-damage-zone sentinel) + `0x37c` = the entry +point, so the range caret reads the structure distance and NO lock ring draws (mech4.cpp:4529, the +`des != hotTarget` branch), exactly the reported authentic behavior. ⚠ There is NO ray query +against the ground/`containedByNode` `BoundingBoxTree` — `FindBoundingBoxUnder` is DOWNWARD-only +(gravity/ground-snap: `*height = FindDistanceBelowBounded`), useless for a horizontal boresight; +the static SOLID tree (`BoxedSolidTree::FindBoundingBoxHitBy`) is the only ray-vs-world query. +Verified: a `BT_WSWEEP` horizontal ray-fan on arena1 tracks position (3/24 hits near the boundary +→ 17-21/24 inside the interior garage cluster → 3/24 past it — discrete interior solids, not just +an enclosing box), no crash. The interactive boresight (`BTGetAimRay`) can't be exercised headless +(no window → `noRay`), so the SWEEP is the headless proof; interactive aim is user-verified. + ## Firing arc + muzzles There is NO firing-arc field in the binary (`Emitter::FireWeapon` fires whenever HasActiveTarget) — the 1995 game got away with it because it was **cockpit-only** (a beam behind you is off-camera). The diff --git a/context/open-questions.md b/context/open-questions.md index 298f836..05e32a8 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -154,6 +154,17 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR centred); (c) pre-burial, the pick still tests the sinking wreck's collision box at its parked position (pre-existing wreck-targeting behavior). +- **✅ WORLD STRUCTURES (garages/walls) now targetable (task #50, 2026-07-15)** — the world-pick's + non-mech tier previously only sampled the visual heightfield (`BTGroundRayHit`), which on arena1 + is a single flat `'sky'` ground mesh → shots passed THROUGH the garages and only mechs moved the + range axis (user-reported regression). Resolved by ray-testing the **zone's static collision + solid tree** (the geometry that already blocks the walk) via the authentic engine query + `Mover::FindBoxedSolidHitBy`'s static-world tail — factored into `Mover::FindStaticSolidHitBy` + + `Mech::WorldStructurePick`. See [[combat-damage]] "WORLD STRUCTURES ARE TARGETABLE". This also + closes the old "0x388 writer is un-exported / unknown world-pick mechanism" residue: the + authentic non-mech world pick is the static-solid-tree ray query (`BoxedSolidTree::FindBoundingBoxHitBy`), + the same one `FindBoxedSolidHitBy` uses for mover-vs-world collision. Interactive aim is + user-verified (headless has no `BTGetAimRay`); the `BT_WSWEEP` ray-fan is the headless proof. - **Cockpit HUD leftovers (tasks #35-#38 residue, 2026-07-08)** — the reticle + 7 weapon pips + ALL the instrument dynamics are LIVE (the Execute @004cdcf0 gap was RECOVERED via capstone disasm, task #37; the authentic Lock producer + the simple-X mode landed in #38 — see diff --git a/engine/MUNGA/MOVER.cpp b/engine/MUNGA/MOVER.cpp index b1b3b33..67eaaef 100644 --- a/engine/MUNGA/MOVER.cpp +++ b/engine/MUNGA/MOVER.cpp @@ -1137,6 +1137,34 @@ BoxedSolid* return solid; } +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +// The "static world" tail of FindBoxedSolidHitBy on its own: ray-test ONLY the +// zone's static solid tree (world structures -- garages, walls, props), NOT the +// tangible movers/doors. Same tree the mech's walk collides against, so a shot +// designated through this lands on exactly the geometry that blocks the mech. +// FindBoundingBoxHitBy clips line->length to the hit distance (HitByBounded -> +// line->length = enter), so the caller reads the entry point via line->FindEnd. +// +BoxedSolid* + Mover::FindStaticSolidHitBy( + Line *line + ) +{ + Check(this); + Check(line); + + InterestManager *interest_mgr = application->GetInterestManager(); + Check(interest_mgr); + InterestZone *zone = interest_mgr->GetInterestZone(interestZoneID); + Check(zone); + BoxedSolidTree* tree = zone->GetCollisionRoot(); + Check(tree); + BoxedSolid *result = (BoxedSolid*)tree->FindBoundingBoxHitBy(line); + Check_Fpu(); + return result; +} + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // BoxedSolidCollisionList* diff --git a/engine/MUNGA/MOVER.h b/engine/MUNGA/MOVER.h index 5dffe01..314b0b3 100644 --- a/engine/MUNGA/MOVER.h +++ b/engine/MUNGA/MOVER.h @@ -300,6 +300,16 @@ public: Line *line, Entity *except_by ); + // STATIC-WORLD-ONLY ray query: the "test against the static world" tail of + // FindBoxedSolidHitBy factored out -- ray-tests ONLY the zone's static solid + // tree (the world structures: garages, walls, props), skipping the tangible + // movers/doors. Used by the weapon boresight pick so a shot lands on the SAME + // static geometry that already blocks the mech's walk (mechs are picked + // separately, with their damage zones/lock). Clips line->length to the entry. + BoxedSolid* + FindStaticSolidHitBy( + Line *line + ); BoxedSolidCollisionList* CollideCenterOfMotion( Line *line, diff --git a/game/reconstructed/btl4vid.cpp b/game/reconstructed/btl4vid.cpp index bad21f8..9bc48e0 100644 --- a/game/reconstructed/btl4vid.cpp +++ b/game/reconstructed/btl4vid.cpp @@ -247,6 +247,23 @@ void else if (gBTTerrainEntity == 0) gBTTerrainEntity = entity; // else the first world entity } + // CENSUS: what world-geometry entities does the RENDER tree walk? + // (the garages/structures may be here even if not in the map's class-42 + // instance stream that BuildTables reads.) + if (getenv("BT_GROUND_LOG")) + { + Derivation *dv = entity->GetClassDerivations(); + const int isTer = entity->IsDerivedFrom(*Terrain::GetClassDerivations())?1:0; + const Point3D &ep = entity->localOrigin.linearPosition; + // tag NON-terrain entities distinctly -- these are the structures + // (garages/walls/props); log their world XZ so we can spawn beside + // one and confirm it is a collision solid the boresight now hits. + DEBUG_STREAM << (isTer ? "[rendent]" : "[rendent-STRUCT]") + << " class='" << (dv && dv->className ? dv->className : "?") + << "' terrain=" << isTer + << " pos=(" << ep.x << "," << ep.y << "," << ep.z << ")" + << "\n" << std::flush; + } } DPLRenderer::MakeEntityRenderables( entity, model_resource, view_type); diff --git a/game/reconstructed/btvisgnd.cpp b/game/reconstructed/btvisgnd.cpp index 5ec4ded..df527df 100644 --- a/game/reconstructed/btvisgnd.cpp +++ b/game/reconstructed/btvisgnd.cpp @@ -168,6 +168,14 @@ bool BuildTables() const float *pos = (const float *)(base + p + 48); p += (mlen + 3) & ~3u; + // CENSUS (BT_GROUND_LOG): log EVERY map entity's class + name, so we can see + // what class the garages/structures are (the class-42 filter may drop them). + if (getenv("BT_GROUND_LOG")) + { + ResourceDescription *crd = rf->FindResourceDescription(rid); + DEBUG_STREAM << "[mapent] cls=" << cls << " mflags=" << mflags + << " name='" << (crd ? crd->resourceName : "?") << "'\n" << std::flush; + } if (mflags & 0x2) continue; // include marker if (cls != 42) continue; // terrain/static cultural only diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 18293f2..ff71852 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -539,6 +539,37 @@ Logical return True; } +// +// World-STRUCTURE boresight pick. Ray-test the zone's STATIC collision solid +// tree (the world geometry -- garages, walls, props -- that the mech's walk +// already collides against) along the boresight, and hand back the entry point +// of the closest structure the ray strikes. This is the authentic non-mech +// world pick: Mover::FindStaticSolidHitBy is the "test against the static world" +// tail of the engine's own FindBoxedSolidHitBy (MOVER.cpp), which is what the +// mover-vs-world collision uses -- so a shot lands on EXACTLY the geometry that +// blocks the walk (the user's "structures block the mech, but weapons don't"). +// +// The ray is WORLD-space (the static tree is world-space, unlike a mech's local +// collision template). FindBoundingBoxHitBy clips the Line's length to the +// entry distance (BoundingBox::HitByBounded -> line->length = enter), so +// line->FindEnd yields the world entry point on the struck solid. +// +Logical + Mech::WorldStructurePick(const Point3D &start, const Vector3D &dir, + Scalar max_range, Point3D *hit_world) +{ + Scalar dlen = (Scalar)Sqrt(dir.x*dir.x + dir.y*dir.y + dir.z*dir.z); + if (dlen < 1e-6f) + return False; + Line ray(start, UnitVector(dir.x/dlen, dir.y/dlen, dir.z/dlen), max_range); + BoxedSolid *solid = FindStaticSolidHitBy(&ray); // static world only + if (solid == 0) + return False; + if (hit_world != 0) + ray.FindEnd(hit_world); // clipped length -> entry point + return True; +} + // // The roster torso (sinkSourceSubsystem @0x438, ClassID 0xBC5) -- parity with // the binary's *(mech+0x438). diff --git a/game/reconstructed/mech.hpp b/game/reconstructed/mech.hpp index 76df0e2..0446c58 100644 --- a/game/reconstructed/mech.hpp +++ b/game/reconstructed/mech.hpp @@ -693,6 +693,14 @@ public: Logical PickRayHit(const Point3D &start, const Vector3D &dir, Scalar max_range, Point3D *hit_world); + // World-STRUCTURE boresight pick: ray-test the zone's STATIC collision solid + // tree (garages/walls/props -- the geometry that blocks the walk) along the + // boresight and return the entry point of the closest structure hit. The + // authentic non-mech world pick (Mover::FindStaticSolidHitBy, the static-world + // half of the engine's FindBoxedSolidHitBy). Defined in mech.cpp. + Logical WorldStructurePick(const Point3D &start, const Vector3D &dir, + Scalar max_range, Point3D *hit_world); + // Per-weapon beam render walk (task #33; extracted task #51 so it runs for // EVERY mech -- player, dummy masters, and MP replicants whose emitters // carry replicated discharge state). Defined in mech4.cpp. diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 9578090..3d43760 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -4325,6 +4325,39 @@ void } } + // WORLD-STRUCTURE SWEEP PROBE (BT_WSWEEP, task #50 verification): once a + // second cast a horizontal fan of rays from the eyepoint against the + // STATIC collision solid tree (WorldStructurePick -> FindStaticSolidHitBy) + // and log the hit count + nearest range. Confirms arena1's structures + // (garages/walls) ARE in the collision tree the boresight now queries -- + // independent of the render sentinel or interactive aim. Diag, off by default. + if (getenv("BT_WSWEEP")) + { + static float s_ws = 0.0f; s_ws += dt; + if (s_ws >= 1.0f) + { + s_ws = 0.0f; + Point3D eye = localOrigin.linearPosition; eye.y += 30.0f; + int hits = 0; float nearest = 1e30f; float ndir = -1.0f; + for (int a = 0; a < 24; ++a) + { + double th = (double)a * (6.2831853071795862 / 24.0); + Vector3D d((float)cos(th), 0.0f, (float)sin(th)); + Point3D hp; + if (WorldStructurePick(eye, d, 2000.0f, &hp)) + { + ++hits; + float dx = hp.x-eye.x, dz = hp.z-eye.z; + float r = (float)Sqrt(dx*dx + dz*dz); + if (r < nearest) { nearest = r; ndir = (float)(th * 57.29578); } + } + } + DEBUG_STREAM << "[wsweep] eye=(" << eye.x << "," << eye.z + << ") struct-hits=" << hits << "/24 nearest=" + << (hits ? nearest : -1.0f) << " dir=" << ndir << "deg\n" << std::flush; + } + } + float rs[3], rd[3]; if (!BTGetAimRay(gBTAimX, gBTAimY, rs, rd)) { @@ -4356,16 +4389,62 @@ void hotPoint = hp; } } - if (hotTarget != 0) + // WORLD-STRUCTURE pick (task #50): ray-test the STATIC collision + // solid tree (garages/walls/props -- the geometry that already + // BLOCKS the mech's walk) along the boresight. A structure CLOSER + // than the mech hit occludes it (the shot stops at the wall); with + // no mech, a structure hit IS the designation. This is the + // authentic non-mech world pick: Mech::WorldStructurePick -> + // Mover::FindStaticSolidHitBy, the "test against the static world" + // half of the engine's own FindBoxedSolidHitBy (MOVER.cpp) -- so a + // shot lands on exactly the geometry the walk collides against + // (the user's "structures block the mech, but weapons pass through"). + Point3D structPoint; + float structDist = 1e30f; + bool haveStruct = false; + if (gBTTerrainEntity != 0) { - pickTarget = hotTarget; + Point3D sp; + if (WorldStructurePick(rayStart, rayDir, 1200.0f, &sp)) + { + float dx = sp.x-rs[0], dy = sp.y-rs[1], dz = sp.z-rs[2]; + structDist = dx*dx + dy*dy + dz*dz; + structPoint = sp; + haveStruct = true; + static float s_wp = 0.0f; s_wp += dt; + if (getenv("BT_GROUND_LOG") && s_wp >= 1.0f) + { + s_wp = 0.0f; + DEBUG_STREAM << "[wpick] structure hit at (" + << sp.x << "," << sp.y << "," << sp.z << ") dist=" + << (float)Sqrt(structDist) + << (hotTarget ? " (mech also under boresight)" : "") + << "\n" << std::flush; + } + } + } + + if (hotTarget != 0 && (!haveStruct || bestDist <= structDist)) + { + pickTarget = hotTarget; // mech is the closest hit pickPoint = hotPoint; ++gAimHits; } + else if (haveStruct) + { + // a world structure is the closest hit (garage/wall) -- or it + // occludes a mech behind it. Designate it as the non-mech world + // pick (the geometry rides in the POINT; the sentinel entity has + // no damage-zone table -> no lock ring, HudSim part_013.c:5620). + pickTarget = gBTTerrainEntity; + pickPoint = structPoint; + ++gAimGround; + } else { - // the ground downrange of the guns (max = the pod's HUD - // range scale; past it the shot is a sky shot -> no target) + // no mech, no structure -> the flat ground downrange of the + // guns (max = the pod's HUD range scale; past it the shot is a + // sky shot -> no target) extern bool BTGroundRayHit(float,float,float, float,float,float, float, float*,float*,float*); float hx, hy, hz;