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>
101 lines
4.1 KiB
Python
101 lines
4.1 KiB
Python
import struct, collections
|
|
|
|
RES = r"C:\git\nick-games\decomp\BTL4.RES"
|
|
data = open(RES, "rb").read()
|
|
|
|
labOnly, maxID = struct.unpack_from("<ii", data, 4)
|
|
offsets = struct.unpack_from("<%dI" % maxID, data, 12)
|
|
Res = collections.namedtuple("Res", "rid rtype name prio flags off length")
|
|
resources = {}
|
|
for o in offsets:
|
|
if o == 0: continue
|
|
rid, rtype = struct.unpack_from("<ii", data, o)
|
|
name = data[o+8:o+40].split(b"\0")[0].decode("ascii", "replace")
|
|
prio, flags, roff, rlen = struct.unpack_from("<iIII", data, o+40)
|
|
resources[rid] = Res(rid, rtype, name, prio, flags, roff, rlen)
|
|
|
|
def list_members(r):
|
|
cnt = struct.unpack_from("<i", data, r.off)[0]
|
|
return struct.unpack_from("<%di" % cnt, data, r.off+4)
|
|
|
|
def solid_member(model_rid):
|
|
r = resources.get(model_rid)
|
|
if not r or r.rtype != 1: return None
|
|
for m in list_members(r):
|
|
mr = resources.get(m)
|
|
if mr and mr.rtype == 9: return mr
|
|
return None
|
|
|
|
SOLID_TYPES = {0:"Block",1:"Sphere",2:"Cone",3:"ReducibleBlock",4:"Ramp-Z",5:"Ramp-X",6:"Ramp+Z",7:"Ramp+X",
|
|
8:"InvRamp-Z",9:"InvRamp-X",10:"InvRamp+Z",11:"InvRamp+X",12:"Wedge-Z+X",13:"Wedge-Z-X",14:"Wedge+Z-X",
|
|
15:"Wedge+Z+X",16:"XCyl",17:"YCyl",18:"ZCyl",19:"RTile",20:"LTile"}
|
|
|
|
def decode_solids(r):
|
|
out = []
|
|
n = r.length // 60
|
|
for i in range(n):
|
|
base = r.off + i*60
|
|
reclen, = struct.unpack_from("<I", data, base)
|
|
se = struct.unpack_from("<6f", data, base+4) # solidExtents minX maxX minY maxY minZ maxZ
|
|
sl = struct.unpack_from("<6f", data, base+28)
|
|
st, mt = struct.unpack_from("<ii", data, base+52)
|
|
out.append((se, sl, st, mt))
|
|
return out
|
|
|
|
MAPS = ["cavern","grass","rav","polar3","polar4","arena1","arena2","dbase"]
|
|
MAKE_MSG_SIZE_MIN = 28 # sanity
|
|
|
|
for mname in MAPS:
|
|
mres = [r for r in resources.values() if r.rtype == 14 and r.name == mname]
|
|
if not mres:
|
|
print("MAP %s: NOT FOUND" % mname); continue
|
|
r = mres[0]
|
|
off = r.off
|
|
count, = struct.unpack_from("<i", data, off)
|
|
p = off + 4
|
|
inst = collections.Counter() # per model name
|
|
inst_class = {}
|
|
solid_recs = collections.Counter() # solids records per model
|
|
includes = []
|
|
total = 0
|
|
bad = 0
|
|
ends = off + r.length
|
|
for i in range(count):
|
|
if p + 28 > ends: bad += 1; break
|
|
mlen, mid, mflags = struct.unpack_from("<IiI", data, p)
|
|
cls, = struct.unpack_from("<i", data, p+28)
|
|
rid, iflags = struct.unpack_from("<iI", data, p+40)
|
|
pos = struct.unpack_from("<3f", data, p+48)
|
|
total += 1
|
|
if mflags & 0x2: # MapStreamMarkerFlag = 1<<1 (ReliableBit=0 -> NextBit=1)
|
|
rr = resources.get(rid)
|
|
includes.append(rr.name if rr else "?%d" % rid)
|
|
else:
|
|
rr = resources.get(rid)
|
|
nm = rr.name if rr else ("?rid=%d" % rid)
|
|
inst[nm] += 1
|
|
inst_class[nm] = cls
|
|
s = solid_member(rid)
|
|
if s: solid_recs[nm] = s.length // 60
|
|
p += (mlen + 3) & ~3
|
|
print("\n===== MAP %s (id=%d, %d messages) =====" % (mname, r.rid, count))
|
|
if includes: print(" includes:", includes)
|
|
tot_solids = 0
|
|
for nm, c in sorted(inst.items()):
|
|
sr = solid_recs.get(nm, 0)
|
|
tot_solids += sr * c
|
|
print(" %-14s x%-3d class=%-3d solidsRecords=%s" % (nm, c, inst_class[nm], sr if sr else "-"))
|
|
print(" TOTAL instances=%d, instances-with-solids=%d, total solid records=%d" %
|
|
(sum(inst.values()), sum(c for nm,c in inst.items() if solid_recs.get(nm)), tot_solids))
|
|
|
|
# dump key ground solids
|
|
print("\n\n== key solid stream contents ==")
|
|
for nm in ["gr100_cv.sld","gr100acv.sld","rfloor_c.sld","afloor~1.sld","hillg1_c.sld","buttea_c.sld","wall1_cv.sld","bld08_cv.sld"]:
|
|
rs = [r for r in resources.values() if r.rtype == 9 and r.name == nm]
|
|
if not rs: print(nm, "NOT FOUND"); continue
|
|
r = rs[0]
|
|
print("\n-- %s (%d records)" % (nm, r.length//60))
|
|
for se, sl, st, mt in decode_solids(r)[:6]:
|
|
print(" type=%-10s mat=%d solidExt X[%g,%g] Y[%g,%g] Z[%g,%g]" %
|
|
(SOLID_TYPES.get(st,st), mt, se[0],se[1],se[2],se[3],se[4],se[5]))
|