The death/respawn "blue whirlwind" (tsphere) now matches the original cabinet
photo (capture.png): a smooth spinning lavender vortex with a bright core.
Root cause of the long-standing "radial spokes" artifact was NOT the warp code
but a general engine bug: L4D3D::SetTextureScrolling computed its texture-matrix
offset as scrollDelta * absolute_time, which grows unbounded and collapses UV
float precision -> a smooth scrolled cloud shatters into grainy radial steps.
Wrapped with fmodf(..., 1.0f) (identical under REPEAT tiling, full precision).
This also cleans the scrolling bexp beam grit and any other SCROLL material.
Visual reconstruction (verified against the real 45-vtx TSPHERE.BGF bicone,
offline-rasterized then ported):
- view ON-AXIS (eye centred on the throat) + spin in place -> concentric rings
(decomp FUN_00453dc4 does spin-about-local-Z + submit; the port had stubbed it)
- bintA cloud through a WIDE lavender ramp at full contrast (drawn as SKY);
no geometry "bands", no log-polar twist, no off-axis tornado (all discarded)
- tessellate the 12-facet bicone smooth; isotropic + trilinear; ramp baked into
the texture and drawn SELECTARG1(TEXTURE) to avoid double-tinting
Env knobs (BT_WARP_*) default to the verified values; BT_WARP_SELFTEST/SELFSHOT
are an off-by-default visual-verification harness (backbuffer frame dump).
Docs: new context/translocation-warp.md (geometry/material/visual/lifecycle/env);
reconstruction-gotchas.md gains the accumulated-time precision-collapse bug class;
rendering.md / multiplayer.md / decomp-reference.md / CLAUDE.md cross-linked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1105 lines
57 KiB
C++
1105 lines
57 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 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) {
|
|
// 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<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;
|
|
const uint32_t vcol = pureEmissive ? 0xFF000000u
|
|
: (useRamp ? rampTint : currentColor);
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
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() {
|
|
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};
|
|
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;
|
|
}
|