diff --git a/emulator/.gitignore b/emulator/.gitignore index 0fb37fe..f4f642d 100644 --- a/emulator/.gitignore +++ b/emulator/.gitignore @@ -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 diff --git a/emulator/PHASE3-PROGRESS.md b/emulator/PHASE3-PROGRESS.md index ffdcad5..769dcf1 100644 --- a/emulator/PHASE3-PROGRESS.md +++ b/emulator/PHASE3-PROGRESS.md @@ -175,8 +175,29 @@ uploads it to GL keyed by material, and maps it with the wire UVs (stride-5 verts: floats 3–4; stride-8/9: floats 6–7). 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 3–5 (uv at 6–7). +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) diff --git a/emulator/game-live-lit.png b/emulator/game-live-lit.png new file mode 100644 index 0000000..eab2c51 Binary files /dev/null and b/emulator/game-live-lit.png differ diff --git a/emulator/patch_btl4opt.py b/emulator/patch_btl4opt.py new file mode 100644 index 0000000..9bf6538 --- /dev/null +++ b/emulator/patch_btl4opt.py @@ -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, + 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 +.orig backup first. Idempotent. + +Usage: patch_btl4opt.py +""" +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() diff --git a/emulator/vpx-device/vpxlog.cpp b/emulator/vpx-device/vpxlog.cpp index dfcd404..04a00da 100644 --- a/emulator/vpx-device/vpxlog.cpp +++ b/emulator/vpx-device/vpxlog.cpp @@ -439,6 +439,7 @@ struct VPoly { float rgb[3]; std::vector xyz; /* x,y,z triples */ std::vector uv; /* u,v pairs (empty = untextured) */ + std::vector 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 type; /* name -> node type */ std::map > verts; /* geometry -> xyz */ std::map > uvs; /* geometry -> u,v */ + std::map > nrms; /* geometry -> nx,ny,nz */ std::map > > polys; std::map mat; /* material -> RGB */ std::map 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 &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 gltex; /* matkey -> GL name */ static std::map 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 &vv = vi->second; const std::vector *uv = NULL; + const std::vector *nr = NULL; std::map >::const_iterator ui = S.uvs.find(geo); if (ui != S.uvs.end() && ui->second.size() * 3 == vv.size() * 2) uv = &ui->second; + std::map >::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 &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 >::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 >::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 &vl = S.verts[S.geom_node]; std::vector &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 &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;