// 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 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) && 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<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;
            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<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;
}
