Files
BT412/engine/MUNGA_L4/bgfload.cpp
T
arcattackandClaude Fable 5 8057c37e5b Cockpit: punch patches ARE the windows -- all 8 mechs authentic (task #55)
Resolved the non-Madcat blocked views. Rendering every *_COP shell offline
from its mech's AUTHORED eye (SKL [jointeye] translation -- incl. the Thor's
+1.13 offset cockpit) with faces coloured by punch flag showed the answer
plainly: the PUNCH-tagged patches sit exactly over the window apertures in
all 8 mechs. On these texture-less black-diffuse shells dpl_Punchize renders
the tagged geogroup as holes -- the punch patches are the transparent
WINDSHIELDS, and the visible frame is only the non-punch geometry.

The loader now drops the punch batches of _cop meshes (bgfload finish();
BT_COP_KEEPPUNCH=1 keeps them for diagnosis). In-game sweep on pure defaults:
- madcat: the dark dome frame (matches gameplay footage)
- thor / owens / sunder: authentic brace/dash/pillar remnants
- bhk1 / loki / vulture / avatar: authentically FRAMELESS (all-punch
  canopies -- the pod showed no frame for those mechs)

(An early-session test that "disproved" drop-punch ran with the broken
pre-inverse eye; its conclusion was wrong and is corrected in the KB.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 07:49:33 -05:00

1216 lines
65 KiB
C++

// bgfload.cpp - see bgfload.h. Parse logic ported from port/src/bgf.cpp; directory
// scanning reimplemented on Win32 (FindFirstFile) so it builds under C++14.
#include "bgfload.h"
#include <windows.h>
#include <cstdio>
#include <cstring>
#include <cctype>
#include <cmath>
#include <map>
#include <array>
namespace {
// ---- DIV-BIZ2 tag ids (PFBIZTAG.H), masked form (tagword & 0x2fff) ----
enum : uint16_t {
TAG_HEADER = 0x0003,
TAG_BIZ_DONE = 0x0005,
TAG_OBJECT = 0x0040,
TAG_LOD = 0x0041,
TAG_PATCH = 0x0042,
TAG_PMESH = 0x0046,
TAG_CONN_LIST = 0x0047,
TAG_SPHERE_LIST = 0x0048,
TAG_PCONN_LIST = 0x004d,
TAG_BOUND = 0x0070,
TAG_VERTEX_XYZ = 0x0080,
TAG_VERTEX_XYZ_N = 0x0081,
TAG_VERTEX_XYZ_RGBA = 0x0082,
TAG_VERTEX_XYZ_UV = 0x0088,
TAG_VERTEX_XYZ_N_UV = 0x0089,
TAG_VERTEX_XYZ_RGBA_UV= 0x008A,
TAG_SV_F_MATERIAL = 0x2030,
TAG_SV_SPECIAL = 0x2037, // dpfB_SV_SPECIAL_TAG -- OBJECT-level carries "ADDITIVE_LODS"
TAG_TEXTURE = 0x0010,
TAG_TEXTURE_MAP = 0x0011,
TAG_BITSLICE = 0x0018, // dpfB_TEXTURE_BITSLICE_TAG: 1 byte = which BSL
// slice this texture samples (dpiBSLTYPE: 0..5
// mono, 7 RGB444, 8 RGBA4444); absent = slice 0
TAG_MATERIAL = 0x0020,
TAG_MATERIAL_TEXTURE = 0x0021,
TAG_AMBIENT = 0x0023,
TAG_DIFFUSE = 0x0024,
TAG_EMISSIVE = 0x0026, // self-lit colour (e.g. btpolar ice-glow materials)
TAG_RAMP_REF = 0x0028, // a material's reference to a ramp by name
TAG_RAMP = 0x0030, // a ramp DEFINITION container (name + values)
TAG_RAMP_DATA = 0x0031, // 6 floats: low RGB, high RGB
TAG_NAME = 0x2008,
};
uint16_t rdU16(const uint8_t* p) { uint16_t v; std::memcpy(&v, p, 2); return v; }
uint32_t rdU32(const uint8_t* p) { uint32_t v; std::memcpy(&v, p, 4); return v; }
float rdF32(const uint8_t* p) { float v; std::memcpy(&v, p, 4); return v; }
std::string toLower(std::string s) {
for (char& c : s) c = (char)std::tolower((unsigned char)c);
return s;
}
uint32_t packColor(float r, float g, float b) {
auto cv = [](float f) {
int v = (int)(f * 255.0f + 0.5f);
return (uint32_t)(v < 0 ? 0 : v > 255 ? 255 : v);
};
return 0xFF000000u | (cv(r) << 16) | (cv(g) << 8) | cv(b);
}
uint32_t colorForMaterial(const std::string& name) {
if (name.empty()) return 0xFFB0B0B8u;
uint32_t h = 2166136261u;
for (char c : name) { h ^= (uint8_t)c; h *= 16777619u; }
uint8_t r = 110 + (h & 0x7F);
uint8_t g = 110 + ((h >> 8) & 0x7F);
uint8_t b = 110 + ((h >> 16) & 0x7F);
return 0xFF000000u | (r << 16) | (g << 8) | b;
}
std::vector<uint8_t> readFileBytes(const std::string& path) {
std::vector<uint8_t> buf;
FILE* f = std::fopen(path.c_str(), "rb");
if (!f) return buf;
std::fseek(f, 0, SEEK_END);
long sz = std::ftell(f);
std::fseek(f, 0, SEEK_SET);
if (sz > 0) {
buf.resize((size_t)sz);
if (std::fread(buf.data(), 1, buf.size(), f) != buf.size()) buf.clear();
}
std::fclose(f);
return buf;
}
std::string stemLower(const std::string& path) {
size_t slash = path.find_last_of("\\/");
std::string base = (slash == std::string::npos) ? path : path.substr(slash + 1);
size_t dot = base.find_last_of('.');
if (dot != std::string::npos) base = base.substr(0, dot);
return toLower(base);
}
std::string extLower(const std::string& path) {
size_t dot = path.find_last_of('.');
std::string e = (dot == std::string::npos) ? "" : path.substr(dot);
return toLower(e);
}
// Recursively index every regular file under `root`, stem(lower) -> full path, for
// ---------------------------------------------------------------------------
// DAY/NIGHT PATH PRIORITY (task #20): the DPL environment (BTDPL.INI) declares
// per-map, per-time-of-day search dirs (objectpath=.\video\geo\day, etc.). The
// same stem exists under multiple time dirs (DSKY.BGF in geo\day|night|..., a
// material lib btfx.bmf in mat\day|night|...). Instead of first-match-wins
// (which only happened to pick DAY because it sorts first), rank every candidate
// by the priority list the renderer set from the INI and keep the BEST per stem.
// Priority list is MOST-SPECIFIC-FIRST (index 0 = highest); a file under no
// listed dir gets the lowest rank (still found, as a fallback).
// ---------------------------------------------------------------------------
static std::map<std::string, std::map<std::string, std::string>> g_indexCache;
static std::map<std::string, std::vector<std::string>> g_pathPriority; // cacheKey -> normalized dirs
static std::string normPath(const std::string& p) {
std::string s = toLower(p);
for (char& c : s) if (c == '/') c = '\\';
// strip a leading ".\" so ".\video\geo\day" == a scanned "video\geo\day"
if (s.size() >= 2 && s[0] == '.' && s[1] == '\\') s = s.substr(2);
// strip a trailing slash
while (!s.empty() && s.back() == '\\') s.pop_back();
return s;
}
// Rank of `full`'s directory against the priority list; list.size() = no match.
static int pathRank(const std::string& full, const std::vector<std::string>& list) {
if (list.empty()) return 0;
size_t slash = full.find_last_of('\\');
std::string dir = normPath(slash == std::string::npos ? std::string() : full.substr(0, slash));
for (size_t i = 0; i < list.size(); ++i) {
const std::string& pre = list[i]; // already normalized
if (dir == pre ||
(dir.size() > pre.size() && dir.compare(0, pre.size(), pre) == 0 && dir[pre.size()] == '\\'))
return (int)i;
}
return (int)list.size();
}
// Recursively index every file under `root`, keeping the highest-priority path
// per stem (per the priority list for cacheKey).
void scanDir(const std::string& root, std::map<std::string, std::string>& idx,
std::map<std::string, int>& rankOf,
const std::vector<std::string>& exts,
const std::vector<std::string>& priority) {
std::string pattern = root + "\\*";
WIN32_FIND_DATAA fd;
HANDLE h = FindFirstFileA(pattern.c_str(), &fd);
if (h == INVALID_HANDLE_VALUE) return;
do {
if (fd.cFileName[0] == '.' &&
(fd.cFileName[1] == 0 || (fd.cFileName[1] == '.' && fd.cFileName[2] == 0)))
continue;
std::string full = root + "\\" + fd.cFileName;
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
scanDir(full, idx, rankOf, exts, priority);
} else {
std::string e = extLower(fd.cFileName);
for (const std::string& want : exts) {
if (e == want) {
std::string stem = stemLower(fd.cFileName);
int r = pathRank(full, priority);
auto it = rankOf.find(stem);
if (it == rankOf.end() || r < it->second) { // strictly better rank wins
idx[stem] = full;
rankOf[stem] = r;
}
break;
}
}
}
} while (FindNextFileA(h, &fd));
FindClose(h);
}
const std::map<std::string, std::string>& indexFor(const std::string& root,
const std::vector<std::string>& exts,
const std::string& cacheKey) {
auto it = g_indexCache.find(cacheKey);
if (it != g_indexCache.end()) return it->second;
std::map<std::string, std::string> idx;
std::map<std::string, int> rankOf;
scanDir(root, idx, rankOf, exts, g_pathPriority[cacheKey]);
return g_indexCache.emplace(cacheKey, std::move(idx)).first->second;
}
const char* VIDEO_ROOT = "VIDEO";
const std::map<std::string, std::string>& bgfIndex() {
return indexFor(VIDEO_ROOT, {".bgf"}, "bgf");
}
const std::map<std::string, std::string>& bmfIndex() {
return indexFor(VIDEO_ROOT, {".bmf"}, "bmf");
}
const std::map<std::string, std::string>& imageIndex() {
return indexFor(VIDEO_ROOT, {".vtx", ".bsl", ".tga", ".sgi"}, "img");
}
int uvOffset(uint16_t id) {
switch (id) {
case TAG_VERTEX_XYZ_UV: return 12;
case TAG_VERTEX_XYZ_N_UV: return 24;
case TAG_VERTEX_XYZ_RGBA_UV: return 28;
default: return -1;
}
}
int vertexStride(uint16_t id) {
switch (id) {
case TAG_VERTEX_XYZ: return 12;
case TAG_VERTEX_XYZ_N: return 24;
case TAG_VERTEX_XYZ_RGBA: return 28;
case TAG_VERTEX_XYZ_UV: return 20;
case TAG_VERTEX_XYZ_N_UV: return 32;
case TAG_VERTEX_XYZ_RGBA_UV: return 36;
default: return 0;
}
}
// The authentic IG-board shading model (DPLTYPES.H) selects shading PER-GEOMETRY by
// vertex type: NORMAL-bearing geometry (vehicles/missiles, XYZ_N*) was LIT by the map
// light and shows the material DIFFUSE colour (x its texture); NO-normal geometry
// (terrain/mesas/sky/buildings/mech, XYZ_UV/XYZ_RGBA_UV) was drawn UNLIT and colourised
// by the material RAMP. So the RAMP must NEVER apply to normal-bearing geometry -- doing
// so (task #20 over-applied it whenever the material merely carried a ramp ref) washes the
// vehicles' distinct diffuse colours (gray/green/tan/red/yellow) to a uniform ramp-white.
bool hasNormals(uint16_t id) {
return id == TAG_VERTEX_XYZ_N || id == TAG_VERTEX_XYZ_N_UV;
}
struct Chunk {
uint16_t id = 0;
const uint8_t* data = nullptr;
size_t len = 0;
std::vector<Chunk> children;
};
bool isContainer(uint16_t id) {
switch (id) {
case TAG_HEADER: case TAG_BOUND: case TAG_OBJECT:
case TAG_LOD: case TAG_PATCH: case TAG_PMESH: case TAG_SPHERE_LIST:
case TAG_TEXTURE: case TAG_MATERIAL: case TAG_RAMP:
return true;
default:
return false;
}
}
bool parseRange(const uint8_t* p, const uint8_t* end, std::vector<Chunk>& out) {
while (p + 3 <= end) {
uint16_t tagword = rdU16(p);
p += 2;
uint16_t id = tagword & 0x2fff;
int lw = (tagword & 0x8000) ? 4 : (tagword & 0x4000) ? 2 : 1;
if (p + lw > end) return false;
size_t len = (lw == 1) ? *p : (lw == 2) ? rdU16(p) : rdU32(p);
p += lw;
if (p + len > end) return false;
Chunk c;
c.id = id; c.data = p; c.len = len;
if (isContainer(id)) {
if (!parseRange(p, p + len, c.children)) return false;
}
out.push_back(std::move(c));
p += len;
if (id == TAG_BIZ_DONE) break;
}
return true;
}
std::string chunkStr(const Chunk& ch, size_t off = 0) {
if (off >= ch.len) return "";
const char* s = (const char*)ch.data + off;
size_t maxn = ch.len - off, n = 0;
while (n < maxn && s[n]) ++n;
return std::string(s, n);
}
struct MatInfo {
uint32_t color = 0xFFB0B0B8u;
bool hasDiffuse = false; // material carried an explicit DIFFUSE/AMBIENT tag
std::string texPath;
int texChannel = 0; // BSL bit-slice (BMF TEXTURE tag 0x18; absent = 0)
// RAMP (task #20): the material's 2-endpoint colour ramp (dpl_SetMaterialRamp).
// The grayscale texture's luminance indexes a low->high colour gradient -- this
// is how the IG board coloured terrain (rock 0.25,0.21,0.16->0.8,0.5,0.4=warm
// tan; grass 0.13,0.13,0.07->0.38,0.33,0.23). Materials with NO diffuse (e.g.
// grass_mtl) relied ENTIRELY on the ramp -- without it they fell back to the
// gray placeholder (the "pale ground that doesn't match the mountains").
bool hasRamp = false;
float rampLo[3] = {0,0,0};
float rampHi[3] = {1,1,1};
// EMISSIVE (tag 0x26): pure-emissive materials (diffuse black) render as an
// unlit glow -- tex x emissive (btpolar:pintBIceEmit_mtl, the polar ice).
bool hasEmissive = false;
float emissive[3] = {0,0,0};
};
// A texture record: the image file basename it maps to + which BSL bit-slice
// it samples (tag 0x18; absent = 0 -- e.g. blkhwk1_tex has no tag = slice 0,
// blkhwk2/3/4_tex carry 01/02/03).
struct TexRef {
std::string map;
int channel = 0;
};
struct MaterialResolver {
std::map<std::string, std::map<std::string, MatInfo>> libs;
std::map<std::string, MatInfo> resolvedCache;
int uniqueTotal = 0, uniqueResolved = 0;
static void collectTextureMaps(const std::vector<Chunk>& cs,
std::map<std::string, TexRef>& out) {
for (const Chunk& c : cs) {
if (c.id == TAG_TEXTURE) {
std::string name;
TexRef ref;
for (const Chunk& ch : c.children) {
if (ch.id == TAG_NAME) name = chunkStr(ch);
else if (ch.id == TAG_TEXTURE_MAP) ref.map = chunkStr(ch);
else if (ch.id == TAG_BITSLICE && ch.len >= 1) ref.channel = ch.data[0];
}
if (!name.empty() && !ref.map.empty()) out[name] = ref;
}
if (!c.children.empty()) collectTextureMaps(c.children, out);
}
}
// Collect all ramp DEFINITIONS (name -> low/high RGB) from the BMF tree.
static void collectRamps(const std::vector<Chunk>& cs,
std::map<std::string, std::array<float,6> >& out) {
for (const Chunk& c : cs) {
if (c.id == TAG_RAMP) {
std::string name;
std::array<float,6> vals = {0,0,0,1,1,1};
bool haveVals = false;
for (const Chunk& ch : c.children) {
if (ch.id == TAG_NAME) name = chunkStr(ch);
else if (ch.id == TAG_RAMP_DATA && ch.len >= 24) {
for (int i = 0; i < 6; ++i) vals[i] = rdF32(ch.data + i*4);
haveVals = true;
}
}
if (!name.empty() && haveVals) out[name] = vals;
}
if (!c.children.empty()) collectRamps(c.children, out);
}
}
// CROSS-LIBRARY RAMP REGISTRY: material libs reference ramps defined in
// OTHER libs (every BLHSKIN.BMF mech-skin material references 'softer',
// which is defined only in BTARENA/BTFX/BTPOLAR/BTVEH...). The original
// engine kept a global name cache (dpl_LookupRamp) accumulated across all
// loaded libraries; a per-file lookup leaves every mech-skin batch
// un-ramped. On the first cross-file miss, sweep the indexed BMFs once
// and cache every ramp definition (first stem in day/night-priority order
// wins -- ramp-name collisions like 'cdusty' differ only in day variants,
// and same-file definitions are preferred before this registry is hit).
// BT_RAMP_XLIB=0 disables (restores same-file-only resolution).
static const std::map<std::string, std::array<float,6> >& globalRamps() {
static std::map<std::string, std::array<float,6> > reg;
static bool built = false;
if (!built) {
built = true;
const char* v = getenv("BT_RAMP_XLIB");
if (v == nullptr || v[0] != '0') {
for (const auto& kv : bmfIndex()) {
std::vector<uint8_t> buf = readFileBytes(kv.second);
if (buf.size() <= 8 || std::memcmp(buf.data(), "DIV-BIZ2", 8) != 0) continue;
std::vector<Chunk> roots;
if (!parseRange(buf.data() + 8, buf.data() + buf.size(), roots)) continue;
std::map<std::string, std::array<float,6> > ramps;
collectRamps(roots, ramps);
for (const auto& r : ramps) reg.insert(r); // first wins
}
}
}
return reg;
}
static void collectMaterials(const std::vector<Chunk>& cs,
const std::map<std::string, TexRef>& texMaps,
const std::map<std::string, std::array<float,6> >& ramps,
std::map<std::string, MatInfo>& out) {
for (const Chunk& c : cs) {
if (c.id == TAG_MATERIAL) {
std::string name, texName, rampName;
MatInfo info;
bool haveColor = false;
for (const Chunk& ch : c.children) {
if (ch.id == TAG_NAME) name = chunkStr(ch);
else if (ch.id == TAG_MATERIAL_TEXTURE && ch.len > 1) texName = chunkStr(ch, 1);
else if (ch.id == TAG_RAMP_REF) rampName = chunkStr(ch);
else if (ch.id == TAG_DIFFUSE && ch.len >= 12) {
info.color = packColor(rdF32(ch.data), rdF32(ch.data + 4), rdF32(ch.data + 8));
haveColor = true;
info.hasDiffuse = true;
} else if (ch.id == TAG_AMBIENT && ch.len >= 12 && !haveColor) {
info.color = packColor(rdF32(ch.data), rdF32(ch.data + 4), rdF32(ch.data + 8));
haveColor = true;
info.hasDiffuse = true;
} else if (ch.id == TAG_EMISSIVE && ch.len >= 12) {
info.emissive[0] = rdF32(ch.data);
info.emissive[1] = rdF32(ch.data + 4);
info.emissive[2] = rdF32(ch.data + 8);
info.hasEmissive = (info.emissive[0] + info.emissive[1] + info.emissive[2]) > 0.001f;
}
}
// Resolve the ramp reference to its low/high colours: this
// file's own definitions first, then the cross-library
// registry (mech skins reference 'softer' defined elsewhere).
if (!rampName.empty()) {
const std::array<float,6>* vals = nullptr;
auto rr = ramps.find(rampName);
if (rr != ramps.end()) vals = &rr->second;
else {
const auto& reg = globalRamps();
auto gr = reg.find(rampName);
if (gr != reg.end()) vals = &gr->second;
}
if (vals) {
info.hasRamp = true;
for (int i = 0; i < 3; ++i) { info.rampLo[i] = (*vals)[i]; info.rampHi[i] = (*vals)[i+3]; }
}
}
if (!name.empty() && (haveColor || !texName.empty() || info.hasRamp)) {
auto tm = texMaps.find(texName);
if (tm != texMaps.end()) {
const auto& imgs = imageIndex();
auto f = imgs.find(stemLower(tm->second.map));
if (f != imgs.end()) { info.texPath = f->second; info.texChannel = tm->second.channel; }
}
out[name] = info;
}
}
if (!c.children.empty()) collectMaterials(c.children, texMaps, ramps, out);
}
}
const std::map<std::string, MatInfo>& library(const std::string& lib) {
auto it = libs.find(lib);
if (it != libs.end()) return it->second;
std::map<std::string, MatInfo> table;
const auto& idx = bmfIndex();
auto f = idx.find(toLower(lib));
if (f != idx.end()) {
std::vector<uint8_t> buf = readFileBytes(f->second);
if (buf.size() > 8 && std::memcmp(buf.data(), "DIV-BIZ2", 8) == 0) {
std::vector<Chunk> roots;
if (parseRange(buf.data() + 8, buf.data() + buf.size(), roots)) {
std::map<std::string, TexRef> texMaps;
std::map<std::string, std::array<float,6> > ramps;
collectTextureMaps(roots, texMaps);
collectRamps(roots, ramps);
collectMaterials(roots, texMaps, ramps, table);
}
}
}
return libs.emplace(lib, std::move(table)).first->second;
}
MatInfo resolve(const std::string& full) {
auto cached = resolvedCache.find(full);
if (cached != resolvedCache.end()) return cached->second;
MatInfo info;
info.color = colorForMaterial(full);
bool real = false;
size_t colon = full.find(':');
if (colon != std::string::npos) {
const auto& table = library(full.substr(0, colon));
std::string mtl = full.substr(colon + 1);
auto m = table.find(mtl);
if (m == table.end()) m = table.find(full);
if (m != table.end()) { info = m->second; real = true; }
}
++uniqueTotal;
if (real) ++uniqueResolved;
resolvedCache[full] = info;
return info;
}
};
struct Builder {
BgfData* mesh;
MaterialResolver* res = nullptr;
uint32_t currentColor = 0xFFB0B0B8u;
bool currentHasDiffuse = false; // material carried an explicit diffuse tag
std::string currentTex;
int currentTexChannel = 0; // BSL bit-slice (BMF tag 0x18)
bool currentHasRamp = false;
float currentRampLo[3] = {0,0,0};
float currentRampHi[3] = {1,1,1};
float currentLodNear = 0.0f, currentLodFar = 1.0e9f; // active LOD band (0x2046)
bool objectAdditive = false; // inside an ADDITIVE_LODS object (SV_SPECIAL 0x2037)
float objectMaxOut = 0.0f; // the additive object's largest stored OutDist (whole-object vanish)
int objectLodCount = 0; // additive object's LOD count
int currentLodIndex = 0; // ordinal of the LOD being collected (0 = finest/nearest band)
int objectBatchOrdinal = 0; // submission-order counter (coplanar-resolution bias)
float currentLodBias = 0.0f; // (retained; per-batch bias now set in buildPmesh)
bool currentPunch = false; // inside a PUNCH patch (SV_SPECIAL 0x2037, geogroup level)
bool currentShadowMat = false; // material name contains "shadow" (baked ground-shadow quads)
bool currentTSphere = false; // material is tsphere_mtl (translocation warp): ramp it despite normals
bool meshIsTSphere = false; // this OBJECT is the translocation warp -> smooth-tessellate the cone
bool meshIsCop = false; // this OBJECT is a *_cop cockpit canopy shell (task #55)
bool currentHasEmissive = false;
float currentEmissive[3] = {0,0,0};
// per-vertex scratch (normals accumulated from forward triangles only)
std::vector<float> px, py, pz, nx, ny, nz, uu, vv;
std::vector<uint32_t> col;
void emitTri(uint32_t a, uint32_t b, uint32_t c) {
// Face normal first (lighting accumulation below).
float ux = px[b]-px[a], uy = py[b]-py[a], uz = pz[b]-pz[a];
float wx = px[c]-px[a], wy = py[c]-py[a], wz = pz[c]-pz[a];
float fx = uy*wz - uz*wy, fy = uz*wx - ux*wz, fz = ux*wy - uy*wx;
// COCKPIT CANOPY SHELL (*_cop.bgf, the first-person cockpit -- task #55): the canopy
// geogroups carry the SV_SPECIAL "PUNCH" tag (dpl_Punchize, VPX board cmd 0x20). The
// visible frame-with-window is produced by the canopy's OWN geometry: it is an OPEN
// STRUT LATTICE (MAX_COP punch patch: 34/74 boundary edges), so rendered SINGLE-SIDED
// from the authentic cockpit eye the struts are the dark frame and the openings show
// the world. Double-siding draws the FAR-side struts through the near openings ->
// the solid "black box" bug. Verified in-game against gameplay footage (Madcat).
// Scoped to *_cop meshes so other texture-less ramp geometry keeps engine behavior.
// BT_COP_DOUBLE=1 restores double-siding; BT_COP_FLIP=1 flips the kept winding (diag).
const bool cop = meshIsCop && currentHasRamp && currentTex.empty();
static int s_copSingle=-1, s_copFlip=-1;
if (s_copSingle < 0) { const char* e=getenv("BT_COP_DOUBLE"); s_copSingle=(e && e[0]!='0')?0:1; }
if (s_copFlip < 0) { const char* e=getenv("BT_COP_FLIP"); s_copFlip =(e && e[0]!='0')?1:0; }
const bool single = s_copSingle && cop;
if (single && s_copFlip) {
mesh->indices.push_back(a); mesh->indices.push_back(c); mesh->indices.push_back(b);
} else {
mesh->indices.push_back(a); mesh->indices.push_back(b); mesh->indices.push_back(c);
if (!single) { mesh->indices.push_back(a); mesh->indices.push_back(c); mesh->indices.push_back(b); }
}
mesh->tris++;
nx[a]+=fx; ny[a]+=fy; nz[a]+=fz;
nx[b]+=fx; ny[b]+=fy; nz[b]+=fz;
nx[c]+=fx; ny[c]+=fy; nz[c]+=fz;
}
void addFace(const std::vector<int32_t>& idx, size_t start, int ppf,
uint32_t base, uint32_t localCount) {
if (ppf < 3) return;
auto valid = [&](int32_t v) { return v >= 0 && (uint32_t)v < localCount; };
for (int k = 1; k + 1 < ppf; ++k) {
int32_t a = idx[start + 0], b = idx[start + k], c = idx[start + k + 1];
if (!valid(a) || !valid(b) || !valid(c)) continue;
emitTri(base + (uint32_t)a, base + (uint32_t)b, base + (uint32_t)c);
}
}
void buildPmesh(const Chunk& pmesh) {
std::vector<float> lx, ly, lz, lu, lv;
uint16_t vtag = 0;
for (const Chunk& ch : pmesh.children) {
int stride = vertexStride(ch.id);
if (!stride) continue;
vtag = ch.id;
int uvOff = uvOffset(ch.id);
size_t count = ch.len / (size_t)stride;
for (size_t i = 0; i < count; ++i) {
const uint8_t* v = ch.data + i * stride;
lx.push_back(rdF32(v)); ly.push_back(rdF32(v + 4)); lz.push_back(rdF32(v + 8));
if (uvOff >= 0) { lu.push_back(rdF32(v + uvOff)); lv.push_back(rdF32(v + uvOff + 4)); }
else { lu.push_back(0.0f); lv.push_back(0.0f); }
}
break;
}
if (lx.empty()) return;
// RAMP applies ONLY to no-normal geometry (see hasNormals()): normal-bearing
// vehicle/missile batches keep their material diffuse colour + texture (lit path),
// so they must NOT be ramped even when the material carries a ramp ref (cdusty).
// TRUECOLOR BSL slices (channel >= 6: RGB444/RGBA4444, e.g. damcolor's bdam8
// damage sheet) are real colour images -- luminance-ramping them would
// destroy their colour, so they take the plain textured path.
const bool useRamp = currentHasRamp && (!hasNormals(vtag) || currentTSphere) && currentTexChannel < 6;
const uint32_t base = (uint32_t)px.size();
const uint32_t localCount = (uint32_t)lx.size();
// RAMP TINT RULE (BSL skin decode): the board's exact ramp/diffuse combine
// is unknowable (libDPL rasterizer is binary-only), but the content pins it
// from both sides: (a) dsky_mtl carries diffuse (0.3,0.5,1.0) AND the
// COLOURED 'sky' ramp, and the footage-matched render is pure-ramp
// near-white -- so diffuse must NOT modulate a coloured ramp; (b) the mech
// skin variants (gen2medgry vs gen3drkgry etc.) share one texture and the
// NEUTRAL 'softer' ramp and differ ONLY by diffuse -- so diffuse MUST
// modulate a neutral ramp or the variants (and the green lgo6 leg logo,
// the mauve leg tints, the near-white searchlights) all render identical
// gray. Rule: a batch with an explicit diffuse + a NEUTRAL (gray) ramp is
// tinted by its diffuse; a coloured ramp always shows untinted (white).
// BT_RAMP_TINT=0 restores the old always-white behavior.
// PURE-EMISSIVE (diffuse black + emissive): vertex colour BLACK -- the
// engine drives lit diffuse/ambient from vertex COLOR1, so any nonzero
// vertex colour would re-add sun/ambient on top of the authored unlit
// glow (the material Emissive term carries the colour instead).
static const bool s_rampTint = [] {
const char* v = getenv("BT_RAMP_TINT");
return v == nullptr || v[0] != '0';
}();
const bool rampNeutral =
currentRampLo[0] == currentRampLo[1] && currentRampLo[1] == currentRampLo[2] &&
currentRampHi[0] == currentRampHi[1] && currentRampHi[1] == currentRampHi[2];
const uint32_t rampTint =
(s_rampTint && currentHasDiffuse && rampNeutral) ? currentColor : 0xFFFFFFFFu;
const bool pureEmissive =
currentHasEmissive && (currentColor & 0x00FFFFFFu) == 0;
uint32_t vcol = pureEmissive ? 0xFF000000u
: (useRamp ? rampTint : currentColor);
// DIAG (task #55): BT_COP_DEBUG paints texture-less ramp batches (the cockpit
// canopy shell blx_cop -- ramp 'softer', no texture) bright green, to SEE what
// the shell actually covers from the real cockpit eyepoint (frame vs open).
// COCKPIT FRAME COLOUR (*_cop shells only): ramped NO-NORMAL geometry with NO
// texture (blakskn_dz + 'softer'). Per the engine material-ramp model the RAMP is
// the surface colour and the (0,0,0) DIFFUSE is IGNORED -- the old tint rule
// collapsed it to black. With no texture there is no per-texel luminance, so
// evaluate the ramp at a flat index. Default 0.08 = near the ramp's DARK end:
// the canopy frame is unlit interior structure, and the dark value matches the
// near-black frame in the cabinet gameplay footage ([T3] tuned-to-footage; the
// board's exact unlit ramp index is not recoverable from the code).
// BT_COP_RAMP_L overrides (diagnostic).
if (useRamp && currentTex.empty() && meshIsCop) {
static float s_copL = -1.0f;
if (s_copL < 0.0f) { const char* e = getenv("BT_COP_RAMP_L"); s_copL = e ? (float)atof(e) : 0.08f; }
float cr = currentRampLo[0] + (currentRampHi[0]-currentRampLo[0])*s_copL;
float cg = currentRampLo[1] + (currentRampHi[1]-currentRampLo[1])*s_copL;
float cb = currentRampLo[2] + (currentRampHi[2]-currentRampLo[2])*s_copL;
auto CB = [](float ff){ int v=(int)(ff*255.0f+0.5f); return (uint32_t)(v<0?0:v>255?255:v); };
vcol = 0xFF000000u | (CB(cr)<<16) | (CB(cg)<<8) | CB(cb);
}
if (useRamp && currentTex.empty() && meshIsCop && getenv("BT_COP_DEBUG"))
vcol = 0xFF00FF00u;
// TRANSLOCATION WARP: tsphere's UVs run U=angle around the Z throat, V=axial
// along the tunnel -- i.e. cylindrical. Viewed down the throat axis (as the
// effect is), those ARE log-polar coordinates (angle, log-radius), the exact
// mapping the offline render matched to the original. Two shaping terms:
// * TWIST (BT_WARP_TWIST, default 0.3): a helical shear U += twist*V so the
// cloud SPIRALS inward (the "looking down a tornado" swirl) instead of
// forming plain concentric rings.
// * RADFREQ (BT_WARP_RADFREQ, default 0.8): axial (log-radius) scale -> band
// spacing. (BT_WARP_TILE_U still scales the angular frequency.)
// AUTHENTIC UVs (verified by rendering the true mesh vs the original -- see
// scratchpad/render_bicone.py + cmp_final2.png): the bicone is viewed ON its
// throat axis (eye centred via BT_WARP_EYE_UP=8.25) and merely SPUN in place, so
// the swirl reads as smooth CONCENTRIC rings. Any helical TWIST shears those
// rings into radial SPOKES (the "sphincter" the user rejected) -> default 0.
// RADFREQ 1.0 = the mesh's authored V (matches the original's ring spacing).
float tileU = 1.0f, tileV = 1.0f, twist = 0.0f;
if (currentTSphere) {
static float sTileU = -1.0f, sTileV = -1.0f, sTwist = -999.0f;
if (sTileU < 0.0f) { const char* e = getenv("BT_WARP_TILE_U"); sTileU = e ? (float)atof(e) : 1.0f; if (sTileU <= 0) sTileU = 1.0f; }
if (sTileV < 0.0f) { const char* e = getenv("BT_WARP_RADFREQ"); if (!e) e = getenv("BT_WARP_TILE_V"); sTileV = e ? (float)atof(e) : 1.0f; if (sTileV <= 0) sTileV = 1.0f; }
if (sTwist < -900.0f) { const char* e = getenv("BT_WARP_TWIST"); sTwist = e ? (float)atof(e) : 0.0f; }
tileU = sTileU; tileV = sTileV; twist = sTwist;
}
for (size_t i = 0; i < lx.size(); ++i) {
px.push_back(lx[i]); py.push_back(ly[i]); pz.push_back(lz[i]);
nx.push_back(0); ny.push_back(0); nz.push_back(0);
uu.push_back(lu[i] * tileU + twist * lv[i]); vv.push_back(lv[i] * tileV);
col.push_back(vcol);
}
const uint32_t idxStart = (uint32_t)mesh->indices.size();
bool gotPconn = false;
for (const Chunk& ch : pmesh.children) {
if (ch.id != TAG_PCONN_LIST || ch.len < 1) continue;
gotPconn = true;
int ppf = ch.data[0];
if (ppf < 3) continue;
size_t n = (ch.len - 1) / 4;
std::vector<int32_t> idx(n);
for (size_t i = 0; i < n; ++i) idx[i] = (int32_t)rdU32(ch.data + 1 + i * 4);
for (size_t f = 0; f + ppf <= n; f += ppf) addFace(idx, f, ppf, base, localCount);
}
// DIAG (BT_CONN_BOTH=0): restore the pre-turret-fix either/or gate (CONN
// skipped when PCONN present) for A/B isolation of CONN-triangle artifacts.
static const int s_connBoth =
(getenv("BT_CONN_BOTH") == 0 || getenv("BT_CONN_BOTH")[0] != '0');
if (s_connBoth || !gotPconn) {
// CONNECTION_LIST is a FLAT TRIANGLE LIST (3 indices per face), NOT a
// single N-gon (task #20 render-fidelity: BGF_FORMAT.md wrongly said
// "one polygon, fan-triangulate"). Fan-triangulating the whole chunk
// as one polygon produced garbage geometry -- e.g. buttec LOD0 got 341
// spurious fan-tris instead of its 115 real ones, with smeared UVs and
// cancelled per-vertex normals = the "inside-out / faceted mesa" look.
// Corpus-verified: ALL 3488 CONN chunks in the pod GEO tree have
// n%3==0 (zero exceptions), so the group-of-3 walk never drops indices.
// BT_TERRAIN_TRILIST=0 restores the old fan behavior for A/B.
//
// CONN + PCONN are NOT alternatives (the turret-base "missing panels"
// bug): a pmesh carries its quads/hexes in PCONN *and* its plain
// triangles in CONN -- 370 of 841 pod models mix both in one pmesh
// (calpb's LOD0: 78 PCONN tris + 24 CONN tris = the base panels the
// old `if (!gotPconn)` gate silently dropped). Process BOTH, always.
static const int s_triList =
(getenv("BT_TERRAIN_TRILIST") == 0 || getenv("BT_TERRAIN_TRILIST")[0] != '0');
for (const Chunk& ch : pmesh.children) {
if (ch.id != TAG_CONN_LIST || ch.len < 12) continue;
size_t n = ch.len / 4;
std::vector<int32_t> idx(n);
for (size_t i = 0; i < n; ++i) idx[i] = (int32_t)rdU32(ch.data + i * 4);
if (s_triList)
for (size_t f = 0; f + 3 <= n; f += 3) addFace(idx, f, 3, base, localCount);
else
addFace(idx, 0, (int)n, base, localCount);
}
}
uint32_t idxCount = (uint32_t)mesh->indices.size() - idxStart;
if (idxCount) {
BgfDrawBatch batch;
batch.indexStart = idxStart;
batch.indexCount = idxCount;
batch.color = pureEmissive ? currentColor // keep BLACK: L4D3D's
: (useRamp ? rampTint : currentColor); // pure-emissive test keys on it
batch.texPath = currentTex;
batch.texChannel = currentTexChannel;
batch.hasRamp = useRamp;
// tsphere warp flag: warpBands>0 signals L4D3D's ramp bake to CONTRAST-
// COMPRESS the bintA cloud toward mid-grey (BT_WARP_CONTRAST) before applying
// the narrow lavender ramp -> soft intersecting spiral streaks. (Value is
// just a nonzero flag now; the old per-vertex "band" model was wrong.)
if (currentTSphere)
batch.warpBands = 1;
for (int i = 0; i < 3; ++i) { batch.rampLo[i] = currentRampLo[i]; batch.rampHi[i] = currentRampHi[i]; }
batch.hasEmissive = currentHasEmissive;
for (int i = 0; i < 3; ++i) batch.emissive[i] = currentEmissive[i];
batch.lodNear = currentLodNear;
batch.lodFar = currentLodFar;
batch.punch = currentPunch;
batch.shadowMat = currentShadowMat;
batch.hasEmissive = currentHasEmissive;
for (int i = 0; i < 3; ++i) batch.emissive[i] = currentEmissive[i];
// SUBMISSION-ORDER DEPTH BIAS (additive objects): the content layers
// EXACTLY-COPLANAR shells (AR02 LOD2: solid concrete + a coplanar
// PUNCH-cutout overlay + an inner shell, plane separations
// 0.000-0.003u). The IG board composited those deterministically --
// exact per-polygon plane equations gave identical depth, so the
// LATER-submitted polygon won via less-equal. D3D9 interpolates
// depth per-vertex, so two tessellations of one plane differ by
// rounding per pixel = crawling dot/stripe interference at ANY
// distance. Reproduce the board's rule: each batch of an additive
// object is biased a few depth-buffer steps CLOSER per submission
// ordinal (file order = authored order), so coplanar contests
// resolve to the later (overlay) patch. ~4 LSB of D24 per step
// beats interpolation rounding everywhere; ordinal capped so the
// worst-case world-equivalent shift stays negligible.
// BT_LAYER_BIAS overrides the per-step magnitude (diagnostic).
if (objectAdditive) {
static const float s_stepBias =
(getenv("BT_LAYER_BIAS") != nullptr && atof(getenv("BT_LAYER_BIAS")) != 0.0)
? (float)atof(getenv("BT_LAYER_BIAS")) : 2.5e-7f;
int ord = objectBatchOrdinal++;
if (ord > 20) ord = 20;
batch.lodBias = -s_stepBias * (float)ord;
}
else
batch.lodBias = 0.0f;
mesh->batches.push_back(batch);
if (getenv("BT_COP_DUMP") && currentHasRamp && currentTex.empty()) {
float lo[3]={1e9f,1e9f,1e9f}, hi[3]={-1e9f,-1e9f,-1e9f}, cen[3]={0,0,0}; int nv=0;
for (uint32_t i = idxStart; i < idxStart + idxCount; ++i) {
uint32_t v = mesh->indices[i];
float p[3]={px[v],py[v],pz[v]};
for (int k=0;k<3;k++){ if(p[k]<lo[k])lo[k]=p[k]; if(p[k]>hi[k])hi[k]=p[k]; cen[k]+=p[k]; }
nv++;
}
if (nv) for(int k=0;k<3;k++) cen[k]/=nv;
fprintf(stderr,"[COP_DUMP] punch=%d tris=%u cen=(%.2f,%.2f,%.2f) bbox=[%.2f,%.2f,%.2f]..[%.2f,%.2f,%.2f]\n",
(int)batch.punch, idxCount/3, cen[0],cen[1],cen[2], lo[0],lo[1],lo[2], hi[0],hi[1],hi[2]);
}
}
}
void collect(const Chunk& c) {
switch (c.id) {
case TAG_OBJECT: {
// LOD policy (DECODED from libDPL + the 1995 L4VIDEO.CPP
// TestSpecialCallBack + a corpus sweep of all 841 pod BGFs):
// - Objects whose SV_SPECIAL (0x2037) carries "ADDITIVE_LODS"
// (135 files: arena structures AB*/AD*/AR*/AW*, PGN turrets,
// buildings, every *D damage model) author their LODs as a
// PARTITION of [0..outmax) with COMPLEMENTARY geometry: near
// LODs ADD close-up detail on top of the coarser massing.
// Draw rule: at eye distance d, EVERY LOD with d < OutDist
// draws (InDist ignored) -- detail drops first as you
// retreat. First-LOD-only turned these into the "fragment"
// structures. BT_ADDLOD=0 disables (falls back to
// first-LOD-only for these too).
// - All other objects keep the SHIPPING 2007-engine behavior
// (DivLoader parseLOD: nearest-band LOD only, always drawn).
// - BT_LODSEL=1 = the older EXPERIMENTAL replacement-selection
// (every LOD gated by its full [in..out) band) -- kept for
// diagnosis only; replacement is provably WRONG for additive
// objects (their bands partition, so exactly one complementary
// piece would show at any distance).
static const int s_lodSel =
(getenv("BT_LODSEL") != nullptr && getenv("BT_LODSEL")[0] == '1');
if (s_lodSel) {
for (const Chunk& ch : c.children) collect(ch);
break;
}
static const int s_addLod =
!(getenv("BT_ADDLOD") != nullptr && getenv("BT_ADDLOD")[0] == '0');
bool additive = false;
if (s_addLod) {
for (const Chunk& ch : c.children) {
if (ch.id != TAG_SV_SPECIAL || ch.len < 13) continue;
const char* s = (const char*)ch.data;
for (size_t i = 0; i + 13 <= ch.len && !additive; ++i)
if (memcmp(s + i, "ADDITIVE_LODS", 13) == 0) additive = true;
if (additive) break;
}
}
if (additive) {
const bool savedAdd = objectAdditive;
const float savedMax = objectMaxOut;
const int savedCount = objectLodCount;
const int savedIndex = currentLodIndex;
objectAdditive = true;
objectBatchOrdinal = 0;
// Pre-scan the object's LODs for the LARGEST OutDist -- the
// whole-object vanish distance (only THAT band gets the
// BT_LOD_SCALE extension, see TAG_LOD) -- and the LOD count
// (the layer depth-bias denominator).
objectMaxOut = 0.0f;
objectLodCount = 0;
for (const Chunk& ch : c.children) {
if (ch.id != TAG_LOD) continue;
++objectLodCount;
for (const Chunk& cc : ch.children)
if (cc.id == 0x2046 && cc.len >= 8) {
const float i2 = rdF32(cc.data), o2 = rdF32(cc.data + 4);
const float mo = (i2 > o2) ? i2 : o2;
if (mo > objectMaxOut) objectMaxOut = mo;
break;
}
}
currentLodIndex = 0;
// DIAG (BT_ONLY_LOD=k): collect ONLY layer k of additive
// objects -- layer-isolation imaging for artifact hunts.
static const int s_onlyLod =
getenv("BT_ONLY_LOD") ? atoi(getenv("BT_ONLY_LOD")) : -1;
if (s_onlyLod >= 0) {
int li = 0;
for (const Chunk& ch : c.children) {
if (ch.id == TAG_LOD) {
if (li == s_onlyLod) collect(ch);
++li;
++currentLodIndex;
}
else collect(ch);
}
}
else
for (const Chunk& ch : c.children) collect(ch);
objectAdditive = savedAdd;
objectMaxOut = savedMax;
objectLodCount = savedCount;
currentLodIndex = savedIndex;
break;
}
const Chunk* firstLod = nullptr;
for (const Chunk& ch : c.children)
if (ch.id == TAG_LOD) { firstLod = &ch; break; }
for (const Chunk& ch : c.children) {
if (ch.id == TAG_LOD) { if (&ch == firstLod) collect(ch); }
else collect(ch);
}
break;
}
case TAG_LOD: {
// Band tagging:
// - additive object (default path): band = [0 .. OutDist*sqrt3)
// -- the cumulative draw rule above. max(in,out) guards the
// one inverted-band authoring quirk in the corpus (MSLG.BGF).
// - BT_LODSEL=1 experiment: full replacement [in..out)*sqrt3.
// - plain object: always-drawn [0..1e9) (shipping behavior).
//
// THE RANGE DECODE (lod-blink-decode workflow, corpus-exact):
// every stored 0x2046 value is the AUTHORED euclidean distance
// DIVIDED BY sqrt(3) -- 92% of the corpus' 3263 nonzero band
// slots are exactly nice-number/sqrt3 to 6 significant figures
// (hand-conversion typos like BUTTEB 2000.73-for-2020.73 prove a
// table conversion): afloor 577.35 = authored 1000, ADWLK1
// 115.47 = 200, AB07/AR* massing 1154.7 = 2000 ~ the des_day
// clip 2100. Comparing plain euclidean eye distance against the
// stored value made every piece switch at 57.7% of its authored
// range = the arena's position-dependent blinking (all large
// massing popped INSIDE the arena's clear-air fog envelope).
// Multiply back by sqrt(3) at load so the euclidean gate sees
// the authored ranges. (A disjoint 7.45% RAW-integer population
// exists -- one prop-family artist skipped the conversion -- all
// NON-additive, so untouched by the default path today.)
// BT_LOD_SCALE (PORT presentation knob, default 1.0 = the
// authored ranges): multiplies EVERY additive band -- both the
// whole-object vanish distance (what reads as "the structure
// popped into existence" on a crisp modern display: the AR ring
// towers' authored max is 500 in a 1400u arena) and the inner
// detail layers (wall-top caps, trusses popping at authored
// 200-800 in clear air). HISTORY: a blanket scale originally
// caused venetian-blind z-fighting (detail shingled 0.05u over
// massing kept drawn beyond the depth-separation distance its
// authored range respected) and was cut back to outermost-only
// -- but the SUBMISSION-ORDER DEPTH BIAS (buildPmesh below) now
// resolves coplanar/shingled contests deterministically at ALL
// distances, so the blanket extension is safe again.
// Default 2.5 (user-approved shipped default): pops pushed to
// where geometry is 2.5x smaller + fog-covered. BT_LOD_SCALE=1
// restores the exact authored 1995 arcade ranges.
static const float s_lodUserScale =
(getenv("BT_LOD_SCALE") != nullptr && atof(getenv("BT_LOD_SCALE")) > 0.0)
? (float)atof(getenv("BT_LOD_SCALE")) : 2.5f;
static const float kLodRangeScale = 1.7320508f;
static const int s_lodSel2 =
(getenv("BT_LODSEL") != nullptr && getenv("BT_LODSEL")[0] == '1');
const float savedNear = currentLodNear, savedFar = currentLodFar;
const float savedBias = currentLodBias;
if (s_lodSel2 || objectAdditive) {
currentLodNear = 0.0f; currentLodFar = 1.0e9f;
for (const Chunk& ch : c.children)
if (ch.id == 0x2046 && ch.len >= 8) {
const float scale = kLodRangeScale *
(objectAdditive ? s_lodUserScale : 1.0f);
const float in = rdF32(ch.data) * scale;
const float out = rdF32(ch.data + 4) * scale;
if (s_lodSel2) { currentLodNear = in; currentLodFar = out; }
else { currentLodFar = (in > out) ? in : out; }
break;
}
}
for (const Chunk& ch : c.children) collect(ch);
currentLodNear = savedNear; currentLodFar = savedFar;
currentLodBias = savedBias;
if (objectAdditive) ++currentLodIndex;
break;
}
case TAG_PATCH: {
uint32_t savedColor = currentColor;
bool savedHasDiffuse = currentHasDiffuse;
std::string savedTex = currentTex;
int savedTexChannel = currentTexChannel;
bool savedHasRamp = currentHasRamp;
bool savedPunch = currentPunch;
bool savedShadowMat = currentShadowMat;
bool savedTSphere = currentTSphere;
bool savedHasEmissive = currentHasEmissive;
float savedEmissive[3] = { currentEmissive[0], currentEmissive[1], currentEmissive[2] };
float savedRampLo[3] = { currentRampLo[0], currentRampLo[1], currentRampLo[2] };
float savedRampHi[3] = { currentRampHi[0], currentRampHi[1], currentRampHi[2] };
// PATCH-level SV_SPECIAL "PUNCH" -> dpl_Punchize (the 1995
// TestSpecialCallBack, L4VIDEO.CPP:563-569): this geogroup's
// texture is a punch-through CUTOUT (black texels = holes).
for (const Chunk& ch : c.children) {
if (ch.id != TAG_SV_SPECIAL || ch.len < 5) continue;
const char* s = (const char*)ch.data;
for (size_t i = 0; i + 5 <= ch.len && !currentPunch; ++i)
if (memcmp(s + i, "PUNCH", 5) == 0) currentPunch = true;
if (currentPunch) break;
}
for (const Chunk& ch : c.children)
if (ch.id == TAG_SV_F_MATERIAL && ch.len > 1) {
const char* s = (const char*)ch.data + 1;
size_t maxn = ch.len - 1, n = 0;
while (n < maxn && s[n]) ++n;
std::string full(s, n);
// baked ground-shadow material (basev:shadow_mtl etc.)
currentShadowMat = false;
for (size_t si = 0; si + 6 <= full.size(); ++si)
if (_strnicmp(full.c_str() + si, "shadow", 6) == 0) { currentShadowMat = true; break; }
// The translocation warp sphere (tsphere_mtl): its swirl COLOUR is
// the "sky" ramp remapping the grayscale bintA cloud, but tsphere.bgf
// carries vertex normals so the normals-gate below would skip the ramp
// and leave it raw gray. Flag it so the ramp bake runs anyway (the
// authentic look; matches L4VIDRND POVTranslocateRenderable).
currentTSphere = (full.find("tsphere_mtl") != std::string::npos);
if (currentTSphere) meshIsTSphere = true;
currentHasRamp = false;
if (res) {
MatInfo info = res->resolve(full);
currentColor = info.color; currentTex = info.texPath;
currentHasDiffuse = info.hasDiffuse;
currentTexChannel = info.texChannel;
currentHasRamp = info.hasRamp;
for (int i = 0; i < 3; ++i) { currentRampLo[i] = info.rampLo[i]; currentRampHi[i] = info.rampHi[i]; }
currentHasEmissive = info.hasEmissive;
for (int i = 0; i < 3; ++i) currentEmissive[i] = info.emissive[i];
}
else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; }
// TRANSLOCATION WARP: override the material's coarse "sky" ramp with the
// NARROW LAVENDER ramp that matched the original (capture.png). The swirl
// is the bintA cloud coloured lo->hi by luminance; a wide sky ramp
// ({0,0,0.6}->white) posterises to hard concentric rings, whereas this
// narrow lavender ramp (LO ~mid lavender-blue, HI ~near-white, lavender
// cast) keeps it a soft, low-contrast spiral vortex. Env-tunable.
if (currentTSphere) {
static float wlo[3] = {-1.0f,0,0}, whi[3] = {0,0,0};
if (wlo[0] < 0.0f) {
const char *e;
// Wide lavender ramp (dark blue-violet -> near-white lavender):
// full luminance range matches the original's contrast + the
// bright central bloom (cmp_final2.png). Narrower ramps washed
// the swirl to a flat lavender.
e = getenv("BT_WARP_LO_R"); wlo[0] = e ? (float)atof(e) : 0.15f;
e = getenv("BT_WARP_LO_G"); wlo[1] = e ? (float)atof(e) : 0.12f;
e = getenv("BT_WARP_LO_B"); wlo[2] = e ? (float)atof(e) : 0.35f;
e = getenv("BT_WARP_HI_R"); whi[0] = e ? (float)atof(e) : 0.98f;
e = getenv("BT_WARP_HI_G"); whi[1] = e ? (float)atof(e) : 0.94f;
e = getenv("BT_WARP_HI_B"); whi[2] = e ? (float)atof(e) : 1.00f;
}
currentHasRamp = true;
for (int i = 0; i < 3; ++i) { currentRampLo[i] = wlo[i]; currentRampHi[i] = whi[i]; }
}
break;
}
for (const Chunk& ch : c.children) collect(ch);
currentColor = savedColor;
currentHasDiffuse = savedHasDiffuse;
currentTex = savedTex;
currentTexChannel = savedTexChannel;
currentHasRamp = savedHasRamp;
currentPunch = savedPunch;
currentShadowMat = savedShadowMat;
currentTSphere = savedTSphere;
currentHasEmissive = savedHasEmissive;
for (int i = 0; i < 3; ++i) currentEmissive[i] = savedEmissive[i];
for (int i = 0; i < 3; ++i) { currentRampLo[i] = savedRampLo[i]; currentRampHi[i] = savedRampHi[i]; }
break;
}
case TAG_PMESH: buildPmesh(c); break;
default: for (const Chunk& ch : c.children) collect(ch); break;
}
}
// SMOOTH the translocation warp cone. tsphere.bgf is a coarse 12-facet bicone;
// on the 1995 pod's low-res CRT (+ phosphor persistence + the fast spin) it read
// as a smooth tunnel, but on a modern high-res display the 12 flat facets stay
// crisp = the "12-sided funnel". Subdivide each triangle 4:1 and PROJECT the new
// vertices onto the true cone surface (revolution about the throat axis (0,8.25,z),
// ring radius 10.5 at z=0, apexes at z=+/-21), so the SHAPE becomes smooth while
// the UVs (hence the swirl) interpolate. BT_WARP_TESS = passes (default 3, 0=off).
void tessellateWarpCone() {
static int s_tess = -1;
if (s_tess < 0) { const char* e = getenv("BT_WARP_TESS"); s_tess = e ? atoi(e) : 3; if (s_tess < 0) s_tess = 0; if (s_tess > 5) s_tess = 5; }
const float AXIS_Y = 8.25f, RING_R = 10.5f, HALF_LEN = 21.0f;
for (int p = 0; p < s_tess; ++p) {
std::vector<uint32_t> out2;
out2.reserve(mesh->indices.size() * 4);
for (size_t i = 0; i + 3 <= mesh->indices.size(); i += 3) {
uint32_t tri[3] = { mesh->indices[i], mesh->indices[i+1], mesh->indices[i+2] };
uint32_t m[3];
for (int e = 0; e < 3; ++e) {
uint32_t a = tri[e], b = tri[(e + 1) % 3];
float mx = (px[a]+px[b])*0.5f, my = (py[a]+py[b])*0.5f, mz = (pz[a]+pz[b])*0.5f;
float t = std::fabs(mz) / HALF_LEN; if (t > 1.0f) t = 1.0f;
float rIdeal = RING_R * (1.0f - t);
float dy = my - AXIS_Y, r = std::sqrt(mx*mx + dy*dy);
if (r > 1e-5f) { float sc = rIdeal / r; mx *= sc; my = AXIS_Y + dy*sc; }
m[e] = (uint32_t)px.size();
px.push_back(mx); py.push_back(my); pz.push_back(mz);
nx.push_back(0); ny.push_back(0); nz.push_back(0);
uu.push_back((uu[a]+uu[b])*0.5f); vv.push_back((vv[a]+vv[b])*0.5f);
col.push_back(col[a]);
}
const uint32_t sub[12] = { tri[0],m[0],m[2], m[0],tri[1],m[1], m[2],m[1],tri[2], m[0],m[1],m[2] };
for (int k = 0; k < 12; ++k) out2.push_back(sub[k]);
}
mesh->indices.swap(out2);
}
mesh->tris = (int)(mesh->indices.size() / 3);
}
void finish() {
// COCKPIT WINDSHIELD = THE PUNCH PATCHES (task #55, resolved 2026-07-11): on the
// texture-less black-diffuse canopy shells, dpl_Punchize renders the tagged
// geogroup as HOLES -- the punch patches are the transparent WINDOWS, and the
// visible frame is ONLY the non-punch geometry. Verified by rendering every
// shell from its mech's AUTHORED eye (SKL [jointeye] translation, incl. the
// Thor's +1.13 offset cockpit): the punch panes sit exactly over the window
// apertures in all 8 mechs; dropping them yields the Madcat's dome frame
// (non-punch) and the authentically FRAMELESS views of the all-punch shells
// (bhk1/loki/vulture/avatar -- the pod showed no canopy for those). (An
// early-session in-game test that "disproved" drop-punch ran with the broken
// pre-inverse eye; its conclusion was wrong.) BT_COP_KEEPPUNCH=1 keeps the
// punch panes (diagnostic).
if (meshIsCop) {
static int s_keepPunch = -1;
if (s_keepPunch < 0) { const char* e = getenv("BT_COP_KEEPPUNCH"); s_keepPunch = (e && e[0] != '0') ? 1 : 0; }
if (!s_keepPunch) {
std::vector<BgfDrawBatch> kept;
kept.reserve(mesh->batches.size());
for (const BgfDrawBatch& b : mesh->batches) {
if (b.punch && b.hasRamp && b.texPath.empty())
continue; // window pane -> transparent
kept.push_back(b);
}
mesh->batches.swap(kept);
}
}
// COCKPIT CANOPY WINDING ORIENTATION (task #55): the *_cop shells are OPEN strut
// lattices (38-59% boundary edges, all 12 mechs) drawn SINGLE-SIDED (emitTri) so
// the openings show the world. But the authored winding is NOT globally
// consistent: the left/right torso patches are MIRRORED copies, and mirroring
// flips winding handedness -- so one global winding choice shows one half's
// struts and the other half's BACKS (the solid-box look on BLX/OWX/VUX...).
// Data-driven fix: orient every canopy face INWARD, toward the mesh interior
// where the pilot's eye sits, so the whole shell reads correctly from inside
// regardless of per-patch mirroring. BT_COP_FLIP=1 flips the final orientation
// (diagnostic); BT_COP_DOUBLE=1 disables single-siding entirely (emitTri).
if (meshIsCop) {
static int s_flip = -1, s_dbl = -1;
if (s_flip < 0) { const char* e = getenv("BT_COP_FLIP"); s_flip = (e && e[0] != '0') ? 1 : 0; }
if (s_dbl < 0) { const char* e = getenv("BT_COP_DOUBLE"); s_dbl = (e && e[0] != '0') ? 1 : 0; }
if (!s_dbl && !px.empty()) {
float cx = 0, cy = 0, cz = 0;
for (size_t i = 0; i < px.size(); ++i) { cx += px[i]; cy += py[i]; cz += pz[i]; }
cx /= px.size(); cy /= px.size(); cz /= px.size();
for (size_t i = 0; i + 3 <= mesh->indices.size(); i += 3) {
uint32_t a = mesh->indices[i], b = mesh->indices[i+1], c = mesh->indices[i+2];
float ux = px[b]-px[a], uy = py[b]-py[a], uz = pz[b]-pz[a];
float wx = px[c]-px[a], wy = py[c]-py[a], wz = pz[c]-pz[a];
float fnx = uy*wz - uz*wy, fny = uz*wx - ux*wz, fnz = ux*wy - uy*wx;
float fcx = (px[a]+px[b]+px[c])/3.0f - cx;
float fcy = (py[a]+py[b]+py[c])/3.0f - cy;
float fcz = (pz[a]+pz[b]+pz[c])/3.0f - cz;
bool inward = (fnx*fcx + fny*fcy + fnz*fcz) < 0.0f; // normal points toward interior
if (inward == (s_flip != 0)) // flip to the chosen orientation
{ mesh->indices[i+1] = c; mesh->indices[i+2] = b; }
}
}
}
if (meshIsTSphere) {
tessellateWarpCone();
// tessellateWarpCone REBUILT mesh->indices -> the per-batch indexStart/
// indexCount set back in buildPmesh now point at the STALE pre-tessellation
// range, so the draw would render only that fragment (the "sliver").
// tsphere is a single ramped batch covering the whole cone: re-point it at
// the full new index list.
if (!mesh->batches.empty()) {
mesh->batches[0].indexStart = 0;
mesh->batches[0].indexCount = (uint32_t)mesh->indices.size();
}
// THE SWIRL (decomp FUN_00453dc4 does only spin+submit -> the pattern is the
// MATERIAL, i.e. the bintA cloud through the ramp, NOT geometry). Earlier we
// wrongly painted per-vertex "bands" by |z|/halfLen -> sterile concentric
// rings. The real look is the cloud in the bicone's log-polar UVs (angle,
// log-radius) with a helical twist + narrow lavender ramp at low contrast,
// all set up on the UVs/ramp above; the mesh just carries it and spins. So
// finish() only smooths the facets -- no vertex-colour override here.
}
size_t nv = px.size();
mesh->verts.resize(nv);
for (size_t i = 0; i < nv; ++i) {
float len = std::sqrt(nx[i]*nx[i] + ny[i]*ny[i] + nz[i]*nz[i]);
if (len < 1e-8f) { nx[i]=0; ny[i]=1; nz[i]=0; len=1; }
BgfVtx& o = mesh->verts[i];
o.x = px[i]; o.y = py[i]; o.z = pz[i];
o.nx = nx[i]/len; o.ny = ny[i]/len; o.nz = nz[i]/len;
o.color = col[i];
o.u = uu[i]; o.v = vv[i];
}
}
};
} // namespace
// EXTERNAL LINKAGE (task #20): must be OUTSIDE the anonymous namespace above,
// or it gets internal linkage -> L4VIDEO.obj's call is unresolved -> the game's
// /FORCE link stubs it to a garbage address -> crash on first call. It can
// still reach the file-static g_pathPriority/g_indexCache + normPath (anonymous-
// namespace names are visible unqualified for the rest of this TU).
void SetVideoPathPriority(const std::string& cacheKey, const std::vector<std::string>& dirs) {
std::vector<std::string> norm;
norm.reserve(dirs.size());
for (const std::string& d : dirs) norm.push_back(normPath(d));
g_pathPriority[cacheKey] = std::move(norm);
g_indexCache.erase(cacheKey); // force a rebuild on next lookup with the new priority
}
bool LoadBgfFile(const std::string& name, BgfData& out) {
const auto& bgfs = bgfIndex();
auto f = bgfs.find(stemLower(name));
if (f == bgfs.end()) { out.error = "bgf not found: " + name; return false; }
const std::string& path = f->second;
std::vector<uint8_t> buf = readFileBytes(path);
if (buf.size() <= 8 || std::memcmp(buf.data(), "DIV-BIZ2", 8) != 0) {
out.error = "bad/empty bgf: " + path; return false;
}
std::vector<Chunk> roots;
if (!parseRange(buf.data() + 8, buf.data() + buf.size(), roots)) {
out.error = "chunk parse failed: " + path; return false;
}
MaterialResolver res;
Builder b{&out};
// Cockpit canopy shells are keyed by filename (blx_cop.bgf, max_cop.bgf, ...): the
// single-siding + dark-frame ramp reconstruction in emitTri/buildPmesh applies to
// these meshes only.
b.meshIsCop = stemLower(name).find("_cop") != std::string::npos;
b.res = &res;
for (const Chunk& c : roots) b.collect(c);
b.finish();
out.matTotal = res.uniqueTotal;
out.matResolved = res.uniqueResolved;
if (out.verts.empty() || out.indices.empty()) {
out.error = "no renderable geometry: " + path; return false;
}
out.ok = true;
return true;
}