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>
1216 lines
65 KiB
C++
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;
|
|
}
|