Initial commit: bt411 -- standalone Windows BattleTech (Tesla 4.10 port)
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>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python
|
||||
"""btconsole.py -- minimal Tesla/WinTesla CONSOLE emulator (the operator station).
|
||||
|
||||
The pod-side engine (MUNGA_L4/L4NET.CPP) boots `-net <port>` into ConsoleOnly state:
|
||||
it listens on TCP <port> and waits for a console to connect and stream the mission
|
||||
egg. The real console software is absent from every archive, so this tool speaks
|
||||
the console's wire protocol:
|
||||
|
||||
packet := NetworkPacketHeader + NetworkManager::ReceiveEggFileMessage
|
||||
off 0 int32 clientID = 0 (NetworkClient::NetworkManagerClientID)
|
||||
off 4 int32 gameID = 0 (NETWORK.cpp:121 -- always 0)
|
||||
off 8 int32 fromHost = 1 (FirstLegalHostID == the console)
|
||||
off 12 int32 timeStamp = 0 (Time; unused by the egg handler)
|
||||
off 16 uint32 messageLength = 1024 (sizeof(ReceiveEggFileMessage))
|
||||
off 20 int32 messageID = 3 (NetworkManager::ReceiveEggFileMessageID)
|
||||
off 24 uint32 messageFlags = 1 (Receiver::Message::ReliableFlag)
|
||||
off 28 int32 sequenceNumber (0..n-1; -1 is the solo-local path only)
|
||||
off 32 int32 notationFileLength (total egg bytes)
|
||||
off 36 int32 thisMessageLength (bytes used in this chunk, <= 1000)
|
||||
off 40 char notationData[1000]
|
||||
total 1040 bytes per chunk
|
||||
(constants verified from the port build via `btl4.exe` BT_NET_PROBE=1)
|
||||
|
||||
The pod reassembles chunks sequentially (L4NET.CPP:773-816: seq 0 allocates,
|
||||
append until notationFileLength reached), sets NormalState, CreateMission ->
|
||||
StartConnecting (the egg's [pilots] list forms the pod mesh), and sends an
|
||||
AcknowledgeEggFile back on this socket. The console must STAY CONNECTED (a
|
||||
disconnect trips the pod's console-loss path, which also closes the game
|
||||
listener -- an engine bug).
|
||||
|
||||
Usage:
|
||||
python btconsole.py <egg-file> <host:port> [<host:port> ...]
|
||||
Example (two instances on one box, -net 1501 / -net 1601):
|
||||
python btconsole.py MP.EGG 127.0.0.1:1501 127.0.0.1:1601
|
||||
"""
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
CHUNK = 1000
|
||||
PKT_FMT_HDR = "<iiii" # clientID, gameID, fromHost, timeStamp
|
||||
PKT_FMT_MSG = "<IiIiii" # messageLength, messageID, messageFlags, seq, fileLen, thisLen
|
||||
MESSAGE_LENGTH = 1024 # sizeof(ReceiveEggFileMessage)
|
||||
EGG_MESSAGE_ID = 3 # NetworkManager::ReceiveEggFileMessageID
|
||||
RELIABLE_FLAG = 1
|
||||
CONSOLE_HOST_ID = 1 # FirstLegalHostID
|
||||
|
||||
# The LAUNCH: pods load to WaitingForLaunch and sit until the console sends
|
||||
# Application::RunMissionMessage (the operator's launch button). The handler
|
||||
# ladder needs it twice: WaitingForLaunch->LaunchingMission (dispatches the
|
||||
# player MissionStarting/translocation), then LaunchingMission->RunningMission.
|
||||
# Sending it early (state==CreatingMission) hits the handler's Fail() -- hence
|
||||
# the settle delay. Constants from BT_NET_PROBE=1.
|
||||
APPLICATION_CLIENT_ID = 4 # NetworkClient::ApplicationClientID
|
||||
RUN_MISSION_MESSAGE_ID = 5 # Application::RunMissionMessageID
|
||||
LAUNCH_SETTLE_SECONDS = 20.0 # egg -> first launch (pods must reach WaitingForLaunch)
|
||||
LAUNCH_STEP_SECONDS = 4.0 # first launch -> second (Launching -> Running)
|
||||
|
||||
|
||||
def run_mission_packet():
|
||||
pkt = struct.pack(PKT_FMT_HDR, APPLICATION_CLIENT_ID, 0, CONSOLE_HOST_ID, 0)
|
||||
pkt += struct.pack("<IiI", 12, RUN_MISSION_MESSAGE_ID, RELIABLE_FLAG)
|
||||
assert len(pkt) == 16 + 12
|
||||
return pkt
|
||||
|
||||
|
||||
def egg_wire_bytes(egg_text_bytes):
|
||||
"""Convert raw egg TEXT to the wire form NotationFile::ReadText expects:
|
||||
NUL-separated lines (NOTATION.cpp:1043 walks `strchr(buffer,'\\0')+1` per
|
||||
line). The real 1996 console pre-processed the egg the same way; sending
|
||||
the raw file text makes the whole egg parse as one garbage line ("ERROR:
|
||||
no map in egg!")."""
|
||||
lines = egg_text_bytes.replace(b"\r\n", b"\n").split(b"\n")
|
||||
return b"".join(line + b"\0" for line in lines)
|
||||
|
||||
|
||||
def egg_packets(egg_bytes):
|
||||
total = len(egg_bytes)
|
||||
seq = 0
|
||||
off = 0
|
||||
while off < total:
|
||||
chunk = egg_bytes[off:off + CHUNK]
|
||||
pkt = struct.pack(PKT_FMT_HDR, 0, 0, CONSOLE_HOST_ID, 0)
|
||||
pkt += struct.pack(PKT_FMT_MSG, MESSAGE_LENGTH, EGG_MESSAGE_ID, RELIABLE_FLAG,
|
||||
seq, total, len(chunk))
|
||||
pkt += chunk.ljust(CHUNK, b"\0")
|
||||
assert len(pkt) == 16 + MESSAGE_LENGTH
|
||||
yield pkt
|
||||
off += len(chunk)
|
||||
seq += 1
|
||||
|
||||
|
||||
def serve_pod(hostport, egg_bytes):
|
||||
host, port = hostport.rsplit(":", 1)
|
||||
port = int(port)
|
||||
name = f"{host}:{port}"
|
||||
# The pod may not be listening yet -- retry like the pods do to each other.
|
||||
while True:
|
||||
try:
|
||||
s = socket.create_connection((host, port), timeout=5)
|
||||
break
|
||||
except OSError as e:
|
||||
print(f"[{name}] waiting for pod ({e})")
|
||||
time.sleep(1)
|
||||
print(f"[{name}] connected; streaming egg ({len(egg_bytes)} bytes)")
|
||||
n = 0
|
||||
for pkt in egg_packets(egg_bytes):
|
||||
s.sendall(pkt)
|
||||
n += 1
|
||||
print(f"[{name}] egg sent in {n} chunk(s); launch in {LAUNCH_SETTLE_SECONDS}s")
|
||||
s.settimeout(2.0)
|
||||
launch_at = time.time() + LAUNCH_SETTLE_SECONDS
|
||||
launches_sent = 0
|
||||
while True:
|
||||
if launches_sent < 2 and time.time() >= launch_at:
|
||||
s.sendall(run_mission_packet())
|
||||
launches_sent += 1
|
||||
launch_at = time.time() + LAUNCH_STEP_SECONDS
|
||||
print(f"[{name}] RunMission #{launches_sent} sent")
|
||||
try:
|
||||
data = s.recv(4096)
|
||||
if not data:
|
||||
print(f"[{name}] pod closed the console socket")
|
||||
return
|
||||
# Expect the AcknowledgeEggFile (and possibly other console traffic).
|
||||
if len(data) >= 24:
|
||||
(clid, gid, fh, ts) = struct.unpack_from("<iiii", data, 0)
|
||||
(mlen, mid) = struct.unpack_from("<Ii", data, 16)
|
||||
print(f"[{name}] pod->console packet: clientID={clid} fromHost={fh} "
|
||||
f"msgID={mid} len={mlen} ({len(data)} bytes)")
|
||||
else:
|
||||
print(f"[{name}] pod->console {len(data)} bytes")
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print(__doc__)
|
||||
return 2
|
||||
egg_bytes = egg_wire_bytes(open(sys.argv[1], "rb").read())
|
||||
pods = sys.argv[2:]
|
||||
threads = [threading.Thread(target=serve_pod, args=(p, egg_bytes), daemon=True)
|
||||
for p in pods]
|
||||
for t in threads:
|
||||
t.start()
|
||||
try:
|
||||
while any(t.is_alive() for t in threads):
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
print("console shutting down")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
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]))
|
||||
@@ -0,0 +1,71 @@
|
||||
import struct, sys, collections
|
||||
|
||||
RES = r"C:\git\nick-games\decomp\BTL4.RES"
|
||||
data = open(RES, "rb").read()
|
||||
|
||||
TYPE_NAMES = {
|
||||
0:"Null",1:"ModelList",2:"MapList",3:"AudioStreamList",4:"VideoList",5:"SubsystemList",
|
||||
6:"ControlMappingsList",7:"DamageZoneList",8:"SkeletonList",9:"BoxedSolidStream",
|
||||
10:"VideoModel",11:"StaticAudioStream",12:"InternalAudioStream",13:"ExternalAudioStream",
|
||||
14:"MakeMessageStream",15:"GameModel",16:"Animation",17:"SubsystemModelStream",
|
||||
18:"GaugeImageStream",19:"ControlMappingStream",20:"DamageZoneStream",21:"SkeletonStream",
|
||||
22:"DropZone",23:"EnvironmentZone",24:"InterestZone",25:"VehicleTable",26:"ExistanceBoxStream",
|
||||
27:"CameraStream",28:"RegistryStaticObjectStream",29:"DamageLookupTableStream",
|
||||
30:"ExplosionTableStream",31:"GaugeAlarmStream",32:"GaugeMissionReviewStream",33:"ScenarioRole"}
|
||||
|
||||
ver = data[0:4]
|
||||
labOnly, maxID = struct.unpack_from("<ii", data, 4)
|
||||
print("version bytes:", list(ver), "labOnly:", labOnly, "maxResourceID:", maxID)
|
||||
off = 12
|
||||
offsets = struct.unpack_from("<%dI" % maxID, data, off)
|
||||
|
||||
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)
|
||||
|
||||
bytype = collections.Counter(r.rtype for r in resources.values())
|
||||
print("\n== resource count per type ==")
|
||||
for t, c in sorted(bytype.items()):
|
||||
print("type %2d %-28s : %d" % (t, TYPE_NAMES.get(t, "?"), c))
|
||||
|
||||
solids = [r for r in resources.values() if r.rtype == 9]
|
||||
print("\n== BoxedSolidStream resources (%d) ==" % len(solids))
|
||||
for r in sorted(solids, key=lambda x: x.name):
|
||||
print(" id=%4d name=%-14s bytes=%6d records=%s" % (r.rid, r.name, r.length, r.length//60))
|
||||
|
||||
maps14 = [r for r in resources.values() if r.rtype == 14]
|
||||
print("\n== MakeMessageStream resources (%d) ==" % len(maps14))
|
||||
for r in sorted(maps14, key=lambda x: x.name):
|
||||
print(" id=%4d name=%-14s bytes=%d" % (r.rid, r.name, r.length))
|
||||
|
||||
exist = [r for r in resources.values() if r.rtype == 26]
|
||||
print("\n== ExistanceBoxStream (%d) ==" % len(exist))
|
||||
for r in sorted(exist, key=lambda x: x.name):
|
||||
print(" id=%4d name=%-14s bytes=%d boxes=%s" % (r.rid, r.name, r.length, r.length//24))
|
||||
|
||||
# Model lists: type 1
|
||||
mlists = {r.rid: r for r in resources.values() if r.rtype == 1}
|
||||
def list_members(r):
|
||||
cnt = struct.unpack_from("<i", data, r.off)[0]
|
||||
ids = struct.unpack_from("<%di" % cnt, data, r.off+4)
|
||||
return ids
|
||||
|
||||
print("\n== ModelList count:", len(mlists))
|
||||
# which model lists contain a BoxedSolidStream member?
|
||||
with_solid = []
|
||||
without_solid = []
|
||||
for rid, r in mlists.items():
|
||||
try:
|
||||
ids = list_members(r)
|
||||
except Exception as e:
|
||||
print(" list decode fail", r.name, e); continue
|
||||
types = [resources[i].rtype if i in resources else None for i in ids]
|
||||
if 9 in types: with_solid.append(r.name)
|
||||
else: without_solid.append(r.name)
|
||||
print("model lists WITH solid member: %d" % len(with_solid))
|
||||
print("model lists WITHOUT solid member: %d" % len(without_solid))
|
||||
Reference in New Issue
Block a user