// 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 #include #include #include #include #include #include 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 readFileBytes(const std::string& path) { std::vector 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> g_indexCache; static std::map> 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& 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& idx, std::map& rankOf, const std::vector& exts, const std::vector& 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& indexFor(const std::string& root, const std::vector& exts, const std::string& cacheKey) { auto it = g_indexCache.find(cacheKey); if (it != g_indexCache.end()) return it->second; std::map idx; std::map 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& bgfIndex() { return indexFor(VIDEO_ROOT, {".bgf"}, "bgf"); } const std::map& bmfIndex() { return indexFor(VIDEO_ROOT, {".bmf"}, "bmf"); } const std::map& 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 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& 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> libs; std::map resolvedCache; int uniqueTotal = 0, uniqueResolved = 0; static void collectTextureMaps(const std::vector& cs, std::map& 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& cs, std::map >& out) { for (const Chunk& c : cs) { if (c.id == TAG_RAMP) { std::string name; std::array 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 >& globalRamps() { static std::map > 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 buf = readFileBytes(kv.second); if (buf.size() <= 8 || std::memcmp(buf.data(), "DIV-BIZ2", 8) != 0) continue; std::vector roots; if (!parseRange(buf.data() + 8, buf.data() + buf.size(), roots)) continue; std::map > ramps; collectRamps(roots, ramps); for (const auto& r : ramps) reg.insert(r); // first wins } } } return reg; } static void collectMaterials(const std::vector& cs, const std::map& texMaps, const std::map >& ramps, std::map& 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* 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& library(const std::string& lib) { auto it = libs.find(lib); if (it != libs.end()) return it->second; std::map table; const auto& idx = bmfIndex(); auto f = idx.find(toLower(lib)); if (f != idx.end()) { std::vector buf = readFileBytes(f->second); if (buf.size() > 8 && std::memcmp(buf.data(), "DIV-BIZ2", 8) == 0) { std::vector roots; if (parseRange(buf.data() + 8, buf.data() + buf.size(), roots)) { std::map texMaps; std::map > 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 currentHasEmissive = false; float currentEmissive[3] = {0,0,0}; // per-vertex scratch (normals accumulated from forward triangles only) std::vector px, py, pz, nx, ny, nz, uu, vv; std::vector col; void emitTri(uint32_t a, uint32_t b, uint32_t c) { // forward winding (+ accumulate face normal) mesh->indices.push_back(a); mesh->indices.push_back(b); mesh->indices.push_back(c); // back winding too -> double sided (engine culls CW; this guarantees visibility) mesh->indices.push_back(a); mesh->indices.push_back(c); mesh->indices.push_back(b); mesh->tris++; 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; 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& 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 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) && 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; const uint32_t vcol = pureEmissive ? 0xFF000000u : (useRamp ? rampTint : currentColor); 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]); vv.push_back(lv[i]); 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 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 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; for (int i = 0; i < 3; ++i) { batch.rampLo[i] = currentRampLo[i]; batch.rampHi[i] = currentRampHi[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); } } 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 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; } 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; } break; } for (const Chunk& ch : c.children) collect(ch); currentColor = savedColor; currentHasDiffuse = savedHasDiffuse; currentTex = savedTex; currentTexChannel = savedTexChannel; currentHasRamp = savedHasRamp; currentPunch = savedPunch; currentShadowMat = savedShadowMat; 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; } } void finish() { 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& dirs) { std::vector 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 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 roots; if (!parseRange(buf.data() + 8, buf.data() + buf.size(), roots)) { out.error = "chunk parse failed: " + path; return false; } MaterialResolver res; Builder b{&out}; 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; }