Phase 3f: lighting from wire normals; defuse shipped-in debug crash

Lighting: stride-8/9 vertices carry a normal (floats 3-5, uv at 6-7). The
backend transforms normals by the instance rotation and lights with a single
directional sun (GL_LIGHT0, smooth, two-sided, GL_COLOR_MATERIAL). Terrain
and hulls shade instead of reading flat.

LOD: the lod flush has no switch distances -- the host keeps the active LOD
at the child-list head and re-orders over the wire, so children[0] is right
and LOD changes come through as the sim runs.

The crash that halted the game once the RIO drove the sim was not ours: fault
at 0047E1D1 writing 0xFFFFFFFF is the RIO driver's DISABLE_AND_DIE debug
macro (PCSPAK.ASM, DIE_ON_ERROR=1), which deliberately faults on a serial-tx
anomaly -- error 3 = body char >0x7F during a RIO packet, i.e. a glitch on a
board reset. Release build (DIE_ON_ERROR=0) compiles it out and recovers
(and al,07Fh). patch_btl4opt.py NOPs all 12 sites by signature (with .orig
backup) = release behavior; game then survives RIO resets (1200+ frames).
(ALPHA_1 is git-ignored, so the tool ships, not the patched binary.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-03 17:13:46 -05:00
co-authored by Claude Opus 4.8
parent 0bb493dd5f
commit 08f71eb471
5 changed files with 139 additions and 8 deletions
+1
View File
@@ -10,6 +10,7 @@ image/
!game-mech-decoded.png
!game-live-gl.png
!game-live-textured.png
!game-live-lit.png
dbx_out.txt
vpx*.txt
sweep_*.txt
+23 -2
View File
@@ -175,8 +175,29 @@ uploads it to GL keyed by material, and maps it with the wire UVs (stride-5
verts: floats 34; stride-8/9: floats 67). Result: the arena renders with
the ravine's actual brown dirt textures, live (`game-live-textured.png`).
Still to come: lighting from the wire normals, per-frame articulation once
the RIO holds sync, LOD selection by distance.
### 3f. Lighting + the shipped-in debug crash
Lighting: stride-8/9 vertices carry a **normal** at floats 35 (uv at 67).
The backend transforms them by the instance world matrix (rotation only) and
lights with a single directional "sun" (`GL_LIGHT0`, smooth shading,
two-sided, `GL_COLOR_MATERIAL` so texture/material color survives). Terrain
and hulls now shade instead of reading flat.
LOD note: the `lod` flush carries **no switch distances** — the host game
keeps the active LOD at the head of the object's child list and re-orders it
over the wire, so `children[0]` is correct and LOD changes as the sim runs
come through for free.
**The crash that halted the game with the RIO in sync was not ours.** Fault
`Exception 0E at 0047E1D1` writing to `0xFFFFFFFF` is a debug landmine the
build shipped with: the RIO serial driver's `DISABLE_AND_DIE` macro
(`PCSPAK.ASM`, gated by `DIE_ON_ERROR equ 1`) deliberately faults on any
serial-transmit anomaly. Error code 3 = a body char > 0x7F during a RIO
packet (`PCSPAK.ASM:1630`) — i.e. a serial glitch during a board reset. The
release build (`DIE_ON_ERROR equ 0`) compiles it out and takes the recovery
path (`and al,07Fh`). `patch_btl4opt.py` finds all 12 sites by their 14-byte
signature and NOPs them — exactly the release behavior — with a `.orig`
backup. After patching, the game survives RIO resets (1200+ frames).
## 3c. The full game runs through the live renderer (sync abort fixed)
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Neutralize the shipped-in debug crash macros in BTL4OPT.EXE.
The Rel 4.10 BattleTech binary was built with the RIO serial driver's
`DIE_ON_ERROR equ 1` diagnostic enabled (CODE/RP/MUNGA_L4/PCSPAK.ASM). Its
`DISABLE_AND_DIE` macro deliberately faults ("crash loudly to the debugger")
on any serial-transmit anomaly:
push eax; push edx
mov edx, 0FFFFFFFFh ; crash loudly
mov eax, <error-code>
mov [edx], eax ; write to 0xFFFFFFFF -> Exception 0E
On the real pod a clean cockpit harness never tripped these; through the
DOSBox-X serial passthrough a RIO glitch (e.g. during a board reset) hits
error 3 (PCSPAK.ASM:1630, "body char > 0x7F") and halts the whole game. The
release build (`DIE_ON_ERROR equ 0`) compiles the macro to nothing and lets
the normal recovery path (`and al,07Fh`) continue.
This tool finds every DISABLE_AND_DIE site by its exact 14-byte signature and
replaces it with NOPs -- exactly the release-build behavior -- writing a
<name>.orig backup first. Idempotent.
Usage: patch_btl4opt.py <path-to-BTL4OPT.EXE>
"""
import re
import sys
SIG = re.compile(
rb'\x50\x52\xBA\xFF\xFF\xFF\xFF\xB8(.)\x00\x00\x00\x89\x02', re.DOTALL)
def main():
if len(sys.argv) != 2:
raise SystemExit(__doc__)
path = sys.argv[1]
data = bytearray(open(path, 'rb').read())
sites = list(SIG.finditer(bytes(data)))
if not sites:
print("no DISABLE_AND_DIE sites found (already patched?)")
return
import os
bak = path + '.orig'
if not os.path.exists(bak):
open(bak, 'wb').write(data)
print(f"backup written: {bak}")
for m in sites:
code = m.group(1)[0]
va = 0x401000 + m.start() - 0xe00 # CODE VA 0x401000 @ file 0xe00
print(f" NOP VA 0x{va:06x} (error code {code})")
data[m.start():m.end()] = b'\x90' * (m.end() - m.start())
open(path, 'wb').write(data)
print(f"patched {len(sites)} sites")
if __name__ == "__main__":
main()
+58 -6
View File
@@ -439,6 +439,7 @@ struct VPoly {
float rgb[3];
std::vector<float> xyz; /* x,y,z triples */
std::vector<float> uv; /* u,v pairs (empty = untextured) */
std::vector<float> nrm; /* nx,ny,nz triples (empty = unlit) */
unsigned matkey; /* material name for texture lookup, 0 = none */
VPoly() : matkey(0) {}
};
@@ -472,6 +473,7 @@ static struct VScene {
std::map<unsigned, unsigned> type; /* name -> node type */
std::map<unsigned, std::vector<float> > verts; /* geometry -> xyz */
std::map<unsigned, std::vector<float> > uvs; /* geometry -> u,v */
std::map<unsigned, std::vector<float> > nrms; /* geometry -> nx,ny,nz */
std::map<unsigned, std::vector<std::vector<int> > > polys;
std::map<unsigned, VCol> mat; /* material -> RGB */
std::map<unsigned, unsigned> ggmat; /* geogroup -> material */
@@ -512,6 +514,10 @@ static void m16_xform(const M16 &w, const float *v, float *o) {
for (int c = 0; c < 3; c++)
o[c] = v[0] * w.m[c] + v[1] * w.m[4 + c] + v[2] * w.m[8 + c] + w.m[12 + c];
}
static void m16_xform_dir(const M16 &w, const float *v, float *o) {
for (int c = 0; c < 3; c++) /* rotation only (normals) */
o[c] = v[0] * w.m[c] + v[1] * w.m[4 + c] + v[2] * w.m[8 + c];
}
/* world transform of a dcs: local * parent_world (row-vector convention) */
static void dcs_world(unsigned dcs, std::map<unsigned, M16> &cache, M16 &out,
int depth = 0) {
@@ -567,8 +573,22 @@ static void rt_draw(HDC dc, const VFrame &f, int cw, int ch) {
glTranslatef(-f.eye[0], -f.eye[1], -f.eye[2]);
glEnable(GL_DEPTH_TEST);
glDisable(GL_CULL_FACE);
glDisable(GL_LIGHTING);
glShadeModel(GL_FLAT);
glShadeModel(GL_SMOOTH);
/* directional sun (world coords; modelview is loaded, so GL maps it
* into eye space). World is y-down: up = -y. */
{
GLfloat lpos[4] = { 0.35f, -0.85f, 0.40f, 0.0f };
GLfloat lamb[4] = { 0.45f, 0.45f, 0.45f, 1.0f };
GLfloat ldif[4] = { 0.80f, 0.80f, 0.78f, 1.0f };
glLightfv(GL_LIGHT0, GL_POSITION, lpos);
glLightfv(GL_LIGHT0, GL_AMBIENT, lamb);
glLightfv(GL_LIGHT0, GL_DIFFUSE, ldif);
glEnable(GL_LIGHT0);
glLightModeli(GL_LIGHT_MODEL_TWO_SIDE, 1);
glEnable(GL_COLOR_MATERIAL);
glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE);
glEnable(GL_NORMALIZE);
}
/* upload any new/changed baked material textures */
static std::map<unsigned, GLuint> gltex; /* matkey -> GL name */
static std::map<unsigned, unsigned> gltex_ver;
@@ -610,14 +630,18 @@ static void rt_draw(HDC dc, const VFrame &f, int cw, int ch) {
glDisable(GL_TEXTURE_2D);
glColor3f(p.rgb[0], p.rgb[1], p.rgb[2]);
}
bool lit = !p.nrm.empty();
if (lit) glEnable(GL_LIGHTING); else glDisable(GL_LIGHTING);
glBegin(GL_POLYGON);
for (size_t v = 0; v + 2 < p.xyz.size(); v += 3) {
if (tex) glTexCoord2f(p.uv[v / 3 * 2], p.uv[v / 3 * 2 + 1]);
if (lit) glNormal3f(p.nrm[v], p.nrm[v + 1], p.nrm[v + 2]);
glVertex3f(p.xyz[v], p.xyz[v + 1], p.xyz[v + 2]);
}
glEnd();
}
glDisable(GL_TEXTURE_2D);
glDisable(GL_LIGHTING);
}
SwapBuffers(dc);
}
@@ -740,10 +764,18 @@ static void emit_geogroup(VFrame &f, unsigned gg, const M16 *world) {
if (vi == S.verts.end() || pi == S.polys.end()) continue;
const std::vector<float> &vv = vi->second;
const std::vector<float> *uv = NULL;
const std::vector<float> *nr = NULL;
std::map<unsigned, std::vector<float> >::const_iterator ui =
S.uvs.find(geo);
if (ui != S.uvs.end() && ui->second.size() * 3 == vv.size() * 2)
uv = &ui->second;
std::map<unsigned, std::vector<float> >::const_iterator ni =
S.nrms.find(geo);
if (ni != S.nrms.end() && ni->second.size() == vv.size()) {
/* only meaningful if not all-zero (stride < 8 stores zeros) */
for (size_t z = 0; z < ni->second.size(); z++)
if (ni->second[z] != 0.0f) { nr = &ni->second; break; }
}
for (size_t q = 0; q < pi->second.size(); q++) {
const std::vector<int> &idx = pi->second[q];
VPoly poly;
@@ -762,7 +794,17 @@ static void emit_geogroup(VFrame &f, unsigned gg, const M16 *world) {
poly.uv.push_back((*uv)[(size_t)idx[j] * 2]);
poly.uv.push_back((*uv)[(size_t)idx[j] * 2 + 1]);
}
if (nr) {
float nout[3];
if (world) m16_xform_dir(*world, &(*nr)[o], nout);
else { nout[0] = (*nr)[o]; nout[1] = (*nr)[o + 1];
nout[2] = (*nr)[o + 2]; }
poly.nrm.push_back(nout[0]);
poly.nrm.push_back(nout[1]);
poly.nrm.push_back(nout[2]);
}
}
if (poly.nrm.size() != poly.xyz.size()) poly.nrm.clear();
if (poly.xyz.size() >= 9) f.polys.push_back(poly);
}
}
@@ -790,7 +832,10 @@ static void scene_publish_frame(void) {
std::map<unsigned, std::vector<unsigned> >::const_iterator li =
S.children.find(oi->second);
if (li == S.children.end() || li->second.empty()) continue;
unsigned lod = li->second[0]; /* highest LOD */
/* First LOD child: the host maintains the active LOD at the list
* head (the wire lod flush carries no switch distances -- LOD
* selection is host-side). */
unsigned lod = li->second[0];
std::map<unsigned, std::vector<unsigned> >::const_iterator ggi =
S.children.find(lod);
if (ggi == S.children.end()) continue;
@@ -824,11 +869,13 @@ static void scene_burst(const unsigned char *p, size_t n) {
if (S.geom_acc.size() >= S.geom_need * S.geom_stride) {
std::vector<float> &vl = S.verts[S.geom_node];
std::vector<float> &tl = S.uvs[S.geom_node];
vl.clear(); tl.clear();
/* uv offset within a vertex record by stride: 5 = xyz+uv,
* 8/9 = xyz+normal+uv(+extra) */
std::vector<float> &nl = S.nrms[S.geom_node];
vl.clear(); tl.clear(); nl.clear();
/* record layout by stride: 5 = xyz+uv,
* 8/9 = xyz + normal(3..5) + uv(6..7) (+extra) */
size_t uvo = (S.geom_stride >= 8) ? 6 :
(S.geom_stride == 5) ? 3 : 0;
bool has_n = S.geom_stride >= 8;
for (size_t i = 0; i + 2 < S.geom_need * S.geom_stride;
i += S.geom_stride) {
vl.push_back(S.geom_acc[i]);
@@ -838,6 +885,11 @@ static void scene_burst(const unsigned char *p, size_t n) {
tl.push_back(S.geom_acc[i + uvo]);
tl.push_back(S.geom_acc[i + uvo + 1]);
} else { tl.push_back(0); tl.push_back(0); }
if (has_n && i + 5 < S.geom_acc.size()) {
nl.push_back(S.geom_acc[i + 3]);
nl.push_back(S.geom_acc[i + 4]);
nl.push_back(S.geom_acc[i + 5]);
} else { nl.push_back(0); nl.push_back(0); nl.push_back(0); }
}
S.geom_acc.clear();
S.geom_active = false;