Clean, self-contained extraction of the BattleTech-specific work from the
reverse-engineering workspace -- engine + game + content + build, with nothing
from Red Planet or the raw archive dumps. Builds green (Win32) and runs the
single-player drive->animate->target->fire->damage->destroy loop out of the box.
Layout:
engine/ MUNGA + MUNGA_L4 shared 2007 engine, carrying our BT render/loader
work (bgfload/L4D3D/L4VIDEO: BSL bit-slice decode, LOD/ground/shadow
models) + image codec; the minimal rp/ headers the audio HAL needs
game/ reconstructed BT logic + surviving-original BT source + fwd shims
+ WinMain launcher
content/ full runtime tree (BTL4.RES, VIDEO/, GAUGE/, AUDIO/, eggs, BTDPL.INI)
docs/ format specs + reconstruction ledgers
reference/ raw Ghidra pseudocode (recon source-of-truth) + decomp exporter
tools/ MP console emulator + map/resource scanners
One top-level CMake builds munga_engine lib + bt410_l4 game lib + btl4.exe.
All paths relativized (186 fwd shims + ~437 CMake abs paths -> repo-relative);
DXSDK is the one external, overridable via -DDXSDK. Verified: builds to a
byte-identical 2.27MB exe and runs combat (TARGET DESTROYED, 0 crashes) against
the bundled content.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
287 lines
9.6 KiB
C++
287 lines
9.6 KiB
C++
//###########################################################################
|
|
//
|
|
// btvisgnd.cpp
|
|
//
|
|
// PORT ADDITION (presentation layer -- NOT a reconstruction): visual-ground
|
|
// conform for the external chase view.
|
|
//
|
|
// WHY: the mech SIM rides the coarse 1995 collision solids (cones/blocks;
|
|
// the authentic snap puts origin.y = solid surfaceY exactly -- see
|
|
// Mech::AuthenticGroundAndCollide). The VISUAL terrain mesh runs 0..~2.1u
|
|
// ABOVE those solids on mound/mesa slopes (LOD0-exact measurement, dbase),
|
|
// so an upright mech's feet clip into the visible sand there. In 1995 this
|
|
// was INVISIBLE: the game rendered cockpit-only (you can never see your own
|
|
// feet; other mechs are 100+m away). Our port adds a close external camera,
|
|
// so the port also adds this presentation fix: sample the RENDERED terrain
|
|
// triangles under the mech and lift the render matrix (localToWorld ONLY --
|
|
// never localOrigin) by the visual-minus-solid gap. Collision, aim, damage,
|
|
// network dead-reckoning all stay on the authentic solid model.
|
|
//
|
|
// Data source: the map's MakeMessage stream in BTL4.RES (class-42 static
|
|
// terrain instances) + each instance's LOD0 geometry via the engine BGF
|
|
// loader (bgfload.h). No tuning constants -- the exact content geometry.
|
|
//
|
|
// Gate: BT_VISUAL_GROUND (default ON; =0 restores raw solid-height render).
|
|
// Diagnostics under BT_GROUND_LOG.
|
|
//
|
|
//###########################################################################
|
|
|
|
#include <bt.hpp>
|
|
#include <APP.hpp> // application / GetCurrentMission / GetResourceFile
|
|
#include <MISSION.hpp> // Mission::GetMapID
|
|
#include <bgfload.h>
|
|
|
|
#include <vector>
|
|
#include <string>
|
|
#include <map>
|
|
#include <cstring>
|
|
#include <cstdlib>
|
|
#include <cmath>
|
|
|
|
//---------------------------------------------------------------------------//
|
|
// Geometry cache
|
|
//---------------------------------------------------------------------------//
|
|
namespace
|
|
{
|
|
|
|
struct VgTri
|
|
{
|
|
float ax, ay, az, bx, by, bz, cx, cy, cz;
|
|
};
|
|
|
|
struct VgMesh
|
|
{
|
|
std::vector<VgTri> tris;
|
|
float minx, maxx, minz, maxz; // model-local XZ bound
|
|
};
|
|
|
|
struct VgInst
|
|
{
|
|
int mesh; // index into gMeshes
|
|
float px, py, pz; // world placement
|
|
float minx, maxx, minz, maxz; // world XZ bound (placed)
|
|
};
|
|
|
|
std::vector<VgMesh> gMeshes;
|
|
std::vector<VgInst> gInsts;
|
|
int gState = 0; // 0 = not tried, 1 = ready, -1 = unavailable
|
|
|
|
bool SkippedName(const char *n)
|
|
{
|
|
// backdrop / sky pieces -- vertical scenery, never ground underfoot
|
|
if (strstr(n, "sky") || strstr(n, "SKY")) return true;
|
|
if (_strnicmp(n, "profile", 7) == 0) return true;
|
|
if (_strnicmp(n, "rwin", 4) == 0) return true;
|
|
return false;
|
|
}
|
|
|
|
int LoadMeshFor(const char *resource_name)
|
|
{
|
|
static std::map<std::string, int> cache; // name -> gMeshes idx (-1 = no geometry)
|
|
std::string key(resource_name);
|
|
std::map<std::string, int>::iterator it = cache.find(key);
|
|
if (it != cache.end())
|
|
return it->second;
|
|
|
|
int idx = -1;
|
|
BgfData data;
|
|
std::string file = key + ".bgf";
|
|
if (LoadBgfFile(file, data) && data.ok && !data.indices.empty())
|
|
{
|
|
VgMesh m;
|
|
m.minx = m.minz = 1e9f;
|
|
m.maxx = m.maxz = -1e9f;
|
|
// The loader emits DOUBLE-SIDED triangles: emitTri pushes (a,b,c) then
|
|
// (a,c,b) -- 6 indices per source triangle. Take the forward one.
|
|
for (size_t i = 0; i + 5 < data.indices.size(); i += 6)
|
|
{
|
|
const BgfVtx &A = data.verts[data.indices[i]];
|
|
const BgfVtx &B = data.verts[data.indices[i + 1]];
|
|
const BgfVtx &C = data.verts[data.indices[i + 2]];
|
|
VgTri t = { A.x, A.y, A.z, B.x, B.y, B.z, C.x, C.y, C.z };
|
|
m.tris.push_back(t);
|
|
float xs[3] = { A.x, B.x, C.x }, zs[3] = { A.z, B.z, C.z };
|
|
for (int k = 0; k < 3; ++k)
|
|
{
|
|
if (xs[k] < m.minx) m.minx = xs[k];
|
|
if (xs[k] > m.maxx) m.maxx = xs[k];
|
|
if (zs[k] < m.minz) m.minz = zs[k];
|
|
if (zs[k] > m.maxz) m.maxz = zs[k];
|
|
}
|
|
}
|
|
if (!m.tris.empty())
|
|
{
|
|
gMeshes.push_back(m);
|
|
idx = (int)gMeshes.size() - 1;
|
|
}
|
|
}
|
|
cache[key] = idx;
|
|
return idx;
|
|
}
|
|
|
|
// Build the instance table from the CURRENT mission's map message stream.
|
|
// Map stream record layout (BTL4.RES, byte-verified):
|
|
// [0] u32 count
|
|
// records: u32 mlen | i32 mid | u32 mflags | ... i32 class @+28 |
|
|
// i32 resourceID @+40 | float pos[3] @+48 ; advance (mlen+3)&~3
|
|
// mflags bit1 = include-marker (skip); class 42 = static terrain/cultural.
|
|
bool BuildTables()
|
|
{
|
|
if (application == 0) return false;
|
|
Mission *mission = application->GetCurrentMission();
|
|
ResourceFile *rf = application->GetResourceFile();
|
|
if (mission == 0 || rf == 0) return false;
|
|
|
|
ResourceDescription *map = rf->FindResourceDescription(mission->GetMapID());
|
|
if (map == 0) return false;
|
|
map->Lock();
|
|
const unsigned char *base = (const unsigned char *)map->resourceAddress;
|
|
size_t size = map->resourceSize;
|
|
if (base == 0 || size < 4) return false;
|
|
|
|
int count = *(const int *)base;
|
|
size_t p = 4;
|
|
int made = 0, skipped = 0;
|
|
for (int i = 0; i < count && p + 52 <= size; ++i)
|
|
{
|
|
unsigned mlen = *(const unsigned *)(base + p);
|
|
unsigned mflags = *(const unsigned *)(base + p + 8);
|
|
int cls = *(const int *)(base + p + 28);
|
|
int rid = *(const int *)(base + p + 40);
|
|
const float *pos = (const float *)(base + p + 48);
|
|
p += (mlen + 3) & ~3u;
|
|
|
|
if (mflags & 0x2) continue; // include marker
|
|
if (cls != 42) continue; // terrain/static cultural only
|
|
|
|
ResourceDescription *rd = rf->FindResourceDescription(rid);
|
|
if (rd == 0) continue;
|
|
if (SkippedName(rd->resourceName)) { ++skipped; continue; }
|
|
|
|
int mi = LoadMeshFor(rd->resourceName);
|
|
if (mi < 0) { ++skipped; continue; }
|
|
|
|
VgInst inst;
|
|
inst.mesh = mi;
|
|
inst.px = pos[0]; inst.py = pos[1]; inst.pz = pos[2];
|
|
inst.minx = gMeshes[mi].minx + pos[0];
|
|
inst.maxx = gMeshes[mi].maxx + pos[0];
|
|
inst.minz = gMeshes[mi].minz + pos[2];
|
|
inst.maxz = gMeshes[mi].maxz + pos[2];
|
|
gInsts.push_back(inst);
|
|
++made;
|
|
}
|
|
if (getenv("BT_GROUND_LOG"))
|
|
DEBUG_STREAM << "[visgnd] sampler ready: " << made << " terrain instances ("
|
|
<< gMeshes.size() << " meshes, " << skipped << " skipped)\n" << std::flush;
|
|
return made > 0;
|
|
}
|
|
|
|
// Highest visual surface at world (x,z) whose height lies in [yLo, yHi].
|
|
// The band keeps the query LOCAL: standing at a butte base it picks the skirt
|
|
// underfoot, not the mesa top 40u above.
|
|
bool SampleBand(float x, float z, float yLo, float yHi, float *out)
|
|
{
|
|
bool found = false;
|
|
float best = 0.0f;
|
|
for (size_t ii = 0; ii < gInsts.size(); ++ii)
|
|
{
|
|
const VgInst &inst = gInsts[ii];
|
|
if (x < inst.minx || x > inst.maxx || z < inst.minz || z > inst.maxz)
|
|
continue;
|
|
const float lx = x - inst.px, lz = z - inst.pz;
|
|
const VgMesh &m = gMeshes[inst.mesh];
|
|
for (size_t ti = 0; ti < m.tris.size(); ++ti)
|
|
{
|
|
const VgTri &t = m.tris[ti];
|
|
// point-in-triangle (XZ), sign-consistent
|
|
const float d1 = (t.bx - t.ax) * (lz - t.az) - (t.bz - t.az) * (lx - t.ax);
|
|
const float d2 = (t.cx - t.bx) * (lz - t.bz) - (t.cz - t.bz) * (lx - t.bx);
|
|
const float d3 = (t.ax - t.cx) * (lz - t.cz) - (t.az - t.cz) * (lx - t.cx);
|
|
if (!((d1 >= 0 && d2 >= 0 && d3 >= 0) || (d1 <= 0 && d2 <= 0 && d3 <= 0)))
|
|
continue;
|
|
const float area = (t.bx - t.ax) * (t.cz - t.az) - (t.bz - t.az) * (t.cx - t.ax);
|
|
if (area > -1e-6f && area < 1e-6f)
|
|
continue;
|
|
const float w1 = ((t.bx - lx) * (t.cz - lz) - (t.bz - lz) * (t.cx - lx)) / area;
|
|
const float w2 = ((t.cx - lx) * (t.az - lz) - (t.cz - lz) * (t.ax - lx)) / area;
|
|
const float w3 = 1.0f - w1 - w2;
|
|
const float y = w1 * t.ay + w2 * t.by + w3 * t.cy + inst.py;
|
|
if (y < yLo || y > yHi)
|
|
continue;
|
|
if (!found || y > best) { found = true; best = y; }
|
|
}
|
|
}
|
|
if (found) *out = best;
|
|
return found;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
//---------------------------------------------------------------------------//
|
|
// BTVisualGroundLift -- render-only Y delta so the mech's feet meet the
|
|
// VISIBLE ground. Returns 0 when gated off, unavailable, or no local surface.
|
|
//---------------------------------------------------------------------------//
|
|
float
|
|
BTVisualGroundLift(float x, float y, float z)
|
|
{
|
|
static int s_on = -1;
|
|
if (s_on < 0)
|
|
{
|
|
const char *v = getenv("BT_VISUAL_GROUND");
|
|
s_on = (v == 0 || v[0] != '0') ? 1 : 0;
|
|
}
|
|
if (!s_on)
|
|
return 0.0f;
|
|
|
|
if (gState == 0)
|
|
gState = BuildTables() ? 1 : -1;
|
|
if (gState != 1)
|
|
return 0.0f;
|
|
|
|
// Local band: up to 3u of visual rising above the solid (measured max ~2.2),
|
|
// up to 1u of visual dipping below it. Outside the band = not the ground
|
|
// underfoot (cliff tops, building roofs) -> no conform.
|
|
float surf;
|
|
if (!SampleBand(x, z, y - 1.0f, y + 3.0f, &surf))
|
|
return 0.0f;
|
|
float lift = surf - y;
|
|
if (lift < -1.0f) lift = -1.0f;
|
|
if (lift > 3.0f) lift = 3.0f;
|
|
return lift;
|
|
}
|
|
|
|
//---------------------------------------------------------------------------//
|
|
// BTGroundRayHit -- march a ray (o + dir*t) against the visible terrain and
|
|
// return the first point where it meets the surface (rising ground: butte
|
|
// faces, dune fronts). Used by the weapon fire path to end the beam + place
|
|
// the impact on the ground. Returns false (no hit) for a level shot over open
|
|
// desert -- the caller then flies the beam to max range.
|
|
//---------------------------------------------------------------------------//
|
|
bool
|
|
BTGroundRayHit(float ox, float oy, float oz,
|
|
float dx, float dy, float dz,
|
|
float maxDist, float *hx, float *hy, float *hz)
|
|
{
|
|
if (gState == 0)
|
|
gState = BuildTables() ? 1 : -1;
|
|
if (gState != 1)
|
|
return false;
|
|
|
|
const float step = 4.0f;
|
|
for (float t = step; t <= maxDist; t += step)
|
|
{
|
|
const float x = ox + dx * t;
|
|
const float y = oy + dy * t;
|
|
const float z = oz + dz * t;
|
|
float surf;
|
|
// wide downward band: catch terrain rising into a mostly-horizontal ray.
|
|
if (SampleBand(x, z, y - 90.0f, y + 6.0f, &surf) && y <= surf)
|
|
{
|
|
*hx = x; *hy = surf; *hz = z;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|