diff --git a/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h b/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h new file mode 100644 index 000000000..99391d7d5 --- /dev/null +++ b/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h @@ -0,0 +1,146 @@ +#pragma once + +#include +#include +#include + +namespace IW3::d3dbsp +{ + inline constexpr std::array BSP_MAGIC{'I', 'B', 'S', 'P'}; + inline constexpr uint32_t BSP_VERSION = 22u; + inline constexpr uint32_t RUNTIME_MATERIAL_CONTENT_MASK = 0xdffffffbu; + inline constexpr uint32_t RAW_BSP_BRUSH_CONTENT_MARKER = 0x20000000u; + + // IW3 v22 stores primary lights as DiskPrimaryLight records. The linker + // converts these to runtime ComPrimaryLight records and derives + // cosHalfFovExpanded while loading. + inline constexpr size_t RAW_PRIMARY_LIGHT_SIZE = 128uz; + + // Synthesized from cod4map/linker_pc/Radiant loader usage. IW3 v22 stores + // some render geometry twice: layered data in the low lump range and simple + // data in the later lump range. + enum class LumpType : uint32_t + { + LUMP_MATERIALS = 0, + LUMP_LIGHTMAPS = 1, + LUMP_LIGHTGRID_ENTRIES = 2, + LUMP_LIGHTGRID_COLORS = 3, + LUMP_PLANES = 4, + LUMP_BRUSHSIDES = 5, + LUMP_BRUSHSIDE_EDGE_COUNTS = 6, + LUMP_BRUSHEDGES = 7, + LUMP_BRUSHES = 8, + + // Layered world geometry. LUMP_VERTEX_LAYER_DATA is the extra payload + // paired with LUMP_LAYERED_VERTS. + LUMP_LAYERED_TRI_SOUPS = 9, + LUMP_LAYERED_VERTS = 10, + LUMP_LAYERED_INDICES = 11, + LUMP_CULLGROUPS = 12, + LUMP_CULLGROUP_INDICES = 13, + + LUMP_OBSOLETE_1 = 14, + LUMP_OBSOLETE_2 = 15, + LUMP_OBSOLETE_3 = 16, + LUMP_OBSOLETE_4 = 17, + LUMP_OBSOLETE_5 = 18, + LUMP_PORTALVERTS = 19, + LUMP_OBSOLETE_6 = 20, + LUMP_UINDS = 21, + LUMP_BRUSHVERTSCOUNTS = 22, + LUMP_BRUSHVERTS = 23, + LUMP_LAYERED_AABBTREES = 24, + LUMP_CELLS = 25, + LUMP_PORTALS = 26, + LUMP_NODES = 27, + LUMP_LEAFS = 28, + LUMP_LEAFBRUSHES = 29, + LUMP_LEAFSURFACES = 30, + LUMP_COLLISIONVERTS = 31, + LUMP_COLLISIONTRIS = 32, + LUMP_COLLISION_EDGE_WALKABLE = 33, + LUMP_COLLISIONBORDERS = 34, + LUMP_COLLISIONPARTITIONS = 35, + LUMP_COLLISIONAABBS = 36, + LUMP_MODELS = 37, + LUMP_VISIBILITY = 38, // Optional PVS data; loaders can fall back when it is absent. + LUMP_ENTITIES = 39, // Raw entity text consumed by linker and MapEnts. + LUMP_PATHCONNECTIONS = 40, // SP path data; absent for MP maps. + LUMP_REFLECTION_PROBES = 41, + LUMP_VERTEX_LAYER_DATA = 42, + LUMP_PRIMARY_LIGHTS = 43, + LUMP_LIGHTGRID_HEADER = 44, + LUMP_LIGHTGRID_ROWS = 45, + LUMP_OBSOLETE_10 = 46, + + // Simple/non-layered world geometry. IW3 v22 keeps this alongside the + // layered set so the linker/Radiant can choose the appropriate path. + LUMP_SIMPLE_TRI_SOUPS = 47, + LUMP_SIMPLE_VERTS = 48, + LUMP_SIMPLE_INDICES = 49, + LUMP_SIMPLE_CULLGROUPS = 50, + LUMP_SIMPLE_AABBTREES = 51, + LUMP_LIGHT_REGION_COUNTS = 52, + LUMP_LIGHT_REGION_HULLS = 53, + LUMP_LIGHT_REGION_AXES = 54, + }; + + // Matches the stock raw BSP loader's TrisType enum. IW3 v22 can contain + // both layered and unlayered render geometry; linker_pc/Radiant select one + // path before loading surfaces, verts, indices, cull groups, and AABB trees. + enum class TrisType : uint32_t + { + TRIS_TYPE_LAYERED = 0, + TRIS_TYPE_SIMPLE = 1, + TRIS_TYPE_COUNT = 2, + }; + + // IW3 v22 d3dbsp files use this order in the stock tools output. + inline constexpr std::array LUMP_WRITE_ORDER{ + LumpType::LUMP_MATERIALS, + LumpType::LUMP_LIGHTMAPS, + LumpType::LUMP_LIGHTGRID_HEADER, + LumpType::LUMP_LIGHTGRID_ROWS, + LumpType::LUMP_LIGHTGRID_ENTRIES, + LumpType::LUMP_LIGHTGRID_COLORS, + LumpType::LUMP_PLANES, + LumpType::LUMP_BRUSHSIDES, + LumpType::LUMP_BRUSHSIDE_EDGE_COUNTS, + LumpType::LUMP_BRUSHEDGES, + LumpType::LUMP_BRUSHES, + LumpType::LUMP_LAYERED_TRI_SOUPS, + LumpType::LUMP_LAYERED_VERTS, + LumpType::LUMP_VERTEX_LAYER_DATA, + LumpType::LUMP_LAYERED_INDICES, + LumpType::LUMP_CULLGROUPS, + LumpType::LUMP_CULLGROUP_INDICES, + LumpType::LUMP_PORTALVERTS, + LumpType::LUMP_LAYERED_AABBTREES, + LumpType::LUMP_CELLS, + LumpType::LUMP_PORTALS, + LumpType::LUMP_NODES, + LumpType::LUMP_LEAFS, + LumpType::LUMP_LEAFBRUSHES, + LumpType::LUMP_LEAFSURFACES, + LumpType::LUMP_COLLISIONVERTS, + LumpType::LUMP_COLLISIONTRIS, + LumpType::LUMP_COLLISION_EDGE_WALKABLE, + LumpType::LUMP_COLLISIONBORDERS, + LumpType::LUMP_COLLISIONPARTITIONS, + LumpType::LUMP_COLLISIONAABBS, + LumpType::LUMP_MODELS, + LumpType::LUMP_VISIBILITY, + LumpType::LUMP_ENTITIES, + LumpType::LUMP_PRIMARY_LIGHTS, + LumpType::LUMP_LIGHT_REGION_COUNTS, + LumpType::LUMP_LIGHT_REGION_HULLS, + LumpType::LUMP_LIGHT_REGION_AXES, + LumpType::LUMP_SIMPLE_TRI_SOUPS, + LumpType::LUMP_SIMPLE_VERTS, + LumpType::LUMP_SIMPLE_INDICES, + LumpType::LUMP_SIMPLE_CULLGROUPS, + LumpType::LUMP_SIMPLE_AABBTREES, + LumpType::LUMP_PATHCONNECTIONS, + LumpType::LUMP_REFLECTION_PROBES, + }; +} // namespace IW3::d3dbsp diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspAssetCreationStateIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspAssetCreationStateIW3.cpp new file mode 100644 index 000000000..c3c207e4b --- /dev/null +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspAssetCreationStateIW3.cpp @@ -0,0 +1,16 @@ +#include "D3DBspAssetCreationStateIW3.h" + +#include + +using namespace IW3::d3dbsp; + +const LoadResult& AssetCreationState::Load(const std::string& assetName, ISearchPath& searchPath) +{ + const auto existingEntry = m_cache.find(assetName); + if (existingEntry != m_cache.end()) + return existingEntry->second; + + auto result = LoadFromSearchPath(assetName, searchPath); + const auto insertResult = m_cache.emplace(assetName, std::move(result)); + return insertResult.first->second; +} diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspAssetCreationStateIW3.h b/src/ObjLoading/Game/IW3/Maps/D3DBspAssetCreationStateIW3.h new file mode 100644 index 000000000..bfb18f12d --- /dev/null +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspAssetCreationStateIW3.h @@ -0,0 +1,19 @@ +#pragma once + +#include "Asset/IZoneAssetCreationState.h" +#include "D3DBspReaderIW3.h" + +#include +#include + +namespace IW3::d3dbsp +{ + class AssetCreationState final : public IZoneAssetCreationState + { + public: + [[nodiscard]] const LoadResult& Load(const std::string& assetName, ISearchPath& searchPath); + + private: + std::unordered_map m_cache; + }; +} // namespace IW3::d3dbsp diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp new file mode 100644 index 000000000..f23d44ee4 --- /dev/null +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -0,0 +1,7089 @@ +#include "D3DBspLoaderIW3.h" + +#include "Asset/AssetRegistration.h" +#include "D3DBspAssetCreationStateIW3.h" +#include "Game/IW3/CommonIW3.h" +#include "Game/IW3/Maps/D3DBspCommonIW3.h" +#include "Image/D3DFormat.h" +#include "Image/ImageCommon.h" +#include "Image/IwiLoader.h" +#include "Image/IwiTypes.h" +#include "Image/Texture.h" +#include "Utils/Logging/Log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace IW3; + +namespace +{ + using enum IW3::d3dbsp::LumpType; + using enum IW3::d3dbsp::TrisType; + + using BspLoadResult = std::expected; + + template using BspLoadValue = std::expected; + + constexpr auto RAW_LIGHT_TYPE_OFFSET = 0uz; + constexpr auto RAW_LIGHT_CAN_USE_SHADOW_MAP_OFFSET = 1uz; + constexpr auto RAW_LIGHT_UNUSED_OFFSET = 2uz; + constexpr auto RAW_LIGHT_COLOR_OFFSET = 4uz; + constexpr auto RAW_LIGHT_DIR_OFFSET = 16uz; + constexpr auto RAW_LIGHT_ORIGIN_OFFSET = 28uz; + constexpr auto RAW_LIGHT_RADIUS_OFFSET = 40uz; + constexpr auto RAW_LIGHT_COS_HALF_FOV_OUTER_OFFSET = 44uz; + constexpr auto RAW_LIGHT_COS_HALF_FOV_INNER_OFFSET = 48uz; + constexpr auto RAW_LIGHT_EXPONENT_OFFSET = 52uz; + constexpr auto RAW_LIGHT_ROTATION_LIMIT_OFFSET = 56uz; + constexpr auto RAW_LIGHT_TRANSLATION_LIMIT_OFFSET = 60uz; + constexpr auto RAW_LIGHT_DEF_NAME_OFFSET = 64uz; + constexpr auto RAW_LIGHT_DEF_NAME_SIZE = 64uz; + constexpr auto RAW_MATERIAL_SIZE = 72uz; + constexpr auto RAW_PLANE_SIZE = 16uz; + constexpr auto RAW_BRUSHSIDE_SIZE = 8uz; + constexpr auto RAW_BRUSH_HEADER_SIZE = 4uz; + constexpr auto RAW_CLIP_NODE_SIZE = 36uz; + constexpr auto RAW_LEAF_SIZE = 24uz; + constexpr auto RAW_LEAF_CELL_INDEX_OFFSET = 20uz; + constexpr auto RAW_LEAF_BRUSH_SIZE = 4uz; + constexpr auto RAW_VEC3_SIZE = 12uz; + constexpr auto RAW_TRI_INDICES_SIZE = 6uz; + constexpr auto RAW_COLLISION_BORDER_SIZE = 28uz; + constexpr auto RAW_COLLISION_PARTITION_SIZE = 12uz; + constexpr auto RAW_COLLISION_AABB_SIZE = 32uz; + constexpr auto RAW_MODEL_SIZE = 48uz; + constexpr auto RAW_WORLD_SURFACE_SIZE = 24uz; + constexpr auto RAW_WORLD_VERTEX_SIZE = 68uz; + constexpr auto RAW_WORLD_AABB_TREE_SIZE = 12uz; + constexpr auto RAW_WORLD_CELL_SIZE = 112uz; + constexpr auto RAW_WORLD_PORTAL_SIZE = 16uz; + constexpr auto RAW_LIGHTGRID_ENTRY_SIZE = sizeof(GfxLightGridEntry); + constexpr auto RAW_LIGHTGRID_COLOR_SIZE = sizeof(GfxLightGridColors); + constexpr auto RAW_LIGHT_REGION_HULL_SIZE = 76uz; + constexpr auto RAW_LIGHT_REGION_AXIS_SIZE = sizeof(GfxLightRegionAxis); + constexpr auto LIGHTMAP_PRIMARY_RAW_PAGE_SIZE = 0x100000uz; + constexpr auto LIGHTMAP_SECONDARY_RAW_PAGE_SIZE = 0x200000uz; + constexpr auto LIGHTMAP_RAW_PAGE_SIZE = LIGHTMAP_SECONDARY_RAW_PAGE_SIZE + LIGHTMAP_PRIMARY_RAW_PAGE_SIZE; + constexpr auto LIGHTMAP_PRIMARY_RAW_WIDTH = 1024u; + constexpr auto LIGHTMAP_PRIMARY_RAW_HEIGHT = 1024u; + constexpr auto LIGHTMAP_SECONDARY_RAW_WIDTH = 512u; + constexpr auto LIGHTMAP_SECONDARY_RAW_HEIGHT = 1024u; + constexpr auto LIGHTMAP_SECONDARY_HALF_HEIGHT = LIGHTMAP_SECONDARY_RAW_HEIGHT / 2u; + constexpr auto LIGHTMAP_SECONDARY_PIXEL_SIZE = 4u; + constexpr auto REFLECTION_PROBE_SIZE = 64u; + constexpr auto REFLECTION_PROBE_MIP_COUNT = 7u; + constexpr auto REFLECTION_PROBE_NAME_SIZE = 64uz; + constexpr auto REFLECTION_PROBE_RAW_DATA_SIZE = 0x1FFF8uz; + constexpr auto REFLECTION_PROBE_RECORD_SIZE = sizeof(float) * 3uz + REFLECTION_PROBE_NAME_SIZE + REFLECTION_PROBE_RAW_DATA_SIZE; + constexpr auto MATERIAL_USAGE_HASH_SIZE = 0x800uz; + constexpr auto MATERIAL_HASH_SEARCH_SIZE = 0x7FFuz; + constexpr auto MATERIAL_GAME_FLAG_MAGIC_PORTAL = 0x20u; + constexpr auto DEFAULT_MATERIAL_NAME = "$default"; + constexpr auto DEFAULT_MATERIAL_REFERENCE_NAME = ",$default"; + constexpr auto OUTDOOR_IMAGE_NAME = "$outdoor"; + constexpr auto SKY_LIGHTMAP_INDEX = 31u; + constexpr auto MAX_LIGHTMAP_PAGE_COUNT = 31uz; + constexpr auto PATHCONNECTIONS_VERSION = 8u; + // linker_pc uses this exact float literal when converting entity angles to + // radians. It is pi / 180, but the decompiled value avoids tiny ULP drift. + constexpr auto DEG_TO_RAD = 0.01745329238474369f; + + [[nodiscard]] const IW3::d3dbsp::File* GetBspForAsset(const std::string& assetName, ISearchPath& searchPath, AssetCreationContext& context) + { + const auto& loadResult = context.GetZoneAssetCreationState().Load(assetName, searchPath); + if (loadResult.status == IW3::d3dbsp::LoadStatus::NotFound) + return nullptr; + + if (loadResult.status == IW3::d3dbsp::LoadStatus::Invalid) + { + con::error("Could not load d3dbsp for asset \"{}\": {}", assetName, loadResult.message); + return nullptr; + } + + return loadResult.file.get(); + } + + [[nodiscard]] bool BspWasInvalid(const std::string& assetName, ISearchPath& searchPath, AssetCreationContext& context) + { + const auto& loadResult = context.GetZoneAssetCreationState().Load(assetName, searchPath); + return loadResult.status == IW3::d3dbsp::LoadStatus::Invalid; + } + + [[nodiscard]] bool FitsInt(const size_t value) + { + return value <= static_cast(std::numeric_limits::max()); + } + + [[nodiscard]] bool FitsUnsigned(const size_t value) + { + return value <= static_cast(std::numeric_limits::max()); + } + + [[nodiscard]] bool FitsUint16(const size_t value) + { + return value <= static_cast(std::numeric_limits::max()); + } + + [[nodiscard]] bool FitsInt16(const int value) + { + return value >= static_cast(std::numeric_limits::min()) && value <= static_cast(std::numeric_limits::max()); + } + + [[nodiscard]] float LinkerTrig(const float radians, const bool cosine) + { + // The stock x86 linker calls the double sin/cos functions and then + // stores the result into float locals before building the axis. + return static_cast(cosine ? std::cos(static_cast(radians)) : std::sin(static_cast(radians))); + } + + [[nodiscard]] float LinkerFloat(const double value) + { + // Make linker-style float spill points explicit. This keeps generated + // quaternions closer to the 32-bit stock tool than pure float math. + return static_cast(value); + } + + [[nodiscard]] unsigned HashAssetName(const char* name) + { + auto hash = 0u; + if (!name) + return hash; + + for (auto* pos = name; *pos; pos++) + hash = static_cast(*pos) ^ (33u * hash); + + return hash; + } + + [[nodiscard]] std::optional MaterialHashIndex(std::array& materialHashTable, Material* material) + { + if (!material || !material->info.name) + return std::nullopt; + + // linker_pc resolves material usage through Material_GetHashIndex: + // R_HashAssetName(name) % 0x7ff, then linear probe. Raw-loaded material + // structs may have info.hashIndex == 0, so recompute the table here. + const auto* name = material->info.name; + auto hashIndex = static_cast(HashAssetName(name)) % MATERIAL_HASH_SEARCH_SIZE; + const auto beginHashIndex = hashIndex; + do + { + auto* existingMaterial = materialHashTable[hashIndex]; + if (!existingMaterial) + { + materialHashTable[hashIndex] = material; + return hashIndex; + } + + if (existingMaterial == material || (existingMaterial->info.name && std::strcmp(existingMaterial->info.name, name) == 0)) + return hashIndex; + + hashIndex = (hashIndex + 1uz) % MATERIAL_HASH_SEARCH_SIZE; + } while (hashIndex != beginHashIndex); + + return std::nullopt; + } + + [[nodiscard]] float LinkerQuatSizeSq(const float (&candidate)[4], const size_t candidateIndex) + { + // Match the stock MatrixToQuat accumulation order for each candidate + // before the testSizeSq float is compared against 1.0f. + switch (candidateIndex) + { + case 0: + return LinkerFloat(static_cast(candidate[3]) * candidate[3] + static_cast(candidate[1]) * candidate[1] + + static_cast(candidate[0]) * candidate[0] + static_cast(candidate[2]) * candidate[2]); + + case 1: + return LinkerFloat(static_cast(candidate[3]) * candidate[3] + static_cast(candidate[2]) * candidate[2] + + static_cast(candidate[0]) * candidate[0] + static_cast(candidate[1]) * candidate[1]); + + case 2: + return LinkerFloat(static_cast(candidate[3]) * candidate[3] + static_cast(candidate[2]) * candidate[2] + + static_cast(candidate[0]) * candidate[0] + static_cast(candidate[1]) * candidate[1]); + + default: + return LinkerFloat(static_cast(candidate[0]) * candidate[0] + static_cast(candidate[1]) * candidate[1] + + static_cast(candidate[2]) * candidate[2] + static_cast(candidate[3]) * candidate[3]); + } + } + + [[nodiscard]] size_t NonSunPrimaryLightCount(const GfxWorld& world) + { + if (world.primaryLightCount <= world.sunPrimaryLightIndex + 1u) + return 0uz; + + return world.primaryLightCount - world.sunPrimaryLightIndex - 1u; + } + + [[nodiscard]] unsigned char U8(const char value) + { + return static_cast(value); + } + + [[nodiscard]] PackedUnitVec PackRawBspUnitVec(const float (&value)[3]) + { + const auto packComponent = [](const float component) + { + const auto packed = static_cast(static_cast(component) * 127.0 + 127.5); + return static_cast(std::clamp(packed, 0, 255)); + }; + + // Raw BSP vertex normals/tangents use the stock linker packer: + // byte = int(component * 127.0 + 127.5), scale byte = 63. + // Do not use the generic best-fit PackedUnitVec encoder here; it can + // choose a different scale byte and will not dump back to linker_pc's + // canonical world-vertex floats. + return PackedUnitVec{static_cast(packComponent(value[0])) | (static_cast(packComponent(value[1])) << 8u) + | (static_cast(packComponent(value[2])) << 16u) | (63u << 24u)}; + } + + [[nodiscard]] const MaterialTechniqueSet* TechniqueSetForMaterial(const Material* material) + { + if (!material || !material->techniqueSet) + return nullptr; + + return material->techniqueSet->remappedTechniqueSet ? material->techniqueSet->remappedTechniqueSet : material->techniqueSet; + } + + [[nodiscard]] bool MaterialHasTechnique(const Material* material, const MaterialTechniqueType techniqueType) + { + const auto* techniqueSet = TechniqueSetForMaterial(material); + if (!techniqueSet) + return false; + + const auto index = static_cast(techniqueType); + if (index >= static_cast(TECHNIQUE_COUNT)) + return false; + + return techniqueSet->techniques[index] != nullptr; + } + + [[nodiscard]] bool MaterialHasWorldLitTechnique(const Material* material) + { + // Some linked/OAT material techsets can be referenced or minimally populated + // even though the material still carries the stock camera region. World decals + // are still sorted as lit surfaces; sortKey separates them into the decal range. + return MaterialHasTechnique(material, TECHNIQUE_LIT_BEGIN) + || (material && (material->cameraRegion == CAMERA_REGION_LIT || material->cameraRegion == CAMERA_REGION_DECAL)); + } + + [[nodiscard]] bool MaterialHasWorldEmissiveTechnique(const Material* material) + { + return MaterialHasTechnique(material, TECHNIQUE_EMISSIVE) || (material && material->cameraRegion == CAMERA_REGION_EMISSIVE); + } + + [[nodiscard]] const MaterialTechnique* MaterialTechniqueForType(const Material* material, const MaterialTechniqueType techniqueType) + { + const auto* techniqueSet = TechniqueSetForMaterial(material); + if (!techniqueSet) + return nullptr; + + const auto index = static_cast(techniqueType); + if (index >= static_cast(TECHNIQUE_COUNT)) + return nullptr; + + return techniqueSet->techniques[index]; + } + + [[nodiscard]] unsigned MaterialPrimarySortKey(const Material* material) + { + return material ? static_cast(material->info.sortKey) : 0u; + } + + [[nodiscard]] unsigned MaterialStandardPrepassSortKey(const Material* material) + { + if (!material || !material->techniqueSet) + return 3u; + + const auto* prepassTechnique = material->techniqueSet->techniques[TECHNIQUE_DEPTH_PREPASS]; + if (prepassTechnique) + return (material->stateFlags & STATE_FLAG_DECAL) ? 3u : ((prepassTechnique->flags & MTL_TECHFLAG_ZPREPASS) == 0u ? 1u : 0u); + + return material->techniqueSet->techniques[TECHNIQUE_BUILD_FLOAT_Z] ? 2u : 3u; + } + + [[nodiscard]] const char* TechniquePixelShaderName(const MaterialTechnique* technique) + { + if (!technique || !technique->passArray[0].pixelShader || !technique->passArray[0].pixelShader->name) + return ""; + + return technique->passArray[0].pixelShader->name; + } + + [[nodiscard]] const char* TechniqueVertexShaderName(const MaterialTechnique* technique) + { + if (!technique || !technique->passArray[0].vertexShader || !technique->passArray[0].vertexShader->name) + return ""; + + return technique->passArray[0].vertexShader->name; + } + + [[nodiscard]] int CompareCString(const char* left, const char* right) + { + return std::strcmp(left ? left : "", right ? right : ""); + } + + struct PixelLiteralConst + { + uint16_t dest; + float value[4]; + }; + + struct PixelConstData + { + std::vector codeConsts; + std::vector literalConsts; + }; + + void InsertPixelLiteralConst(std::vector& literalConsts, const uint16_t dest, const float* value) + { + if (!value) + return; + + PixelLiteralConst entry{ + dest, {value[0], value[1], value[2], value[3]} + }; + auto insertPosition = literalConsts.begin(); + while (insertPosition != literalConsts.end() && insertPosition->dest <= dest) + ++insertPosition; + + literalConsts.insert(insertPosition, entry); + } + + [[nodiscard]] const MaterialConstantDef* FindMaterialConstantByHash(const Material& material, const unsigned nameHash) + { + for (auto constantIndex = 0u; constantIndex < material.constantCount; constantIndex++) + { + const auto& constant = material.constantTable[constantIndex]; + if (constant.nameHash == nameHash) + return &constant; + } + + return nullptr; + } + + [[nodiscard]] PixelConstData PixelConstDataForTechnique(const Material& material, const MaterialTechnique& technique) + { + PixelConstData result; + const auto& pass = technique.passArray[0]; + auto argIndex = static_cast(U8(pass.perPrimArgCount)) + static_cast(U8(pass.perObjArgCount)); + auto remainingArgs = static_cast(U8(pass.stableArgCount)); + + while (remainingArgs > 0u && pass.args[argIndex].type < MTL_ARG_CODE_PIXEL_CONST) + { + argIndex++; + remainingArgs--; + } + + while (remainingArgs > 0u && pass.args[argIndex].type == MTL_ARG_CODE_PIXEL_CONST) + { + result.codeConsts.emplace_back(pass.args[argIndex].u.codeConst.index); + argIndex++; + remainingArgs--; + } + + while (remainingArgs > 0u && pass.args[argIndex].type < MTL_ARG_MATERIAL_PIXEL_CONST) + { + argIndex++; + remainingArgs--; + } + + while (remainingArgs > 0u && pass.args[argIndex].type == MTL_ARG_MATERIAL_PIXEL_CONST) + { + const auto* constant = FindMaterialConstantByHash(material, pass.args[argIndex].u.nameHash); + if (constant) + InsertPixelLiteralConst(result.literalConsts, pass.args[argIndex].dest, constant->literal.v); + + argIndex++; + remainingArgs--; + } + + while (remainingArgs > 0u && pass.args[argIndex].type == MTL_ARG_LITERAL_PIXEL_CONST) + { + if (pass.args[argIndex].u.literalConst) + InsertPixelLiteralConst(result.literalConsts, pass.args[argIndex].dest, *pass.args[argIndex].u.literalConst); + + argIndex++; + remainingArgs--; + } + + return result; + } + + [[nodiscard]] int + ComparePixelConsts(const Material& left, const MaterialTechnique& leftTechnique, const Material& right, const MaterialTechnique& rightTechnique) + { + // linker_pc uses pixel constant ordering as a material sort tie-breaker. + // This affects world surface order for materials that otherwise share the + // same sort key, prepass, shaders, and techset. + const auto leftConsts = PixelConstDataForTechnique(left, leftTechnique); + const auto rightConsts = PixelConstDataForTechnique(right, rightTechnique); + + if (leftConsts.codeConsts.size() != rightConsts.codeConsts.size()) + return static_cast(leftConsts.codeConsts.size()) - static_cast(rightConsts.codeConsts.size()); + + for (auto constIndex = 0uz; constIndex < leftConsts.codeConsts.size(); constIndex++) + { + if (leftConsts.codeConsts[constIndex] != rightConsts.codeConsts[constIndex]) + return static_cast(leftConsts.codeConsts[constIndex]) - static_cast(rightConsts.codeConsts[constIndex]); + } + + if (leftConsts.literalConsts.size() != rightConsts.literalConsts.size()) + return static_cast(leftConsts.literalConsts.size()) - static_cast(rightConsts.literalConsts.size()); + + for (auto constIndex = 0uz; constIndex < leftConsts.literalConsts.size(); constIndex++) + { + const auto& leftConst = leftConsts.literalConsts[constIndex]; + const auto& rightConst = rightConsts.literalConsts[constIndex]; + if (leftConst.dest != rightConst.dest) + return static_cast(leftConst.dest) - static_cast(rightConst.dest); + + for (auto componentIndex = 0uz; componentIndex < 4uz; componentIndex++) + { + if (rightConst.value[componentIndex] > leftConst.value[componentIndex]) + return -1; + if (rightConst.value[componentIndex] < leftConst.value[componentIndex]) + return 1; + } + } + + return 0; + } + + [[nodiscard]] int CompareWorldMaterialsForSort(const Material* left, const Material* right) + { + if (left == right) + return 0; + if (!left) + return 1; + if (!right) + return -1; + + const auto* leftLit = MaterialTechniqueForType(left, TECHNIQUE_LIT_BEGIN); + const auto* rightLit = MaterialTechniqueForType(right, TECHNIQUE_LIT_BEGIN); + const auto leftHasLit = MaterialHasWorldLitTechnique(left); + const auto rightHasLit = MaterialHasWorldLitTechnique(right); + if (leftHasLit != rightHasLit) + return rightHasLit - leftHasLit; + + const auto leftHasLightmap = (left->info.gameFlags & MTL_GAMEFLAG_2) != 0u; + const auto rightHasLightmap = (right->info.gameFlags & MTL_GAMEFLAG_2) != 0u; + const auto* leftEmissive = MaterialTechniqueForType(left, TECHNIQUE_EMISSIVE); + const auto* rightEmissive = MaterialTechniqueForType(right, TECHNIQUE_EMISSIVE); + const auto leftHasEmissive = MaterialHasWorldEmissiveTechnique(left); + const auto rightHasEmissive = MaterialHasWorldEmissiveTechnique(right); + + if (leftHasLit) + { + if (left->info.sortKey != right->info.sortKey) + return static_cast(left->info.sortKey) - static_cast(right->info.sortKey); + if (leftHasLightmap != rightHasLightmap) + return rightHasLightmap - leftHasLightmap; + } + else + { + if (leftHasEmissive != rightHasEmissive) + return rightHasEmissive - leftHasEmissive; + if (left->info.sortKey != right->info.sortKey) + return static_cast(left->info.sortKey) - static_cast(right->info.sortKey); + } + + const auto leftPrepass = MaterialStandardPrepassSortKey(left); + const auto rightPrepass = MaterialStandardPrepassSortKey(right); + if (leftPrepass != rightPrepass) + return static_cast(leftPrepass) - static_cast(rightPrepass); + + const auto leftWritesDepth = (left->stateFlags & STATE_FLAG_WRITES_DEPTH) != 0u; + const auto rightWritesDepth = (right->stateFlags & STATE_FLAG_WRITES_DEPTH) != 0u; + if (leftWritesDepth != rightWritesDepth) + return rightWritesDepth - leftWritesDepth; + + if (leftHasLit && leftLit && rightLit) + { + if (const auto shaderComparison = CompareCString(TechniquePixelShaderName(leftLit), TechniquePixelShaderName(rightLit))) + return shaderComparison; + if (leftWritesDepth) + { + if (const auto constComparison = ComparePixelConsts(*left, *leftLit, *right, *rightLit)) + return constComparison; + } + if (const auto shaderComparison = CompareCString(TechniqueVertexShaderName(leftLit), TechniqueVertexShaderName(rightLit))) + return shaderComparison; + } + else if (leftHasEmissive && leftEmissive && rightEmissive) + { + if (const auto shaderComparison = CompareCString(TechniquePixelShaderName(leftEmissive), TechniquePixelShaderName(rightEmissive))) + return shaderComparison; + if (const auto constComparison = ComparePixelConsts(*left, *leftEmissive, *right, *rightEmissive)) + return constComparison; + if (const auto shaderComparison = CompareCString(TechniqueVertexShaderName(leftEmissive), TechniqueVertexShaderName(rightEmissive))) + return shaderComparison; + } + + const auto* leftTechniqueSet = TechniqueSetForMaterial(left); + const auto* rightTechniqueSet = TechniqueSetForMaterial(right); + if (const auto techniqueSetComparison = + CompareCString(leftTechniqueSet ? leftTechniqueSet->name : nullptr, rightTechniqueSet ? rightTechniqueSet->name : nullptr)) + return techniqueSetComparison; + + return CompareCString(left->info.name, right->info.name); + } + + void AssignWorldMaterialDrawSurfSortKeys(const std::vector*>& materialDependencies) + { + // The stock linker runs Material_SortInternal before sorting BSP surfaces. + // Recompute the drawSurf keys for the world materials locally so the raw + // triangle lumps dump in linker_pc's canonical order. + std::vector materials; + std::unordered_set seenMaterials; + materials.reserve(materialDependencies.size()); + + for (auto* dependency : materialDependencies) + { + auto* material = dependency ? dependency->Asset() : nullptr; + if (!material || seenMaterials.contains(material)) + continue; + + seenMaterials.emplace(material); + materials.emplace_back(material); + } + + std::sort(materials.begin(), + materials.end(), + [](const Material* left, const Material* right) + { + return CompareWorldMaterialsForSort(left, right) < 0; + }); + + for (auto sortedIndex = 0uz; sortedIndex < materials.size(); sortedIndex++) + { + auto* material = materials[sortedIndex]; + material->info.drawSurf.packed = 0u; + material->info.drawSurf.fields.primarySortKey = MaterialPrimarySortKey(material); + material->info.drawSurf.fields.prepass = MaterialStandardPrepassSortKey(material); + material->info.drawSurf.fields.customIndex = (material->info.gameFlags & MTL_GAMEFLAG_CASTS_SHADOW) != 0u; + material->info.drawSurf.fields.materialSortedIndex = sortedIndex; + } + } + + [[nodiscard]] unsigned char SamplerStateByte(const MaterialTextureDefSamplerState& samplerState) + { + return static_cast((samplerState.filter & SAMPLER_FILTER_MASK) | ((samplerState.mipMap & SAMPLER_MIPMAP_COUNT) << SAMPLER_MIPMAP_SHIFT) + | (samplerState.clampU ? SAMPLER_CLAMP_U : 0) | (samplerState.clampV ? SAMPLER_CLAMP_V : 0) + | (samplerState.clampW ? SAMPLER_CLAMP_W : 0)); + } + + [[nodiscard]] BspLoadResult + ValidateRecordLump(const IW3::d3dbsp::File& bsp, const IW3::d3dbsp::Lump* lump, const IW3::d3dbsp::LumpType type, const size_t recordSize) + { + if (!lump) + return std::unexpected(std::format("missing lump {}", std::to_underlying(type))); + + if (recordSize == 0uz || lump->data.size() % recordSize != 0uz) + return std::unexpected(std::format("{} lump {} has funny size {}", bsp.m_file_name, std::to_underlying(type), lump->data.size())); + + return {}; + } + + [[nodiscard]] size_t RecordCount(const IW3::d3dbsp::Lump& lump, const size_t recordSize) + { + return recordSize > 0uz ? lump.data.size() / recordSize : 0uz; + } + + [[nodiscard]] BspLoadValue + RequiredRecordLump(const IW3::d3dbsp::File& bsp, const IW3::d3dbsp::LumpType type, const size_t recordSize) + { + const auto* lump = bsp.GetLump(type); + if (auto result = ValidateRecordLump(bsp, lump, type, recordSize); !result) + return std::unexpected(std::move(result.error())); + + return lump; + } + + template T* AllocZeroed(MemoryManager& memory, const size_t count = 1uz) + { + auto* result = memory.Alloc(count); + if (result && count > 0uz) + std::memset(result, 0, sizeof(T) * count); + + return result; + } + + template T* AllocCopy(MemoryManager& memory, const std::vector& data) + { + if (data.empty()) + return nullptr; + + auto* result = memory.Alloc(data.size() / sizeof(T)); + std::memcpy(result, data.data(), data.size()); + return result; + } + + template void FillArray(T* data, const size_t count, const unsigned char value) + { + if (data && count > 0uz) + std::memset(data, value, sizeof(T) * count); + } + + template void CopyUnaligned(const std::byte* source, T& destination) + { + std::memcpy(&destination, source, sizeof(T)); + } + + template [[nodiscard]] T ReadUnaligned(const std::byte* source) + { + T result; + std::memcpy(&result, source, sizeof(T)); + return result; + } + + void CopyFloat3(const std::byte* source, float (&destination)[3]) + { + std::memcpy(destination, source, sizeof(destination)); + } + + [[nodiscard]] char ReadRawByte(const std::byte* source, const size_t offset) + { + return static_cast(std::to_integer(source[offset])); + } + + [[nodiscard]] uint32_t ReadU32(const std::byte* source, const size_t offset = 0uz) + { + return ReadUnaligned(source + offset); + } + + [[nodiscard]] int32_t ReadI32(const std::byte* source, const size_t offset = 0uz) + { + return ReadUnaligned(source + offset); + } + + [[nodiscard]] uint16_t ReadU16(const std::byte* source, const size_t offset = 0uz) + { + return ReadUnaligned(source + offset); + } + + [[nodiscard]] float ReadFloat(const std::byte* source, const size_t offset = 0uz) + { + return ReadUnaligned(source + offset); + } + + [[nodiscard]] std::optional ParseFloat(std::string_view value) + { + std::string temp(value); + char* end = nullptr; + const auto result = std::strtof(temp.c_str(), &end); + if (end == temp.c_str()) + return std::nullopt; + + return result; + } + + [[nodiscard]] int ParseInt(std::string_view value, const int fallback = 0) + { + std::string temp(value); + char* end = nullptr; + const auto result = std::strtol(temp.c_str(), &end, 10); + if (end == temp.c_str()) + return fallback; + + return static_cast(result); + } + + [[nodiscard]] std::optional> ParseFloat3(std::string_view value) + { + std::array result{}; + std::string temp(value); + const char* cursor = temp.c_str(); + + for (auto i = 0uz; i < result.size(); i++) + { + while (*cursor && std::isspace(static_cast(*cursor))) + cursor++; + + char* end = nullptr; + result[i] = std::strtof(cursor, &end); + if (end == cursor) + return std::nullopt; + + cursor = end; + } + + return result; + } + + [[nodiscard]] std::optional> ParseLinkerFloat3(std::string_view value) + { + if (value.empty()) + return std::nullopt; + + std::array result{}; + std::string temp(value); + if (std::sscanf(temp.c_str(), "%f %f %f", &result[0], &result[1], &result[2]) != 3) + return std::nullopt; + + return result; + } + + [[nodiscard]] std::string RawString(const std::byte* data, const size_t maxLength) + { + auto length = 0uz; + while (length < maxLength && data[length] != std::byte{}) + length++; + + return std::string(reinterpret_cast(data), length); + } + + [[nodiscard]] std::string RawMaterialName(const std::byte* record) + { + return RawString(record, 64uz); + } + + [[nodiscard]] bool HasPathSeparator(const std::string_view value) + { + return value.find('/') != std::string_view::npos || value.find('\\') != std::string_view::npos; + } + + [[nodiscard]] std::string BspBaseName(std::string_view assetName) + { + const auto lastSlash = assetName.find_last_of("/\\"); + auto baseName = lastSlash == std::string_view::npos ? assetName : assetName.substr(lastSlash + 1uz); + + constexpr std::string_view BSP_EXTENSION = ".d3dbsp"; + if (baseName.size() >= BSP_EXTENSION.size() && baseName.substr(baseName.size() - BSP_EXTENSION.size()) == BSP_EXTENSION) + baseName.remove_suffix(BSP_EXTENSION.size()); + + return std::string(baseName); + } + + void CrossProduct(const float (&left)[3], const float (&right)[3], float (&out)[3]) + { + out[0] = left[1] * right[2] - left[2] * right[1]; + out[1] = left[2] * right[0] - left[0] * right[2]; + out[2] = left[0] * right[1] - left[1] * right[0]; + } + + [[nodiscard]] float DotProduct(const float (&left)[3], const float (&right)[3]) + { + return left[0] * right[0] + left[1] * right[1] + left[2] * right[2]; + } + + [[nodiscard]] float LengthSquared(const float (&value)[3]) + { + return DotProduct(value, value); + } + + void Normalize(float (&value)[3]) + { + const auto lengthSq = LengthSquared(value); + if (lengthSq <= 0.0f) + return; + + const auto invLength = 1.0f / std::sqrt(lengthSq); + for (auto axis = 0uz; axis < 3uz; axis++) + value[axis] *= invLength; + } + + void PerpendicularVector(const float (&source)[3], float (&destination)[3]) + { + auto bestAxis = 0uz; + auto bestAbs = std::fabs(source[0]); + for (auto axis = 1uz; axis < 3uz; axis++) + { + const auto currentAbs = std::fabs(source[axis]); + if (currentAbs < bestAbs) + { + bestAxis = axis; + bestAbs = currentAbs; + } + } + + float temp[3]{}; + temp[bestAxis] = 1.0f; + const auto projection = DotProduct(temp, source); + for (auto axis = 0uz; axis < 3uz; axis++) + destination[axis] = temp[axis] - projection * source[axis]; + Normalize(destination); + } + + [[nodiscard]] uint8_t ClampToByte(const int value) + { + return static_cast(std::clamp(value, 0, 255)); + } + + [[nodiscard]] uint32_t PackRgba(const uint8_t r, const uint8_t g, const uint8_t b, const uint8_t a) + { + return static_cast(r) | (static_cast(g) << 8u) | (static_cast(b) << 16u) | (static_cast(a) << 24u); + } + + [[nodiscard]] uint32_t TransformReflectionProbeColor(const uint8_t r, const uint8_t g, const uint8_t b) + { + constexpr double LUMA_R = 0.1140000000596046; + constexpr double LUMA_G = 0.5870000123977661; + constexpr double LUMA_B = 0.2989999949932098; + constexpr double BLACK_LEVEL = 0.3; + constexpr double WHITE_LEVEL = 0.7; + constexpr double GAMMA = 1.2; + constexpr double SATURATION = 0.5; + + const auto range = WHITE_LEVEL - BLACK_LEVEL; + double color[3]{ + std::pow(std::max(0.0, (static_cast(r) / 255.0 - BLACK_LEVEL) / range), GAMMA), + std::pow(std::max(0.0, (static_cast(g) / 255.0 - BLACK_LEVEL) / range), GAMMA), + std::pow(std::max(0.0, (static_cast(b) / 255.0 - BLACK_LEVEL) / range), GAMMA), + }; + + const auto luma = LUMA_R * color[0] + LUMA_G * color[1] + LUMA_B * color[2]; + for (auto& channel : color) + channel = SATURATION * channel + (1.0 - SATURATION) * luma; + + const auto scale = std::max({0.1, color[0], color[1], color[2]}); + return PackRgba(ClampToByte(static_cast(color[0] / scale * 255.0)), + ClampToByte(static_cast(color[1] / scale * 255.0)), + ClampToByte(static_cast(color[2] / scale * 255.0)), + ClampToByte(static_cast(255.0 * (scale * 0.25)))); + } + + [[nodiscard]] std::optional ParseHexByte(const std::string_view value, const size_t offset) + { + if (offset + 2uz > value.size()) + return std::nullopt; + + const auto hexValue = [](const char c) -> int + { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; + }; + + const auto high = hexValue(value[offset]); + const auto low = hexValue(value[offset + 1uz]); + if (high < 0 || low < 0) + return std::nullopt; + + return static_cast((high << 4) | low); + } + + [[nodiscard]] std::string GeneratedImageName(const std::string& assetName, const std::string_view kind, const size_t index) + { + return std::format("{}_{}_{}", assetName, kind, index); + } + + [[nodiscard]] GfxImageLoadDef* CreateLoadDef(MemoryManager& memory, + const uint16_t width, + const uint16_t height, + const uint16_t depth, + const int format, + const char flags, + const std::byte* data, + const size_t dataSize) + { + auto* loadDef = static_cast(memory.AllocRaw(offsetof(GfxImageLoadDef, data) + dataSize)); + loadDef->levelCount = (flags & image::iwi6::IMG_FLAG_CUBEMAP) != 0 ? static_cast(REFLECTION_PROBE_MIP_COUNT) : 1; + loadDef->flags = flags; + loadDef->dimensions[0] = width; + loadDef->dimensions[1] = height; + loadDef->dimensions[2] = depth; + loadDef->format = format; + loadDef->resourceSize = static_cast(dataSize); + if (data && dataSize > 0uz) + std::memcpy(loadDef->data, data, dataSize); + + return loadDef; + } + + [[nodiscard]] GfxImage* CreateGeneratedImage(MemoryManager& memory, + const std::string& name, + const MapType mapType, + const TextureSemantic semantic, + const ImageCategory category, + const uint16_t width, + const uint16_t height, + const uint16_t depth, + const int format, + const char loadFlags, + const std::byte* data, + const size_t dataSize) + { + auto* image = AllocZeroed(memory); + image->name = memory.Dup(name.c_str()); + image->mapType = mapType; + image->noPicmip = true; + image->semantic = static_cast(semantic); + image->category = static_cast(category); + image->width = width; + image->height = height; + image->depth = depth; + image->delayLoadPixels = true; + image->texture.loadDef = CreateLoadDef(memory, width, height, depth, format, loadFlags, data, dataSize); + return image; + } + + struct EntityBlock + { + std::string text; + std::string classname; + std::unordered_map fields; + }; + + struct RawLightPixel + { + uint8_t r = 0u; + uint8_t g = 0u; + uint8_t b = 0u; + uint8_t a = 255u; + }; + + [[nodiscard]] float CosOfSumOfArcCos(const float cos0, const float cos1) + { + return cos0 * cos1 - std::sqrt((1.0f - cos0 * cos0) * (1.0f - cos1 * cos1)); + } + + void ParsePrimaryLightRecord(const std::byte* record, ComPrimaryLight& light, MemoryManager& memory) + { + light = {}; + light.type = ReadRawByte(record, RAW_LIGHT_TYPE_OFFSET); + light.canUseShadowMap = ReadRawByte(record, RAW_LIGHT_CAN_USE_SHADOW_MAP_OFFSET); + light.exponent = static_cast(ReadI32(record, RAW_LIGHT_EXPONENT_OFFSET)); + light.unused = 0; + + CopyFloat3(record + RAW_LIGHT_COLOR_OFFSET, light.color); + CopyFloat3(record + RAW_LIGHT_DIR_OFFSET, light.dir); + CopyFloat3(record + RAW_LIGHT_ORIGIN_OFFSET, light.origin); + CopyUnaligned(record + RAW_LIGHT_RADIUS_OFFSET, light.radius); + CopyUnaligned(record + RAW_LIGHT_COS_HALF_FOV_OUTER_OFFSET, light.cosHalfFovOuter); + CopyUnaligned(record + RAW_LIGHT_COS_HALF_FOV_INNER_OFFSET, light.cosHalfFovInner); + CopyUnaligned(record + RAW_LIGHT_ROTATION_LIMIT_OFFSET, light.rotationLimit); + CopyUnaligned(record + RAW_LIGHT_TRANSLATION_LIMIT_OFFSET, light.translationLimit); + + // IW3 v22 stores a DiskPrimaryLight. The linker normalises this into a + // runtime ComPrimaryLight, including inner-FOV correction and derived + // expanded FOV for spot/omni lights. + if (light.type >= 2) + { + const auto defName = RawString(record + RAW_LIGHT_DEF_NAME_OFFSET, RAW_LIGHT_DEF_NAME_SIZE); + light.defName = defName.empty() ? nullptr : memory.Dup(defName.c_str()); + + if (light.cosHalfFovOuter >= light.cosHalfFovInner) + light.cosHalfFovInner = light.cosHalfFovOuter * 0.75f + 0.25f; + + if (light.rotationLimit == 1.0f) + light.cosHalfFovExpanded = light.cosHalfFovOuter; + else if (-light.cosHalfFovOuter < light.rotationLimit) + light.cosHalfFovExpanded = CosOfSumOfArcCos(light.cosHalfFovOuter, light.rotationLimit); + else + light.cosHalfFovExpanded = -1.0f; + } + else + { + light.defName = nullptr; + light.cosHalfFovExpanded = light.cosHalfFovOuter; + } + } + + [[nodiscard]] std::string LowercaseAscii(std::string value) + { + for (auto& c : value) + c = static_cast(std::tolower(static_cast(c))); + + return value; + } + + [[nodiscard]] std::string AssetNameWithoutReferencePrefix(const std::string_view assetName) + { + if (!assetName.empty() && assetName.front() == ',') + return std::string(assetName.substr(1)); + + return std::string(assetName); + } + + [[nodiscard]] uint8_t ReadUnsignedChannel(const uint64_t pixel, const unsigned offset, const unsigned size, const uint8_t fallback) + { + if (size == 0u) + return fallback; + + const auto maxValue = (1ull << size) - 1ull; + const auto value = (pixel >> offset) & maxValue; + if (size == 8u) + return static_cast(value); + + return static_cast((value * 255ull + maxValue / 2ull) / maxValue); + } + + [[nodiscard]] RawLightPixel ReadRawLightPixel(const uint8_t* buffer, const image::ImageFormatUnsigned& format, const size_t pixelIndex) + { + const auto bytesPerPixel = format.m_bits_per_pixel / 8u; + + uint64_t pixel = 0u; + for (auto byteIndex = 0u; byteIndex < bytesPerPixel; byteIndex++) + pixel |= static_cast(buffer[pixelIndex * bytesPerPixel + byteIndex]) << (byteIndex * 8u); + + RawLightPixel result{}; + result.r = ReadUnsignedChannel(pixel, format.m_r_offset, format.m_r_size, 0u); + result.g = format.HasG() ? ReadUnsignedChannel(pixel, format.m_g_offset, format.m_g_size, 0u) : result.r; + result.b = format.HasB() ? ReadUnsignedChannel(pixel, format.m_b_offset, format.m_b_size, 0u) : result.r; + result.a = ReadUnsignedChannel(pixel, format.m_a_offset, format.m_a_size, 255u); + return result; + } + + [[nodiscard]] bool LoadStockLightDefAttenuationPixels(const std::string& imageName, std::vector& pixels) + { + if (LowercaseAscii(imageName) != "falloff_linear") + return false; + + // Stock CoD4 light_point_linear references falloff_linear. Some build + // setups resolve the lightdef as a referenced asset without exposing the + // IWI to OAT's search path, so keep the exact stock 32x1 L8 top mip here + // as a narrow fallback. linker_pc samples this and writes the falloff + // strip into the generated secondary lightmap atlas. + constexpr std::array FALLBACK_FALLOFF_LINEAR{ + 255u, 255u, 255u, 254u, 250u, 244u, 235u, 225u, 215u, 205u, 195u, 186u, 175u, 167u, 155u, 146u, + 136u, 126u, 117u, 108u, 98u, 87u, 78u, 66u, 58u, 49u, 39u, 28u, 19u, 10u, 4u, 0u, + }; + + pixels.clear(); + pixels.reserve(FALLBACK_FALLOFF_LINEAR.size()); + for (const auto value : FALLBACK_FALLOFF_LINEAR) + { + pixels.emplace_back(RawLightPixel{ + .r = value, + .g = value, + .b = value, + .a = 255u, + }); + } + + return true; + } + + [[nodiscard]] BspLoadValue> LoadLightDefAttenuationPixels(const GfxLightDef& lightDef, ISearchPath& searchPath) + { + const auto* image = lightDef.attenuation.image; + if (!image || !image->name) + return std::unexpected(std::format("light def \"{}\" has no attenuation image", lightDef.name ? lightDef.name : "")); + + const auto imageName = AssetNameWithoutReferencePrefix(image->name); + const auto file = searchPath.Open(image::GetFileNameForAsset(imageName, ".iwi")); + if (!file.IsOpen()) + { + std::vector pixels; + if (LoadStockLightDefAttenuationPixels(imageName, pixels)) + return pixels; + + return std::unexpected(std::format("missing attenuation image \"{}\" for light def \"{}\"", imageName, lightDef.name ? lightDef.name : "")); + } + + auto loadResult = image::LoadIwi(*file.m_stream); + if (!loadResult || !loadResult->m_texture) + return std::unexpected(std::format("could not load attenuation image \"{}\" for light def \"{}\"", imageName, lightDef.name ? lightDef.name : "")); + + const auto& texture = *loadResult->m_texture; + if (texture.GetTextureType() != image::TextureType::T_2D) + return std::unexpected( + std::format("attenuation image \"{}\" for light def \"{}\" is not a 2D image", imageName, lightDef.name ? lightDef.name : "")); + + const auto* format = texture.GetFormat(); + if (!format || format->GetType() != image::ImageFormatType::UNSIGNED) + return std::unexpected( + std::format("attenuation image \"{}\" for light def \"{}\" has unsupported format", imageName, lightDef.name ? lightDef.name : "")); + + const auto* unsignedFormat = dynamic_cast(format); + if (!unsignedFormat || unsignedFormat->m_bits_per_pixel == 0u || unsignedFormat->m_bits_per_pixel % 8u != 0u || unsignedFormat->m_bits_per_pixel > 64u) + return std::unexpected( + std::format("attenuation image \"{}\" for light def \"{}\" has unsupported pixel size", imageName, lightDef.name ? lightDef.name : "")); + + const auto* buffer = texture.GetBufferForMipLevel(0); + if (!buffer) + return std::unexpected(std::format("attenuation image \"{}\" for light def \"{}\" has no pixels", imageName, lightDef.name ? lightDef.name : "")); + + std::vector pixels; + pixels.clear(); + pixels.reserve(texture.GetWidth()); + for (auto x = 0u; x < texture.GetWidth(); x++) + pixels.emplace_back(ReadRawLightPixel(buffer, *unsignedFormat, x)); + + return pixels; + } + + void WriteSecondaryLightmapPixel(std::vector& out, const size_t pixelOffset, const RawLightPixel& pixel) + { + const auto byteOffset = pixelOffset * LIGHTMAP_SECONDARY_PIXEL_SIZE; + out[byteOffset + 0uz] = static_cast(pixel.b); + out[byteOffset + 1uz] = static_cast(pixel.g); + out[byteOffset + 2uz] = static_cast(pixel.r); + out[byteOffset + 3uz] = static_cast(pixel.a); + } + + [[nodiscard]] RawLightPixel LerpLightPixel(const RawLightPixel& current, const RawLightPixel& next, const unsigned zoom, const unsigned lerp) + { + const auto scale = 2u * zoom; + const auto currentScale = scale - lerp; + + const auto lerpChannel = [zoom, lerp, currentScale, scale](const uint8_t currentChannel, const uint8_t nextChannel) + { + return static_cast((zoom + lerp * static_cast(nextChannel) + currentScale * static_cast(currentChannel)) / scale); + }; + + return RawLightPixel{ + .r = lerpChannel(current.r, next.r), + .g = lerpChannel(current.g, next.g), + .b = lerpChannel(current.b, next.b), + .a = lerpChannel(current.a, next.a), + }; + } + + [[nodiscard]] std::vector QuotedEntityTokens(const std::string& block) + { + std::vector tokens; + for (auto i = 0uz; i < block.size(); i++) + { + if (block[i] != '"') + continue; + + std::string token; + bool escape = false; + for (++i; i < block.size(); i++) + { + const auto c = block[i]; + if (escape) + { + token.push_back(c); + escape = false; + } + else if (c == '\\') + { + escape = true; + } + else if (c == '"') + { + break; + } + else + { + token.push_back(c); + } + } + + tokens.emplace_back(std::move(token)); + } + + return tokens; + } + + [[nodiscard]] std::unordered_map QuotedEntityFields(const std::string& block) + { + const auto tokens = QuotedEntityTokens(block); + std::unordered_map fields; + fields.reserve(tokens.size() / 2uz); + + for (auto i = 0uz; i + 1uz < tokens.size(); i += 2uz) + fields.emplace(tokens[i], tokens[i + 1uz]); + + return fields; + } + + [[nodiscard]] std::string_view EntityField(const EntityBlock& block, const std::string& key) + { + const auto existingField = block.fields.find(key); + if (existingField != block.fields.end()) + return existingField->second; + + // linker_pc treats entity keys case-insensitively. Preserve the + // original field map so duplicate-key behavior is unchanged, but allow + // stock mixed-case keys such as "modelSCALE" to satisfy normal lookups. + for (const auto& [fieldKey, fieldValue] : block.fields) + { + if (fieldKey.size() != key.size()) + continue; + + auto matches = true; + for (auto i = 0uz; i < key.size(); i++) + { + if (std::tolower(static_cast(fieldKey[i])) != std::tolower(static_cast(key[i]))) + { + matches = false; + break; + } + } + + if (matches) + return fieldValue; + } + + return {}; + } + + [[nodiscard]] std::expected, std::string> ParseEntityBlocks(const std::vector& lump) + { + auto textLen = lump.size(); + while (textLen > 0uz && lump[textLen - 1uz] == std::byte{}) + textLen--; + + const std::string text(reinterpret_cast(lump.data()), textLen); + std::vector blocks; + auto offset = 0uz; + + while (offset < text.size()) + { + while (offset < text.size() && text[offset] != '{') + { + if (!std::isspace(static_cast(text[offset]))) + return std::unexpected("unexpected non-whitespace before entity block"); + offset++; + } + + if (offset >= text.size()) + break; + + const auto blockStart = offset; + auto depth = 1; + auto inQuote = false; + auto escape = false; + + for (++offset; offset < text.size(); offset++) + { + const auto c = text[offset]; + if (inQuote) + { + if (escape) + escape = false; + else if (c == '\\') + escape = true; + else if (c == '"') + inQuote = false; + } + else + { + if (c == '"') + inQuote = true; + else if (c == '{') + depth++; + else if (c == '}') + { + depth--; + if (depth == 0) + { + offset++; + if (offset < text.size() && text[offset] == '\r') + offset++; + if (offset < text.size() && text[offset] == '\n') + offset++; + + EntityBlock block; + block.text = text.substr(blockStart, offset - blockStart); + block.fields = QuotedEntityFields(block.text); + block.classname = std::string(EntityField(block, "classname")); + blocks.emplace_back(std::move(block)); + break; + } + } + } + } + + if (depth != 0) + return std::unexpected("unterminated entity block"); + } + + return blocks; + } + + [[nodiscard]] bool IsLinkerConsumedMapEntity(const EntityBlock& block) + { + // linker_pc removes these editor/compiler entities from runtime + // MapEnts while building the corresponding static model, dynamic + // entity, reflection probe, prefab, or helper data. + if (block.classname == "misc_model" || block.classname == "misc_prefab" || block.classname == "dyn_brushmodel" || block.classname == "dyn_model" + || block.classname == "reflection_probe" || block.classname == "info_null" || block.classname == "func_group") + { + return true; + } + + // linker_pc keeps primary-light entities only when the entity was tagged + // with a generated pl# key. Uncompiled light entities are consumed by the + // primary-light path and are not retained in runtime MapEnts. + if (block.classname == "light" && EntityField(block, "pl#").empty()) + return true; + + return false; + } + + [[nodiscard]] std::vector CompileMapEntsEntityString(const std::vector& blocks) + { + std::string out; + for (const auto& block : blocks) + { + if (IsLinkerConsumedMapEntity(block)) + continue; + + out += block.text; + } + + if (out.size() >= 2uz && out[out.size() - 2uz] == '\r' && out[out.size() - 1uz] == '\n') + out.resize(out.size() - 2uz); + else if (!out.empty() && (out.back() == '\n' || out.back() == '\r')) + out.pop_back(); + + std::vector result; + result.reserve(out.size() + 1uz); + const auto* outBytes = reinterpret_cast(out.data()); + result.insert(result.end(), outBytes, outBytes + out.size()); + result.emplace_back(std::byte{}); + return result; + } + + [[nodiscard]] bool IsMiscModel(const EntityBlock& block) + { + return block.classname == "misc_model"; + } + + [[nodiscard]] bool IsDynModel(const EntityBlock& block) + { + return block.classname == "dyn_model"; + } + + [[nodiscard]] DynEntityType DynEntityTypeFromString(const std::string_view value) + { + if (value.empty() || value == "clutter") + return DYNENT_TYPE_CLUTTER; + + if (value == "destruct") + return DYNENT_TYPE_DESTRUCT; + + return DYNENT_TYPE_INVALID; + } + + [[nodiscard]] float StaticModelScale(const EntityBlock& block) + { + const auto scaleValue = EntityField(block, "modelscale"); + if (scaleValue.empty()) + return 1.0f; + + // linker_pc parses misc_model modelscale with atof, not sscanf/strtof. + const std::string temp(scaleValue); + const auto parsedScale = static_cast(std::atof(temp.c_str())); + if (parsedScale <= std::numeric_limits::epsilon()) + return 1.0f; + + return parsedScale; + } + + void AnglesToAxis(const std::array& angles, float (&axis)[3][3]) + { + const auto pitch = angles[0] * DEG_TO_RAD; + const auto yaw = angles[1] * DEG_TO_RAD; + const auto roll = angles[2] * DEG_TO_RAD; + + const auto sp = LinkerTrig(pitch, false); + const auto cp = LinkerTrig(pitch, true); + const auto sy = LinkerTrig(yaw, false); + const auto cy = LinkerTrig(yaw, true); + const auto sr = LinkerTrig(roll, false); + const auto cr = LinkerTrig(roll, true); + + axis[0][0] = LinkerFloat(static_cast(cp) * cy); + axis[0][1] = LinkerFloat(static_cast(cp) * sy); + axis[0][2] = -sp; + + // linker_pc keeps these products as FPU intermediates until the final + // matrix store; rounding them early changes a few static-model scales + // when the raw entity text is reconstructed from clipMap.staticModelList. + const auto srSp = static_cast(sr) * sp; + axis[1][0] = LinkerFloat(static_cast(cy) * srSp - static_cast(cr) * sy); + axis[1][1] = LinkerFloat(static_cast(srSp) * sy + static_cast(cr) * cy); + axis[1][2] = LinkerFloat(static_cast(sr) * cp); + + const auto crSp = static_cast(cr) * sp; + axis[2][0] = LinkerFloat(static_cast(cy) * crSp + static_cast(sy) * sr); + axis[2][1] = LinkerFloat(static_cast(sy) * crSp - static_cast(cy) * sr); + axis[2][2] = LinkerFloat(static_cast(cr) * cp); + } + + void AxisToQuat(const float (&axis)[3][3], float (&quat)[4]) + { + // Match linker_pc's Com_Math MatrixToQuat path. It tests quaternion + // candidates in this fixed order, normalizes the first candidate with + // sizeSq >= 1, and intentionally preserves the resulting sign. + float candidates[4][4]{}; + candidates[0][0] = LinkerFloat(static_cast(axis[1][2]) - axis[2][1]); + candidates[0][1] = LinkerFloat(static_cast(axis[2][0]) - axis[0][2]); + candidates[0][2] = LinkerFloat(static_cast(axis[0][1]) - axis[1][0]); + candidates[0][3] = LinkerFloat(static_cast(axis[1][1]) + axis[0][0] + axis[2][2] + 1.0); + + candidates[1][0] = LinkerFloat(static_cast(axis[2][0]) + axis[0][2]); + candidates[1][1] = LinkerFloat(static_cast(axis[2][1]) + axis[1][2]); + candidates[1][2] = LinkerFloat(static_cast(axis[2][2]) - axis[1][1] - axis[0][0] + 1.0); + candidates[1][3] = candidates[0][2]; + + candidates[2][0] = LinkerFloat(static_cast(axis[0][0]) - axis[1][1] - axis[2][2] + 1.0); + candidates[2][1] = LinkerFloat(static_cast(axis[1][0]) + axis[0][1]); + candidates[2][2] = candidates[1][0]; + candidates[2][3] = candidates[0][0]; + + candidates[3][0] = candidates[2][1]; + candidates[3][1] = LinkerFloat(static_cast(axis[1][1]) - axis[0][0] - axis[2][2] + 1.0); + candidates[3][2] = candidates[1][1]; + candidates[3][3] = candidates[0][1]; + + auto selectedCandidate = 3uz; + auto selectedSizeSq = 0.0f; + + for (auto candidateIndex = 0uz; candidateIndex < 4uz; candidateIndex++) + { + const auto sizeSq = LinkerQuatSizeSq(candidates[candidateIndex], candidateIndex); + + selectedCandidate = candidateIndex; + selectedSizeSq = sizeSq; + + if (sizeSq >= 1.0f) + break; + } + + const auto sqrtSize = LinkerFloat(std::sqrt(static_cast(selectedSizeSq))); + const auto scale = LinkerFloat(1.0 / sqrtSize); + for (auto component = 0uz; component < 4uz; component++) + quat[component] = LinkerFloat(static_cast(candidates[selectedCandidate][component]) * scale); + } + + void TransformStaticModelPoint( + const float (&axis)[3][3], const std::array& origin, const float scale, const float x, const float y, const float z, float (&out)[3]) + { + for (auto component = 0uz; component < 3uz; component++) + out[component] = origin[component] + scale * (axis[0][component] * x + axis[1][component] * y + axis[2][component] * z); + } + + void BuildStaticModelBounds(const XModel& model, const float (&axis)[3][3], const std::array& origin, const float scale, cStaticModel_s& out) + { + for (auto component = 0uz; component < 3uz; component++) + { + out.absmin[component] = std::numeric_limits::max(); + out.absmax[component] = std::numeric_limits::lowest(); + } + + for (auto corner = 0u; corner < 8u; corner++) + { + const auto x = (corner & 1u) != 0u ? model.maxs.x : model.mins.x; + const auto y = (corner & 2u) != 0u ? model.maxs.y : model.mins.y; + const auto z = (corner & 4u) != 0u ? model.maxs.z : model.mins.z; + + float transformed[3]{}; + TransformStaticModelPoint(axis, origin, scale, x, y, z, transformed); + for (auto component = 0uz; component < 3uz; component++) + { + out.absmin[component] = std::min(out.absmin[component], transformed[component]); + out.absmax[component] = std::max(out.absmax[component], transformed[component]); + } + } + } + + [[nodiscard]] std::vector StaticModelEntityBlocks(const std::vector& blocks) + { + std::vector result; + for (const auto& block : blocks) + { + if (IsMiscModel(block)) + result.emplace_back(&block); + } + + return result; + } + + [[nodiscard]] std::vector*> LoadStaticModelDependencies(const std::vector& staticModelBlocks, + AssetCreationContext& context) + { + std::vector*> result; + result.reserve(staticModelBlocks.size()); + + for (const auto* block : staticModelBlocks) + { + const auto modelName = EntityField(*block, "model"); + if (modelName.empty()) + { + result.emplace_back(nullptr); + continue; + } + + result.emplace_back(context.LoadDependency(std::string(modelName))); + } + + return result; + } + + struct DynModelDependency + { + XAssetInfo* model; + XAssetInfo* physPreset; + }; + + [[nodiscard]] std::vector DynModelEntityBlocks(const std::vector& blocks) + { + std::vector result; + for (const auto& block : blocks) + { + if (IsDynModel(block)) + result.emplace_back(&block); + } + + return result; + } + + [[nodiscard]] std::vector LoadDynModelDependencies(const std::vector& dynModelBlocks, AssetCreationContext& context) + { + std::vector result; + result.reserve(dynModelBlocks.size()); + + for (const auto* block : dynModelBlocks) + { + DynModelDependency dependency{}; + + const auto modelName = EntityField(*block, "model"); + if (!modelName.empty()) + dependency.model = context.LoadDependency(std::string(modelName)); + + const auto physPresetName = EntityField(*block, "physPreset"); + if (!physPresetName.empty()) + dependency.physPreset = context.LoadDependency(std::string(physPresetName)); + + result.emplace_back(dependency); + } + + return result; + } + + [[nodiscard]] PhysPreset* ResolveDynModelPhysPreset(const DynModelDependency& dependency, AssetCreationContext& context) + { + if (dependency.physPreset) + return dependency.physPreset->Asset(); + + if (dependency.model && dependency.model->Asset()->physPreset) + return dependency.model->Asset()->physPreset; + + // linker_pc falls back to "default" when neither the entity nor the + // model provides a physics preset. + auto* defaultPreset = context.LoadDependency("default"); + return defaultPreset ? defaultPreset->Asset() : nullptr; + } + + void PopulateDynModelMass(const EntityBlock& block, DynEntityDef& dynEnt) + { + if (const auto centerOfMass = ParseFloat3(EntityField(block, "centerofmass"))) + std::copy(centerOfMass->begin(), centerOfMass->end(), dynEnt.mass.centerOfMass); + + if (const auto momentsOfInertia = ParseFloat3(EntityField(block, "momofinertia"))) + std::copy(momentsOfInertia->begin(), momentsOfInertia->end(), dynEnt.mass.momentsOfInertia); + + if (const auto productsOfInertia = ParseFloat3(EntityField(block, "prodofinertia"))) + std::copy(productsOfInertia->begin(), productsOfInertia->end(), dynEnt.mass.productsOfInertia); + } + + void PopulateStaticModels(clipMap_t& clipMap, + const std::vector& staticModelBlocks, + const std::vector*>& staticModelDependencies, + MemoryManager& memory) + { + std::vector> validStaticModels; + validStaticModels.reserve(staticModelBlocks.size()); + + for (auto i = 0uz; i < staticModelBlocks.size(); i++) + { + if (i >= staticModelDependencies.size() || !staticModelDependencies[i]) + continue; + + validStaticModels.emplace_back(staticModelBlocks[i], staticModelDependencies[i]->Asset()); + } + + if (validStaticModels.empty()) + return; + + clipMap.numStaticModels = static_cast(validStaticModels.size()); + clipMap.staticModelList = AllocZeroed(memory, validStaticModels.size()); + + for (auto modelIndex = 0uz; modelIndex < validStaticModels.size(); modelIndex++) + { + const auto& [block, model] = validStaticModels[modelIndex]; + auto& staticModel = clipMap.staticModelList[modelIndex]; + const auto origin = ParseLinkerFloat3(EntityField(*block, "origin")).value_or(std::array{}); + const auto angles = ParseLinkerFloat3(EntityField(*block, "angles")).value_or(std::array{}); + const auto scale = StaticModelScale(*block); + float axis[3][3]{}; + + AnglesToAxis(angles, axis); + + staticModel.writable.nextModelInWorldSector = std::numeric_limits::max(); + staticModel.xmodel = model; + std::copy(origin.begin(), origin.end(), staticModel.origin); + + // Runtime cStaticModel_s stores the transpose of the model axis divided + // by scale. The raw BSP stores editor angles/modelscale, so this is the + // inverse of D3DBspDumperIW3::StaticModelAxis/StaticModelScale. + const auto invScale = LinkerFloat(1.0 / scale); + for (auto row = 0uz; row < 3uz; row++) + { + for (auto column = 0uz; column < 3uz; column++) + staticModel.invScaledAxis[column][row] = LinkerFloat(static_cast(axis[row][column]) * invScale); + } + + BuildStaticModelBounds(*model, axis, origin, scale, staticModel); + } + } + + void PopulateDynModelEntities(clipMap_t& clipMap, + const std::vector& dynModelBlocks, + const std::vector& dynModelDependencies, + AssetCreationContext& context, + MemoryManager& memory) + { + struct DynModelBuildEntry + { + const EntityBlock* block; + XModel* model; + PhysPreset* physPreset; + size_t sourceIndex; + }; + + std::vector validDynModels; + validDynModels.reserve(dynModelBlocks.size()); + + for (auto i = 0uz; i < dynModelBlocks.size(); i++) + { + if (i >= dynModelDependencies.size() || !dynModelDependencies[i].model) + continue; + + const auto type = DynEntityTypeFromString(EntityField(*dynModelBlocks[i], "type")); + if (type == DYNENT_TYPE_INVALID) + continue; + + validDynModels.emplace_back( + dynModelBlocks[i], dynModelDependencies[i].model->Asset(), ResolveDynModelPhysPreset(dynModelDependencies[i], context), i); + } + + if (validDynModels.empty()) + return; + + // linker_pc sorts dynents by runtime xmodel pointer before splitting the + // model/brush arrays. Pointer order depends on linker asset allocation, + // not the BSP payload, so OAT writes a deterministic canonical order. + std::stable_sort(validDynModels.begin(), + validDynModels.end(), + [](const DynModelBuildEntry& left, const DynModelBuildEntry& right) + { + const auto leftName = left.model && left.model->name ? left.model->name : ""; + const auto rightName = right.model && right.model->name ? right.model->name : ""; + const auto nameCompare = std::strcmp(leftName, rightName); + if (nameCompare != 0) + return nameCompare < 0; + + return left.sourceIndex < right.sourceIndex; + }); + + const auto count = std::min(validDynModels.size(), static_cast(std::numeric_limits::max())); + clipMap.dynEntCount[0] = static_cast(count); + clipMap.dynEntDefList[0] = AllocZeroed(memory, count); + clipMap.dynEntPoseList[0] = AllocZeroed(memory, count); + clipMap.dynEntClientList[0] = AllocZeroed(memory, count); + clipMap.dynEntCollList[0] = AllocZeroed(memory, count); + + for (auto dynEntIndex = 0uz; dynEntIndex < count; dynEntIndex++) + { + const auto& entry = validDynModels[dynEntIndex]; + auto& dynEnt = clipMap.dynEntDefList[0][dynEntIndex]; + const auto origin = ParseFloat3(EntityField(*entry.block, "origin")).value_or(std::array{}); + const auto angles = ParseFloat3(EntityField(*entry.block, "angles")).value_or(std::array{}); + float axis[3][3]{}; + + AnglesToAxis(angles, axis); + + dynEnt.type = DynEntityTypeFromString(EntityField(*entry.block, "type")); + dynEnt.xModel = entry.model; + dynEnt.physPreset = entry.physPreset; + dynEnt.health = ParseInt(EntityField(*entry.block, "health")); + dynEnt.contents = entry.model ? entry.model->contents : 0; + std::copy(origin.begin(), origin.end(), dynEnt.pose.origin); + AxisToQuat(axis, dynEnt.pose.quat); + PopulateDynModelMass(*entry.block, dynEnt); + } + } + + [[nodiscard]] char PlaneTypeForNormal(const float (&normal)[3]) + { + for (auto axis = 0uz; axis < 3uz; axis++) + { + if (normal[axis] != 1.0f) + continue; + + auto isAxial = true; + for (auto otherAxis = 0uz; otherAxis < 3uz; otherAxis++) + { + if (otherAxis != axis && normal[otherAxis] != 0.0f) + isAxial = false; + } + + if (isAxial) + return static_cast(axis); + } + + return 3; + } + + [[nodiscard]] char PlaneSignBitsForNormal(const float (&normal)[3]) + { + auto signBits = 0u; + for (auto axis = 0uz; axis < 3uz; axis++) + { + if (normal[axis] < 0.0f) + signBits |= 1u << axis; + } + + return static_cast(signBits); + } + + [[nodiscard]] float RadiusFromBounds(const float (&mins)[3], const float (&maxs)[3]) + { + auto radiusSquared = 0.0f; + for (auto axis = 0uz; axis < 3uz; axis++) + { + const auto extent = std::max(std::abs(mins[axis]), std::abs(maxs[axis])); + radiusSquared += extent * extent; + } + + return std::sqrt(radiusSquared); + } + + [[nodiscard]] int LeafTerrainContents(const clipMap_t& clipMap, const cLeaf_t& leaf) + { + auto contents = 0; + if (!clipMap.aabbTrees || !clipMap.materials) + return contents; + + const auto endIndex = std::min(clipMap.aabbTreeCount, static_cast(leaf.firstCollAabbIndex) + leaf.collAabbCount); + for (auto aabbIndex = static_cast(leaf.firstCollAabbIndex); aabbIndex < endIndex; aabbIndex++) + { + const auto materialIndex = clipMap.aabbTrees[aabbIndex].materialIndex; + if (materialIndex < clipMap.numMaterials) + contents |= clipMap.materials[materialIndex].contentFlags; + } + + return contents; + } + + [[nodiscard]] int BrushContents(const clipMap_t& clipMap, const cbrush_t& brush) + { + auto contents = 0; + for (auto axis = 0uz; axis < 3uz; axis++) + { + for (auto side = 0uz; side < 2uz; side++) + { + const auto materialIndex = brush.axialMaterialNum[side][axis]; + if (materialIndex >= 0 && static_cast(materialIndex) < clipMap.numMaterials) + contents |= clipMap.materials[materialIndex].contentFlags; + } + } + + for (auto sideIndex = 0uz; sideIndex < brush.numsides; sideIndex++) + { + const auto materialIndex = brush.sides[sideIndex].materialNum; + if (materialIndex < clipMap.numMaterials) + contents |= clipMap.materials[materialIndex].contentFlags; + } + + return contents; + } + + void InitLeafBrushNode(cLeafBrushNode_s& node) + { + node.axis = 0; + node.leafBrushCount = 0; + node.contents = 0; + node.data.leaf.brushes = nullptr; + node.data.children.dist = -std::numeric_limits::max(); + node.data.children.range = 0.0f; + node.data.children.childOffset[0] = 0u; + node.data.children.childOffset[1] = 0u; + } + + [[nodiscard]] size_t AllocLeafBrushNode(std::vector& nodes) + { + const auto index = nodes.size(); + auto& node = nodes.emplace_back(); + node = {}; + InitLeafBrushNode(node); + return index; + } + + [[nodiscard]] float LeafBrushPartitionScore(const clipMap_t& clipMap, + const LeafBrush* leafBrushes, + const int leafBrushCount, + const int axis, + const float (&mins)[3], + const float (&maxs)[3], + float& dist) + { + auto rightBrushCount = -1; + auto leftBrushCount = -1; + auto min = -std::numeric_limits::max(); + auto max = std::numeric_limits::max(); + + for (auto brushOffset = 0; brushOffset < leafBrushCount; brushOffset++) + { + const auto brushIndex = leafBrushes[brushOffset]; + const auto& brush = clipMap.brushes[brushIndex]; + if (dist > brush.mins[axis]) + { + if (dist >= brush.maxs[axis]) + { + leftBrushCount++; + min = std::max(min, brush.maxs[axis]); + } + } + else + { + rightBrushCount++; + max = std::min(max, brush.mins[axis]); + } + } + + const auto scoreBrushCount = std::min(rightBrushCount, leftBrushCount); + dist = (min + max) * 0.5f; + if (scoreBrushCount <= 0) + return 0.0f; + + return static_cast(scoreBrushCount) * std::min(max - mins[axis], maxs[axis] - min); + } + + [[nodiscard]] BspLoadValue PartitionLeafBrushes_r(const clipMap_t& clipMap, + std::vector& nodes, + LeafBrush* leafBrushes, + const int leafBrushCount, + const float (&mins)[3], + const float (&maxs)[3]) + { + if (leafBrushCount <= 0) + return std::unexpected("cannot partition an empty leafbrush range"); + + const auto nodeIndex = AllocLeafBrushNode(nodes); + auto bestScore = 0.0f; + auto axis = -1; + auto dist = 0.0f; + + for (auto testAxis = 0; testAxis < 3; testAxis++) + { + for (auto brushOffset = 0; brushOffset < leafBrushCount; brushOffset++) + { + const auto brushIndex = leafBrushes[brushOffset]; + const auto& brush = clipMap.brushes[brushIndex]; + + auto testDist = brush.mins[testAxis]; + auto score = LeafBrushPartitionScore(clipMap, leafBrushes, leafBrushCount, testAxis, mins, maxs, testDist); + if (bestScore < score) + { + bestScore = score; + axis = testAxis; + dist = testDist; + } + + testDist = brush.maxs[testAxis]; + score = LeafBrushPartitionScore(clipMap, leafBrushes, leafBrushCount, testAxis, mins, maxs, testDist); + if (bestScore < score) + { + bestScore = score; + axis = testAxis; + dist = testDist; + } + } + } + + if (axis >= 0) + { + std::vector leafBrushesCopy(leafBrushes, leafBrushes + leafBrushCount); + auto* childLeafBrushes = leafBrushes; + auto centerBrushCount = 0; + for (const auto brushIndex : leafBrushesCopy) + { + const auto& brush = clipMap.brushes[brushIndex]; + if (dist > brush.mins[axis] && dist < brush.maxs[axis]) + childLeafBrushes[centerBrushCount++] = brushIndex; + } + + if (centerBrushCount > 0) + { + const auto childNodeIndex = PartitionLeafBrushes_r(clipMap, nodes, childLeafBrushes, centerBrushCount, mins, maxs); + if (!childNodeIndex) + return std::unexpected(std::move(childNodeIndex.error())); + + nodes[nodeIndex].leafBrushCount = -1; + nodes[nodeIndex].contents = nodes[*childNodeIndex].contents; + childLeafBrushes += centerBrushCount; + } + + auto range = std::numeric_limits::max(); + nodes[nodeIndex].axis = static_cast(axis); + nodes[nodeIndex].data.children.dist = dist; + + for (auto side = 0; side < 2; side++) + { + auto childBrushCount = 0; + for (const auto brushIndex : leafBrushesCopy) + { + const auto& brush = clipMap.brushes[brushIndex]; + if (side != 0) + { + if (dist < brush.maxs[axis]) + continue; + + range = std::min(range, dist - brush.maxs[axis]); + } + else + { + if (dist > brush.mins[axis]) + continue; + + range = std::min(range, brush.mins[axis] - dist); + } + + childLeafBrushes[childBrushCount++] = brushIndex; + } + + if (childBrushCount <= 0) + return std::unexpected("leafbrush partition produced an empty child"); + + float childMins[3]{mins[0], mins[1], mins[2]}; + float childMaxs[3]{maxs[0], maxs[1], maxs[2]}; + if (side != 0) + childMaxs[axis] = dist - range; + else + childMins[axis] = dist + range; + + const auto childNodeIndex = PartitionLeafBrushes_r(clipMap, nodes, childLeafBrushes, childBrushCount, childMins, childMaxs); + if (!childNodeIndex) + return std::unexpected(std::move(childNodeIndex.error())); + + const auto childOffset = *childNodeIndex - nodeIndex; + if (childOffset > std::numeric_limits::max()) + return std::unexpected("leafbrush partition child offset exceeded uint16 range"); + + nodes[nodeIndex].data.children.childOffset[side] = static_cast(childOffset); + nodes[nodeIndex].contents |= nodes[*childNodeIndex].contents; + childLeafBrushes += childBrushCount; + } + + nodes[nodeIndex].data.children.range = range; + return nodeIndex; + } + + if (leafBrushCount > std::numeric_limits::max()) + return std::unexpected("leafbrush partition leaf count exceeded int16 range"); + + nodes[nodeIndex].leafBrushCount = static_cast(leafBrushCount); + for (auto brushOffset = 0; brushOffset < leafBrushCount; brushOffset++) + { + const auto brushIndex = leafBrushes[brushOffset]; + nodes[nodeIndex].contents |= clipMap.brushes[brushIndex].contents; + } + + if (nodes[nodeIndex].contents == 0) + return std::unexpected("leafbrush partition produced a leaf with no contents"); + + nodes[nodeIndex].data.leaf.brushes = leafBrushes; + return nodeIndex; + } + + [[nodiscard]] BspLoadResult + PartitionLeafBrushes(const clipMap_t& clipMap, std::vector& nodes, LeafBrush* leafBrushes, const int leafBrushCount, cLeaf_t& leaf) + { + leaf.brushContents = 0; + leaf.terrainContents = LeafTerrainContents(clipMap, leaf); + leaf.leafBrushNode = 0; + + if (leafBrushCount <= 0) + return {}; + + float mins[3]{ + std::numeric_limits::max(), + std::numeric_limits::max(), + std::numeric_limits::max(), + }; + float maxs[3]{ + -std::numeric_limits::max(), + -std::numeric_limits::max(), + -std::numeric_limits::max(), + }; + + for (auto brushOffset = 0; brushOffset < leafBrushCount; brushOffset++) + { + const auto brushIndex = leafBrushes[brushOffset]; + if (brushIndex >= clipMap.numBrushes) + return std::unexpected("leafbrush references invalid brush"); + + const auto& brush = clipMap.brushes[brushIndex]; + leaf.brushContents |= brush.contents; + for (auto axis = 0uz; axis < 3uz; axis++) + { + mins[axis] = std::min(mins[axis], brush.mins[axis]); + maxs[axis] = std::max(maxs[axis], brush.maxs[axis]); + } + } + + for (auto axis = 0uz; axis < 3uz; axis++) + { + leaf.mins[axis] = mins[axis] - 0.125f; + leaf.maxs[axis] = maxs[axis] + 0.125f; + } + + const auto nodeIndex = PartitionLeafBrushes_r(clipMap, nodes, leafBrushes, leafBrushCount, mins, maxs); + if (!nodeIndex) + return std::unexpected(std::move(nodeIndex.error())); + + leaf.leafBrushNode = static_cast(*nodeIndex); + return {}; + } + + [[nodiscard]] BspLoadResult PopulateClipMapMaterials(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto materials = RequiredRecordLump(bsp, LUMP_MATERIALS, RAW_MATERIAL_SIZE); + if (!materials) + return std::unexpected(std::move(materials.error())); + + const auto count = RecordCount(**materials, RAW_MATERIAL_SIZE); + if (!FitsUnsigned(count)) + return std::unexpected("too many material records"); + + clipMap.numMaterials = static_cast(count); + clipMap.materials = AllocZeroed(memory, count); + + for (auto i = 0uz; i < count; i++) + { + const auto* record = (*materials)->data.data() + i * RAW_MATERIAL_SIZE; + std::memcpy(clipMap.materials[i].material, record, sizeof(clipMap.materials[i].material)); + clipMap.materials[i].surfaceFlags = ReadI32(record, 64uz); + // linker_pc strips raw-only BSP content bits before storing the + // runtime clipMap material table. Brush contents are derived from + // this masked value, while the dumper reconstructs the raw marker + // where needed when writing a .d3dbsp back out. + clipMap.materials[i].contentFlags = static_cast(static_cast(ReadI32(record, 68uz)) & IW3::d3dbsp::RUNTIME_MATERIAL_CONTENT_MASK); + } + + return {}; + } + + [[nodiscard]] BspLoadResult PopulateClipMapPlanes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto planes = RequiredRecordLump(bsp, LUMP_PLANES, RAW_PLANE_SIZE); + if (!planes) + return std::unexpected(std::move(planes.error())); + + const auto count = RecordCount(**planes, RAW_PLANE_SIZE); + if (!FitsInt(count)) + return std::unexpected("too many plane records"); + + clipMap.planeCount = static_cast(count); + clipMap.planes = AllocZeroed(memory, count); + + for (auto i = 0uz; i < count; i++) + { + const auto* record = (*planes)->data.data() + i * RAW_PLANE_SIZE; + CopyFloat3(record, clipMap.planes[i].normal); + clipMap.planes[i].dist = ReadFloat(record, 12uz); + clipMap.planes[i].type = PlaneTypeForNormal(clipMap.planes[i].normal); + clipMap.planes[i].signbits = PlaneSignBitsForNormal(clipMap.planes[i].normal); + } + + return {}; + } + + [[nodiscard]] BspLoadResult PopulateClipMapBrushes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto brushHeaders = RequiredRecordLump(bsp, LUMP_BRUSHES, RAW_BRUSH_HEADER_SIZE); + if (!brushHeaders) + return std::unexpected(std::move(brushHeaders.error())); + + const auto brushSides = RequiredRecordLump(bsp, LUMP_BRUSHSIDES, RAW_BRUSHSIDE_SIZE); + if (!brushSides) + return std::unexpected(std::move(brushSides.error())); + + const auto* edgeCounts = bsp.GetLump(LUMP_BRUSHSIDE_EDGE_COUNTS); + const auto* brushEdges = bsp.GetLump(LUMP_BRUSHEDGES); + + if (!edgeCounts) + return std::unexpected(std::format("missing lump {}", std::to_underlying(LUMP_BRUSHSIDE_EDGE_COUNTS))); + + const auto brushCount = RecordCount(**brushHeaders, RAW_BRUSH_HEADER_SIZE); + if (!FitsUint16(brushCount)) + return std::unexpected("too many brush records"); + + auto totalSideCount = 0uz; + auto nonAxialSideCount = 0uz; + for (auto brushIndex = 0uz; brushIndex < brushCount; brushIndex++) + { + const auto sideCount = ReadU16((*brushHeaders)->data.data() + brushIndex * RAW_BRUSH_HEADER_SIZE); + if (sideCount < 6u) + return std::unexpected("brush has fewer than six axial sides"); + + totalSideCount += sideCount; + nonAxialSideCount += sideCount - 6u; + } + + if ((*brushSides)->data.size() != totalSideCount * RAW_BRUSHSIDE_SIZE || edgeCounts->data.size() != totalSideCount) + return std::unexpected("brush side/edge-count lumps do not match brush headers"); + + if (!FitsUnsigned(nonAxialSideCount)) + return std::unexpected("too many non-axial brush sides"); + + clipMap.numBrushes = static_cast(brushCount); + // The stock linker allocates one extra brush after the raw brush array + // for the runtime box hull used by CM_InitThreadData/trace code. Keep + // numBrushes as the raw count; box_brush points at the extra slot. + clipMap.brushes = AllocZeroed(memory, brushCount + 1uz); + clipMap.numBrushSides = static_cast(nonAxialSideCount); + clipMap.brushsides = nonAxialSideCount > 0uz ? AllocZeroed(memory, nonAxialSideCount) : nullptr; + clipMap.numBrushEdges = brushEdges ? static_cast(brushEdges->data.size()) : 0u; + clipMap.brushEdges = brushEdges && !brushEdges->data.empty() ? AllocCopy(memory, brushEdges->data) : nullptr; + + auto rawSideIndex = 0uz; + auto nonAxialSideIndex = 0uz; + auto edgeOffset = 0uz; + + for (auto brushIndex = 0uz; brushIndex < brushCount; brushIndex++) + { + auto& brush = clipMap.brushes[brushIndex]; + const auto* header = (*brushHeaders)->data.data() + brushIndex * RAW_BRUSH_HEADER_SIZE; + const auto sideCount = ReadU16(header); + const auto nonAxialCount = static_cast(sideCount - 6u); + + brush.numsides = nonAxialCount; + brush.sides = nonAxialCount > 0u ? &clipMap.brushsides[nonAxialSideIndex] : nullptr; + brush.baseAdjacentSide = clipMap.brushEdges && edgeOffset < clipMap.numBrushEdges ? &clipMap.brushEdges[edgeOffset] : nullptr; + + auto localEdgeOffset = 0uz; + for (auto axis = 0uz; axis < 3uz; axis++) + { + for (auto side = 0uz; side < 2uz; side++) + { + const auto* rawSide = (*brushSides)->data.data() + rawSideIndex * RAW_BRUSHSIDE_SIZE; + const auto materialIndex = static_cast(ReadU32(rawSide, 4uz)); + const auto edgeCount = std::to_integer(edgeCounts->data[rawSideIndex]); + + if (side == 0uz) + brush.mins[axis] = ReadFloat(rawSide); + else + brush.maxs[axis] = ReadFloat(rawSide); + + brush.axialMaterialNum[side][axis] = materialIndex; + brush.firstAdjacentSideOffsets[side][axis] = static_cast(localEdgeOffset); + brush.edgeCount[side][axis] = static_cast(edgeCount); + localEdgeOffset += edgeCount; + rawSideIndex++; + } + } + + for (auto sideIndex = 0uz; sideIndex < nonAxialCount; sideIndex++) + { + const auto* rawSide = (*brushSides)->data.data() + rawSideIndex * RAW_BRUSHSIDE_SIZE; + const auto planeIndex = ReadU32(rawSide); + const auto materialIndex = ReadU32(rawSide, 4uz); + if (planeIndex >= static_cast(std::max(clipMap.planeCount, 0))) + return std::unexpected("brush side references an invalid plane"); + + auto& side = brush.sides[sideIndex]; + side.plane = &clipMap.planes[planeIndex]; + side.materialNum = materialIndex; + side.firstAdjacentSideOffset = static_cast(localEdgeOffset); + side.edgeCount = static_cast(std::to_integer(edgeCounts->data[rawSideIndex])); + localEdgeOffset += static_cast(side.edgeCount); + rawSideIndex++; + } + + edgeOffset += localEdgeOffset; + nonAxialSideIndex += nonAxialCount; + brush.contents = BrushContents(clipMap, brush); + } + + return {}; + } + + [[nodiscard]] BspLoadResult PopulateClipMapNodes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* nodes = bsp.GetLump(LUMP_NODES); + if (!nodes) + return {}; + + if (nodes->data.size() % RAW_CLIP_NODE_SIZE != 0uz) + return std::unexpected("node lump has funny size"); + + const auto nodeCount = RecordCount(*nodes, RAW_CLIP_NODE_SIZE); + if (!FitsUnsigned(nodeCount)) + return std::unexpected("too many node records"); + + clipMap.numNodes = static_cast(nodeCount); + clipMap.nodes = AllocZeroed(memory, nodeCount); + + for (auto nodeIndex = 0uz; nodeIndex < nodeCount; nodeIndex++) + { + const auto* record = nodes->data.data() + nodeIndex * RAW_CLIP_NODE_SIZE; + const auto planeIndex = ReadI32(record); + const auto child0 = ReadI32(record, 4uz); + const auto child1 = ReadI32(record, 8uz); + + if (planeIndex < 0 || planeIndex >= clipMap.planeCount || !FitsInt16(child0) || !FitsInt16(child1)) + return std::unexpected("node record references invalid plane or child"); + + clipMap.nodes[nodeIndex].plane = &clipMap.planes[planeIndex]; + clipMap.nodes[nodeIndex].children[0] = static_cast(child0); + clipMap.nodes[nodeIndex].children[1] = static_cast(child1); + } + + return {}; + } + + [[nodiscard]] BspLoadResult PopulateClipMapLeafBrushes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* leafBrushes = bsp.GetLump(LUMP_LEAFBRUSHES); + if (!leafBrushes) + { + clipMap.numLeafBrushes = 0u; + clipMap.leafbrushes = AllocZeroed(memory, 1uz); + return {}; + } + + if (leafBrushes->data.size() % RAW_LEAF_BRUSH_SIZE != 0uz) + return std::unexpected("leafbrush lump has funny size"); + + const auto leafBrushCount = RecordCount(*leafBrushes, RAW_LEAF_BRUSH_SIZE); + if (!FitsUnsigned(leafBrushCount)) + return std::unexpected("too many leafbrush records"); + + clipMap.numLeafBrushes = static_cast(leafBrushCount); + // CM_InitBoxHull writes one extra entry at leafbrushes[numLeafBrushes] + // without increasing numLeafBrushes. Allocate that spare slot so the + // serialized box leaf node can reference it safely. + clipMap.leafbrushes = AllocZeroed(memory, leafBrushCount + 1uz); + + for (auto i = 0uz; i < leafBrushCount; i++) + { + const auto brushIndex = ReadU32(leafBrushes->data.data() + i * RAW_LEAF_BRUSH_SIZE); + if (brushIndex > std::numeric_limits::max()) + return std::unexpected("leafbrush index exceeds runtime range"); + + clipMap.leafbrushes[i] = static_cast(brushIndex); + } + + return {}; + } + + [[nodiscard]] BspLoadResult PopulateClipMapCollision(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* verts = bsp.GetLump(LUMP_COLLISIONVERTS); + if (verts) + { + if (verts->data.size() % RAW_VEC3_SIZE != 0uz || !FitsUnsigned(RecordCount(*verts, RAW_VEC3_SIZE))) + return std::unexpected("collision vert lump has funny size"); + + clipMap.vertCount = static_cast(RecordCount(*verts, RAW_VEC3_SIZE)); + clipMap.verts = AllocCopy(memory, verts->data); + } + + const auto* tris = bsp.GetLump(LUMP_COLLISIONTRIS); + if (tris) + { + if (tris->data.size() % RAW_TRI_INDICES_SIZE != 0uz || !FitsInt(RecordCount(*tris, RAW_TRI_INDICES_SIZE))) + return std::unexpected("collision tri lump has funny size"); + + clipMap.triCount = static_cast(RecordCount(*tris, RAW_TRI_INDICES_SIZE)); + clipMap.triIndices = AllocCopy(memory, tris->data); + } + + const auto* walkable = bsp.GetLump(LUMP_COLLISION_EDGE_WALKABLE); + if (walkable) + clipMap.triEdgeIsWalkable = AllocCopy(memory, walkable->data); + + const auto* borders = bsp.GetLump(LUMP_COLLISIONBORDERS); + if (borders) + { + if (borders->data.size() % RAW_COLLISION_BORDER_SIZE != 0uz || !FitsInt(RecordCount(*borders, RAW_COLLISION_BORDER_SIZE))) + return std::unexpected("collision border lump has funny size"); + + clipMap.borderCount = static_cast(RecordCount(*borders, RAW_COLLISION_BORDER_SIZE)); + clipMap.borders = AllocCopy(memory, borders->data); + } + + const auto* partitions = bsp.GetLump(LUMP_COLLISIONPARTITIONS); + if (partitions) + { + if (partitions->data.size() % RAW_COLLISION_PARTITION_SIZE != 0uz || !FitsInt(RecordCount(*partitions, RAW_COLLISION_PARTITION_SIZE))) + return std::unexpected("collision partition lump has funny size"); + + const auto partitionCount = RecordCount(*partitions, RAW_COLLISION_PARTITION_SIZE); + clipMap.partitionCount = static_cast(partitionCount); + clipMap.partitions = AllocZeroed(memory, partitionCount); + const auto runtimeBorderCount = static_cast(std::max(clipMap.borderCount, 0)); + for (auto i = 0uz; i < partitionCount; i++) + { + const auto* record = partitions->data.data() + i * RAW_COLLISION_PARTITION_SIZE; + const auto borderIndex = ReadU32(record, 8uz); + const auto borderCount = static_cast(std::to_integer(record[3])); + if (borderCount > 0u && (borderIndex > runtimeBorderCount || borderCount > runtimeBorderCount - borderIndex)) + return std::unexpected("collision partition references invalid border"); + + clipMap.partitions[i].triCount = static_cast(std::to_integer(record[2])); + clipMap.partitions[i].borderCount = borderCount; + clipMap.partitions[i].firstTri = ReadI32(record, 4uz); + // Raw partitions can retain an in-range border index even when + // borderCount is zero. Runtime traces ignore it in that case, + // but preserving the pointer lets the dumper reproduce the raw + // index after an OAT link/unlink round trip. + clipMap.partitions[i].borders = clipMap.borders && borderIndex < runtimeBorderCount ? &clipMap.borders[borderIndex] : nullptr; + } + } + + const auto* aabbs = bsp.GetLump(LUMP_COLLISIONAABBS); + if (aabbs) + { + if (aabbs->data.size() % RAW_COLLISION_AABB_SIZE != 0uz || !FitsInt(RecordCount(*aabbs, RAW_COLLISION_AABB_SIZE))) + return std::unexpected("collision AABB lump has funny size"); + + clipMap.aabbTreeCount = static_cast(RecordCount(*aabbs, RAW_COLLISION_AABB_SIZE)); + clipMap.aabbTrees = AllocCopy(memory, aabbs->data); + } + + return {}; + } + + [[nodiscard]] BspLoadResult PopulateClipMapLeafs(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* leafs = bsp.GetLump(LUMP_LEAFS); + if (!leafs) + return {}; + + if (leafs->data.size() % RAW_LEAF_SIZE != 0uz || !FitsUnsigned(RecordCount(*leafs, RAW_LEAF_SIZE))) + return std::unexpected("leaf lump has funny size"); + + const auto leafCount = RecordCount(*leafs, RAW_LEAF_SIZE); + clipMap.numLeafs = static_cast(leafCount); + clipMap.leafs = AllocZeroed(memory, leafCount); + + auto maxCluster = -1; + for (auto leafIndex = 0uz; leafIndex < leafCount; leafIndex++) + { + const auto* record = leafs->data.data() + leafIndex * RAW_LEAF_SIZE; + auto& leaf = clipMap.leafs[leafIndex]; + const auto cluster = ReadI32(record); + const auto firstCollAabbIndex = ReadI32(record, 4uz); + const auto collAabbCount = ReadI32(record, 8uz); + + if (firstCollAabbIndex < 0 || collAabbCount < 0) + return std::unexpected("leaf contains negative runtime count/index"); + + leaf.firstCollAabbIndex = static_cast(std::min(firstCollAabbIndex, static_cast(std::numeric_limits::max()))); + leaf.collAabbCount = static_cast(std::min(collAabbCount, static_cast(std::numeric_limits::max()))); + leaf.cluster = static_cast( + std::clamp(cluster, static_cast(std::numeric_limits::min()), static_cast(std::numeric_limits::max()))); + leaf.leafBrushNode = 0; + + if (cluster >= 0) + maxCluster = std::max(maxCluster, cluster); + } + + clipMap.numClusters = maxCluster + 1; + return {}; + } + + [[nodiscard]] BspLoadResult PopulateClipMapLeafBrushNodes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* leafs = bsp.GetLump(LUMP_LEAFS); + if (!leafs || !clipMap.leafs) + return {}; + + if (leafs->data.size() % RAW_LEAF_SIZE != 0uz || RecordCount(*leafs, RAW_LEAF_SIZE) != clipMap.numLeafs) + return std::unexpected("leaf lump changed before leafbrush node build"); + + // Stock cm_load_obj builds this array in temp memory with index 0 left + // as an unused sentinel. Leaf traces assert leafBrushNode is non-zero, + // so real nodes start at index 1. + std::vector nodes; + nodes.reserve(static_cast(clipMap.numLeafBrushes) + static_cast(clipMap.numBrushes) + 2uz); + nodes.emplace_back(); + nodes[0] = {}; + + for (auto leafIndex = 0uz; leafIndex < clipMap.numLeafs; leafIndex++) + { + const auto* record = leafs->data.data() + leafIndex * RAW_LEAF_SIZE; + const auto firstLeafBrush = ReadI32(record, 12uz); + const auto leafBrushCount = ReadI32(record, 16uz); + if (firstLeafBrush < 0 || leafBrushCount < 0) + return std::unexpected("leaf contains negative leafbrush range"); + + if (static_cast(firstLeafBrush + leafBrushCount) > clipMap.numLeafBrushes) + return std::unexpected("leaf references invalid leafbrush range"); + + auto partitionResult = PartitionLeafBrushes(clipMap, nodes, &clipMap.leafbrushes[firstLeafBrush], leafBrushCount, clipMap.leafs[leafIndex]); + if (!partitionResult) + return std::unexpected(std::move(partitionResult.error())); + } + + const auto* models = bsp.GetLump(LUMP_MODELS); + if (models && clipMap.cmodels) + { + if (models->data.size() % RAW_MODEL_SIZE != 0uz || RecordCount(*models, RAW_MODEL_SIZE) != clipMap.numSubModels) + return std::unexpected("model lump changed before leafbrush node build"); + + for (auto modelIndex = 1uz; modelIndex < clipMap.numSubModels; modelIndex++) + { + const auto* record = models->data.data() + modelIndex * RAW_MODEL_SIZE; + const auto firstBrush = ReadU32(record, 40uz); + const auto brushCount = ReadU32(record, 44uz); + if (brushCount == 0u) + continue; + + if (firstBrush + brushCount > clipMap.numBrushes) + return std::unexpected("model references invalid brush range"); + + auto* modelLeafBrushes = AllocZeroed(memory, brushCount); + for (auto brushOffset = 0u; brushOffset < brushCount; brushOffset++) + modelLeafBrushes[brushOffset] = static_cast(firstBrush + brushOffset); + + auto partitionResult = PartitionLeafBrushes(clipMap, nodes, modelLeafBrushes, static_cast(brushCount), clipMap.cmodels[modelIndex].leaf); + if (!partitionResult) + return std::unexpected(std::move(partitionResult.error())); + } + } + + clipMap.box_brush = &clipMap.brushes[clipMap.numBrushes]; + clipMap.box_brush->contents = -1; + clipMap.box_brush->sides = nullptr; + clipMap.box_brush->baseAdjacentSide = nullptr; + for (auto side = 0uz; side < 2uz; side++) + { + for (auto axis = 0uz; axis < 3uz; axis++) + clipMap.box_brush->axialMaterialNum[side][axis] = -1; + } + + clipMap.box_model.leaf.brushContents = -1; + clipMap.box_model.leaf.terrainContents = 0; + clipMap.box_model.leaf.leafBrushNode = static_cast(AllocLeafBrushNode(nodes)); + for (auto axis = 0uz; axis < 3uz; axis++) + { + clipMap.box_model.leaf.mins[axis] = std::numeric_limits::max(); + clipMap.box_model.leaf.maxs[axis] = -std::numeric_limits::max(); + } + + auto& boxLeafBrushNode = nodes[clipMap.box_model.leaf.leafBrushNode]; + boxLeafBrushNode.leafBrushCount = 1; + boxLeafBrushNode.data.leaf.brushes = &clipMap.leafbrushes[clipMap.numLeafBrushes]; + clipMap.leafbrushes[clipMap.numLeafBrushes] = clipMap.numBrushes; + + clipMap.leafbrushNodesCount = static_cast(nodes.size()); + clipMap.leafbrushNodes = AllocZeroed(memory, nodes.size()); + std::copy(nodes.begin(), nodes.end(), clipMap.leafbrushNodes); + return {}; + } + + [[nodiscard]] BspLoadResult PopulateClipMapModels(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* models = bsp.GetLump(LUMP_MODELS); + if (!models) + return {}; + + if (models->data.size() % RAW_MODEL_SIZE != 0uz || !FitsUnsigned(RecordCount(*models, RAW_MODEL_SIZE))) + return std::unexpected("model lump has funny size"); + + const auto modelCount = RecordCount(*models, RAW_MODEL_SIZE); + clipMap.numSubModels = static_cast(modelCount); + clipMap.cmodels = AllocZeroed(memory, modelCount); + + for (auto modelIndex = 0uz; modelIndex < modelCount; modelIndex++) + { + const auto* record = models->data.data() + modelIndex * RAW_MODEL_SIZE; + auto& model = clipMap.cmodels[modelIndex]; + for (auto axis = 0uz; axis < 3uz; axis++) + { + model.mins[axis] = ReadFloat(record, axis * sizeof(float)) - 1.0f; + model.maxs[axis] = ReadFloat(record, 12uz + axis * sizeof(float)) + 1.0f; + } + model.radius = RadiusFromBounds(model.mins, model.maxs); + + if (modelIndex > 0uz) + { + const auto firstCollAabbIndex = ReadU32(record, 32uz); + const auto collAabbCount = ReadU32(record, 36uz); + if (firstCollAabbIndex > std::numeric_limits::max() || collAabbCount > std::numeric_limits::max()) + return std::unexpected("model collision AABB range exceeded uint16"); + + model.leaf.firstCollAabbIndex = static_cast(firstCollAabbIndex); + model.leaf.collAabbCount = static_cast(collAabbCount); + } + } + + return PopulateClipMapLeafBrushNodes(clipMap, bsp, memory); + } + + [[nodiscard]] BspLoadResult PopulateClipMapVisibility(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* visibility = bsp.GetLump(LUMP_VISIBILITY); + if (!visibility || visibility->data.empty()) + { + // CMod_LoadVisibility synthesizes an all-visible table when the BSP + // has no visibility lump. It sizes clusterBytes from the leaf + // cluster count collected earlier, then collapses numClusters to 1. + clipMap.clusterBytes = ((clipMap.numClusters + 63) & ~63) >> 3; + clipMap.numClusters = 1; + clipMap.visibility = AllocZeroed(memory, static_cast(std::max(clipMap.clusterBytes, 0))); + FillArray(clipMap.visibility, static_cast(std::max(clipMap.clusterBytes, 0)), 0xffu); + return {}; + } + + if (visibility->data.size() < 8uz) + return std::unexpected("visibility lump has a truncated header"); + + const auto numClusters = ReadI32(visibility->data.data()); + const auto clusterBytes = ReadI32(visibility->data.data(), 4uz); + if (numClusters < 0 || clusterBytes < 0) + return std::unexpected("visibility lump has negative dimensions"); + + const auto expectedSize = 8uz + static_cast(numClusters) * static_cast(clusterBytes); + if (visibility->data.size() != expectedSize) + return std::unexpected("visibility lump size does not match its header"); + + clipMap.numClusters = numClusters; + clipMap.clusterBytes = clusterBytes; + clipMap.visibility = AllocZeroed(memory, visibility->data.size() - 8uz); + if (clipMap.visibility) + std::memcpy(clipMap.visibility, visibility->data.data() + 8uz, visibility->data.size() - 8uz); + clipMap.vised = 1; + return {}; + } + + [[nodiscard]] BspLoadResult PopulateClipMapLeafSurfaces(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* leafSurfaces = bsp.GetLump(LUMP_LEAFSURFACES); + if (!leafSurfaces) + return {}; + + if (leafSurfaces->data.size() % sizeof(uint32_t) != 0uz || !FitsUnsigned(leafSurfaces->data.size() / sizeof(uint32_t))) + return std::unexpected("leafsurface lump has funny size"); + + clipMap.numLeafSurfaces = static_cast(leafSurfaces->data.size() / sizeof(uint32_t)); + clipMap.leafsurfaces = AllocCopy(memory, leafSurfaces->data); + return {}; + } + + [[nodiscard]] BspLoadResult PopulateClipMap(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + if (auto result = PopulateClipMapMaterials(clipMap, bsp, memory); !result) + return result; + if (auto result = PopulateClipMapPlanes(clipMap, bsp, memory); !result) + return result; + if (auto result = PopulateClipMapBrushes(clipMap, bsp, memory); !result) + return result; + if (auto result = PopulateClipMapNodes(clipMap, bsp, memory); !result) + return result; + if (auto result = PopulateClipMapLeafBrushes(clipMap, bsp, memory); !result) + return result; + if (auto result = PopulateClipMapCollision(clipMap, bsp, memory); !result) + return result; + if (auto result = PopulateClipMapLeafs(clipMap, bsp, memory); !result) + return result; + if (auto result = PopulateClipMapModels(clipMap, bsp, memory); !result) + return result; + if (auto result = PopulateClipMapVisibility(clipMap, bsp, memory); !result) + return result; + + return PopulateClipMapLeafSurfaces(clipMap, bsp, memory); + } + + [[nodiscard]] IW3::d3dbsp::TrisType ChooseTrisContextType(const IW3::d3dbsp::File& bsp) + { + // Stock R_ChooseTrisContextType selects the unlayered path when that + // geometry exists and layered materials are disabled. OAT currently + // mirrors that linker_pc-compatible path. + const auto* unlayeredSurfaces = bsp.GetLump(LUMP_SIMPLE_TRI_SOUPS); + return unlayeredSurfaces && !unlayeredSurfaces->data.empty() ? TRIS_TYPE_SIMPLE : TRIS_TYPE_LAYERED; + } + + [[nodiscard]] const IW3::d3dbsp::Lump* + SelectWorldLumpForTrisType(const IW3::d3dbsp::File& bsp, const IW3::d3dbsp::LumpType layered, const IW3::d3dbsp::LumpType unlayered) + { + return bsp.GetLump(ChooseTrisContextType(bsp) == TRIS_TYPE_SIMPLE ? unlayered : layered); + } + + [[nodiscard]] bool UsesSimpleWorldGeometry(const IW3::d3dbsp::File& bsp) + { + return ChooseTrisContextType(bsp) == TRIS_TYPE_SIMPLE; + } + + struct LightmapAtlasGroup + { + unsigned wideCount = 1u; + unsigned highCount = 1u; + std::vector rawPageForPackedSlot; + }; + + struct LightmapAtlasLayout + { + unsigned rawPageCount = 0u; + std::vector groups; + std::vector atlasIndexForRawPage; + std::vector packedSlotForRawPage; + }; + + [[nodiscard]] BspLoadResult + CopyLightDefAttenuationImage(std::vector& secondary, const LightmapAtlasGroup& group, const GfxLightDef& lightDef, ISearchPath& searchPath) + { + if (lightDef.lmapLookupStart <= 0) + return std::unexpected(std::format("light def \"{}\" has invalid lightmap lookup start", lightDef.name ? lightDef.name : "")); + + auto pixelsResult = LoadLightDefAttenuationPixels(lightDef, searchPath); + if (!pixelsResult) + return std::unexpected(std::move(pixelsResult.error())); + + auto pixels = std::move(*pixelsResult); + + if (pixels.empty()) + return {}; + + const auto zoom = group.wideCount; + const auto firstPixelOffset = static_cast(zoom) * static_cast(lightDef.lmapLookupStart - 1); + size_t pixelOffset = firstPixelOffset; + const auto overflowError = [&lightDef] + { + return std::format("light def \"{}\" attenuation image overflowed the secondary lightmap atlas", lightDef.name ? lightDef.name : ""); + }; + + const auto writePixel = [&](const RawLightPixel& pixel) + { + if ((pixelOffset + 1uz) * LIGHTMAP_SECONDARY_PIXEL_SIZE > secondary.size()) + return false; + + WriteSecondaryLightmapPixel(secondary, pixelOffset, pixel); + pixelOffset++; + return true; + }; + + if (zoom == 1u) + { + if (!writePixel(pixels.front())) + return std::unexpected(overflowError()); + + for (const auto& pixel : pixels) + { + if (!writePixel(pixel)) + return std::unexpected(overflowError()); + } + + if (!writePixel(pixels.back())) + return std::unexpected(overflowError()); + } + else + { + if ((zoom & (zoom - 1u)) != 0u) + return std::unexpected("lightmap atlas zoom is not a power of two"); + + const auto endCount = zoom + (zoom >> 1u); + for (auto i = 0u; i < endCount; i++) + { + if (!writePixel(pixels.front())) + return std::unexpected(overflowError()); + } + + for (auto pixelIndex = 0uz; pixelIndex + 1uz < pixels.size(); pixelIndex++) + { + for (auto lerp = 1u; lerp <= 2u * zoom; lerp += 2u) + { + if (!writePixel(LerpLightPixel(pixels[pixelIndex], pixels[pixelIndex + 1uz], zoom, lerp))) + return std::unexpected(overflowError()); + } + } + + for (auto i = 0u; i < endCount; i++) + { + if (!writePixel(pixels.back())) + return std::unexpected(overflowError()); + } + } + + return {}; + } + + [[nodiscard]] BspLoadResult ApplyLightDefAttenuationImages(std::vector& secondary, + const LightmapAtlasGroup& group, + const std::vector& lightDefs, + ISearchPath& searchPath) + { + // linker_pc overlays loaded lightdef falloff images into each generated + // secondary lightmap atlas. These bytes are not authored in the raw BSP + // LIGHTMAPS lump, but they are present after link -> unlink canonicalizes it. + for (const auto* lightDef : lightDefs) + { + if (!lightDef) + continue; + + auto copyResult = CopyLightDefAttenuationImage(secondary, group, *lightDef, searchPath); + if (!copyResult) + return std::unexpected(std::move(copyResult.error())); + } + + return {}; + } + + [[nodiscard]] std::expected, std::string> + LoadPrimaryLightDefDependencies(const IW3::d3dbsp::File& bsp, AssetCreationContext& context, AssetRegistration& registration) + { + std::vector lightDefs; + const auto* primaryLights = bsp.GetLump(LUMP_PRIMARY_LIGHTS); + if (!primaryLights || primaryLights->data.empty()) + return lightDefs; + + if (primaryLights->data.size() % IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE != 0uz) + return std::unexpected("primary-light lump has funny size"); + + std::unordered_map loadedLightDefs; + const auto primaryLightCount = RecordCount(*primaryLights, IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE); + for (auto lightIndex = 0uz; lightIndex < primaryLightCount; lightIndex++) + { + const auto* record = primaryLights->data.data() + lightIndex * IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE; + const auto defName = RawString(record + RAW_LIGHT_DEF_NAME_OFFSET, RAW_LIGHT_DEF_NAME_SIZE); + if (defName.empty()) + continue; + + const auto lookupName = LowercaseAscii(defName); + if (loadedLightDefs.find(lookupName) != loadedLightDefs.end()) + continue; + + loadedLightDefs.emplace(lookupName, true); + auto* dependency = context.LoadDependency(defName); + if (!dependency) + return std::unexpected(std::format("missing light def \"{}\"", defName)); + + auto* lightDef = dependency->Asset(); + if (!lightDef || !lightDef->attenuation.image) + return std::unexpected(std::format("light def \"{}\" has no attenuation image", defName)); + + registration.AddDependency(dependency); + lightDefs.emplace_back(lightDef); + } + + return lightDefs; + } + + [[nodiscard]] int SaturatingAdd(const int left, const int right) + { + if (right > 0 && left > std::numeric_limits::max() - right) + return std::numeric_limits::max(); + + return left + right; + } + + [[nodiscard]] BspLoadResult BuildLightmapCouplingMatrix(const IW3::d3dbsp::File& bsp, + const unsigned rawPageCount, + std::array& coupling) + { + const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); + if (!surfaces) + return {}; + + if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) + return std::unexpected("world surface lump has funny size"); + + const auto* materials = bsp.GetLump(LUMP_MATERIALS); + const auto materialCount = materials && materials->data.size() % RAW_MATERIAL_SIZE == 0uz ? RecordCount(*materials, RAW_MATERIAL_SIZE) : 0uz; + const auto surfaceCount = RecordCount(*surfaces, RAW_WORLD_SURFACE_SIZE); + + for (auto materialIndex = 0uz; materialIndex < materialCount; materialIndex++) + { + std::array vertexCountByLightmap{}; + + for (auto surfaceIndex = 0uz; surfaceIndex < surfaceCount; surfaceIndex++) + { + const auto* record = surfaces->data.data() + surfaceIndex * RAW_WORLD_SURFACE_SIZE; + if (ReadU16(record) != materialIndex) + continue; + + const auto lightmapIndex = std::to_integer(record[2]); + if (lightmapIndex == SKY_LIGHTMAP_INDEX) + continue; + + if (lightmapIndex >= rawPageCount) + return std::unexpected(std::format("world surface {} references missing lightmap page {}", surfaceIndex, lightmapIndex)); + + vertexCountByLightmap[lightmapIndex] = SaturatingAdd(vertexCountByLightmap[lightmapIndex], static_cast(ReadU16(record, 16uz))); + } + + for (auto left = 0u; left < rawPageCount; left++) + { + if (vertexCountByLightmap[left] == 0) + continue; + + for (auto right = left + 1u; right < rawPageCount; right++) + { + if (vertexCountByLightmap[right] == 0) + continue; + + const auto combinedWeight = SaturatingAdd(vertexCountByLightmap[left], vertexCountByLightmap[right]); + auto& leftToRight = coupling[left * MAX_LIGHTMAP_PAGE_COUNT + right]; + leftToRight = SaturatingAdd(leftToRight, combinedWeight); + coupling[right * MAX_LIGHTMAP_PAGE_COUNT + left] = leftToRight; + } + } + } + + return {}; + } + + [[nodiscard]] BspLoadValue ReferencedLightmapPageCount(const IW3::d3dbsp::File& bsp) + { + auto pageCount = 0u; + const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); + if (!surfaces) + return pageCount; + + if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) + return std::unexpected("world surface lump has funny size"); + + const auto surfaceCount = RecordCount(*surfaces, RAW_WORLD_SURFACE_SIZE); + for (auto surfaceIndex = 0uz; surfaceIndex < surfaceCount; surfaceIndex++) + { + const auto* record = surfaces->data.data() + surfaceIndex * RAW_WORLD_SURFACE_SIZE; + const auto lightmapIndex = std::to_integer(record[2]); + if (lightmapIndex == SKY_LIGHTMAP_INDEX) + continue; + + if (lightmapIndex >= MAX_LIGHTMAP_PAGE_COUNT) + return std::unexpected(std::format("world surface {} has invalid lightmap page {}", surfaceIndex, lightmapIndex)); + + pageCount = std::max(pageCount, lightmapIndex + 1u); + } + + return pageCount; + } + + [[nodiscard]] BspLoadValue BuildLightmapAtlasLayout(const IW3::d3dbsp::File& bsp) + { + LightmapAtlasLayout layout; + const auto* lightmaps = bsp.GetLump(LUMP_LIGHTMAPS); + if (!lightmaps || lightmaps->data.empty()) + return layout; + + if (lightmaps->data.size() % LIGHTMAP_RAW_PAGE_SIZE != 0uz || !FitsUnsigned(lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE)) + return std::unexpected("lightmap lump has funny size"); + + layout.rawPageCount = static_cast(lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE); + if (layout.rawPageCount > MAX_LIGHTMAP_PAGE_COUNT) + return std::unexpected(std::format("lightmap lump has too many pages: {}", layout.rawPageCount)); + + auto referencedPageCount = ReferencedLightmapPageCount(bsp); + if (!referencedPageCount) + return std::unexpected(std::move(referencedPageCount.error())); + + // linker_pc/Radiant treats the lightmap lump size and the highest + // non-sky surface lightmap index as the same original-page count. + if (*referencedPageCount != layout.rawPageCount) + return std::unexpected(std::format("lightmap page count {} does not match surface references {}", layout.rawPageCount, *referencedPageCount)); + + std::array coupling{}; + auto couplingResult = BuildLightmapCouplingMatrix(bsp, layout.rawPageCount, coupling); + if (!couplingResult) + return std::unexpected(std::move(couplingResult.error())); + + std::array used{}; + layout.atlasIndexForRawPage.assign(layout.rawPageCount, 0u); + layout.packedSlotForRawPage.assign(layout.rawPageCount, 0u); + + auto wideCount = 2u; + auto highCount = 2u; + auto packedRawPageCount = 0u; + while (packedRawPageCount < layout.rawPageCount) + { + while (wideCount * highCount > layout.rawPageCount - packedRawPageCount) + { + if (wideCount < highCount) + highCount >>= 1u; + else + wideCount >>= 1u; + } + + LightmapAtlasGroup group; + group.wideCount = std::max(1u, wideCount); + group.highCount = std::max(1u, highCount); + const auto groupPageCount = group.wideCount * group.highCount; + group.rawPageForPackedSlot.reserve(groupPageCount); + + if (groupPageCount < 2u) + { + for (auto rawPage = 0u; rawPage < layout.rawPageCount; rawPage++) + { + if (used[rawPage]) + continue; + + group.rawPageForPackedSlot.emplace_back(rawPage); + used[rawPage] = true; + break; + } + } + else + { + auto bestLeft = SKY_LIGHTMAP_INDEX; + auto bestRight = SKY_LIGHTMAP_INDEX; + for (auto left = 0u; left + 1u < layout.rawPageCount; left++) + { + if (used[left]) + continue; + + for (auto right = left + 1u; right < layout.rawPageCount; right++) + { + if (used[right]) + continue; + + if (bestLeft == SKY_LIGHTMAP_INDEX + || coupling[left * MAX_LIGHTMAP_PAGE_COUNT + right] > coupling[bestLeft * MAX_LIGHTMAP_PAGE_COUNT + bestRight]) + { + bestLeft = left; + bestRight = right; + } + } + } + + if (bestLeft == SKY_LIGHTMAP_INDEX || bestRight == SKY_LIGHTMAP_INDEX) + return std::unexpected("could not pair lightmap pages"); + + // The stock linker writes the selected pair into the atlas in + // right,left order, then greedily extends that group from the + // accumulated material-coupling weights. + group.rawPageForPackedSlot.emplace_back(bestRight); + group.rawPageForPackedSlot.emplace_back(bestLeft); + used[bestRight] = true; + used[bestLeft] = true; + + std::array aggregateWeights{}; + for (auto rawPage = 0u; rawPage < layout.rawPageCount; rawPage++) + aggregateWeights[rawPage] = coupling[bestLeft * MAX_LIGHTMAP_PAGE_COUNT + rawPage]; + + auto selectedRawPage = bestRight; + while (group.rawPageForPackedSlot.size() < groupPageCount) + { + for (auto rawPage = 0u; rawPage < layout.rawPageCount; rawPage++) + { + aggregateWeights[rawPage] = SaturatingAdd(aggregateWeights[rawPage], coupling[selectedRawPage * MAX_LIGHTMAP_PAGE_COUNT + rawPage]); + } + + auto bestNext = SKY_LIGHTMAP_INDEX; + for (auto rawPage = 0u; rawPage < layout.rawPageCount; rawPage++) + { + if (used[rawPage]) + continue; + + if (bestNext == SKY_LIGHTMAP_INDEX || aggregateWeights[rawPage] > aggregateWeights[bestNext]) + bestNext = rawPage; + } + + if (bestNext == SKY_LIGHTMAP_INDEX) + return std::unexpected("could not extend lightmap atlas group"); + + group.rawPageForPackedSlot.emplace_back(bestNext); + used[bestNext] = true; + selectedRawPage = bestNext; + } + } + + const auto atlasIndex = static_cast(layout.groups.size()); + for (auto packedSlot = 0uz; packedSlot < group.rawPageForPackedSlot.size(); packedSlot++) + { + const auto rawPage = group.rawPageForPackedSlot[packedSlot]; + layout.atlasIndexForRawPage[rawPage] = atlasIndex; + layout.packedSlotForRawPage[rawPage] = static_cast(packedSlot); + } + + packedRawPageCount += static_cast(group.rawPageForPackedSlot.size()); + layout.groups.emplace_back(std::move(group)); + } + + return layout; + } + + [[nodiscard]] std::vector WorldMaterialNameCandidates(const std::string& rawMaterialName) + { + std::vector result; + + if (rawMaterialName.empty()) + return result; + + if (rawMaterialName == DEFAULT_MATERIAL_NAME) + { + result.emplace_back("wc/$default3d"); + result.emplace_back("wc/$default2d"); + result.emplace_back("$default3d"); + result.emplace_back("$default2d"); + result.emplace_back(DEFAULT_MATERIAL_NAME); + return result; + } + + // Raw v22 BSP world material names are stored as editor basenames + // ("me_wire_black"), while the stock linker loads the Material asset + // under the runtime name ("wc/me_wire_black"). Prefer the world + // category first so optional probes do not emit a missing-material error + // for the basename. + if (!HasPathSeparator(rawMaterialName) && rawMaterialName[0] != '$') + result.emplace_back(std::format("wc/{}", rawMaterialName)); + + result.emplace_back(rawMaterialName); + + return result; + } + + [[nodiscard]] XAssetInfo* TryLoadWorldMaterialDependency(AssetCreationContext& context, const std::string& materialName) + { + return static_cast*>(context.LoadDependencyGeneric(AssetMaterial::EnumEntry, materialName, false)); + } + + [[nodiscard]] XAssetInfo* GetOrCreateDefaultMaterialReference(AssetCreationContext& context, MemoryManager& memory) + { + if (auto* dependency = TryLoadWorldMaterialDependency(context, DEFAULT_MATERIAL_NAME)) + return dependency; + + // Raw BSPs can reference "$default" as a render material. The stock + // game always provides that fallback material, so emit it as a fastfile + // reference instead of requiring a local material file. + auto* material = memory.Alloc(); + material->info.name = memory.Dup(DEFAULT_MATERIAL_REFERENCE_NAME); + return context.AddAsset(DEFAULT_MATERIAL_REFERENCE_NAME, material); + } + + [[nodiscard]] std::expected, std::string> WorldSurfaceMaterialUsage(const IW3::d3dbsp::File& bsp, const size_t materialCount) + { + std::vector result(materialCount); + const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); + if (!surfaces) + return result; + + if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) + return std::unexpected("world surface lump has funny size"); + + const auto surfaceCount = RecordCount(*surfaces, RAW_WORLD_SURFACE_SIZE); + for (auto surfaceIndex = 0uz; surfaceIndex < surfaceCount; surfaceIndex++) + { + const auto* record = surfaces->data.data() + surfaceIndex * RAW_WORLD_SURFACE_SIZE; + const auto materialIndex = static_cast(ReadU16(record)); + if (materialIndex >= materialCount) + return std::unexpected(std::format("world surface {} references invalid material index {}", surfaceIndex, materialIndex)); + + result[materialIndex] = true; + } + + return result; + } + + [[nodiscard]] std::expected*>, std::string> + LoadWorldMaterials(const IW3::d3dbsp::File& bsp, AssetCreationContext& context, MemoryManager& memory) + { + auto materialsResult = RequiredRecordLump(bsp, LUMP_MATERIALS, RAW_MATERIAL_SIZE); + if (!materialsResult) + return std::unexpected(std::move(materialsResult.error())); + + std::vector*> result; + const auto* materials = *materialsResult; + const auto materialCount = RecordCount(*materials, RAW_MATERIAL_SIZE); + auto renderMaterialUsage = WorldSurfaceMaterialUsage(bsp, materialCount); + if (!renderMaterialUsage) + return std::unexpected(std::move(renderMaterialUsage.error())); + + result.reserve(materialCount); + + for (auto materialIndex = 0uz; materialIndex < materialCount; materialIndex++) + { + const auto* record = materials->data.data() + materialIndex * RAW_MATERIAL_SIZE; + auto materialName = RawMaterialName(record); + if (materialName.empty()) + { + if ((*renderMaterialUsage)[materialIndex]) + return std::unexpected(std::format("world surface references unnamed material index {}", materialIndex)); + + result.emplace_back(nullptr); + continue; + } + + // The BSP material table is shared by render and collision data. + // Tool-only entries such as "caulk" are valid in the table but are + // not Material assets required by GfxWorld. + if (!(*renderMaterialUsage)[materialIndex]) + { + result.emplace_back(nullptr); + continue; + } + + XAssetInfo* dependency = nullptr; + for (const auto& candidateName : WorldMaterialNameCandidates(materialName)) + { + dependency = TryLoadWorldMaterialDependency(context, candidateName); + if (dependency) + break; + } + + if (!dependency && materialName == DEFAULT_MATERIAL_NAME) + dependency = GetOrCreateDefaultMaterialReference(context, memory); + + if (!dependency) + return std::unexpected(std::format("missing render material \"{}\"", materialName)); + + result.emplace_back(dependency); + } + + return result; + } + + [[nodiscard]] std::expected, std::string> RawWorldMaterialNames(const IW3::d3dbsp::File& bsp) + { + auto materialsResult = RequiredRecordLump(bsp, LUMP_MATERIALS, RAW_MATERIAL_SIZE); + if (!materialsResult) + return std::unexpected(std::move(materialsResult.error())); + + std::vector result; + const auto* materials = *materialsResult; + const auto materialCount = RecordCount(*materials, RAW_MATERIAL_SIZE); + result.reserve(materialCount); + + for (auto materialIndex = 0uz; materialIndex < materialCount; materialIndex++) + { + const auto* record = materials->data.data() + materialIndex * RAW_MATERIAL_SIZE; + result.emplace_back(LowercaseAscii(RawMaterialName(record))); + } + + return result; + } + + void SetWorldBoundsFromVertices(GfxWorld& world) + { + if (!world.vd.vertices || world.vertexCount == 0u) + return; + + for (auto axis = 0uz; axis < 3uz; axis++) + { + world.mins[axis] = std::numeric_limits::max(); + world.maxs[axis] = std::numeric_limits::lowest(); + } + + for (auto vertexIndex = 0uz; vertexIndex < world.vertexCount; vertexIndex++) + { + const auto& vertex = world.vd.vertices[vertexIndex]; + for (auto axis = 0uz; axis < 3uz; axis++) + { + world.mins[axis] = std::min(world.mins[axis], vertex.xyz[axis]); + world.maxs[axis] = std::max(world.maxs[axis], vertex.xyz[axis]); + } + } + } + + void ClearBounds(float (&mins)[3], float (&maxs)[3]) + { + for (auto axis = 0uz; axis < 3uz; axis++) + { + mins[axis] = 131072.0f; + maxs[axis] = -131072.0f; + } + } + + void ExpandBounds(const float (&addedMins)[3], const float (&addedMaxs)[3], float (&mins)[3], float (&maxs)[3]) + { + for (auto axis = 0uz; axis < 3uz; axis++) + { + mins[axis] = std::min(mins[axis], addedMins[axis]); + maxs[axis] = std::max(maxs[axis], addedMaxs[axis]); + } + } + + [[nodiscard]] BspLoadResult PopulateWorldIndices(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* indices = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_INDICES, LUMP_SIMPLE_INDICES); + if (!indices) + return {}; + + if (indices->data.size() % sizeof(uint16_t) != 0uz || !FitsInt(indices->data.size() / sizeof(uint16_t))) + return std::unexpected("world index lump has funny size"); + + world.indexCount = static_cast(indices->data.size() / sizeof(uint16_t)); + world.indices = AllocCopy(memory, indices->data); + return {}; + } + + [[nodiscard]] BspLoadResult PopulateWorldVertices(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* verts = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_VERTS, LUMP_SIMPLE_VERTS); + if (!verts) + return {}; + + if (verts->data.size() % RAW_WORLD_VERTEX_SIZE != 0uz || !FitsUnsigned(RecordCount(*verts, RAW_WORLD_VERTEX_SIZE))) + return std::unexpected("world vertex lump has funny size"); + + world.vertexCount = static_cast(RecordCount(*verts, RAW_WORLD_VERTEX_SIZE)); + world.vd.vertices = AllocZeroed(memory, world.vertexCount); + + for (auto vertexIndex = 0uz; vertexIndex < world.vertexCount; vertexIndex++) + { + const auto* record = verts->data.data() + vertexIndex * RAW_WORLD_VERTEX_SIZE; + auto& vertex = world.vd.vertices[vertexIndex]; + float normal[3]{}; + float tangent[3]{}; + float binormal[3]{}; + float expectedBinormal[3]{}; + + CopyFloat3(record, vertex.xyz); + CopyFloat3(record + 12uz, normal); + vertex.color.packed = ReadU32(record, 24uz); + std::memcpy(vertex.texCoord, record + 28uz, sizeof(vertex.texCoord)); + std::memcpy(vertex.lmapCoord, record + 36uz, sizeof(vertex.lmapCoord)); + CopyFloat3(record + 44uz, tangent); + CopyFloat3(record + 56uz, binormal); + + CrossProduct(normal, tangent, expectedBinormal); + vertex.binormalSign = DotProduct(expectedBinormal, binormal) < 0.0f ? -1.0f : 1.0f; + vertex.normal = PackRawBspUnitVec(normal); + vertex.tangent = PackRawBspUnitVec(tangent); + } + + SetWorldBoundsFromVertices(world); + return {}; + } + + void PopulateSurfaceBounds(GfxWorld& world, GfxSurface& surface) + { + for (auto axis = 0uz; axis < 3uz; axis++) + { + surface.bounds[0][axis] = std::numeric_limits::max(); + surface.bounds[1][axis] = std::numeric_limits::lowest(); + } + + auto foundVertex = false; + const auto firstIndex = surface.tris.baseIndex; + const auto indexCount = static_cast(surface.tris.triCount) * 3; + if (world.indices && world.vd.vertices && firstIndex >= 0 && indexCount > 0 && firstIndex + indexCount <= world.indexCount) + { + for (auto indexOffset = 0; indexOffset < indexCount; indexOffset++) + { + const auto vertexIndex = surface.tris.firstVertex + world.indices[firstIndex + indexOffset]; + if (vertexIndex < 0 || static_cast(vertexIndex) >= world.vertexCount) + continue; + + const auto& vertex = world.vd.vertices[vertexIndex]; + for (auto axis = 0uz; axis < 3uz; axis++) + { + surface.bounds[0][axis] = std::min(surface.bounds[0][axis], vertex.xyz[axis]); + surface.bounds[1][axis] = std::max(surface.bounds[1][axis], vertex.xyz[axis]); + } + foundVertex = true; + } + } + + if (!foundVertex) + { + for (auto axis = 0uz; axis < 3uz; axis++) + { + surface.bounds[0][axis] = world.mins[axis]; + surface.bounds[1][axis] = world.maxs[axis]; + } + } + } + + void ApplySurfaceLightmapRemap( + GfxWorld& world, const GfxSurface& surface, const LightmapAtlasGroup& group, const unsigned packedSlot, std::vector& vertexLightmapRemaps) + { + if (group.wideCount * group.highCount <= 1u || !world.indices || !world.vd.vertices) + return; + + const auto firstIndex = surface.tris.baseIndex; + const auto indexCount = static_cast(surface.tris.triCount) * 3; + if (firstIndex < 0 || indexCount <= 0 || firstIndex + indexCount > world.indexCount) + return; + + const auto slotX = packedSlot % group.wideCount; + const auto slotY = packedSlot / group.wideCount; + const auto scaleU = LinkerFloat(1.0 / static_cast(group.wideCount)); + const auto scaleV = LinkerFloat(1.0 / static_cast(group.highCount)); + const auto offsetU = LinkerFloat(static_cast(slotX) * scaleU); + const auto offsetV = LinkerFloat(static_cast(slotY) * scaleV); + + for (auto indexOffset = 0; indexOffset < indexCount; indexOffset++) + { + const auto vertexIndex = surface.tris.firstVertex + world.indices[firstIndex + indexOffset]; + if (vertexIndex < 0 || static_cast(vertexIndex) >= world.vertexCount) + continue; + + // Raw BSP vertices are shared by all surfaces that reference them. + // Applying the same atlas scale/offset repeatedly corrupts the UVs + // and later dumps as black lightmap stripes in Radiant. + auto& vertexRemap = vertexLightmapRemaps[vertexIndex]; + if (vertexRemap >= 0) + continue; + + vertexRemap = static_cast(packedSlot); + auto& vertex = world.vd.vertices[vertexIndex]; + vertex.lmapCoord[0] = LinkerFloat(static_cast(vertex.lmapCoord[0]) * scaleU + offsetU); + vertex.lmapCoord[1] = LinkerFloat(static_cast(vertex.lmapCoord[1]) * scaleV + offsetV); + } + } + + [[nodiscard]] BspLoadResult ApplyMagicPortalVertexCoords(GfxWorld& world, const GfxSurface& surface) + { + if (!surface.material || (surface.material->info.gameFlags & MATERIAL_GAME_FLAG_MAGIC_PORTAL) == 0u) + return {}; + + const auto triCount = static_cast(surface.tris.triCount); + const auto indexCount = triCount * 3uz; + if (triCount == 0uz) + return {}; + + if (!world.indices || !world.vd.vertices || surface.tris.baseIndex < 0 + || static_cast(surface.tris.baseIndex) > static_cast(world.indexCount) + || indexCount > static_cast(world.indexCount) - static_cast(surface.tris.baseIndex)) + return std::unexpected("magic portal surface index range is out of bounds"); + + std::vector fillId(triCount); + std::vector> centerAccum(triCount); + std::vector centerWeight(triCount); + for (auto triIndex = 0uz; triIndex < triCount; triIndex++) + fillId[triIndex] = triIndex; + + // Stock R_SurfCalculateMagicPortalVerts groups connected triangles by + // shared vertex, averages each connected component's xyz center, then + // stores that center as texCoord.xy/lmapCoord.x with lmapCoord.y = 1. + // These portal materials do not preserve authored UVs in the runtime + // world and must be normalized this way to dump back to linker_pc form. + auto changed = true; + while (changed) + { + changed = false; + for (auto leftTri = 0uz; leftTri < triCount; leftTri++) + { + std::array leftVerts{}; + for (auto triVertex = 0uz; triVertex < 3uz; triVertex++) + leftVerts[triVertex] = surface.tris.firstVertex + world.indices[surface.tris.baseIndex + static_cast(leftTri * 3uz + triVertex)]; + + for (auto rightTri = 0uz; rightTri < triCount; rightTri++) + { + std::array rightVerts{}; + for (auto triVertex = 0uz; triVertex < 3uz; triVertex++) + rightVerts[triVertex] = surface.tris.firstVertex + world.indices[surface.tris.baseIndex + static_cast(rightTri * 3uz + triVertex)]; + + for (const auto leftVertex : leftVerts) + { + for (const auto rightVertex : rightVerts) + { + if (leftVertex != rightVertex || fillId[leftTri] == fillId[rightTri]) + continue; + + if (fillId[leftTri] >= fillId[rightTri]) + fillId[leftTri] = fillId[rightTri]; + else + fillId[rightTri] = fillId[leftTri]; + changed = true; + } + } + } + } + } + + for (auto triIndex = 0uz; triIndex < triCount; triIndex++) + { + const auto targetFill = fillId[triIndex]; + for (auto triVertex = 0uz; triVertex < 3uz; triVertex++) + { + const auto vertexIndex = surface.tris.firstVertex + world.indices[surface.tris.baseIndex + static_cast(triIndex * 3uz + triVertex)]; + if (vertexIndex < 0 || static_cast(vertexIndex) >= world.vertexCount) + return std::unexpected("magic portal surface references invalid vertex"); + + const auto& vertex = world.vd.vertices[vertexIndex]; + for (auto axis = 0uz; axis < 3uz; axis++) + centerAccum[targetFill][axis] = LinkerFloat(static_cast(centerAccum[targetFill][axis]) + vertex.xyz[axis]); + centerWeight[targetFill] = LinkerFloat(static_cast(centerWeight[targetFill]) + 1.0); + } + } + + for (auto triIndex = 0uz; triIndex < triCount; triIndex++) + { + if (centerWeight[triIndex] <= 0.0f) + continue; + + const auto scale = LinkerFloat(1.0 / static_cast(centerWeight[triIndex])); + for (auto axis = 0uz; axis < 3uz; axis++) + centerAccum[triIndex][axis] = LinkerFloat(static_cast(centerAccum[triIndex][axis]) * scale); + } + + for (auto triIndex = 0uz; triIndex < triCount; triIndex++) + { + const auto sourceFill = fillId[triIndex]; + for (auto triVertex = 0uz; triVertex < 3uz; triVertex++) + { + const auto vertexIndex = surface.tris.firstVertex + world.indices[surface.tris.baseIndex + static_cast(triIndex * 3uz + triVertex)]; + auto& vertex = world.vd.vertices[vertexIndex]; + vertex.texCoord[0] = centerAccum[sourceFill][0]; + vertex.texCoord[1] = centerAccum[sourceFill][1]; + vertex.lmapCoord[0] = centerAccum[sourceFill][2]; + vertex.lmapCoord[1] = 1.0f; + } + } + + return {}; + } + + struct RawWorldSurfaceIndexInfo + { + uint16_t materialIndex = 0u; + uint8_t lightmapIndex = 0u; + uint8_t reflectionProbeIndex = 0u; + int firstIndex = 0; + uint16_t indexCount = 0u; + }; + + [[nodiscard]] bool RawSurfaceMaterialsMatch(const RawWorldSurfaceIndexInfo& left, + const RawWorldSurfaceIndexInfo& right, + const size_t firstSurfaceIndex, + const std::vector& rawMaterialNames) + { + if (left.materialIndex == right.materialIndex) + return true; + + if (left.materialIndex >= rawMaterialNames.size() || firstSurfaceIndex >= rawMaterialNames.size()) + return false; + + // linker_pc's R_LoadSurfaces fallback compares the candidate material + // name to materialTable[firstSurfaceIndex], not to + // materialTable[firstSurface.materialIndex]. This odd-looking offset is + // visible in the decomp and is required to reproduce the canonical + // runtime index-buffer order. + return rawMaterialNames[left.materialIndex] == rawMaterialNames[firstSurfaceIndex]; + } + + [[nodiscard]] bool RawSurfaceIndexGroupsMatch(const RawWorldSurfaceIndexInfo& left, + const RawWorldSurfaceIndexInfo& right, + const size_t firstSurfaceIndex, + const std::vector& rawMaterialNames) + { + return RawSurfaceMaterialsMatch(left, right, firstSurfaceIndex, rawMaterialNames) && left.reflectionProbeIndex == right.reflectionProbeIndex + && left.lightmapIndex == right.lightmapIndex; + } + + [[nodiscard]] BspLoadResult RewriteWorldIndicesLikeLinker(GfxWorld& world, + const std::vector& rawSurfaces, + const std::vector& rawMaterialNames, + MemoryManager& memory) + { + if (rawSurfaces.empty()) + return {}; + + if (!world.indices) + return std::unexpected("world surfaces require an index lump"); + + auto rewrittenIndexCount = 0uz; + for (const auto& surface : rawSurfaces) + rewrittenIndexCount += surface.indexCount; + + if (!FitsInt(rewrittenIndexCount)) + return std::unexpected("world index count is too large"); + + std::vector rewrittenIndices(rewrittenIndexCount); + std::vector assigned(rawSurfaces.size()); + auto writeIndex = 0uz; + + // linker_pc does not keep the raw draw-index lump as-is. During + // R_LoadSurfaces it walks raw surfaces in order, groups still-unwritten + // surfaces by raw material name, raw reflection probe, and raw lightmap + // page, and appends each raw index span into a canonical runtime index + // buffer. The surface baseIndex values then point into this rewritten + // buffer before R_SortSurfaces moves the surface records. + for (auto firstSurface = 0uz; firstSurface < rawSurfaces.size(); firstSurface++) + { + if (assigned[firstSurface]) + continue; + + const auto& first = rawSurfaces[firstSurface]; + for (auto surfaceIndex = firstSurface; surfaceIndex < rawSurfaces.size(); surfaceIndex++) + { + if (assigned[surfaceIndex] || !RawSurfaceIndexGroupsMatch(rawSurfaces[surfaceIndex], first, firstSurface, rawMaterialNames)) + continue; + + const auto& rawSurface = rawSurfaces[surfaceIndex]; + if (rawSurface.firstIndex < 0 || static_cast(rawSurface.firstIndex) > static_cast(world.indexCount) + || static_cast(rawSurface.indexCount) > static_cast(world.indexCount) - static_cast(rawSurface.firstIndex)) + return std::unexpected(std::format("world surface {} index range is out of bounds", surfaceIndex)); + + if (writeIndex + rawSurface.indexCount > rewrittenIndices.size()) + return std::unexpected("world index rewrite exceeded output size"); + + std::memcpy( + &rewrittenIndices[writeIndex], &world.indices[rawSurface.firstIndex], static_cast(rawSurface.indexCount) * sizeof(uint16_t)); + world.dpvs.surfaces[surfaceIndex].tris.baseIndex = static_cast(writeIndex); + assigned[surfaceIndex] = true; + writeIndex += rawSurface.indexCount; + } + } + + if (writeIndex != rewrittenIndices.size()) + return std::unexpected("world index rewrite did not write every index"); + + world.indexCount = static_cast(rewrittenIndices.size()); + world.indices = memory.Alloc(rewrittenIndices.size()); + if (world.indices && !rewrittenIndices.empty()) + std::memcpy(world.indices, rewrittenIndices.data(), rewrittenIndices.size() * sizeof(uint16_t)); + + return {}; + } + + [[nodiscard]] BspLoadResult PopulateWorldSurfaces(GfxWorld& world, + const IW3::d3dbsp::File& bsp, + const LightmapAtlasLayout& lightmapLayout, + const std::vector*>& materialDependencies, + MemoryManager& memory) + { + const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); + if (!surfaces) + return {}; + + if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz || !FitsInt(RecordCount(*surfaces, RAW_WORLD_SURFACE_SIZE))) + return std::unexpected("world surface lump has funny size"); + + world.surfaceCount = static_cast(RecordCount(*surfaces, RAW_WORLD_SURFACE_SIZE)); + world.dpvs.staticSurfaceCount = static_cast(world.surfaceCount); + world.dpvs.staticSurfaceCountNoDecal = static_cast(world.surfaceCount); + world.dpvs.litSurfsBegin = 0u; + world.dpvs.litSurfsEnd = static_cast(world.surfaceCount); + world.dpvs.surfaces = AllocZeroed(memory, world.surfaceCount); + std::vector vertexLightmapRemaps(world.vertexCount, -1); + std::vector rawSurfaceIndexInfo(static_cast(world.surfaceCount)); + auto rawMaterialNamesResult = RawWorldMaterialNames(bsp); + if (!rawMaterialNamesResult) + return std::unexpected(std::move(rawMaterialNamesResult.error())); + auto rawMaterialNames = std::move(*rawMaterialNamesResult); + + for (auto surfaceIndex = 0uz; surfaceIndex < static_cast(world.surfaceCount); surfaceIndex++) + { + const auto* record = surfaces->data.data() + surfaceIndex * RAW_WORLD_SURFACE_SIZE; + auto& surface = world.dpvs.surfaces[surfaceIndex]; + const auto materialIndex = ReadU16(record); + if (materialIndex >= materialDependencies.size() || !materialDependencies[materialIndex]) + return std::unexpected(std::format("world surface {} references missing render material index {}", surfaceIndex, materialIndex)); + + surface.material = materialDependencies[materialIndex]->Asset(); + + const auto rawLightmapIndex = std::to_integer(record[2]); + surface.lightmapIndex = static_cast(rawLightmapIndex); + surface.reflectionProbeIndex = static_cast(std::to_integer(record[3])); + surface.primaryLightIndex = static_cast(std::to_integer(record[4])); + surface.flags = static_cast(std::to_integer(record[5])); + surface.tris.vertexLayerData = ReadI32(record, 8uz); + surface.tris.firstVertex = ReadI32(record, 12uz); + surface.tris.vertexCount = ReadU16(record, 16uz); + surface.tris.triCount = static_cast(ReadU16(record, 18uz) / 3u); + surface.tris.baseIndex = -1; + + rawSurfaceIndexInfo[surfaceIndex].materialIndex = materialIndex; + rawSurfaceIndexInfo[surfaceIndex].lightmapIndex = static_cast(rawLightmapIndex); + rawSurfaceIndexInfo[surfaceIndex].reflectionProbeIndex = U8(surface.reflectionProbeIndex); + rawSurfaceIndexInfo[surfaceIndex].firstIndex = ReadI32(record, 20uz); + rawSurfaceIndexInfo[surfaceIndex].indexCount = ReadU16(record, 18uz); + } + + auto rewriteResult = RewriteWorldIndicesLikeLinker(world, rawSurfaceIndexInfo, rawMaterialNames, memory); + if (!rewriteResult) + return std::unexpected(std::move(rewriteResult.error())); + + for (auto surfaceIndex = 0uz; surfaceIndex < static_cast(world.surfaceCount); surfaceIndex++) + { + auto& surface = world.dpvs.surfaces[surfaceIndex]; + const auto rawLightmapIndex = rawSurfaceIndexInfo[surfaceIndex].lightmapIndex; + if (rawLightmapIndex != SKY_LIGHTMAP_INDEX && rawLightmapIndex < lightmapLayout.atlasIndexForRawPage.size()) + { + const auto atlasIndex = lightmapLayout.atlasIndexForRawPage[rawLightmapIndex]; + const auto packedSlot = lightmapLayout.packedSlotForRawPage[rawLightmapIndex]; + if (atlasIndex < lightmapLayout.groups.size()) + { + // Raw BSP surfaces reference original lightmap pages. The + // linker replaces that with the merged runtime atlas index + // and folds the original page slot into vertex lmap UVs. + surface.lightmapIndex = static_cast(atlasIndex); + ApplySurfaceLightmapRemap(world, surface, lightmapLayout.groups[atlasIndex], packedSlot, vertexLightmapRemaps); + } + } + + auto portalResult = ApplyMagicPortalVertexCoords(world, surface); + if (!portalResult) + return std::unexpected(std::move(portalResult.error())); + + PopulateSurfaceBounds(world, surface); + } + + return {}; + } + + void PopulateWorldMaterialMemory(GfxWorld& world, MemoryManager& memory) + { + struct MaterialUsage + { + Material* material = nullptr; + int memory = 0; + std::vector firstVertices; + }; + + std::array usages; + std::array materialHashTable{}; + if (!world.dpvs.surfaces || world.surfaceCount <= 0) + return; + + for (auto surfaceIndex = 0; surfaceIndex < world.surfaceCount; surfaceIndex++) + { + const auto& surface = world.dpvs.surfaces[surfaceIndex]; + auto* material = surface.material; + const auto materialHashIndex = MaterialHashIndex(materialHashTable, material); + if (!materialHashIndex) + continue; + + auto& usage = usages[*materialHashIndex]; + usage.material = material; + // R_MaterialUsage accounts for the surface header, index payload, + // and a fixed per-surface cost. Vertex data is charged once for + // each unique firstVertex used by that material. + usage.memory += static_cast(6u * surface.tris.triCount + 16u + 48u); + + const auto existingVertexRange = std::find(usage.firstVertices.begin(), usage.firstVertices.end(), surface.tris.firstVertex); + if (existingVertexRange == usage.firstVertices.end()) + { + usage.firstVertices.emplace_back(surface.tris.firstVertex); + usage.memory += 44 * surface.tris.vertexCount; + } + } + + auto materialMemoryCount = 0uz; + for (const auto& usage : usages) + { + if (usage.memory != 0) + materialMemoryCount++; + } + + if (materialMemoryCount == 0uz) + return; + + world.materialMemoryCount = static_cast(materialMemoryCount); + world.materialMemory = AllocZeroed(memory, materialMemoryCount); + + auto outIndex = 0uz; + for (const auto& usage : usages) + { + if (usage.memory == 0) + continue; + + world.materialMemory[outIndex].material = usage.material; + world.materialMemory[outIndex].memory = usage.memory; + outIndex++; + } + } + + [[nodiscard]] BspLoadResult PopulateWorldVertexLayerData(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* vertexLayerData = bsp.GetLump(LUMP_VERTEX_LAYER_DATA); + if (UsesSimpleWorldGeometry(bsp) || !vertexLayerData || vertexLayerData->data.empty()) + { + // linker_pc only copies LUMP_VERTEX_LAYER_DATA for the layered + // geometry path. Simple/unlayered BSP geometry uses a 4-byte dummy + // layer buffer even when the raw file still contains lump 42. + world.vertexLayerDataSize = 4u; + world.vld.data = AllocZeroed(memory, world.vertexLayerDataSize); + return {}; + } + + if (!FitsUnsigned(vertexLayerData->data.size())) + return std::unexpected("vertex layer data lump is too large"); + + world.vertexLayerDataSize = static_cast(vertexLayerData->data.size()); + world.vld.data = AllocCopy(memory, vertexLayerData->data); + return {}; + } + + [[nodiscard]] XAssetInfo* + AddGeneratedImage(AssetCreationContext& context, AssetRegistration& registration, const std::string& imageName, GfxImage* image) + { + auto* imageInfo = context.AddAsset(imageName, image); + if (imageInfo) + registration.AddDependency(imageInfo); + return imageInfo; + } + + [[nodiscard]] std::string LightmapImageName(const unsigned lightmapIndex, const std::string_view suffix) + { + return std::format("*lightmap{}_{}", lightmapIndex, suffix); + } + + void CopyPrimaryLightmapRawPageToAtlas(std::vector& out, const std::byte* page, const LightmapAtlasGroup& group, const unsigned packedSlot) + { + const auto atlasWidth = static_cast(group.wideCount) * LIGHTMAP_PRIMARY_RAW_WIDTH; + const auto slotX = packedSlot % group.wideCount; + const auto slotY = packedSlot / group.wideCount; + const auto* source = page + LIGHTMAP_SECONDARY_RAW_PAGE_SIZE; + const auto destinationX = static_cast(slotX) * LIGHTMAP_PRIMARY_RAW_WIDTH; + const auto destinationY = static_cast(slotY) * LIGHTMAP_PRIMARY_RAW_HEIGHT; + + for (auto row = 0u; row < LIGHTMAP_PRIMARY_RAW_HEIGHT; row++) + { + const auto destinationOffset = (destinationY + row) * atlasWidth + destinationX; + std::memcpy(out.data() + destinationOffset, source + static_cast(row) * LIGHTMAP_PRIMARY_RAW_WIDTH, LIGHTMAP_PRIMARY_RAW_WIDTH); + } + } + + void CopySecondaryLightmapRawPageToAtlas(std::vector& out, const std::byte* page, const LightmapAtlasGroup& group, const unsigned packedSlot) + { + const auto atlasStride = static_cast(group.wideCount) * LIGHTMAP_SECONDARY_RAW_WIDTH * LIGHTMAP_SECONDARY_PIXEL_SIZE; + const auto slotX = packedSlot % group.wideCount; + const auto slotY = packedSlot / group.wideCount; + const auto destinationX = static_cast(slotX) * LIGHTMAP_SECONDARY_RAW_WIDTH * LIGHTMAP_SECONDARY_PIXEL_SIZE; + constexpr auto rowSize = static_cast(LIGHTMAP_SECONDARY_RAW_WIDTH) * LIGHTMAP_SECONDARY_PIXEL_SIZE; + + // Runtime secondary lightmaps keep all page top halves first, followed + // by all bottom halves. This is the inverse of the raw page layout. + for (auto row = 0u; row < LIGHTMAP_SECONDARY_HALF_HEIGHT; row++) + { + const auto destinationY = static_cast(slotY) * LIGHTMAP_SECONDARY_HALF_HEIGHT + row; + const auto destinationOffset = destinationY * atlasStride + destinationX; + std::memcpy(out.data() + destinationOffset, page + static_cast(row) * rowSize, rowSize); + } + + for (auto row = 0u; row < LIGHTMAP_SECONDARY_HALF_HEIGHT; row++) + { + const auto destinationY = static_cast(group.highCount + slotY) * LIGHTMAP_SECONDARY_HALF_HEIGHT + row; + const auto destinationOffset = destinationY * atlasStride + destinationX; + const auto sourceOffset = static_cast(LIGHTMAP_SECONDARY_HALF_HEIGHT + row) * rowSize; + std::memcpy(out.data() + destinationOffset, page + sourceOffset, rowSize); + } + } + + [[nodiscard]] bool IsUniformReflectionProbePixels(const std::byte* source, const uint8_t b, const uint8_t g, const uint8_t r, const uint8_t a) + { + const auto pixelCount = REFLECTION_PROBE_RAW_DATA_SIZE / sizeof(uint32_t); + for (auto pixelIndex = 0uz; pixelIndex < pixelCount; pixelIndex++) + { + const auto* pixel = source + pixelIndex * sizeof(uint32_t); + if (std::to_integer(pixel[0]) != b || std::to_integer(pixel[1]) != g || std::to_integer(pixel[2]) != r + || std::to_integer(pixel[3]) != a) + { + return false; + } + } + + return true; + } + + [[nodiscard]] bool IsPlaceholderReflectionProbePixels(const std::byte* source) + { + return IsUniformReflectionProbePixels(source, 0u, 0u, 0u, 0u) || IsUniformReflectionProbePixels(source, 0x48u, 0x48u, 0x48u, 0u); + } + + void FillDefaultAuthoredReflectionProbePixels(char* out) + { + const auto pixelCount = REFLECTION_PROBE_RAW_DATA_SIZE / sizeof(uint32_t); + for (auto pixelIndex = 0uz; pixelIndex < pixelCount; pixelIndex++) + { + auto* pixel = out + pixelIndex * sizeof(uint32_t); + pixel[0] = 0; + pixel[1] = 0; + pixel[2] = static_cast(0xFF); + pixel[3] = static_cast(0xFF); + } + } + + [[nodiscard]] BspLoadValue, std::vector>> BuildLightmapAtlasImages(const IW3::d3dbsp::Lump& lightmaps, + const LightmapAtlasGroup& group, + const std::vector& lightDefs, + ISearchPath& searchPath) + { + const auto primaryAtlasSize = + static_cast(group.wideCount) * LIGHTMAP_PRIMARY_RAW_WIDTH * static_cast(group.highCount) * LIGHTMAP_PRIMARY_RAW_HEIGHT; + const auto secondaryAtlasSize = static_cast(group.wideCount) * LIGHTMAP_SECONDARY_RAW_WIDTH * LIGHTMAP_SECONDARY_PIXEL_SIZE + * static_cast(group.highCount) * LIGHTMAP_SECONDARY_RAW_HEIGHT; + + std::vector primary(primaryAtlasSize); + std::vector secondary(secondaryAtlasSize); + + for (auto packedSlot = 0uz; packedSlot < group.rawPageForPackedSlot.size(); packedSlot++) + { + const auto rawPage = group.rawPageForPackedSlot[packedSlot]; + const auto* page = lightmaps.data.data() + static_cast(rawPage) * LIGHTMAP_RAW_PAGE_SIZE; + CopySecondaryLightmapRawPageToAtlas(secondary, page, group, static_cast(packedSlot)); + CopyPrimaryLightmapRawPageToAtlas(primary, page, group, static_cast(packedSlot)); + } + + auto attenuationResult = ApplyLightDefAttenuationImages(secondary, group, lightDefs, searchPath); + if (!attenuationResult) + return std::unexpected(std::move(attenuationResult.error())); + + return std::make_pair(std::move(primary), std::move(secondary)); + } + + [[nodiscard]] BspLoadResult PopulateWorldLightmaps(GfxWorld& world, + const IW3::d3dbsp::File& bsp, + const LightmapAtlasLayout& lightmapLayout, + const std::vector& lightDefs, + ISearchPath& searchPath, + AssetCreationContext& context, + AssetRegistration& registration, + MemoryManager& memory) + { + const auto* lightmaps = bsp.GetLump(LUMP_LIGHTMAPS); + if (!lightmaps || lightmaps->data.empty()) + return {}; + + if (lightmaps->data.size() % LIGHTMAP_RAW_PAGE_SIZE != 0uz || !FitsInt(lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE)) + return std::unexpected("lightmap lump has funny size"); + + const auto pageCount = static_cast(lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE); + if (pageCount != lightmapLayout.rawPageCount) + return std::unexpected("lightmap atlas layout does not match lightmap lump"); + + world.lightmapCount = static_cast(lightmapLayout.groups.size()); + world.lightmaps = AllocZeroed(memory, lightmapLayout.groups.size()); + // These arrays are runtime handles. The fastfile DB loader only + // allocates them when the serialized pointers are non-null; R_LoadWorld + // fills them from the generated lightmap images after loading. + world.lightmapPrimaryTextures = AllocZeroed(memory, lightmapLayout.groups.size()); + world.lightmapSecondaryTextures = AllocZeroed(memory, lightmapLayout.groups.size()); + + for (auto lightmapIndex = 0uz; lightmapIndex < lightmapLayout.groups.size(); lightmapIndex++) + { + const auto& group = lightmapLayout.groups[lightmapIndex]; + auto atlasImages = BuildLightmapAtlasImages(*lightmaps, group, lightDefs, searchPath); + if (!atlasImages) + return std::unexpected(std::move(atlasImages.error())); + + const auto& primaryPixels = atlasImages->first; + const auto& secondaryPixels = atlasImages->second; + const auto primaryName = LightmapImageName(static_cast(lightmapIndex), "primary"); + const auto secondaryName = LightmapImageName(static_cast(lightmapIndex), "secondary"); + constexpr auto lightmapFlags = static_cast(image::iwi6::IMG_FLAG_NOMIPMAPS); + + auto* primary = CreateGeneratedImage(memory, + primaryName, + MAPTYPE_2D, + TS_FUNCTION, + IMG_CATEGORY_LIGHTMAP, + static_cast(group.wideCount * LIGHTMAP_PRIMARY_RAW_WIDTH), + static_cast(group.highCount * LIGHTMAP_PRIMARY_RAW_HEIGHT), + 1u, + oat::D3DFMT_L8, + lightmapFlags, + primaryPixels.data(), + primaryPixels.size()); + auto* secondary = CreateGeneratedImage(memory, + secondaryName, + MAPTYPE_2D, + TS_FUNCTION, + IMG_CATEGORY_LIGHTMAP, + static_cast(group.wideCount * LIGHTMAP_SECONDARY_RAW_WIDTH), + static_cast(group.highCount * LIGHTMAP_SECONDARY_RAW_HEIGHT), + 1u, + oat::D3DFMT_A8R8G8B8, + lightmapFlags, + secondaryPixels.data(), + secondaryPixels.size()); + + auto* primaryInfo = AddGeneratedImage(context, registration, primaryName, primary); + auto* secondaryInfo = AddGeneratedImage(context, registration, secondaryName, secondary); + if (!primaryInfo || !secondaryInfo) + return std::unexpected("could not register generated lightmap image"); + + world.lightmaps[lightmapIndex].primary = primaryInfo->Asset(); + world.lightmaps[lightmapIndex].secondary = secondaryInfo->Asset(); + } + + return {}; + } + + void CopyTransformedReflectionProbePixels(char* out, const std::byte* source) + { + // cod4map can emit placeholder probe pixels when reflections were not + // generated. linker_pc normalizes those authored probes to an opaque + // blue fallback image; preserving/color-correcting the placeholder would + // produce black probes and a non-canonical d3dbsp after dumping. + if (IsPlaceholderReflectionProbePixels(source)) + { + FillDefaultAuthoredReflectionProbePixels(out); + return; + } + + const auto pixelCount = REFLECTION_PROBE_RAW_DATA_SIZE / sizeof(uint32_t); + for (auto pixelIndex = 0uz; pixelIndex < pixelCount; pixelIndex++) + { + const auto* pixel = source + pixelIndex * sizeof(uint32_t); + const auto transformed = + TransformReflectionProbeColor(std::to_integer(pixel[0]), std::to_integer(pixel[1]), std::to_integer(pixel[2])); + std::memcpy(out + pixelIndex * sizeof(uint32_t), &transformed, sizeof(transformed)); + } + } + + [[nodiscard]] BspLoadResult + CreateDefaultReflectionProbe(GfxWorld& world, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory) + { + auto* image = CreateGeneratedImage(memory, + "*reflection_probe0", + MAPTYPE_CUBE, + TS_COLOR_MAP, + IMG_CATEGORY_AUTO_GENERATED, + REFLECTION_PROBE_SIZE, + REFLECTION_PROBE_SIZE, + 1u, + oat::D3DFMT_A8R8G8B8, + static_cast(image::iwi6::IMG_FLAG_CUBEMAP), + nullptr, + REFLECTION_PROBE_RAW_DATA_SIZE); + + // R_CreateDefaultProbe fills the raw probe with 0xFFFF0000 pixels, then + // runs it through the same reflection image generation path as authored + // probes. In raw byte order that is RGB 0,0,255. + const auto transformed = TransformReflectionProbeColor(0u, 0u, 255u); + for (auto pixelOffset = 0uz; pixelOffset < REFLECTION_PROBE_RAW_DATA_SIZE; pixelOffset += sizeof(uint32_t)) + std::memcpy(image->texture.loadDef->data + pixelOffset, &transformed, sizeof(transformed)); + + auto* imageInfo = AddGeneratedImage(context, registration, image->name, image); + if (!imageInfo) + return std::unexpected("could not register generated default reflection probe image"); + + world.reflectionProbes[0].reflectionImage = imageInfo->Asset(); + return {}; + } + + [[nodiscard]] BspLoadResult PopulateWorldReflectionProbes( + GfxWorld& world, const IW3::d3dbsp::File& bsp, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory) + { + const auto* reflectionProbes = bsp.GetLump(LUMP_REFLECTION_PROBES); + if (!reflectionProbes || reflectionProbes->data.empty()) + { + world.reflectionProbeCount = 1u; + world.reflectionProbes = AllocZeroed(memory, 1uz); + world.reflectionProbeTextures = AllocZeroed(memory, 1uz); + return CreateDefaultReflectionProbe(world, context, registration, memory); + } + + if (reflectionProbes->data.size() % REFLECTION_PROBE_RECORD_SIZE != 0uz + || !FitsUnsigned(reflectionProbes->data.size() / REFLECTION_PROBE_RECORD_SIZE + 1uz)) + return std::unexpected("reflection-probe lump has funny size"); + + const auto rawProbeCount = reflectionProbes->data.size() / REFLECTION_PROBE_RECORD_SIZE; + world.reflectionProbeCount = static_cast(rawProbeCount + 1uz); + world.reflectionProbes = AllocZeroed(memory, world.reflectionProbeCount); + // The fastfile DB loader only allocates this runtime array when the + // serialized pointer is non-null. R_LoadWorld then fills it from each + // probe image's basemap after the world has been loaded. + world.reflectionProbeTextures = AllocZeroed(memory, world.reflectionProbeCount); + + auto defaultProbeResult = CreateDefaultReflectionProbe(world, context, registration, memory); + if (!defaultProbeResult) + return std::unexpected(std::move(defaultProbeResult.error())); + + for (auto rawProbeIndex = 0uz; rawProbeIndex < rawProbeCount; rawProbeIndex++) + { + const auto probeIndex = rawProbeIndex + 1uz; + const auto* record = reflectionProbes->data.data() + rawProbeIndex * REFLECTION_PROBE_RECORD_SIZE; + auto& probe = world.reflectionProbes[probeIndex]; + CopyFloat3(record, probe.origin); + + const auto imageName = std::format("*reflection_probe{}", probeIndex); + auto* image = CreateGeneratedImage(memory, + imageName, + MAPTYPE_CUBE, + TS_COLOR_MAP, + IMG_CATEGORY_AUTO_GENERATED, + REFLECTION_PROBE_SIZE, + REFLECTION_PROBE_SIZE, + 1u, + oat::D3DFMT_A8R8G8B8, + static_cast(image::iwi6::IMG_FLAG_CUBEMAP), + nullptr, + REFLECTION_PROBE_RAW_DATA_SIZE); + CopyTransformedReflectionProbePixels(image->texture.loadDef->data, record + sizeof(float) * 3uz + REFLECTION_PROBE_NAME_SIZE); + + auto* imageInfo = AddGeneratedImage(context, registration, imageName, image); + if (!imageInfo) + return std::unexpected("could not register generated reflection probe image"); + + probe.reflectionImage = imageInfo->Asset(); + } + + return {}; + } + + [[nodiscard]] int CellForPoint(const GfxWorld& world, const float (&origin)[3]) + { + if (!world.dpvsPlanes.nodes || !world.dpvsPlanes.planes || world.dpvsPlanes.cellCount <= 0) + return -1; + + const auto cellCountPlusOne = world.dpvsPlanes.cellCount + 1; + auto* node = world.dpvsPlanes.nodes; + while (true) + { + const auto cellValue = static_cast(node[0]); + const auto planeIndex = cellValue - cellCountPlusOne; + if (planeIndex < 0) + return cellValue - 1; + + if (planeIndex >= world.planeCount) + return -1; + + const auto& plane = world.dpvsPlanes.planes[planeIndex]; + const auto dist = origin[0] * plane.normal[0] + origin[1] * plane.normal[1] + origin[2] * plane.normal[2] - plane.dist; + node += dist <= 0.0f ? node[1] : 2u; + } + } + + [[nodiscard]] float DistanceSquared(const float (&left)[3], const float (&right)[3]) + { + const auto x = left[0] - right[0]; + const auto y = left[1] - right[1]; + const auto z = left[2] - right[2]; + return x * x + y * y + z * z; + } + + [[nodiscard]] unsigned FindNearestReflectionProbe(const GfxWorld& world, const float (&origin)[3]) + { + auto bestProbe = 0u; + auto bestProbeDist = std::numeric_limits::max(); + + // Probe 0 is the generated default. The linker only falls back to it + // when there are no authored probes. + for (auto probeIndex = 1u; probeIndex < world.reflectionProbeCount; probeIndex++) + { + const auto testProbeDist = DistanceSquared(origin, world.reflectionProbes[probeIndex].origin); + if (testProbeDist < bestProbeDist) + { + bestProbeDist = testProbeDist; + bestProbe = probeIndex; + } + } + + return bestProbe; + } + + [[nodiscard]] unsigned FindNearestReflectionProbeInCell(const GfxWorld& world, const GfxCell& cell, const float (&origin)[3]) + { + if (cell.reflectionProbeCount <= 0 || !cell.reflectionProbes) + return FindNearestReflectionProbe(world, origin); + + auto bestProbe = 0u; + auto bestProbeDist = std::numeric_limits::max(); + for (auto cellProbeIndex = 0; cellProbeIndex < cell.reflectionProbeCount; cellProbeIndex++) + { + const auto probeIndex = static_cast(static_cast(cell.reflectionProbes[cellProbeIndex])); + if (probeIndex >= world.reflectionProbeCount) + continue; + + const auto testProbeDist = DistanceSquared(origin, world.reflectionProbes[probeIndex].origin); + if (testProbeDist < bestProbeDist) + { + bestProbeDist = testProbeDist; + bestProbe = probeIndex; + } + } + + return bestProbe; + } + + [[nodiscard]] unsigned CalcReflectionProbeIndex(const GfxWorld& world, const float (&origin)[3]) + { + const auto cellIndex = CellForPoint(world, origin); + if (cellIndex < 0 || cellIndex >= world.dpvsPlanes.cellCount || !world.cells) + return FindNearestReflectionProbe(world, origin); + + return FindNearestReflectionProbeInCell(world, world.cells[cellIndex], origin); + } + + void PopulateWorldStaticModelReflectionProbes(GfxWorld& world) + { + if (world.reflectionProbeCount == 0u || !world.reflectionProbes || world.dpvs.smodelCount == 0u || !world.dpvs.smodelInsts + || !world.dpvs.smodelDrawInsts) + return; + + for (auto smodelIndex = 0u; smodelIndex < world.dpvs.smodelCount; smodelIndex++) + { + const auto& smodelInst = world.dpvs.smodelInsts[smodelIndex]; + float center[3]{ + (smodelInst.mins[0] + smodelInst.maxs[0]) * 0.5f, + (smodelInst.mins[1] + smodelInst.maxs[1]) * 0.5f, + (smodelInst.mins[2] + smodelInst.maxs[2]) * 0.5f, + }; + + world.dpvs.smodelDrawInsts[smodelIndex].reflectionProbeIndex = static_cast(CalcReflectionProbeIndex(world, center)); + } + } + + [[nodiscard]] BspLoadResult PopulateWorldLightGrid(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* header = bsp.GetLump(LUMP_LIGHTGRID_HEADER); + if (header) + { + if (header->data.size() < 20uz || (header->data.size() - 20uz) % sizeof(uint16_t) != 0uz) + return std::unexpected("lightgrid header lump has funny size"); + + auto& lightGrid = world.lightGrid; + std::memcpy(lightGrid.mins, header->data.data(), sizeof(lightGrid.mins)); + std::memcpy(lightGrid.maxs, header->data.data() + sizeof(lightGrid.mins), sizeof(lightGrid.maxs)); + lightGrid.rowAxis = ReadU32(header->data.data(), 12uz); + lightGrid.colAxis = ReadU32(header->data.data(), 16uz); + + const auto rowCount = (header->data.size() - 20uz) / sizeof(uint16_t); + lightGrid.rowDataStart = rowCount > 0uz ? AllocZeroed(memory, rowCount) : nullptr; + if (rowCount > 0uz) + std::memcpy(lightGrid.rowDataStart, header->data.data() + 20uz, rowCount * sizeof(uint16_t)); + } + + const auto* rawRows = bsp.GetLump(LUMP_LIGHTGRID_ROWS); + if (rawRows) + { + if (!FitsUnsigned(rawRows->data.size())) + return std::unexpected("lightgrid raw row lump is too large"); + + world.lightGrid.rawRowDataSize = static_cast(rawRows->data.size()); + world.lightGrid.rawRowData = AllocCopy(memory, rawRows->data); + } + + const auto* entries = bsp.GetLump(LUMP_LIGHTGRID_ENTRIES); + if (entries) + { + if (entries->data.size() % RAW_LIGHTGRID_ENTRY_SIZE != 0uz || !FitsUnsigned(RecordCount(*entries, RAW_LIGHTGRID_ENTRY_SIZE))) + return std::unexpected("lightgrid entry lump has funny size"); + + world.lightGrid.entryCount = static_cast(RecordCount(*entries, RAW_LIGHTGRID_ENTRY_SIZE)); + world.lightGrid.entries = AllocCopy(memory, entries->data); + } + + const auto* colors = bsp.GetLump(LUMP_LIGHTGRID_COLORS); + if (colors) + { + if (colors->data.size() % RAW_LIGHTGRID_COLOR_SIZE != 0uz || !FitsUnsigned(RecordCount(*colors, RAW_LIGHTGRID_COLOR_SIZE) + 1uz)) + return std::unexpected("lightgrid color lump has funny size"); + + const auto rawColorCount = RecordCount(*colors, RAW_LIGHTGRID_COLOR_SIZE); + // The stock linker appends a runtime fallback color set that is not + // present in the raw BSP. Keep the raw colors first so dumping can + // drop the synthesized final entry again. + world.lightGrid.colorCount = static_cast(rawColorCount + 1uz); + world.lightGrid.colors = AllocZeroed(memory, rawColorCount + 1uz); + if (rawColorCount > 0uz) + std::memcpy(world.lightGrid.colors, colors->data.data(), rawColorCount * RAW_LIGHTGRID_COLOR_SIZE); + } + + return {}; + } + + struct WorldAabbTrees + { + GfxAabbTree* trees = nullptr; + int treeCount = 0; + }; + + [[nodiscard]] BspLoadValue + FinishWorldAabbTree_r(const GfxWorld& world, GfxAabbTree* trees, const size_t treeIndex, size_t totalTreesUsed, const size_t treeCount) + { + auto& tree = trees[treeIndex]; + ClearBounds(tree.mins, tree.maxs); + + if (tree.childCount > 0u) + { + const auto childStart = totalTreesUsed; + const auto childCount = static_cast(tree.childCount); + if (childStart + childCount > treeCount) + return std::unexpected(std::format("AABB tree {} children extend past tree count", treeIndex)); + + // Raw BSP AABB trees are stored as a flat list. The linker turns + // each parent into a byte offset to the contiguous child group; + // runtime mark/DPVS walks use ((char*)tree + childrenOffset). + tree.childrenOffset = static_cast(reinterpret_cast(&trees[childStart]) - reinterpret_cast(&tree)); + + totalTreesUsed += childCount; + for (auto childIndex = 0uz; childIndex < childCount; childIndex++) + { + const auto childTreeIndex = childStart + childIndex; + const auto nextTree = FinishWorldAabbTree_r(world, trees, childTreeIndex, totalTreesUsed, treeCount); + if (!nextTree) + return std::unexpected(std::move(nextTree.error())); + + totalTreesUsed = *nextTree; + ExpandBounds(trees[childTreeIndex].mins, trees[childTreeIndex].maxs, tree.mins, tree.maxs); + } + } + else + { + const auto startSurface = static_cast(tree.startSurfIndex); + const auto surfaceCount = static_cast(tree.surfaceCount); + if (startSurface + surfaceCount > static_cast(world.surfaceCount)) + return std::unexpected(std::format("AABB tree {} surface range is outside world surfaces", treeIndex)); + + for (auto surfaceOffset = 0uz; surfaceOffset < surfaceCount; surfaceOffset++) + ExpandBounds(world.dpvs.surfaces[startSurface + surfaceOffset].bounds[0], + world.dpvs.surfaces[startSurface + surfaceOffset].bounds[1], + tree.mins, + tree.maxs); + } + + return totalTreesUsed; + } + + [[nodiscard]] BspLoadResult FinishWorldAabbTrees(const GfxWorld& world, GfxAabbTree* trees, const size_t treeCount) + { + auto treeIndex = 0uz; + while (treeIndex < treeCount) + { + const auto nextTree = FinishWorldAabbTree_r(world, trees, treeIndex, treeIndex + 1uz, treeCount); + if (!nextTree) + return std::unexpected(std::move(nextTree.error())); + + treeIndex = *nextTree; + } + + return {}; + } + + [[nodiscard]] size_t AabbTreeSubtreeCount(const GfxAabbTree& tree) + { + auto count = 1uz; + const auto* children = reinterpret_cast(reinterpret_cast(&tree) + tree.childrenOffset); + for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) + count += AabbTreeSubtreeCount(children[childIndex]); + + return count; + } + + [[nodiscard]] GfxAabbTree* AabbTreeChildren(GfxAabbTree& tree) + { + return reinterpret_cast(reinterpret_cast(&tree) + tree.childrenOffset); + } + + [[nodiscard]] const GfxAabbTree* AabbTreeChildren(const GfxAabbTree& tree) + { + return reinterpret_cast(reinterpret_cast(&tree) + tree.childrenOffset); + } + + [[nodiscard]] BspLoadResult SetAabbTreeChildrenOffset(GfxAabbTree& tree, const GfxAabbTree* children) + { + const auto offset = reinterpret_cast(children) - reinterpret_cast(&tree); + if (offset < static_cast(std::numeric_limits::min()) || offset > static_cast(std::numeric_limits::max())) + return std::unexpected("AABB tree children offset is outside int range"); + + tree.childrenOffset = static_cast(offset); + return {}; + } + + [[nodiscard]] BspLoadValue BuildWorldAabbTrees(const GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* aabbTrees = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_AABBTREES, LUMP_SIMPLE_AABBTREES); + if (!aabbTrees || aabbTrees->data.empty()) + { + const auto treeCount = world.surfaceCount > 0 ? 1 : 0; + if (treeCount == 0) + return WorldAabbTrees{}; + + auto* result = AllocZeroed(memory, 1uz); + std::memcpy(result->mins, world.mins, sizeof(result->mins)); + std::memcpy(result->maxs, world.maxs, sizeof(result->maxs)); + result->surfaceCount = static_cast(std::min(world.surfaceCount, static_cast(UINT16_MAX))); + result->surfaceCountNoDecal = result->surfaceCount; + return WorldAabbTrees{result, treeCount}; + } + + if (aabbTrees->data.size() % RAW_WORLD_AABB_TREE_SIZE != 0uz || !FitsInt(RecordCount(*aabbTrees, RAW_WORLD_AABB_TREE_SIZE))) + return std::unexpected("world AABB tree lump has funny size"); + + const auto treeCount = static_cast(RecordCount(*aabbTrees, RAW_WORLD_AABB_TREE_SIZE)); + auto* result = AllocZeroed(memory, treeCount); + for (auto treeIndex = 0uz; treeIndex < static_cast(treeCount); treeIndex++) + { + const auto* record = aabbTrees->data.data() + treeIndex * RAW_WORLD_AABB_TREE_SIZE; + auto& tree = result[treeIndex]; + const auto startSurface = ReadU32(record); + const auto surfaceCount = ReadU32(record, 4uz); + const auto childCount = ReadU32(record, 8uz); + if (startSurface > UINT16_MAX || surfaceCount > UINT16_MAX || childCount > UINT16_MAX) + return std::unexpected(std::format("AABB tree {} value is out of uint16 range", treeIndex)); + + tree.startSurfIndex = static_cast(startSurface); + tree.surfaceCount = static_cast(surfaceCount); + tree.childCount = static_cast(childCount); + tree.startSurfIndexNoDecal = tree.startSurfIndex; + tree.surfaceCountNoDecal = tree.surfaceCount; + } + + auto finishResult = FinishWorldAabbTrees(world, result, static_cast(treeCount)); + if (!finishResult) + return std::unexpected(std::move(finishResult.error())); + + return WorldAabbTrees{result, treeCount}; + } + + [[nodiscard]] BspLoadResult PopulateWorldCells(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + auto aabbTreesResult = BuildWorldAabbTrees(world, bsp, memory); + if (!aabbTreesResult) + return std::unexpected(std::move(aabbTreesResult.error())); + + auto* aabbTrees = aabbTreesResult->trees; + const auto aabbTreeCount = aabbTreesResult->treeCount; + + const auto* cells = bsp.GetLump(LUMP_CELLS); + auto cellCount = cells && !cells->data.empty() ? cells->data.size() / RAW_WORLD_CELL_SIZE : 1uz; + if (cells && cells->data.size() % RAW_WORLD_CELL_SIZE != 0uz) + return std::unexpected("cell lump has funny size"); + + if (!FitsInt(cellCount)) + return std::unexpected("too many cell records"); + + world.dpvsPlanes.cellCount = static_cast(cellCount); + // Stock R_LoadBsp computes this as a byte count for stack/local cell + // masks, not as a count of 32-bit words. + world.cellBitsCount = static_cast(16uz * ((cellCount + 127uz) >> 7uz)); + world.cells = AllocZeroed(memory, cellCount); + + for (auto cellIndex = 0uz; cellIndex < cellCount; cellIndex++) + { + auto& cell = world.cells[cellIndex]; + if (cells && !cells->data.empty()) + { + const auto* record = cells->data.data() + cellIndex * RAW_WORLD_CELL_SIZE; + CopyFloat3(record, cell.mins); + CopyFloat3(record + 12uz, cell.maxs); + + constexpr auto SIMPLE_AABB_TREE_INDEX_OFFSET = 26uz; + const auto aabbTreeIndex = static_cast(ReadU16(record, SIMPLE_AABB_TREE_INDEX_OFFSET)); + if (aabbTreeIndex >= static_cast(aabbTreeCount)) + return std::unexpected(std::format("cell {} references invalid AABB tree {}", cellIndex, aabbTreeIndex)); + + // v22 stores both layered and simple AABB roots in each cell. + // The loader currently imports the simple surface/index lumps, + // so use the simple root index and serialize the contiguous + // subtree rooted there, matching linker_pc's per-cell fixup. + cell.aabbTree = &aabbTrees[aabbTreeIndex]; + cell.aabbTreeCount = static_cast(AabbTreeSubtreeCount(*cell.aabbTree)); + + constexpr auto REFLECTION_PROBE_LIST_OFFSET = 44uz; + cell.reflectionProbeCount = static_cast(std::to_integer(record[REFLECTION_PROBE_LIST_OFFSET])); + const auto reflectionProbeCount = std::to_integer(record[REFLECTION_PROBE_LIST_OFFSET]); + if (reflectionProbeCount > 0u) + { + cell.reflectionProbes = AllocZeroed(memory, reflectionProbeCount); + for (auto probeIndex = 0u; probeIndex < reflectionProbeCount && REFLECTION_PROBE_LIST_OFFSET + 1uz + probeIndex < RAW_WORLD_CELL_SIZE; + probeIndex++) + cell.reflectionProbes[probeIndex] = + static_cast(std::to_integer(record[REFLECTION_PROBE_LIST_OFFSET + 1uz + probeIndex])); + } + } + else + { + std::memcpy(cell.mins, world.mins, sizeof(cell.mins)); + std::memcpy(cell.maxs, world.maxs, sizeof(cell.maxs)); + if (aabbTreeCount > 0) + { + cell.aabbTree = aabbTrees; + cell.aabbTreeCount = static_cast(AabbTreeSubtreeCount(*cell.aabbTree)); + } + } + } + + return {}; + } + + [[nodiscard]] char PortalPlaneSide(const float value, const char positiveValue) + { + return value > 0.0f ? positiveValue : 0; + } + + [[nodiscard]] BspLoadResult PopulateWorldPortals(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* portals = bsp.GetLump(LUMP_PORTALS); + const auto* portalVerts = bsp.GetLump(LUMP_PORTALVERTS); + const auto* cells = bsp.GetLump(LUMP_CELLS); + if (!portals || !portalVerts || !cells || !world.cells || !world.dpvsPlanes.planes) + return {}; + + if (portals->data.size() % RAW_WORLD_PORTAL_SIZE != 0uz || portalVerts->data.size() % RAW_VEC3_SIZE != 0uz + || cells->data.size() % RAW_WORLD_CELL_SIZE != 0uz) + return std::unexpected("portal, portal-vertex, or cell lump has funny size"); + + const auto portalCount = RecordCount(*portals, RAW_WORLD_PORTAL_SIZE); + const auto portalVertCount = RecordCount(*portalVerts, RAW_VEC3_SIZE); + const auto cellCount = RecordCount(*cells, RAW_WORLD_CELL_SIZE); + if (!FitsInt(portalCount) || cellCount > static_cast(std::max(world.dpvsPlanes.cellCount, 0))) + return std::unexpected("portal or cell count is invalid"); + + auto* vertexData = portalVertCount > 0uz ? AllocZeroed(memory, portalVertCount) : nullptr; + for (auto vertexIndex = 0uz; vertexIndex < portalVertCount; vertexIndex++) + std::memcpy(vertexData[vertexIndex].v, portalVerts->data.data() + vertexIndex * RAW_VEC3_SIZE, RAW_VEC3_SIZE); + + auto* portalData = portalCount > 0uz ? AllocZeroed(memory, portalCount) : nullptr; + for (auto portalIndex = 0uz; portalIndex < portalCount; portalIndex++) + { + const auto* record = portals->data.data() + portalIndex * RAW_WORLD_PORTAL_SIZE; + const auto planeIndex = static_cast(ReadU32(record)); + const auto cellIndex = static_cast(ReadU32(record, 4uz)); + const auto firstVertex = static_cast(ReadU32(record, 8uz)); + const auto vertexCount = std::to_integer(record[12]); + + if (planeIndex >= static_cast(world.planeCount)) + return std::unexpected(std::format("portal {} references invalid plane {}", portalIndex, planeIndex)); + + if (cellIndex >= static_cast(world.dpvsPlanes.cellCount)) + return std::unexpected(std::format("portal {} references invalid cell {}", portalIndex, cellIndex)); + + if (firstVertex + vertexCount > portalVertCount) + return std::unexpected(std::format("portal {} vertex range is outside portal vertices", portalIndex)); + + auto& portal = portalData[portalIndex]; + const auto& plane = world.dpvsPlanes.planes[planeIndex]; + portal.plane.coeffs[0] = plane.normal[0]; + portal.plane.coeffs[1] = plane.normal[1]; + portal.plane.coeffs[2] = plane.normal[2]; + portal.plane.coeffs[3] = -plane.dist; + portal.plane.side[0] = PortalPlaneSide(portal.plane.coeffs[0], 0xC); + portal.plane.side[1] = PortalPlaneSide(portal.plane.coeffs[1], 0x10); + portal.plane.side[2] = PortalPlaneSide(portal.plane.coeffs[2], 0x14); + portal.cell = &world.cells[cellIndex]; + portal.vertices = vertexData ? &vertexData[firstVertex] : nullptr; + portal.vertexCount = static_cast(vertexCount); + PerpendicularVector(plane.normal, portal.hullAxis[0]); + CrossProduct(plane.normal, portal.hullAxis[0], portal.hullAxis[1]); + } + + for (auto cellIndex = 0uz; cellIndex < cellCount; cellIndex++) + { + const auto* record = cells->data.data() + cellIndex * RAW_WORLD_CELL_SIZE; + const auto firstPortal = static_cast(ReadU32(record, 28uz)); + const auto portalCountForCell = ReadU32(record, 32uz); + if (firstPortal + portalCountForCell > portalCount) + return std::unexpected(std::format("cell {} portal range is outside portals", cellIndex)); + + auto& cell = world.cells[cellIndex]; + cell.portalCount = static_cast(portalCountForCell); + cell.portals = portalCountForCell > 0u ? &portalData[firstPortal] : nullptr; + } + + return {}; + } + + [[nodiscard]] BspLoadResult PopulateWorldModels(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* models = bsp.GetLump(LUMP_MODELS); + if (!models) + return {}; + + if (models->data.size() % RAW_MODEL_SIZE != 0uz || !FitsInt(RecordCount(*models, RAW_MODEL_SIZE))) + return std::unexpected("world model lump has funny size"); + + world.modelCount = static_cast(RecordCount(*models, RAW_MODEL_SIZE)); + world.models = AllocZeroed(memory, world.modelCount); + + for (auto modelIndex = 0uz; modelIndex < static_cast(world.modelCount); modelIndex++) + { + const auto* record = models->data.data() + modelIndex * RAW_MODEL_SIZE; + auto& model = world.models[modelIndex]; + CopyFloat3(record, model.writable.mins); + CopyFloat3(record + 12uz, model.writable.maxs); + std::memcpy(model.bounds[0], model.writable.mins, sizeof(model.bounds[0])); + std::memcpy(model.bounds[1], model.writable.maxs, sizeof(model.bounds[1])); + // The raw model record keeps the no-decal surface split separately. + // Radiant recomputes this from AABB/surface data and asserts it + // still matches, so preserving it matters for round-tripped BSPs. + model.startSurfIndex = ReadU16(record, 26uz); + model.surfaceCountNoDecal = ReadU16(record, 28uz); + model.surfaceCount = ReadU16(record, 30uz); + } + + if (world.modelCount > 0) + { + std::memcpy(world.mins, world.models[0].bounds[0], sizeof(world.mins)); + std::memcpy(world.maxs, world.models[0].bounds[1], sizeof(world.maxs)); + + // The raw BSP loader recomputes the no-decal split while loading, + // but the stock BSP already carries enough of the split in model0 + // to tell us whether AABB ranges need the decal-safe fallback when + // dumping again. Without this, imported maps with decal surfaces get + // dumped as if every static surface belonged to the no-decal range. + world.dpvs.staticSurfaceCountNoDecal = world.models[0].surfaceCountNoDecal; + } + + return {}; + } + + [[nodiscard]] uint32_t FloatKeyBits(const float value) + { + uint32_t result; + std::memcpy(&result, &value, sizeof(result)); + if ((result & 0x7fffffffu) == 0u) + return 0u; + + return result; + } + + struct TriangleKey + { + std::array values{}; + + bool operator==(const TriangleKey& other) const + { + return values == other.values; + } + }; + + struct TriangleKeyHash + { + static uint64_t Mix(uint64_t value) + { + value ^= value >> 30u; + value *= 0xbf58476d1ce4e5b9ull; + value ^= value >> 27u; + value *= 0x94d049bb133111ebull; + value ^= value >> 31u; + return value; + } + + std::size_t operator()(const TriangleKey& key) const + { + // MSVC's 32-bit unordered_map only sees the low bits of size_t. + // Final avalanche mixing keeps coincident-triangle lookups from + // collapsing into large buckets on dense map geometry. + auto result = 0x9e3779b97f4a7c15ull; + for (const auto value : key.values) + { + result ^= Mix(static_cast(value) + 0x9e3779b97f4a7c15ull + (result << 6u) + (result >> 2u)); + } + + return static_cast(Mix(result)); + } + }; + + using DecalTriangleMaterialMap = std::unordered_map; + + struct DecalTriangleData + { + unsigned firstSurfaceIndex = 0u; + DecalTriangleMaterialMap minMaterialForTriangle; + std::vector> firstSurfaceTriangleKey; + }; + + [[nodiscard]] std::optional BuildTriangleKey(const GfxWorld& world, const GfxSurface& surface, const int baseIndex) + { + if (!world.indices || !world.vd.vertices || baseIndex < 0 || baseIndex + 2 >= world.indexCount) + return std::nullopt; + + std::array, 3> points{}; + for (auto triVertex = 0uz; triVertex < 3uz; triVertex++) + { + const auto vertexIndex = surface.tris.firstVertex + world.indices[baseIndex + static_cast(triVertex)]; + if (vertexIndex < 0 || static_cast(vertexIndex) >= world.vertexCount) + return std::nullopt; + + const auto* xyz = world.vd.vertices[vertexIndex].xyz; + for (auto axis = 0uz; axis < 3uz; axis++) + points[triVertex][axis] = FloatKeyBits(xyz[axis]); + } + + // Stock R_DoWorldTrisCoincide accepts cyclic shifts of the same + // winding. Canonicalize only those rotations; reversed winding remains + // distinct, as in the linker. + TriangleKey best; + auto initialized = false; + for (auto rotation = 0uz; rotation < 3uz; rotation++) + { + TriangleKey rotated; + for (auto triVertex = 0uz; triVertex < 3uz; triVertex++) + { + const auto& point = points[(rotation + triVertex) % 3uz]; + for (auto axis = 0uz; axis < 3uz; axis++) + rotated.values[triVertex * 3uz + axis] = point[axis]; + } + + if (!initialized || rotated.values < best.values) + { + best = rotated; + initialized = true; + } + } + + return best; + } + + [[nodiscard]] DecalTriangleData BuildDecalTriangleData(const GfxWorld& world, const unsigned modelSurfIndexBegin, const unsigned modelSurfIndexEnd) + { + DecalTriangleData result; + result.firstSurfaceIndex = modelSurfIndexBegin; + result.firstSurfaceTriangleKey.resize(modelSurfIndexEnd - modelSurfIndexBegin); + + auto totalTriCount = 0uz; + for (auto surfIndex = modelSurfIndexBegin; surfIndex < modelSurfIndexEnd; surfIndex++) + totalTriCount += world.dpvs.surfaces[surfIndex].tris.triCount; + + result.minMaterialForTriangle.reserve(totalTriCount); + for (auto surfIndex = modelSurfIndexBegin; surfIndex < modelSurfIndexEnd; surfIndex++) + { + const auto& surface = world.dpvs.surfaces[surfIndex]; + if (!surface.material) + continue; + + auto& firstSurfaceTriangleKey = result.firstSurfaceTriangleKey[surfIndex - modelSurfIndexBegin]; + const auto materialSortedIndex = static_cast(surface.material->info.drawSurf.fields.materialSortedIndex); + for (auto triIter = 0u; triIter < surface.tris.triCount; triIter++) + { + const auto triangleKey = BuildTriangleKey(world, surface, surface.tris.baseIndex + static_cast(3u * triIter)); + if (!triangleKey) + continue; + + if (!firstSurfaceTriangleKey) + firstSurfaceTriangleKey = *triangleKey; + + auto [entry, inserted] = result.minMaterialForTriangle.emplace(*triangleKey, materialSortedIndex); + if (!inserted) + entry->second = std::min(entry->second, materialSortedIndex); + } + } + + return result; + } + + [[nodiscard]] bool IsSurfaceDecalLayer(const GfxWorld& world, const DecalTriangleData& decalTriangleData, const unsigned surfIndex) + { + const auto& surface = world.dpvs.surfaces[surfIndex]; + if (!surface.material || surface.tris.triCount == 0u) + return false; + + const auto& firstSurfaceTriangleKey = decalTriangleData.firstSurfaceTriangleKey[surfIndex - decalTriangleData.firstSurfaceIndex]; + if (!firstSurfaceTriangleKey) + return false; + + // linker_pc's R_IsSurfaceDecalLayer loops over triCount, but passes + // surf->tris.baseIndex to R_DoesTriCoverAnyOtherTri each time. That + // makes the first triangle decide the whole surface's decal flag. + const auto materialSortedIndex = static_cast(surface.material->info.drawSurf.fields.materialSortedIndex); + const auto existingTriangle = decalTriangleData.minMaterialForTriangle.find(*firstSurfaceTriangleKey); + return existingTriangle != decalTriangleData.minMaterialForTriangle.end() && materialSortedIndex > existingTriangle->second; + } + + [[nodiscard]] bool CompareWorldSurfaces(const GfxSurface& left, const GfxSurface& right) + { + const auto leftHasLit = MaterialHasWorldLitTechnique(left.material); + const auto rightHasLit = MaterialHasWorldLitTechnique(right.material); + if (leftHasLit != rightHasLit) + return leftHasLit > rightHasLit; + + if (!leftHasLit) + { + const auto leftHasEmissive = MaterialHasWorldEmissiveTechnique(left.material); + const auto rightHasEmissive = MaterialHasWorldEmissiveTechnique(right.material); + if (leftHasEmissive != rightHasEmissive) + return leftHasEmissive > rightHasEmissive; + } + + const auto leftPrimarySortKey = left.material ? left.material->info.drawSurf.fields.primarySortKey : 0u; + const auto rightPrimarySortKey = right.material ? right.material->info.drawSurf.fields.primarySortKey : 0u; + if (leftPrimarySortKey != rightPrimarySortKey) + return leftPrimarySortKey < rightPrimarySortKey; + + if (U8(left.primaryLightIndex) != U8(right.primaryLightIndex)) + return U8(left.primaryLightIndex) < U8(right.primaryLightIndex); + + const auto leftMaterialSortedIndex = left.material ? left.material->info.drawSurf.fields.materialSortedIndex : 0u; + const auto rightMaterialSortedIndex = right.material ? right.material->info.drawSurf.fields.materialSortedIndex : 0u; + if (leftMaterialSortedIndex != rightMaterialSortedIndex) + return leftMaterialSortedIndex < rightMaterialSortedIndex; + + if (U8(left.reflectionProbeIndex) != U8(right.reflectionProbeIndex)) + return U8(left.reflectionProbeIndex) < U8(right.reflectionProbeIndex); + + if (U8(left.lightmapIndex) != U8(right.lightmapIndex)) + return U8(left.lightmapIndex) < U8(right.lightmapIndex); + + if (left.tris.firstVertex != right.tris.firstVertex) + return left.tris.firstVertex < right.tris.firstVertex; + + return left.tris.vertexCount < right.tris.vertexCount; + } + + void ClassifySortedSurfaceRanges(GfxWorld& world, const unsigned surfaceCount) + { + auto surfIndex = 0u; + world.dpvs.litSurfsBegin = 0u; + while (surfIndex < surfaceCount) + { + const auto* material = world.dpvs.surfaces[surfIndex].material; + if (!material || !material->techniqueSet || !MaterialHasWorldLitTechnique(material) || material->info.sortKey >= 0x18u) + break; + + surfIndex++; + } + + world.dpvs.litSurfsEnd = surfIndex; + world.dpvs.decalSurfsBegin = surfIndex; + while (surfIndex < surfaceCount) + { + const auto* material = world.dpvs.surfaces[surfIndex].material; + if (!material || !material->techniqueSet || !MaterialHasWorldLitTechnique(material)) + break; + + surfIndex++; + } + + world.dpvs.decalSurfsEnd = surfIndex; + world.dpvs.emissiveSurfsBegin = surfIndex; + while (surfIndex < surfaceCount) + { + const auto* material = world.dpvs.surfaces[surfIndex].material; + if (!material || !material->techniqueSet || !MaterialHasWorldEmissiveTechnique(material)) + break; + + surfIndex++; + } + + world.dpvs.emissiveSurfsEnd = surfIndex; + } + + [[nodiscard]] BspLoadResult AppendNoDecalAabbTreeSurfaces( + const GfxWorld& world, GfxAabbTree& tree, std::vector& sortedSurfIndex, const unsigned sourceSurfaceCount, unsigned& writeIndex) + { + if (writeIndex > UINT16_MAX) + return std::unexpected("no-decal AABB tree start surface index is out of uint16 range"); + + tree.startSurfIndexNoDecal = static_cast(writeIndex); + if (tree.childCount > 0u) + { + auto* children = reinterpret_cast(reinterpret_cast(&tree) + tree.childrenOffset); + for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) + { + auto childResult = AppendNoDecalAabbTreeSurfaces(world, children[childIndex], sortedSurfIndex, sourceSurfaceCount, writeIndex); + if (!childResult) + return std::unexpected(std::move(childResult.error())); + } + } + else + { + const auto firstSurfaceIndex = static_cast(tree.startSurfIndex); + const auto surfaceCount = static_cast(tree.surfaceCount); + if (firstSurfaceIndex > sourceSurfaceCount || surfaceCount > sourceSurfaceCount - firstSurfaceIndex) + return std::unexpected("AABB tree no-decal source surface range is out of bounds"); + + for (auto surfaceOffset = 0u; surfaceOffset < surfaceCount; surfaceOffset++) + { + const auto surfaceIndex = sortedSurfIndex[firstSurfaceIndex + surfaceOffset]; + if (surfaceIndex >= world.dpvs.staticSurfaceCount) + return std::unexpected("AABB tree no-decal source references invalid surface"); + + if ((U8(world.dpvs.surfaces[surfaceIndex].flags) & 2u) != 0u) + continue; + + if (writeIndex >= sortedSurfIndex.size()) + return std::unexpected("too many no-decal AABB tree surfaces"); + + sortedSurfIndex[writeIndex++] = surfaceIndex; + } + } + + const auto surfaceCountNoDecal = writeIndex - static_cast(tree.startSurfIndexNoDecal); + if (surfaceCountNoDecal > UINT16_MAX) + return std::unexpected("no-decal AABB tree surface count is out of uint16 range"); + + tree.surfaceCountNoDecal = static_cast(surfaceCountNoDecal); + return {}; + } + + [[nodiscard]] BspLoadValue BuildNoDecalSubModels(GfxWorld& world, std::vector& sortedSurfIndex) + { + if (!world.models || world.modelCount <= 0) + return 0u; + + for (auto modelIndex = 0; modelIndex < world.modelCount; modelIndex++) + { + auto& model = world.models[modelIndex]; + model.surfaceCountNoDecal = 0; + if (model.surfaceCount == 0u) + continue; + + const auto begin = static_cast(model.startSurfIndex); + const auto end = begin + static_cast(model.surfaceCount); + if (end > static_cast(world.surfaceCount)) + return std::unexpected(std::format("world model {} surface range is out of bounds", modelIndex)); + + const auto decalTriangleData = BuildDecalTriangleData(world, begin, end); + for (auto surfIndex = begin; surfIndex < end; surfIndex++) + { + auto& surface = world.dpvs.surfaces[surfIndex]; + surface.flags = static_cast(U8(surface.flags) & ~2u); + if (IsSurfaceDecalLayer(world, decalTriangleData, surfIndex)) + surface.flags = static_cast(U8(surface.flags) | 2u); + else + model.surfaceCountNoDecal++; + } + } + + const auto& rootModel = world.models[0]; + const auto rootSurfaceCount = static_cast(rootModel.surfaceCount); + auto writeIndex = rootSurfaceCount; + if (world.dpvsPlanes.cellCount > 0 && world.cells) + { + // linker_pc appends the no-decal duplicate surface range by walking + // each cell's AABB tree, not by scanning model0 linearly. The same + // pass also rewrites startSurfIndexNoDecal/surfaceCountNoDecal on + // every tree node for runtime DPVS traversal. + for (auto cellIndex = 0; cellIndex < world.dpvsPlanes.cellCount; cellIndex++) + { + auto& cell = world.cells[cellIndex]; + if (!cell.aabbTree) + continue; + + auto appendResult = AppendNoDecalAabbTreeSurfaces(world, *cell.aabbTree, sortedSurfIndex, rootSurfaceCount, writeIndex); + if (!appendResult) + return std::unexpected(std::move(appendResult.error())); + } + } + else + { + for (auto originalSurfIndex = 0u; originalSurfIndex < rootSurfaceCount; originalSurfIndex++) + { + const auto sortedIndex = sortedSurfIndex[originalSurfIndex]; + if ((U8(world.dpvs.surfaces[sortedIndex].flags) & 2u) == 0u) + sortedSurfIndex[writeIndex++] = sortedIndex; + } + } + + return writeIndex - rootSurfaceCount; + } + + [[nodiscard]] BspLoadResult PopulateWorldSurfaceOrganization(GfxWorld& world, MemoryManager& memory) + { + if (!world.models || world.modelCount <= 0 || !world.dpvs.surfaces) + return {}; + + auto& rootModel = world.models[0]; + if (rootModel.surfaceCount == 0u) + return {}; + + if (rootModel.startSurfIndex != 0u) + return std::unexpected("root world model does not start at surface 0"); + + const auto surfaceCount = static_cast(rootModel.surfaceCount); + if (surfaceCount > static_cast(world.surfaceCount) || !FitsUint16(surfaceCount * 2uz)) + return std::unexpected("root world model surface count is invalid"); + + std::vector sortedSurfIndex(static_cast(surfaceCount) * 2uz); + for (auto surfIndex = 0u; surfIndex < surfaceCount; surfIndex++) + { + auto& surface = world.dpvs.surfaces[surfIndex]; + sortedSurfIndex[surfIndex] = surface.tris.vertexCount; + surface.tris.vertexCount = static_cast(surfIndex); + } + + std::sort(&world.dpvs.surfaces[0], &world.dpvs.surfaces[surfaceCount], CompareWorldSurfaces); + + for (auto surfIndex = 0u; surfIndex < surfaceCount; surfIndex++) + { + auto& surface = world.dpvs.surfaces[surfIndex]; + const auto originalSurfIndex = static_cast(surface.tris.vertexCount); + if (originalSurfIndex >= surfaceCount) + return std::unexpected("surface sort produced an invalid original surface index"); + + surface.tris.vertexCount = sortedSurfIndex[originalSurfIndex]; + sortedSurfIndex[originalSurfIndex] = static_cast(surfIndex); + } + + ClassifySortedSurfaceRanges(world, surfaceCount); + + auto noDecalSurfaceCount = BuildNoDecalSubModels(world, sortedSurfIndex); + if (!noDecalSurfaceCount) + return std::unexpected(std::move(noDecalSurfaceCount.error())); + + const auto finalSortedCount = static_cast(surfaceCount) + *noDecalSurfaceCount; + world.dpvs.sortedSurfIndex = AllocZeroed(memory, finalSortedCount); + std::memcpy(world.dpvs.sortedSurfIndex, sortedSurfIndex.data(), finalSortedCount * sizeof(uint16_t)); + world.dpvs.staticSurfaceCount = surfaceCount; + world.dpvs.staticSurfaceCountNoDecal = *noDecalSurfaceCount; + rootModel.surfaceCountNoDecal = static_cast(*noDecalSurfaceCount); + return {}; + } + + void ParseGfxLightRecord(const std::byte* record, GfxLight& light) + { + light = {}; + light.type = ReadRawByte(record, RAW_LIGHT_TYPE_OFFSET); + light.canUseShadowMap = ReadRawByte(record, RAW_LIGHT_CAN_USE_SHADOW_MAP_OFFSET); + light.unused[0] = ReadRawByte(record, RAW_LIGHT_UNUSED_OFFSET); + light.unused[1] = ReadRawByte(record, RAW_LIGHT_UNUSED_OFFSET + 1uz); + CopyFloat3(record + RAW_LIGHT_COLOR_OFFSET, light.color); + CopyFloat3(record + RAW_LIGHT_DIR_OFFSET, light.dir); + CopyFloat3(record + RAW_LIGHT_ORIGIN_OFFSET, light.origin); + CopyUnaligned(record + RAW_LIGHT_RADIUS_OFFSET, light.radius); + CopyUnaligned(record + RAW_LIGHT_COS_HALF_FOV_OUTER_OFFSET, light.cosHalfFovOuter); + CopyUnaligned(record + RAW_LIGHT_COS_HALF_FOV_INNER_OFFSET, light.cosHalfFovInner); + light.exponent = ReadI32(record, RAW_LIGHT_EXPONENT_OFFSET); + } + + [[nodiscard]] BspLoadValue InferWorldPrimaryLightCount(const IW3::d3dbsp::File& bsp, const size_t rawPrimaryLightCount) + { + const auto* regionCounts = bsp.GetLump(LUMP_LIGHT_REGION_COUNTS); + if (regionCounts && !regionCounts->data.empty()) + { + if (!FitsUnsigned(regionCounts->data.size())) + return std::unexpected("light-region count lump is too large"); + + // The primary-light lump is ComWorld data. GfxWorld primary-light + // arrays are sized by the light-region count lump when it exists. + return static_cast(regionCounts->data.size()); + } + + const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); + auto maxPrimaryLightIndex = 0u; + auto foundSurface = false; + + if (surfaces && !surfaces->data.empty()) + { + if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) + return std::unexpected("world surface lump has funny size"); + + const auto surfaceCount = RecordCount(*surfaces, RAW_WORLD_SURFACE_SIZE); + for (auto surfaceIndex = 0uz; surfaceIndex < surfaceCount; surfaceIndex++) + { + const auto* record = surfaces->data.data() + surfaceIndex * RAW_WORLD_SURFACE_SIZE; + maxPrimaryLightIndex = std::max(maxPrimaryLightIndex, std::to_integer(record[4])); + foundSurface = true; + } + } + + return foundSurface ? maxPrimaryLightIndex + 1u : static_cast(std::min(rawPrimaryLightCount, static_cast(UINT32_MAX))); + } + + [[nodiscard]] BspLoadResult PopulateWorldPrimaryLights(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* primaryLights = bsp.GetLump(LUMP_PRIMARY_LIGHTS); + const auto size = primaryLights ? primaryLights->data.size() : 0uz; + if (size % IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE != 0uz || !FitsUnsigned(size / IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE)) + return std::unexpected("primary-light lump has funny size"); + + const auto rawPrimaryLightCount = size / IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE; + auto primaryLightCount = InferWorldPrimaryLightCount(bsp, rawPrimaryLightCount); + if (!primaryLightCount) + return std::unexpected(std::move(primaryLightCount.error())); + world.primaryLightCount = *primaryLightCount; + + if (world.primaryLightCount > rawPrimaryLightCount) + return std::unexpected( + std::format("GfxWorld primary light count {} exceeds raw primary-light record count {}", world.primaryLightCount, rawPrimaryLightCount)); + + if (rawPrimaryLightCount == 0uz) + return {}; + + // Stock v22 maps normally reserve primary light 0 as "none" and store + // the sun at index 1. The stock loader uses that exact convention + // instead of scanning for any sun record. + world.sunPrimaryLightIndex = 0u; + if (rawPrimaryLightCount > 1uz) + { + const auto* candidateSunRecord = primaryLights->data.data() + IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE; + if (ReadRawByte(candidateSunRecord, RAW_LIGHT_TYPE_OFFSET) == 1) + world.sunPrimaryLightIndex = 1u; + } + + world.sunLight = AllocZeroed(memory); + ParseGfxLightRecord(primaryLights->data.data() + static_cast(world.sunPrimaryLightIndex) * IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE, + *world.sunLight); + std::memcpy(world.sunColorFromBsp, world.sunLight->color, sizeof(world.sunColorFromBsp)); + world.lightGrid.sunPrimaryLightIndex = world.sunPrimaryLightIndex; + return {}; + } + + [[nodiscard]] BspLoadResult PopulateWorldLightRegions(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + if (world.primaryLightCount == 0u) + return {}; + + const auto* counts = bsp.GetLump(LUMP_LIGHT_REGION_COUNTS); + if (!counts || counts->data.empty()) + return {}; + + world.lightGrid.hasLightRegions = true; + world.lightRegion = AllocZeroed(memory, world.primaryLightCount); + + const auto* hulls = bsp.GetLump(LUMP_LIGHT_REGION_HULLS); + const auto* axes = bsp.GetLump(LUMP_LIGHT_REGION_AXES); + auto hullOffset = 0uz; + auto axisOffset = 0uz; + + for (auto lightIndex = 0uz; lightIndex < world.primaryLightCount && lightIndex < counts->data.size(); lightIndex++) + { + const auto hullCount = std::to_integer(counts->data[lightIndex]); + if (hullCount == 0u) + continue; + + if (!hulls || hullOffset + static_cast(hullCount) * RAW_LIGHT_REGION_HULL_SIZE > hulls->data.size()) + return std::unexpected("light-region hull lump is truncated"); + + auto& region = world.lightRegion[lightIndex]; + region.hullCount = hullCount; + region.hulls = AllocZeroed(memory, hullCount); + + for (auto hullIndex = 0uz; hullIndex < hullCount; hullIndex++) + { + const auto* hullRecord = hulls->data.data() + hullOffset; + auto& hull = region.hulls[hullIndex]; + std::memcpy(hull.kdopMidPoint, hullRecord, sizeof(hull.kdopMidPoint)); + std::memcpy(hull.kdopHalfSize, hullRecord + sizeof(hull.kdopMidPoint), sizeof(hull.kdopHalfSize)); + hull.axisCount = ReadU32(hullRecord, 72uz); + hullOffset += RAW_LIGHT_REGION_HULL_SIZE; + + if (hull.axisCount == 0u) + continue; + + if (!axes || axisOffset + static_cast(hull.axisCount) * RAW_LIGHT_REGION_AXIS_SIZE > axes->data.size()) + return std::unexpected("light-region axis lump is truncated"); + + hull.axis = AllocZeroed(memory, hull.axisCount); + std::memcpy(hull.axis, axes->data.data() + axisOffset, static_cast(hull.axisCount) * RAW_LIGHT_REGION_AXIS_SIZE); + axisOffset += static_cast(hull.axisCount) * RAW_LIGHT_REGION_AXIS_SIZE; + } + } + + return {}; + } + + [[nodiscard]] bool PopulateWorldShadowGeometry(GfxWorld& world, MemoryManager& memory) + { + if (world.primaryLightCount == 0u || world.shadowGeom) + return true; + + // R_AllocShadowGeometryHeaderMemory creates one zeroed header per + // primary light while loading raw BSPs. Fastfile loading expects that + // array to already exist before R_SetPrimaryLightShadowSurfaces clears + // and rebuilds the surface counts at runtime. + world.shadowGeom = AllocZeroed(memory, world.primaryLightCount); + return true; + } + + [[nodiscard]] float Dot3(const float (&left)[3], const float (&right)[3]) + { + return left[0] * right[0] + left[1] * right[1] + left[2] * right[2]; + } + + [[nodiscard]] float LengthSquared3(const float (&value)[3]) + { + return Dot3(value, value); + } + + [[nodiscard]] bool CullBoxFromSphere(const float (&sphereOrigin)[3], const float radius, const float (&boxCenter)[3], const float (&boxHalfSize)[3]) + { + float distFromBoxToMid[3]{}; + for (auto axis = 0uz; axis < 3uz; axis++) + distFromBoxToMid[axis] = std::max(0.0f, std::fabs(sphereOrigin[axis] - boxCenter[axis]) - boxHalfSize[axis]); + + return radius * radius < LengthSquared3(distFromBoxToMid); + } + + [[nodiscard]] bool CullBoxFromConicSectionOfSphere(const float (&coneOrigin)[3], + const float (&coneDir)[3], + const float cosHalfFov, + const float radius, + const float (&boxCenter)[3], + const float (&boxHalfSize)[3]) + { + float deltaMid[3]{boxCenter[0] - coneOrigin[0], boxCenter[1] - coneOrigin[1], boxCenter[2] - coneOrigin[2]}; + float distFromBoxToMid[3]{}; + for (auto axis = 0uz; axis < 3uz; axis++) + distFromBoxToMid[axis] = std::max(0.0f, std::fabs(deltaMid[axis]) - boxHalfSize[axis]); + + if (radius * radius < LengthSquared3(distFromBoxToMid)) + return true; + + float farCorner[3]{}; + for (auto axis = 0uz; axis < 3uz; axis++) + farCorner[axis] = deltaMid[axis] - boxHalfSize[axis] * (coneDir[axis] < 0.0f ? -1.0f : 1.0f); + + const auto dist = Dot3(farCorner, coneDir); + if (dist >= 0.0f) + return true; + + float perpendicular[3]{ + farCorner[0] + (-dist) * coneDir[0], + farCorner[1] + (-dist) * coneDir[1], + farCorner[2] + (-dist) * coneDir[2], + }; + + const auto perpLenSq = LengthSquared3(perpendicular); + const auto cosHalfFovSq = cosHalfFov * cosHalfFov; + const auto sinHalfFovSq = 1.0f - cosHalfFovSq; + if (dist * dist * sinHalfFovSq >= perpLenSq * cosHalfFovSq) + return false; + + const auto scale = cosHalfFov / std::sqrt(perpLenSq * sinHalfFovSq); + float scaledSepAxis[3]{ + coneDir[0] + scale * perpendicular[0], + coneDir[1] + scale * perpendicular[1], + coneDir[2] + scale * perpendicular[2], + }; + auto scaledSepDist = Dot3(scaledSepAxis, deltaMid); + for (auto axis = 0uz; axis < 3uz; axis++) + scaledSepDist -= std::fabs(scaledSepAxis[axis] * boxHalfSize[axis]); + + return scaledSepDist >= 0.0f; + } + + [[nodiscard]] bool CullBoxFromPrimaryLight(const ComPrimaryLight& light, const float (&boxCenter)[3], const float (&boxHalfSize)[3]) + { + if (U8(light.type) == 2u && light.cosHalfFovExpanded >= 0.0f) + return CullBoxFromConicSectionOfSphere(light.origin, light.dir, light.cosHalfFovExpanded, light.radius, boxCenter, boxHalfSize); + + return CullBoxFromSphere(light.origin, light.radius, boxCenter, boxHalfSize); + } + + void TransformPlacementPoint(const GfxPackedPlacement& placement, const vec3_t& local, float (&out)[3]) + { + for (auto component = 0uz; component < 3uz; component++) + { + out[component] = + placement.origin[component] + + placement.scale + * (placement.axis[0][component] * local.v[0] + placement.axis[1][component] * local.v[1] + placement.axis[2][component] * local.v[2]); + } + } + + [[nodiscard]] bool GetModelSurfaces(const XModel& model, const unsigned lod, const XSurface*& surfaces, unsigned& surfaceCount) + { + constexpr auto XMODEL_LOD_COUNT = 4u; + // Referenced or placeholder models can have incomplete render data. + // Treat that as "no vertex vote"; linker_pc leaves the model with no primary light in that case. + if (!model.surfs || model.numLods == 0u || lod >= model.numLods || lod >= XMODEL_LOD_COUNT || model.numsurfs == 0u) + return false; + + const auto& lodInfo = model.lodInfo[lod]; + const auto firstSurface = static_cast(lodInfo.surfIndex); + const auto surfacesInLod = static_cast(lodInfo.numsurfs); + if (surfacesInLod == 0u || firstSurface >= model.numsurfs || firstSurface + surfacesInLod > model.numsurfs) + return false; + + surfaces = &model.surfs[firstSurface]; + surfaceCount = surfacesInLod; + return true; + } + + [[nodiscard]] bool PointInLightRegionHull(const GfxLightRegionHull& hull, const float (&relativePoint)[3]) + { + const float points[9]{ + relativePoint[0], + relativePoint[1], + relativePoint[2], + relativePoint[0] + relativePoint[1], + relativePoint[0] - relativePoint[1], + relativePoint[0] + relativePoint[2], + relativePoint[0] - relativePoint[2], + relativePoint[1] + relativePoint[2], + relativePoint[1] - relativePoint[2], + }; + + for (auto axis = 0uz; axis < 9uz; axis++) + { + if (hull.kdopHalfSize[axis] <= std::fabs(points[axis] - hull.kdopMidPoint[axis])) + return false; + } + + if (hull.axisCount > 0u && !hull.axis) + return false; + + for (auto axisIndex = 0u; axisIndex < hull.axisCount; axisIndex++) + { + const auto& axis = hull.axis[axisIndex]; + const auto midpointAlongDir = Dot3(relativePoint, axis.dir); + if (axis.halfSize <= std::fabs(midpointAlongDir - axis.midPoint)) + return false; + } + + return true; + } + + [[nodiscard]] unsigned + PrimaryLightForModelVertex(const GfxWorld& world, const ComWorld& comWorld, const std::vector& checkLight, const float (&point)[3]) + { + for (auto primaryLightIndex = 0u; primaryLightIndex < world.primaryLightCount && primaryLightIndex < comWorld.primaryLightCount; primaryLightIndex++) + { + if (!checkLight[primaryLightIndex]) + continue; + + const auto& light = comWorld.primaryLights[primaryLightIndex]; + float relativePoint[3]{point[0] - light.origin[0], point[1] - light.origin[1], point[2] - light.origin[2]}; + const auto lenSq = LengthSquared3(relativePoint); + if (lenSq > light.radius * light.radius) + continue; + + if (U8(light.type) == 2u) + { + const auto cosHalfFov = light.cosHalfFovExpanded; + const auto dot = Dot3(relativePoint, light.dir); + if (cosHalfFov >= 0.0f) + { + if (dot > 0.0f || cosHalfFov * cosHalfFov * lenSq > dot * dot) + continue; + } + else if (dot > 0.0f && cosHalfFov * cosHalfFov * lenSq < dot * dot) + { + continue; + } + } + + if (!world.lightRegion || world.lightRegion[primaryLightIndex].hullCount == 0u) + return primaryLightIndex; + + const auto& region = world.lightRegion[primaryLightIndex]; + if (!region.hulls) + continue; + + for (auto hullIndex = 0u; hullIndex < region.hullCount; hullIndex++) + { + if (PointInLightRegionHull(region.hulls[hullIndex], relativePoint)) + return primaryLightIndex; + } + } + + return 0u; + } + + [[nodiscard]] unsigned GetPrimaryLightForModel( + const GfxWorld& world, const ComWorld& comWorld, const GfxStaticModelDrawInst& drawInst, const float (&mins)[3], const float (&maxs)[3]) + { + if (!drawInst.model || !comWorld.primaryLights) + return 0u; + + const float boxCenter[3]{ + (mins[0] + maxs[0]) * 0.5f, + (mins[1] + maxs[1]) * 0.5f, + (mins[2] + maxs[2]) * 0.5f, + }; + const float boxHalfSize[3]{boxCenter[0] - mins[0], boxCenter[1] - mins[1], boxCenter[2] - mins[2]}; + + std::vector checkLight(std::min(world.primaryLightCount, comWorld.primaryLightCount)); + auto checkCount = 0u; + for (auto primaryLightIndex = 0u; primaryLightIndex < world.primaryLightCount && primaryLightIndex < comWorld.primaryLightCount; primaryLightIndex++) + { + const auto& light = comWorld.primaryLights[primaryLightIndex]; + const auto lightType = U8(light.type); + if (lightType != 0u && lightType != 1u && !CullBoxFromPrimaryLight(light, boxCenter, boxHalfSize)) + { + checkLight[primaryLightIndex] = true; + checkCount++; + } + } + + if (checkCount == 0u) + return 0u; + + const auto lod = drawInst.model->numLods > 0u ? static_cast(drawInst.model->numLods - 1u) : 0u; + const XSurface* surfaces = nullptr; + unsigned surfaceCount = 0u; + if (!GetModelSurfaces(*drawInst.model, lod, surfaces, surfaceCount)) + return 0u; + + std::vector votes(checkLight.size()); + auto mostVotes = 0u; + auto bestLight = 0u; + for (auto surfaceIndex = 0u; surfaceIndex < surfaceCount; surfaceIndex++) + { + const auto& surface = surfaces[surfaceIndex]; + if (!surface.verts0) + continue; + + for (auto vertexIndex = 0u; vertexIndex < surface.vertCount; vertexIndex++) + { + float point[3]{}; + TransformPlacementPoint(drawInst.placement, surface.verts0[vertexIndex].xyz, point); + const auto chosenLight = PrimaryLightForModelVertex(world, comWorld, checkLight, point); + if (chosenLight >= votes.size()) + continue; + + votes[chosenLight]++; + if (chosenLight && votes[chosenLight] > mostVotes) + { + mostVotes = votes[chosenLight]; + bestLight = chosenLight; + if (checkCount == 1u) + break; + } + } + } + + return bestLight; + } + + [[nodiscard]] unsigned RecomputeStaticModelPrimaryLight( + const GfxWorld& world, const ComWorld& comWorld, const GfxStaticModelDrawInst& drawInst, const float (&mins)[3], const float (&maxs)[3]) + { + const auto primaryLightIndex = GetPrimaryLightForModel(world, comWorld, drawInst, mins, maxs); + return primaryLightIndex < world.primaryLightCount ? primaryLightIndex : 0u; + } + + void PopulateStaticModelGroundLighting( + const GfxWorld& world, const ComWorld& comWorld, const EntityBlock& block, GfxStaticModelInst& inst, GfxStaticModelDrawInst& drawInst) + { + const auto gndLt = EntityField(block, "gndLt"); + auto groundLightContainsValidData = gndLt.size() >= 10uz; + + auto b = groundLightContainsValidData ? ParseHexByte(gndLt, 0uz) : std::optional{static_cast(0xffu)}; + auto g = groundLightContainsValidData ? ParseHexByte(gndLt, 2uz) : std::optional{static_cast(0u)}; + auto r = groundLightContainsValidData ? ParseHexByte(gndLt, 4uz) : std::optional{static_cast(0u)}; + auto a = groundLightContainsValidData ? ParseHexByte(gndLt, 6uz) : std::optional{static_cast(0u)}; + auto primaryLightIndex = groundLightContainsValidData ? ParseHexByte(gndLt, 8uz) : std::optional{static_cast(0u)}; + if (!b || !g || !r || !a || !primaryLightIndex) + groundLightContainsValidData = false; + + drawInst.primaryLightIndex = static_cast(primaryLightIndex.value_or(0u)); + + // linker_pc only preserves the parsed misc_model ground-light color + // when the model has XModel::flags bit 0 set and the color is non-zero. + if (!drawInst.model || (static_cast(drawInst.model->flags) & 1u) == 0u || !groundLightContainsValidData + || (r.value_or(0u) == 0u && g.value_or(0u) == 0u && b.value_or(0u) == 0u && a.value_or(0u) == 0u)) + { + inst.groundLighting.packed = 0; + drawInst.primaryLightIndex = static_cast(RecomputeStaticModelPrimaryLight(world, comWorld, drawInst, inst.mins, inst.maxs)); + return; + } + + // The linker parses gndLt as B,G,R,A,primaryLightIndex. Runtime + // GfxColor is stored in R,G,B,A byte order. + inst.groundLighting.array[0] = static_cast(*r); + inst.groundLighting.array[1] = static_cast(*g); + inst.groundLighting.array[2] = static_cast(*b); + inst.groundLighting.array[3] = static_cast(*a); + } + + [[nodiscard]] BspLoadResult PopulateWorldStaticModels(GfxWorld& world, + const ComWorld& comWorld, + const clipMap_t* clipMap, + const std::vector& staticModelBlocks, + const std::vector*>& staticModelDependencies, + MemoryManager& memory) + { + std::vector> validStaticModels; + validStaticModels.reserve(staticModelBlocks.size()); + for (auto i = 0uz; i < staticModelBlocks.size(); i++) + { + if (i < staticModelDependencies.size() && staticModelDependencies[i]) + validStaticModels.emplace_back(staticModelBlocks[i], staticModelDependencies[i]->Asset()); + } + + if (!FitsUnsigned(validStaticModels.size())) + return std::unexpected("too many static model records"); + + world.dpvs.smodelCount = static_cast(validStaticModels.size()); + if (validStaticModels.empty()) + return {}; + + world.dpvs.smodelDrawInsts = AllocZeroed(memory, validStaticModels.size()); + world.dpvs.smodelInsts = AllocZeroed(memory, validStaticModels.size()); + + for (auto modelIndex = 0uz; modelIndex < validStaticModels.size(); modelIndex++) + { + const auto& [block, model] = validStaticModels[modelIndex]; + auto& drawInst = world.dpvs.smodelDrawInsts[modelIndex]; + auto& inst = world.dpvs.smodelInsts[modelIndex]; + const auto origin = ParseLinkerFloat3(EntityField(*block, "origin")).value_or(std::array{}); + const auto angles = ParseLinkerFloat3(EntityField(*block, "angles")).value_or(std::array{}); + const auto scale = StaticModelScale(*block); + + float axis[3][3]{}; + AnglesToAxis(angles, axis); + + drawInst.cullDist = std::numeric_limits::max(); + drawInst.model = model; + drawInst.placement.scale = scale; + std::copy(origin.begin(), origin.end(), drawInst.placement.origin); + std::memcpy(drawInst.placement.axis, axis, sizeof(drawInst.placement.axis)); + // Static model cache slots are runtime state. Zero means uncached; + // R_CacheStaticModelSurface allocates a cache entry on demand. + // 0xffff is SMODEL_INDEX_NONE for cache leaves, not for draw insts. + + if ((ParseInt(EntityField(*block, "spawnflags")) & 2) != 0) + drawInst.flags = 1; + + if (clipMap && clipMap->staticModelList && modelIndex < clipMap->numStaticModels) + { + std::memcpy(inst.mins, clipMap->staticModelList[modelIndex].absmin, sizeof(inst.mins)); + std::memcpy(inst.maxs, clipMap->staticModelList[modelIndex].absmax, sizeof(inst.maxs)); + } + else if (model) + { + float mins[3]{}; + float maxs[3]{}; + for (auto axisIndex = 0uz; axisIndex < 3uz; axisIndex++) + { + mins[axisIndex] = std::numeric_limits::max(); + maxs[axisIndex] = std::numeric_limits::lowest(); + } + + for (auto corner = 0u; corner < 8u; corner++) + { + const auto x = (corner & 1u) != 0u ? model->maxs.x : model->mins.x; + const auto y = (corner & 2u) != 0u ? model->maxs.y : model->mins.y; + const auto z = (corner & 4u) != 0u ? model->maxs.z : model->mins.z; + float transformed[3]{}; + TransformStaticModelPoint(axis, origin, scale, x, y, z, transformed); + for (auto axisIndex = 0uz; axisIndex < 3uz; axisIndex++) + { + mins[axisIndex] = std::min(mins[axisIndex], transformed[axisIndex]); + maxs[axisIndex] = std::max(maxs[axisIndex], transformed[axisIndex]); + } + } + + std::memcpy(inst.mins, mins, sizeof(inst.mins)); + std::memcpy(inst.maxs, maxs, sizeof(inst.maxs)); + } + + PopulateStaticModelGroundLighting(world, comWorld, *block, inst, drawInst); + } + + return {}; + } + + using StaticModelSortOrder = std::unordered_map; + + struct StaticModelCombinedInst + { + GfxStaticModelDrawInst drawInst; + GfxStaticModelInst inst; + }; + + [[nodiscard]] StaticModelSortOrder BuildStaticModelSortOrder(const GfxWorld& world) + { + StaticModelSortOrder result; + if (!world.dpvs.smodelDrawInsts) + return result; + + for (auto smodelIndex = 0u; smodelIndex < world.dpvs.smodelCount; smodelIndex++) + { + const auto* model = world.dpvs.smodelDrawInsts[smodelIndex].model; + if (model && !result.contains(model)) + result.emplace(model, result.size()); + } + + return result; + } + + [[nodiscard]] unsigned StaticModelPrimaryLightType(const ComWorld& comWorld, const GfxStaticModelDrawInst& drawInst) + { + const auto primaryLightIndex = static_cast(drawInst.primaryLightIndex); + if (!comWorld.primaryLights || primaryLightIndex >= comWorld.primaryLightCount) + return 0u; + + return static_cast(comWorld.primaryLights[primaryLightIndex].type); + } + + [[nodiscard]] size_t StaticModelOrder(const StaticModelSortOrder& modelOrder, const XModel* model) + { + const auto existing = modelOrder.find(model); + return existing != modelOrder.end() ? existing->second : std::numeric_limits::max(); + } + + void SortWorldStaticModels(GfxWorld& world, const ComWorld& comWorld) + { + if (world.dpvs.smodelCount == 0u || !world.dpvs.smodelDrawInsts || !world.dpvs.smodelInsts) + return; + + const auto modelOrder = BuildStaticModelSortOrder(world); + std::vector combined; + combined.reserve(world.dpvs.smodelCount); + for (auto smodelIndex = 0u; smodelIndex < world.dpvs.smodelCount; smodelIndex++) + combined.push_back({world.dpvs.smodelDrawInsts[smodelIndex], world.dpvs.smodelInsts[smodelIndex]}); + + // linker_pc sorts combined draw/inst records before it filters static + // models into world cells. The stock tie-breaker compares XModel + // pointers; first-seen model order gives us the same deterministic + // ordering without depending on OAT allocator addresses. + std::sort(combined.begin(), + combined.end(), + [&comWorld, &modelOrder](const StaticModelCombinedInst& lhs, const StaticModelCombinedInst& rhs) + { + const auto lhsLightType = StaticModelPrimaryLightType(comWorld, lhs.drawInst); + const auto rhsLightType = StaticModelPrimaryLightType(comWorld, rhs.drawInst); + if (lhsLightType != rhsLightType) + return lhsLightType < rhsLightType; + + const auto lhsPrimaryLightIndex = static_cast(lhs.drawInst.primaryLightIndex); + const auto rhsPrimaryLightIndex = static_cast(rhs.drawInst.primaryLightIndex); + if (lhsPrimaryLightIndex != rhsPrimaryLightIndex) + return lhsPrimaryLightIndex < rhsPrimaryLightIndex; + + const auto lhsModelOrder = StaticModelOrder(modelOrder, lhs.drawInst.model); + const auto rhsModelOrder = StaticModelOrder(modelOrder, rhs.drawInst.model); + if (lhsModelOrder != rhsModelOrder) + return lhsModelOrder < rhsModelOrder; + + return static_cast(lhs.drawInst.reflectionProbeIndex) < static_cast(rhs.drawInst.reflectionProbeIndex); + }); + + for (auto smodelIndex = 0u; smodelIndex < world.dpvs.smodelCount; smodelIndex++) + { + world.dpvs.smodelDrawInsts[smodelIndex] = combined[smodelIndex].drawInst; + world.dpvs.smodelInsts[smodelIndex] = combined[smodelIndex].inst; + } + } + + [[nodiscard]] bool BoundsContain(const float (&outerMins)[3], const float (&outerMaxs)[3], const float (&innerMins)[3], const float (&innerMaxs)[3]) + { + return outerMins[0] <= innerMins[0] && outerMins[1] <= innerMins[1] && outerMins[2] <= innerMins[2] && outerMaxs[0] >= innerMaxs[0] + && outerMaxs[1] >= innerMaxs[1] && outerMaxs[2] >= innerMaxs[2]; + } + + [[nodiscard]] int BoxOnPlaneSide(const float (&mins)[3], const float (&maxs)[3], const cplane_s& plane) + { + const auto dist = plane.dist; + const auto planeType = static_cast(plane.type); + if (planeType < 3u) + { + if (mins[planeType] >= dist) + return 1; + if (maxs[planeType] <= dist) + return 2; + + return 3; + } + + auto minDist = 0.0f; + auto maxDist = 0.0f; + for (auto axis = 0uz; axis < 3uz; axis++) + { + const auto normal = plane.normal[axis]; + if (normal >= 0.0f) + { + minDist += normal * mins[axis]; + maxDist += normal * maxs[axis]; + } + else + { + minDist += normal * maxs[axis]; + maxDist += normal * mins[axis]; + } + } + + if (minDist >= dist) + return 1; + if (maxDist <= dist) + return 2; + + return 3; + } + + using StaticModelIndexLists = std::unordered_map>; + + void AddStaticModelToTreeList(StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& tree, const uint16_t staticModelIndex) + { + auto& indexes = staticModelIndexesByTree[&tree]; + if (indexes.empty() || indexes.back() != staticModelIndex) + indexes.emplace_back(staticModelIndex); + } + + void MoveStaticModelTreeList(StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& oldTree, GfxAabbTree& newTree) + { + auto existing = staticModelIndexesByTree.find(&oldTree); + if (existing == staticModelIndexesByTree.end()) + return; + + auto indexes = std::move(existing->second); + staticModelIndexesByTree.erase(existing); + auto& targetIndexes = staticModelIndexesByTree[&newTree]; + targetIndexes.insert(targetIndexes.end(), std::make_move_iterator(indexes.begin()), std::make_move_iterator(indexes.end())); + } + + [[nodiscard]] BspLoadResult CopyAabbTreeToNewAddress(StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& oldTree, GfxAabbTree& newTree) + { + newTree = oldTree; + MoveStaticModelTreeList(staticModelIndexesByTree, oldTree, newTree); + + if (oldTree.childCount > 0u) + return SetAabbTreeChildrenOffset(newTree, AabbTreeChildren(oldTree)); + + newTree.childrenOffset = 0; + return {}; + } + + [[nodiscard]] BspLoadResult AppendStaticModelOnlyChild(StaticModelIndexLists& staticModelIndexesByTree, + GfxAabbTree& tree, + const GfxStaticModelInst& smodelInst, + MemoryManager& memory) + { + if (tree.childCount == std::numeric_limits::max()) + return std::unexpected("too many AABB tree children"); + + const auto oldChildCount = tree.childCount; + auto* oldChildren = AabbTreeChildren(tree); + auto* newChildren = AllocZeroed(memory, static_cast(oldChildCount) + 1uz); + for (auto childIndex = 0u; childIndex < oldChildCount; childIndex++) + { + auto result = CopyAabbTreeToNewAddress(staticModelIndexesByTree, oldChildren[childIndex], newChildren[childIndex]); + if (!result) + return std::unexpected(std::move(result.error())); + } + + if (auto result = SetAabbTreeChildrenOffset(tree, newChildren); !result) + return std::unexpected(std::move(result.error())); + + auto& newChild = newChildren[oldChildCount]; + std::memcpy(newChild.mins, smodelInst.mins, sizeof(newChild.mins)); + std::memcpy(newChild.maxs, smodelInst.maxs, sizeof(newChild.maxs)); + tree.childCount = static_cast(oldChildCount + 1u); + return {}; + } + + [[nodiscard]] BspLoadResult AddStaticModelToAabbTree_r( + const GfxWorld& world, StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& tree, const uint16_t staticModelIndex, MemoryManager& memory) + { + AddStaticModelToTreeList(staticModelIndexesByTree, tree, staticModelIndex); + + if (tree.childCount == 0u || tree.childrenOffset == 0) + return {}; + + const auto& smodelInst = world.dpvs.smodelInsts[staticModelIndex]; + auto* children = AabbTreeChildren(tree); + + // linker_pc descends through the one child that fully contains the + // static model bounds. If none contains it, it reuses an existing + // static-model-only child or appends a new one; it does not fan out to + // all overlapping children. This tree mutation is required for the + // SIMPLE_AABBTREES lump to reach the same fixed point as linker_pc. + for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) + { + auto& child = children[childIndex]; + if (BoundsContain(child.mins, child.maxs, smodelInst.mins, smodelInst.maxs)) + { + return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex, memory); + } + } + + for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) + { + auto& child = children[childIndex]; + if (child.surfaceCount == 0u) + { + ExpandBounds(smodelInst.mins, smodelInst.maxs, child.mins, child.maxs); + return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex, memory); + } + } + + if (auto result = AppendStaticModelOnlyChild(staticModelIndexesByTree, tree, smodelInst, memory); !result) + return std::unexpected(std::move(result.error())); + + children = AabbTreeChildren(tree); + return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, children[tree.childCount - 1u], staticModelIndex, memory); + } + + [[nodiscard]] BspLoadResult AddStaticModelToCell( + const GfxWorld& world, StaticModelIndexLists& staticModelIndexesByTree, const uint16_t staticModelIndex, const int cellIndex, MemoryManager& memory) + { + if (cellIndex < 0 || cellIndex >= world.dpvsPlanes.cellCount || !world.cells) + return std::unexpected("static model references invalid cell"); + + auto& cell = world.cells[cellIndex]; + if (!cell.aabbTree) + return {}; + + const auto existing = staticModelIndexesByTree.find(cell.aabbTree); + if (existing != staticModelIndexesByTree.end() && !existing->second.empty() && existing->second.back() == staticModelIndex) + return {}; + + return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, *cell.aabbTree, staticModelIndex, memory); + } + + [[nodiscard]] BspLoadResult FilterStaticModelIntoCells_r(const GfxWorld& world, + StaticModelIndexLists& staticModelIndexesByTree, + const uint16_t staticModelIndex, + const uint16_t* node, + const float (&mins)[3], + const float (&maxs)[3], + MemoryManager& memory) + { + while (true) + { + if (!node) + return std::unexpected("world node stream is missing"); + + const auto cellValue = static_cast(node[0]); + const auto planeIndex = cellValue - world.dpvsPlanes.cellCount - 1; + if (planeIndex < 0) + return cellValue != 0 ? AddStaticModelToCell(world, staticModelIndexesByTree, staticModelIndex, cellValue - 1, memory) : BspLoadResult{}; + + if (planeIndex >= world.planeCount || !world.dpvsPlanes.planes) + return std::unexpected("world node stream references invalid plane"); + + const auto& plane = world.dpvsPlanes.planes[planeIndex]; + const auto boxSide = BoxOnPlaneSide(mins, maxs, plane); + if (boxSide == 3) + { + const auto* rightNode = node + node[1]; + const auto planeType = static_cast(plane.type); + if (planeType >= 3u) + { + auto result = FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, node + 2, mins, maxs, memory); + if (!result) + return std::unexpected(std::move(result.error())); + } + else + { + float frontMins[3]{mins[0], mins[1], mins[2]}; + float backMaxs[3]{maxs[0], maxs[1], maxs[2]}; + frontMins[planeType] = plane.dist; + backMaxs[planeType] = plane.dist; + + if (maxs[planeType] > plane.dist) + { + auto result = FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, node + 2, frontMins, maxs, memory); + if (!result) + return std::unexpected(std::move(result.error())); + } + + return FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, rightNode, mins, backMaxs, memory); + } + + node = rightNode; + continue; + } + + if (boxSide == 1) + { + node += 2; + continue; + } + + if (boxSide == 2) + { + node += node[1]; + continue; + } + + return std::unexpected("world node plane-side classification failed"); + } + } + + [[nodiscard]] BspLoadResult CommitStaticModelAabbTreeIndexes(StaticModelIndexLists& staticModelIndexesByTree, MemoryManager& memory) + { + for (auto& [tree, indexes] : staticModelIndexesByTree) + { + std::sort(indexes.begin(), indexes.end()); + + if (!FitsUint16(indexes.size())) + return std::unexpected("too many static model indexes in world AABB tree"); + + tree->smodelIndexCount = static_cast(indexes.size()); + if (!indexes.empty()) + { + tree->smodelIndexes = AllocZeroed(memory, indexes.size()); + std::copy(indexes.begin(), indexes.end(), tree->smodelIndexes); + } + } + + return {}; + } + + [[nodiscard]] unsigned + SortGfxAabbTreeChildren(const GfxWorld& world, const float (&mins)[3], const float (&maxs)[3], uint16_t* staticModels, const unsigned staticModelCount) + { + auto childCount = 0u; + for (auto staticModelOffset = 0u; staticModelOffset < staticModelCount; staticModelOffset++) + { + const auto staticModelIndex = staticModels[staticModelOffset]; + const auto& smodelInst = world.dpvs.smodelInsts[staticModelIndex]; + if (!BoundsContain(mins, maxs, smodelInst.mins, smodelInst.maxs)) + continue; + + std::swap(staticModels[childCount], staticModels[staticModelOffset]); + childCount++; + } + + return childCount < 2u ? 0u : childCount; + } + + [[nodiscard]] BspLoadResult + AddSortedStaticModelChild(GfxAabbTree& tree, uint16_t*& smodelIndexes, unsigned& remainingModelCount, const unsigned childModelCount) + { + if (childModelCount == 0u) + return {}; + + if (tree.childCount == std::numeric_limits::max()) + return std::unexpected("too many sorted AABB tree children"); + + auto* children = AabbTreeChildren(tree); + auto& childTree = children[tree.childCount++]; + childTree.smodelIndexCount = static_cast(childModelCount); + childTree.smodelIndexes = smodelIndexes; + smodelIndexes += childModelCount; + remainingModelCount -= childModelCount; + return {}; + } + + [[nodiscard]] BspLoadResult SortGfxAabbTree(const GfxWorld& world, GfxAabbTree& tree, MemoryManager& memory) + { + if (tree.smodelIndexCount > 1u) + std::sort(tree.smodelIndexes, tree.smodelIndexes + tree.smodelIndexCount); + + if (tree.childCount > 0u) + { + auto* children = AabbTreeChildren(tree); + for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) + { + auto result = SortGfxAabbTree(world, children[childIndex], memory); + if (!result) + return std::unexpected(std::move(result.error())); + } + + return {}; + } + + if (tree.smodelIndexCount == 0u) + return {}; + + float mins[3]{std::numeric_limits::max(), std::numeric_limits::max(), std::numeric_limits::max()}; + float maxs[3]{-std::numeric_limits::max(), -std::numeric_limits::max(), -std::numeric_limits::max()}; + for (auto smodelOffset = 0u; smodelOffset < tree.smodelIndexCount; smodelOffset++) + { + const auto& smodelInst = world.dpvs.smodelInsts[tree.smodelIndexes[smodelOffset]]; + ExpandBounds(smodelInst.mins, smodelInst.maxs, mins, maxs); + } + + if (tree.surfaceCount == 0u) + { + std::memcpy(tree.mins, mins, sizeof(tree.mins)); + std::memcpy(tree.maxs, maxs, sizeof(tree.maxs)); + } + + if (tree.smodelIndexCount < 8u) + return {}; + + const float middle[3]{(mins[0] + maxs[0]) * 0.5f, (mins[1] + maxs[1]) * 0.5f, (mins[2] + maxs[2]) * 0.5f}; + auto* smodelIndexes = tree.smodelIndexes; + auto remainingModelCount = static_cast(tree.smodelIndexCount); + unsigned childModelCounts[4]{}; + + float childMins[3]{mins[0], mins[1], mins[2]}; + float childMaxs[3]{middle[0], maxs[1], maxs[2]}; + childModelCounts[0] = SortGfxAabbTreeChildren(world, childMins, childMaxs, smodelIndexes, remainingModelCount); + smodelIndexes += childModelCounts[0]; + remainingModelCount -= childModelCounts[0]; + + childMins[0] = middle[0]; + childMins[1] = mins[1]; + childMins[2] = mins[2]; + childMaxs[0] = maxs[0]; + childMaxs[1] = maxs[1]; + childMaxs[2] = maxs[2]; + childModelCounts[1] = SortGfxAabbTreeChildren(world, childMins, childMaxs, smodelIndexes, remainingModelCount); + smodelIndexes += childModelCounts[1]; + remainingModelCount -= childModelCounts[1]; + + childMins[0] = mins[0]; + childMins[1] = mins[1]; + childMins[2] = mins[2]; + childMaxs[0] = maxs[0]; + childMaxs[1] = middle[1]; + childMaxs[2] = maxs[2]; + childModelCounts[2] = SortGfxAabbTreeChildren(world, childMins, childMaxs, smodelIndexes, remainingModelCount); + smodelIndexes += childModelCounts[2]; + remainingModelCount -= childModelCounts[2]; + + childMins[0] = mins[0]; + childMins[1] = middle[1]; + childMins[2] = mins[2]; + childMaxs[0] = maxs[0]; + childMaxs[1] = maxs[1]; + childMaxs[2] = maxs[2]; + childModelCounts[3] = SortGfxAabbTreeChildren(world, childMins, childMaxs, smodelIndexes, remainingModelCount); + smodelIndexes += childModelCounts[3]; + remainingModelCount -= childModelCounts[3]; + + auto childCount = 0u; + for (const auto childModelCount : childModelCounts) + childCount += childModelCount != 0u ? 1u : 0u; + + if (childCount == 0u) + return {}; + + if (tree.surfaceCount > 0u) + childCount++; + if (remainingModelCount > 0u) + childCount++; + + auto* children = AllocZeroed(memory, childCount); + if (auto result = SetAabbTreeChildrenOffset(tree, children); !result) + return std::unexpected(std::move(result.error())); + + tree.childCount = 0u; + if (tree.surfaceCount > 0u) + { + auto& childTree = children[tree.childCount++]; + std::memcpy(childTree.mins, tree.mins, sizeof(childTree.mins)); + std::memcpy(childTree.maxs, tree.maxs, sizeof(childTree.maxs)); + childTree.startSurfIndex = tree.startSurfIndex; + childTree.surfaceCount = tree.surfaceCount; + childTree.startSurfIndexNoDecal = tree.startSurfIndexNoDecal; + childTree.surfaceCountNoDecal = tree.surfaceCountNoDecal; + } + + smodelIndexes = tree.smodelIndexes; + remainingModelCount = tree.smodelIndexCount; + for (const auto childModelCount : childModelCounts) + { + auto result = AddSortedStaticModelChild(tree, smodelIndexes, remainingModelCount, childModelCount); + if (!result) + return std::unexpected(std::move(result.error())); + + if (childModelCount > 0u) + { + result = SortGfxAabbTree(world, children[tree.childCount - 1u], memory); + if (!result) + return std::unexpected(std::move(result.error())); + } + } + + if (remainingModelCount > 0u) + { + auto result = AddSortedStaticModelChild(tree, smodelIndexes, remainingModelCount, remainingModelCount); + if (!result) + return std::unexpected(std::move(result.error())); + + result = SortGfxAabbTree(world, children[tree.childCount - 1u], memory); + if (!result) + return std::unexpected(std::move(result.error())); + } + + return {}; + } + + [[nodiscard]] GfxAabbTree* MoveAabbTree_r(GfxAabbTree& tree, GfxAabbTree& newTree, GfxAabbTree* nextChild) + { + newTree = tree; + if (tree.childCount == 0u) + { + newTree.childrenOffset = 0; + return nextChild; + } + + auto* children = AabbTreeChildren(tree); + newTree.childrenOffset = static_cast(reinterpret_cast(nextChild) - reinterpret_cast(&newTree)); + auto* nextFreeTree = nextChild + tree.childCount; + for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) + nextFreeTree = MoveAabbTree_r(children[childIndex], nextChild[childIndex], nextFreeTree); + + return nextFreeTree; + } + + [[nodiscard]] BspLoadResult FixupGfxAabbTrees(GfxCell& cell, MemoryManager& memory) + { + if (!cell.aabbTree) + return {}; + + const auto treeCount = AabbTreeSubtreeCount(*cell.aabbTree); + if (!FitsInt(treeCount)) + return std::unexpected("too many AABB tree nodes after static model sort"); + + auto* newTree = AllocZeroed(memory, treeCount); + const auto* nextTree = MoveAabbTree_r(*cell.aabbTree, *newTree, newTree + 1); + if (nextTree != newTree + treeCount) + return std::unexpected("AABB tree fixup produced an unexpected node count"); + + cell.aabbTree = newTree; + cell.aabbTreeCount = static_cast(treeCount); + return {}; + } + + [[nodiscard]] BspLoadResult PopulateWorldStaticModelAabbTrees(GfxWorld& world, const ComWorld& comWorld, MemoryManager& memory) + { + if (world.dpvs.smodelCount == 0u || !world.dpvs.smodelInsts || !world.cells || world.dpvsPlanes.cellCount <= 0) + return {}; + + if (world.dpvs.smodelCount > std::numeric_limits::max()) + return std::unexpected("too many static models for AABB tree indexes"); + + SortWorldStaticModels(world, comWorld); + + StaticModelIndexLists staticModelIndexesByTree; + for (auto smodelIndex = 0u; smodelIndex < world.dpvs.smodelCount; smodelIndex++) + { + const auto packedIndex = static_cast(smodelIndex); + const auto& smodelInst = world.dpvs.smodelInsts[smodelIndex]; + + if (world.dpvsPlanes.nodes) + { + auto result = FilterStaticModelIntoCells_r( + world, staticModelIndexesByTree, packedIndex, world.dpvsPlanes.nodes, smodelInst.mins, smodelInst.maxs, memory); + if (!result) + return std::unexpected(std::move(result.error())); + } + else if (auto result = AddStaticModelToCell(world, staticModelIndexesByTree, packedIndex, 0, memory); !result) + { + return std::unexpected(std::move(result.error())); + } + } + + if (auto result = CommitStaticModelAabbTreeIndexes(staticModelIndexesByTree, memory); !result) + return std::unexpected(std::move(result.error())); + + // After static models are assigned, linker_pc recursively sorts each + // tree, splits heavy static-model leaves into smaller buckets, then + // flattens every cell tree into a contiguous array. The raw + // SIMPLE_AABBTREES lump is derived from these fixed-up runtime trees. + for (auto cellIndex = 0; cellIndex < world.dpvsPlanes.cellCount; cellIndex++) + { + auto& cell = world.cells[cellIndex]; + if (cell.aabbTree) + { + auto result = SortGfxAabbTree(world, *cell.aabbTree, memory); + if (!result) + return std::unexpected(std::move(result.error())); + } + } + + for (auto cellIndex = 0; cellIndex < world.dpvsPlanes.cellCount; cellIndex++) + { + auto result = FixupGfxAabbTrees(world.cells[cellIndex], memory); + if (!result) + return std::unexpected(std::move(result.error())); + } + + return {}; + } + + [[nodiscard]] BspLoadResult PopulateWorldDynamicEntities(GfxWorld& world, const clipMap_t* clipMap, MemoryManager& memory) + { + if (!clipMap) + return {}; + + if (world.dpvsPlanes.cellCount < 0) + return std::unexpected("negative world cell count"); + + const auto nonSunLightCount = NonSunPrimaryLightCount(world); + if (nonSunLightCount > 0uz) + { + // linker_pc emits this runtime shadow visibility buffer zeroed. It + // is not sourced from a named d3dbsp lump, but its serialized size + // is derived from the number of non-sun primary lights. + world.primaryLightEntityShadowVis = AllocZeroed(memory, nonSunLightCount * 4096uz); + } + + for (auto dynType = 0uz; dynType < 2uz; dynType++) + { + const auto count = static_cast(clipMap->dynEntCount[dynType]); + world.dpvsDyn.dynEntClientCount[dynType] = count; + world.dpvsDyn.dynEntClientWordCount[dynType] = (count + 31u) / 32u; + + if (count == 0u) + continue; + + // These buffers are runtime visibility state. linker_pc emits them + // zeroed while sizing them from the clipmap dynamic entity counts. + if (dynType == 0uz) + world.sceneDynModel = AllocZeroed(memory, count); + else + world.sceneDynBrush = AllocZeroed(memory, count); + + const auto wordCount = static_cast(world.dpvsDyn.dynEntClientWordCount[dynType]); + const auto cellCount = static_cast(world.dpvsPlanes.cellCount); + if (cellCount > 0uz) + world.dpvsDyn.dynEntCellBits[dynType] = AllocZeroed(memory, wordCount * cellCount); + + for (auto viewIndex = 0uz; viewIndex < 3uz; viewIndex++) + world.dpvsDyn.dynEntVisData[dynType][viewIndex] = AllocZeroed(memory, 32uz * wordCount); + + if (nonSunLightCount > 0uz) + world.primaryLightDynEntShadowVis[dynType] = AllocZeroed(memory, static_cast(count) * nonSunLightCount); + } + + if (world.dpvsDyn.dynEntClientCount[0] > 0u) + world.nonSunPrimaryLightForModelDynEnt = AllocZeroed(memory, world.dpvsDyn.dynEntClientCount[0]); + + return {}; + } + + void PopulateWorldRuntimeData(GfxWorld& world, MemoryManager& memory) + { + const auto cellCount = static_cast(std::max(world.dpvsPlanes.cellCount, 0)); + const auto cellWordCount = (cellCount + 31uz) >> 5uz; + + // R_LoadWorldRuntime derives these runtime DPVS buffers after raw BSP + // loading. They are serialized into fastfiles by linker_pc, but they do + // not have dedicated d3dbsp lumps. + world.cellBitsCount = static_cast(16uz * ((cellCount + 127uz) >> 7uz)); + if (cellCount > 0uz) + { + if (!world.cellCasterBits) + world.cellCasterBits = AllocZeroed(memory, cellCount * cellWordCount); + if (!world.dpvsPlanes.sceneEntCellBits) + world.dpvsPlanes.sceneEntCellBits = AllocZeroed(memory, cellCount * 0x100uz); + } + + if (world.modelCount > 0 && world.models) + { + world.dpvs.staticSurfaceCount = world.models[0].surfaceCount; + world.dpvs.staticSurfaceCountNoDecal = world.models[0].surfaceCountNoDecal; + } + + if (world.dpvs.staticSurfaceCount > 0u) + { + world.dpvs.litSurfsBegin = 0u; + if (world.dpvs.litSurfsEnd == 0u || world.dpvs.litSurfsEnd > world.dpvs.staticSurfaceCount) + world.dpvs.litSurfsEnd = world.dpvs.staticSurfaceCount; + if (world.dpvs.decalSurfsBegin == 0u && world.dpvs.decalSurfsEnd == 0u) + { + world.dpvs.decalSurfsBegin = world.dpvs.staticSurfaceCount; + world.dpvs.decalSurfsEnd = world.dpvs.staticSurfaceCount; + } + if (world.dpvs.emissiveSurfsBegin == 0u && world.dpvs.emissiveSurfsEnd == 0u) + { + world.dpvs.emissiveSurfsBegin = world.dpvs.staticSurfaceCount; + world.dpvs.emissiveSurfsEnd = world.dpvs.staticSurfaceCount; + } + } + + world.dpvs.smodelVisDataCount = 4u * ((world.dpvs.smodelCount + 127u) >> 7u); + world.dpvs.surfaceVisDataCount = 4u * ((world.dpvs.staticSurfaceCount + 127u) >> 7u); + + for (auto viewIndex = 0uz; viewIndex < 3uz; viewIndex++) + { + if (world.dpvs.smodelCount > 0u && !world.dpvs.smodelVisData[viewIndex]) + world.dpvs.smodelVisData[viewIndex] = AllocZeroed(memory, world.dpvs.smodelCount); + if (world.dpvs.staticSurfaceCount > 0u && !world.dpvs.surfaceVisData[viewIndex]) + world.dpvs.surfaceVisData[viewIndex] = AllocZeroed(memory, world.dpvs.staticSurfaceCount); + } + + if (world.dpvs.smodelCount > 0u && !world.dpvs.lodData) + world.dpvs.lodData = AllocZeroed(memory, 2uz * world.dpvs.smodelVisDataCount); + + if (world.dpvs.staticSurfaceCount > 0u) + { + if (!world.dpvs.surfaceMaterials) + world.dpvs.surfaceMaterials = AllocZeroed(memory, world.dpvs.staticSurfaceCount); + if (!world.dpvs.surfaceCastsSunShadow) + world.dpvs.surfaceCastsSunShadow = AllocZeroed(memory, world.dpvs.surfaceVisDataCount); + } + + const auto sortedSurfIndexCount = static_cast(world.dpvs.staticSurfaceCount + world.dpvs.staticSurfaceCountNoDecal); + if (sortedSurfIndexCount > 0uz && !world.dpvs.sortedSurfIndex) + { + world.dpvs.sortedSurfIndex = AllocZeroed(memory, sortedSurfIndexCount); + for (auto i = 0uz; i < sortedSurfIndexCount; i++) + { + const auto sourceIndex = world.surfaceCount > 0 ? i % static_cast(world.surfaceCount) : 0uz; + world.dpvs.sortedSurfIndex[i] = static_cast(std::min(sourceIndex, static_cast(UINT16_MAX))); + } + } + } + + struct DpvsNodeLoad + { + int planeIndex = -1; + int children[2]{}; + int cellIndex = -2; + }; + + [[nodiscard]] bool SetDpvsNodeCells_r(std::vector& nodes, std::vector& visitState, const size_t nodeIndex, const size_t rawNodeCount) + { + if (nodeIndex >= nodes.size()) + return false; + + if (nodeIndex >= rawNodeCount) + return true; + + if (visitState[nodeIndex] == 1u) + return false; + + if (visitState[nodeIndex] == 2u) + return true; + + visitState[nodeIndex] = 1u; + auto& node = nodes[nodeIndex]; + if (!SetDpvsNodeCells_r(nodes, visitState, static_cast(node.children[0]), rawNodeCount) + || !SetDpvsNodeCells_r(nodes, visitState, static_cast(node.children[1]), rawNodeCount)) + { + return false; + } + + node.cellIndex = -2; + if (nodes[static_cast(node.children[0])].cellIndex == nodes[static_cast(node.children[1])].cellIndex) + node.cellIndex = nodes[static_cast(node.children[0])].cellIndex; + + visitState[nodeIndex] = 2u; + return true; + } + + [[nodiscard]] bool CountDpvsNodeStream_r(const std::vector& nodes, const size_t nodeIndex, size_t& count) + { + if (nodeIndex >= nodes.size()) + return false; + + const auto& node = nodes[nodeIndex]; + if (node.cellIndex != -2) + { + count++; + return true; + } + + count += 2uz; + return CountDpvsNodeStream_r(nodes, static_cast(node.children[0]), count) + && CountDpvsNodeStream_r(nodes, static_cast(node.children[1]), count); + } + + [[nodiscard]] bool WriteDpvsNodeStream_r(const std::vector& nodes, const size_t nodeIndex, const int cellCount, uint16_t*& out) + { + const auto& node = nodes[nodeIndex]; + if (node.cellIndex != -2) + { + const auto cellValue = node.cellIndex + 1; + if (cellValue < 0 || cellValue > static_cast(std::numeric_limits::max())) + return false; + + *out++ = static_cast(cellValue); + return true; + } + + const auto internalValue = cellCount + node.planeIndex + 1; + if (internalValue < 0 || internalValue > static_cast(std::numeric_limits::max())) + return false; + + auto* current = out; + *out++ = static_cast(internalValue); + auto* rightChildOffset = out++; + + if (!WriteDpvsNodeStream_r(nodes, static_cast(node.children[0]), cellCount, out)) + return false; + + const auto offset = static_cast(out - current); + if (!FitsUint16(offset)) + return false; + + *rightChildOffset = static_cast(offset); + return WriteDpvsNodeStream_r(nodes, static_cast(node.children[1]), cellCount, out); + } + + [[nodiscard]] BspLoadResult PopulateWorldDpvsNodes(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + const auto* rawNodes = bsp.GetLump(LUMP_NODES); + const auto* rawLeafs = bsp.GetLump(LUMP_LEAFS); + if (!rawNodes || rawNodes->data.empty() || !rawLeafs || rawLeafs->data.empty()) + { + if (world.dpvsPlanes.cellCount <= 0) + return {}; + + // A valid runtime world still needs a root node for bounds-to-cell + // filtering. A single leaf routes all dynamic entities to cell 0. + world.nodeCount = 1; + world.dpvsPlanes.nodes = AllocZeroed(memory, 1uz); + world.dpvsPlanes.nodes[0] = 1u; + return {}; + } + + if (rawNodes->data.size() % RAW_CLIP_NODE_SIZE != 0uz || rawLeafs->data.size() % RAW_LEAF_SIZE != 0uz) + return std::unexpected("world node/leaf lump has funny size"); + + const auto rawNodeCount = RecordCount(*rawNodes, RAW_CLIP_NODE_SIZE); + const auto rawLeafCount = RecordCount(*rawLeafs, RAW_LEAF_SIZE); + if (rawNodeCount == 0uz || rawLeafCount == 0uz) + return std::unexpected("world node tree is empty"); + + if (!FitsInt(rawNodeCount) || rawLeafCount > std::numeric_limits::max() - rawNodeCount || !FitsInt(rawNodeCount + rawLeafCount)) + return std::unexpected("world node tree is too large"); + + std::vector nodes(rawNodeCount + rawLeafCount); + for (auto nodeIndex = 0uz; nodeIndex < rawNodeCount; nodeIndex++) + { + const auto* record = rawNodes->data.data() + nodeIndex * RAW_CLIP_NODE_SIZE; + auto& node = nodes[nodeIndex]; + node.planeIndex = ReadI32(record); + node.cellIndex = -2; + + if (node.planeIndex < 0 || node.planeIndex >= world.planeCount) + return std::unexpected("world node references invalid plane"); + + for (auto childIndex = 0uz; childIndex < 2uz; childIndex++) + { + const auto rawChild = ReadI32(record, 4uz + childIndex * sizeof(int32_t)); + const auto convertedChild = rawChild < 0 ? static_cast(rawNodeCount) - 1ll - rawChild : static_cast(rawChild); + if (convertedChild < 0 || static_cast(convertedChild) >= nodes.size()) + return std::unexpected("world node references invalid child"); + + node.children[childIndex] = static_cast(convertedChild); + } + } + + for (auto leafIndex = 0uz; leafIndex < rawLeafCount; leafIndex++) + { + const auto cellIndex = ReadI32(rawLeafs->data.data() + leafIndex * RAW_LEAF_SIZE, RAW_LEAF_CELL_INDEX_OFFSET); + if (cellIndex < -1 || cellIndex >= world.dpvsPlanes.cellCount) + return std::unexpected("world leaf references invalid cell"); + + nodes[rawNodeCount + leafIndex].cellIndex = cellIndex; + } + + std::vector visitState(rawNodeCount); + if (!SetDpvsNodeCells_r(nodes, visitState, 0uz, rawNodeCount)) + return std::unexpected("world node tree is cyclic or invalid"); + + auto streamCount = 0uz; + if (!CountDpvsNodeStream_r(nodes, 0uz, streamCount) || !FitsInt(streamCount)) + return std::unexpected("world node stream is too large"); + + world.nodeCount = static_cast(streamCount); + world.dpvsPlanes.nodes = AllocZeroed(memory, streamCount); + + auto* out = world.dpvsPlanes.nodes; + if (!WriteDpvsNodeStream_r(nodes, 0uz, world.dpvsPlanes.cellCount, out) || static_cast(out - world.dpvsPlanes.nodes) != streamCount) + return std::unexpected("world node stream could not be packed"); + + return {}; + } + + [[nodiscard]] BspLoadResult PopulateWorldDpvsPlanes(GfxWorld& world, const clipMap_t* clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) + { + if (!clipMap) + return {}; + + if (clipMap->planeCount > 0 && clipMap->planes) + { + world.planeCount = clipMap->planeCount; + world.dpvsPlanes.planes = AllocZeroed(memory, static_cast(clipMap->planeCount)); + std::memcpy(world.dpvsPlanes.planes, clipMap->planes, static_cast(clipMap->planeCount) * sizeof(cplane_s)); + } + + return PopulateWorldDpvsNodes(world, bsp, memory); + } + + class ClipMapPvsLoader final : public AssetCreator + { + public: + ClipMapPvsLoader(MemoryManager& memory, ISearchPath& searchPath) + : m_memory(memory), + m_search_path(searchPath) + { + } + + AssetCreationResult CreateAsset(const std::string& assetName, AssetCreationContext& context) override + { + const auto* bsp = GetBspForAsset(assetName, m_search_path, context); + if (!bsp) + return BspWasInvalid(assetName, m_search_path, context) ? AssetCreationResult::Failure() : AssetCreationResult::NoAction(); + + auto* clipMap = AllocZeroed(m_memory); + clipMap->name = m_memory.Dup(assetName.c_str()); + clipMap->isInUse = 1; + + const auto populateResult = PopulateClipMap(*clipMap, *bsp, m_memory); + if (!populateResult) + { + con::error("Could not create clipmap \"{}\" from {}: {}", assetName, bsp->m_file_name, populateResult.error()); + return AssetCreationResult::Failure(); + } + + std::vector entityBlocks; + const auto* entityLump = bsp->GetLump(LUMP_ENTITIES); + if (entityLump) + { + auto parsedEntityBlocks = ParseEntityBlocks(entityLump->data); + if (!parsedEntityBlocks) + { + con::error("Could not create clipmap \"{}\" from {}: {}", assetName, bsp->m_file_name, parsedEntityBlocks.error()); + return AssetCreationResult::Failure(); + } + entityBlocks = std::move(*parsedEntityBlocks); + } + + const auto staticModelBlocks = StaticModelEntityBlocks(entityBlocks); + const auto staticModelDependencies = LoadStaticModelDependencies(staticModelBlocks, context); + const auto dynModelBlocks = DynModelEntityBlocks(entityBlocks); + const auto dynModelDependencies = LoadDynModelDependencies(dynModelBlocks, context); + PopulateStaticModels(*clipMap, staticModelBlocks, staticModelDependencies, m_memory); + PopulateDynModelEntities(*clipMap, dynModelBlocks, dynModelDependencies, context, m_memory); + + auto* mapEntsDependency = context.LoadDependency(assetName); + if (mapEntsDependency) + clipMap->mapEnts = mapEntsDependency->Asset(); + + AssetRegistration registration(assetName, clipMap); + if (mapEntsDependency) + registration.AddDependency(mapEntsDependency); + for (auto* dependency : staticModelDependencies) + { + if (dependency) + registration.AddDependency(dependency); + } + for (const auto& dependency : dynModelDependencies) + { + if (dependency.model) + registration.AddDependency(dependency.model); + if (dependency.physPreset) + registration.AddDependency(dependency.physPreset); + } + + return AssetCreationResult::Success(context.AddAsset(std::move(registration))); + } + + private: + MemoryManager& m_memory; + ISearchPath& m_search_path; + }; + + class MapEntsLoader final : public AssetCreator + { + public: + MapEntsLoader(MemoryManager& memory, ISearchPath& searchPath) + : m_memory(memory), + m_search_path(searchPath) + { + } + + AssetCreationResult CreateAsset(const std::string& assetName, AssetCreationContext& context) override + { + const auto* bsp = GetBspForAsset(assetName, m_search_path, context); + if (!bsp) + return BspWasInvalid(assetName, m_search_path, context) ? AssetCreationResult::Failure() : AssetCreationResult::NoAction(); + + const auto* entities = bsp->GetLump(LUMP_ENTITIES); + if (!entities) + { + con::error("Could not create MapEnts \"{}\" from {}: missing entity lump", assetName, bsp->m_file_name); + return AssetCreationResult::Failure(); + } + + auto entityBlocks = ParseEntityBlocks(entities->data); + if (!entityBlocks) + { + con::error("Could not create MapEnts \"{}\" from {}: {}", assetName, bsp->m_file_name, entityBlocks.error()); + return AssetCreationResult::Failure(); + } + + const auto compiledEntityString = CompileMapEntsEntityString(*entityBlocks); + const auto entityCharCount = compiledEntityString.size(); + if (!FitsInt(entityCharCount)) + { + con::error("Could not create MapEnts \"{}\" from {}: entity lump is too large", assetName, bsp->m_file_name); + return AssetCreationResult::Failure(); + } + + auto* entityString = m_memory.Alloc(std::max(1uz, entityCharCount)); + std::memcpy(entityString, compiledEntityString.data(), compiledEntityString.size()); + + auto* mapEnts = m_memory.Alloc(); + mapEnts->name = m_memory.Dup(assetName.c_str()); + mapEnts->entityString = entityString; + mapEnts->numEntityChars = static_cast(entityCharCount); + + return AssetCreationResult::Success(context.AddAsset(assetName, mapEnts)); + } + + private: + MemoryManager& m_memory; + ISearchPath& m_search_path; + }; + + [[nodiscard]] BspLoadValue DecodePathVisRle(const std::vector& data, size_t& offset, const size_t expectedSize, MemoryManager& memory) + { + auto* pathVis = expectedSize > 0uz ? AllocZeroed(memory, expectedSize) : nullptr; + auto outOffset = 0uz; + + while (outOffset < expectedSize) + { + if (offset >= data.size()) + return std::unexpected("path visibility RLE ended early"); + + const auto marker = std::to_integer(data[offset++]); + if ((marker & 0x80u) == 0u) + { + const auto zeroCount = static_cast(marker); + if (zeroCount > expectedSize - outOffset || offset >= data.size()) + return std::unexpected("path visibility zero run exceeds expected size"); + + outOffset += zeroCount; + pathVis[outOffset++] = static_cast(std::to_integer(data[offset++])); + } + else + { + const auto literalCount = static_cast(static_cast(~marker)); + if (literalCount > expectedSize - outOffset || literalCount > data.size() - offset) + return std::unexpected("path visibility literal run exceeds expected size"); + + std::memcpy(pathVis + outOffset, data.data() + offset, literalCount); + outOffset += literalCount; + offset += literalCount; + } + } + + return pathVis; + } + + class GameWorldSpLoader final : public AssetCreator + { + public: + GameWorldSpLoader(MemoryManager& memory, ISearchPath& searchPath) + : m_memory(memory), + m_search_path(searchPath) + { + } + + AssetCreationResult CreateAsset(const std::string& assetName, AssetCreationContext& context) override + { + const auto* bsp = GetBspForAsset(assetName, m_search_path, context); + if (!bsp) + return BspWasInvalid(assetName, m_search_path, context) ? AssetCreationResult::Failure() : AssetCreationResult::NoAction(); + + const auto* pathConnections = bsp->GetLump(LUMP_PATHCONNECTIONS); + if (!pathConnections) + return AssetCreationResult::NoAction(); + + const auto& data = pathConnections->data; + if (data.size() < sizeof(uint32_t) + sizeof(uint16_t)) + { + con::error("Could not create GameWorldSp \"{}\" from {}: pathconnections lump is truncated", assetName, bsp->m_file_name); + return AssetCreationResult::Failure(); + } + + auto offset = 0uz; + const auto version = ReadU32(data.data() + offset); + offset += sizeof(uint32_t); + if (version != PATHCONNECTIONS_VERSION) + { + con::error("Could not create GameWorldSp \"{}\" from {}: unsupported pathconnections version {}", assetName, bsp->m_file_name, version); + return AssetCreationResult::Failure(); + } + + const auto nodeCount = ReadU16(data.data() + offset); + offset += sizeof(uint16_t); + + auto* gameWorld = AllocZeroed(m_memory); + gameWorld->name = m_memory.Dup(assetName.c_str()); + gameWorld->path.nodeCount = nodeCount; + gameWorld->path.nodes = nodeCount > 0u ? AllocZeroed(m_memory, nodeCount) : nullptr; + gameWorld->path.basenodes = nodeCount > 0u ? AllocZeroed(m_memory, nodeCount) : nullptr; + + for (auto nodeIndex = 0uz; nodeIndex < nodeCount; nodeIndex++) + { + if (offset + sizeof(uint16_t) > data.size()) + { + con::error("Could not create GameWorldSp \"{}\" from {}: path link counts are truncated", assetName, bsp->m_file_name); + return AssetCreationResult::Failure(); + } + + auto& node = gameWorld->path.nodes[nodeIndex]; + const auto linkCount = ReadU16(data.data() + offset); + offset += sizeof(uint16_t); + node.constant.totalLinkCount = linkCount; + node.constant.Links = linkCount > 0u ? AllocZeroed(m_memory, linkCount) : nullptr; + + for (auto linkIndex = 0uz; linkIndex < linkCount; linkIndex++) + { + if (offset + sizeof(uint16_t) + sizeof(float) > data.size()) + { + con::error("Could not create GameWorldSp \"{}\" from {}: path links are truncated", assetName, bsp->m_file_name); + return AssetCreationResult::Failure(); + } + + auto& link = node.constant.Links[linkIndex]; + link.nodeNum = ReadU16(data.data() + offset); + offset += sizeof(uint16_t); + link.fDist = ReadFloat(data.data() + offset); + offset += sizeof(float); + } + } + + const auto visBytes = (static_cast(nodeCount) * (static_cast(nodeCount) - 1uz) + 7uz) >> 3uz; + gameWorld->path.visBytes = static_cast(visBytes); + + auto pathVisResult = DecodePathVisRle(data, offset, visBytes, m_memory); + if (!pathVisResult) + { + con::error("Could not create GameWorldSp \"{}\" from {}: {}", assetName, bsp->m_file_name, pathVisResult.error()); + return AssetCreationResult::Failure(); + } + gameWorld->path.pathVis = *pathVisResult; + + return AssetCreationResult::Success(context.AddAsset(assetName, gameWorld)); + } + + private: + MemoryManager& m_memory; + ISearchPath& m_search_path; + }; + + class GameWorldMpLoader final : public AssetCreator + { + public: + GameWorldMpLoader(MemoryManager& memory, ISearchPath& searchPath) + : m_memory(memory), + m_search_path(searchPath) + { + } + + AssetCreationResult CreateAsset(const std::string& assetName, AssetCreationContext& context) override + { + const auto* bsp = GetBspForAsset(assetName, m_search_path, context); + if (!bsp) + return BspWasInvalid(assetName, m_search_path, context) ? AssetCreationResult::Failure() : AssetCreationResult::NoAction(); + + auto* gameWorld = AllocZeroed(m_memory); + gameWorld->name = m_memory.Dup(assetName.c_str()); + return AssetCreationResult::Success(context.AddAsset(assetName, gameWorld)); + } + + private: + MemoryManager& m_memory; + ISearchPath& m_search_path; + }; + + [[nodiscard]] BspLoadResult + PopulateWorldSkySurfaces(GfxWorld& world, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory) + { + if (!world.dpvs.surfaces || world.surfaceCount <= 0) + return {}; + + std::vector skySurfaces; + const Material* skyMaterial = nullptr; + for (auto surfaceIndex = 0; surfaceIndex < world.surfaceCount; surfaceIndex++) + { + const auto& surface = world.dpvs.surfaces[surfaceIndex]; + if (!surface.material || (surface.material->info.gameFlags & 8u) == 0u) + continue; + + // linker_pc identifies sky surfaces from the material game flag, + // not from the raw sky lightmap index. It also supports exactly + // one sky material and uses that material's colorMap cubemap as + // GfxWorld::skyImage. + if (skyMaterial && skyMaterial != surface.material) + return std::unexpected(std::format("map has at least two different skies: {} and {}", surface.material->info.name, skyMaterial->info.name)); + + skyMaterial = surface.material; + skySurfaces.emplace_back(surfaceIndex); + } + + if (skySurfaces.empty()) + return {}; + + constexpr auto colorMapHash = Common::R_HashString("colorMap"); + for (auto textureIndex = 0uz; textureIndex < skyMaterial->textureCount; textureIndex++) + { + const auto& texture = skyMaterial->textureTable[textureIndex]; + if (texture.nameHash != colorMapHash) + continue; + + const auto* image = texture.u.image; + if (!image || texture.semantic == TS_WATER_MAP || image->mapType != MAPTYPE_CUBE) + return std::unexpected(std::format("colorMap for sky material \"{}\" is not a cubemap", skyMaterial->info.name)); + + auto* imageDependency = context.LoadDependency(image->name); + if (!imageDependency) + return std::unexpected(std::format("missing sky image \"{}\"", image->name)); + + registration.AddDependency(imageDependency); + world.skyImage = imageDependency->Asset(); + world.skySamplerState = static_cast(SamplerStateByte(texture.samplerState)); + break; + } + + if (!world.skyImage) + return std::unexpected(std::format("sky material \"{}\" has no colorMap", skyMaterial->info.name)); + + world.skySurfCount = static_cast(skySurfaces.size()); + world.skyStartSurfs = AllocZeroed(memory, skySurfaces.size()); + std::memcpy(world.skyStartSurfs, skySurfaces.data(), skySurfaces.size() * sizeof(int)); + return {}; + } + + void SetOutdoorLookupIdentity(GfxWorld& world) + { + for (auto row = 0uz; row < 4uz; row++) + { + for (auto column = 0uz; column < 4uz; column++) + world.outdoorLookupMatrix[row][column] = row == column ? 1.0f : 0.0f; + } + } + + [[nodiscard]] BspLoadResult PopulateWorldOutdoorData( + GfxWorld& world, const IW3::d3dbsp::File& bsp, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory) + { + const auto* materials = bsp.GetLump(LUMP_MATERIALS); + const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); + if (!materials || !surfaces || world.modelCount <= 0 || !world.models || !world.dpvs.surfaces) + return {}; + + if (materials->data.size() % RAW_MATERIAL_SIZE != 0uz || surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) + return std::unexpected("could not calculate outdoor bounds from funny-sized material or surface lump"); + + const auto rawMaterialCount = RecordCount(*materials, RAW_MATERIAL_SIZE); + const auto rawSurfaceCount = RecordCount(*surfaces, RAW_WORLD_SURFACE_SIZE); + const auto& rootModel = world.models[0]; + const auto rootStartSurface = static_cast(rootModel.startSurfIndex); + const auto rootSurfaceCount = static_cast(rootModel.surfaceCount); + if (rootStartSurface + rootSurfaceCount > rawSurfaceCount || rootStartSurface + rootSurfaceCount > static_cast(world.surfaceCount)) + return std::unexpected("root model surface range is outside the world surface lump"); + + float outdoorMins[3]{131072.0f, 131072.0f, 131072.0f}; + float outdoorMaxs[3]{-131072.0f, -131072.0f, -131072.0f}; + for (auto surfaceIndex = rootStartSurface; surfaceIndex < rootStartSurface + rootSurfaceCount; surfaceIndex++) + { + const auto* rawSurface = surfaces->data.data() + surfaceIndex * RAW_WORLD_SURFACE_SIZE; + const auto rawMaterialIndex = static_cast(ReadU16(rawSurface)); + if (rawMaterialIndex >= rawMaterialCount) + return std::unexpected(std::format("world surface {} references invalid material index {}", surfaceIndex, rawMaterialIndex)); + + const auto& surface = world.dpvs.surfaces[surfaceIndex]; + const auto rawContentFlags = ReadI32(materials->data.data() + rawMaterialIndex * RAW_MATERIAL_SIZE, 68uz); + // R_CalculateOutdoorBounds includes only non-sky root-model + // surfaces whose raw BSP material contents carry outdoor-relevant + // solid/detail bits. The generated matrix is consumed by outdoor + // shader code through TEXTURE_SRC_CODE_OUTDOOR. + if (surface.material && (surface.material->info.gameFlags & 8u) == 0u && (rawContentFlags & 0x2001) != 0) + ExpandBounds(surface.bounds[0], surface.bounds[1], outdoorMins, outdoorMaxs); + } + + for (auto axis = 0uz; axis < 3uz; axis++) + { + if (outdoorMins[axis] == 131072.0f) + { + outdoorMins[axis] = 0.0f; + outdoorMaxs[axis] = 0.0f; + } + + if (outdoorMaxs[axis] - outdoorMins[axis] < 1.0f) + { + outdoorMins[axis] -= 0.5f; + outdoorMaxs[axis] += 0.5f; + } + } + + SetOutdoorLookupIdentity(world); + for (auto axis = 0uz; axis < 3uz; axis++) + { + const auto scale = LinkerFloat(1.0 / static_cast(outdoorMaxs[axis] - outdoorMins[axis])); + world.outdoorLookupMatrix[axis][axis] = scale; + world.outdoorLookupMatrix[3][axis] = LinkerFloat(-static_cast(outdoorMins[axis]) * scale); + } + + constexpr auto OUTDOOR_IMAGE_SIZE = 512u; + constexpr auto OUTDOOR_IMAGE_DATA_SIZE = static_cast(OUTDOOR_IMAGE_SIZE) * OUTDOOR_IMAGE_SIZE; + std::vector pixels(OUTDOOR_IMAGE_DATA_SIZE); + + // linker_pc generates this image by tracing height through the loaded + // clipmap. Keep the serialized image shape/name identical for now; the + // exact texel-generation pass can be added once the collision trace + // path is complete. + auto* image = CreateGeneratedImage(memory, + OUTDOOR_IMAGE_NAME, + MAPTYPE_2D, + TS_FUNCTION, + IMG_CATEGORY_AUTO_GENERATED, + OUTDOOR_IMAGE_SIZE, + OUTDOOR_IMAGE_SIZE, + 1u, + oat::D3DFMT_L8, + static_cast(image::iwi6::IMG_FLAG_NOMIPMAPS), + pixels.data(), + pixels.size()); + auto* imageInfo = AddGeneratedImage(context, registration, OUTDOOR_IMAGE_NAME, image); + if (!imageInfo) + return std::unexpected("could not register generated outdoor image"); + + world.outdoorImage = imageInfo->Asset(); + return {}; + } + + class GfxWorldLoader final : public AssetCreator + { + public: + GfxWorldLoader(MemoryManager& memory, ISearchPath& searchPath) + : m_memory(memory), + m_search_path(searchPath) + { + } + + AssetCreationResult CreateAsset(const std::string& assetName, AssetCreationContext& context) override + { + const auto* bsp = GetBspForAsset(assetName, m_search_path, context); + if (!bsp) + return BspWasInvalid(assetName, m_search_path, context) ? AssetCreationResult::Failure() : AssetCreationResult::NoAction(); + + auto* clipMapDependency = context.LoadDependency(assetName); + auto* mapEntsDependency = context.LoadDependency(assetName); + auto* comWorldDependency = context.LoadDependency(assetName); + if (!clipMapDependency || !mapEntsDependency || !comWorldDependency) + { + con::error("Could not create GfxWorld \"{}\" from {}: required sibling map asset is missing", assetName, bsp->m_file_name); + return AssetCreationResult::Failure(); + } + + std::vector entityBlocks; + const auto* entityLump = bsp->GetLump(LUMP_ENTITIES); + if (entityLump) + { + auto parsedEntityBlocks = ParseEntityBlocks(entityLump->data); + if (!parsedEntityBlocks) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, parsedEntityBlocks.error()); + return AssetCreationResult::Failure(); + } + entityBlocks = std::move(*parsedEntityBlocks); + } + + auto materialDependenciesResult = LoadWorldMaterials(*bsp, context, m_memory); + if (!materialDependenciesResult) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, materialDependenciesResult.error()); + return AssetCreationResult::Failure(); + } + auto materialDependencies = std::move(*materialDependenciesResult); + AssignWorldMaterialDrawSurfSortKeys(materialDependencies); + + const auto staticModelBlocks = StaticModelEntityBlocks(entityBlocks); + const auto staticModelDependencies = LoadStaticModelDependencies(staticModelBlocks, context); + + auto lightmapLayoutResult = BuildLightmapAtlasLayout(*bsp); + if (!lightmapLayoutResult) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, lightmapLayoutResult.error()); + return AssetCreationResult::Failure(); + } + auto lightmapLayout = std::move(*lightmapLayoutResult); + + auto* world = AllocZeroed(m_memory); + world->name = m_memory.Dup(assetName.c_str()); + const auto baseName = BspBaseName(assetName); + world->baseName = m_memory.Dup(baseName.c_str()); + SetOutdoorLookupIdentity(*world); + + AssetRegistration registration(assetName, world); + registration.AddDependency(clipMapDependency); + registration.AddDependency(mapEntsDependency); + registration.AddDependency(comWorldDependency); + for (auto* dependency : materialDependencies) + { + if (dependency) + registration.AddDependency(dependency); + } + for (auto* dependency : staticModelDependencies) + { + if (dependency) + registration.AddDependency(dependency); + } + + auto lightDefDependenciesResult = LoadPrimaryLightDefDependencies(*bsp, context, registration); + if (!lightDefDependenciesResult) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, lightDefDependenciesResult.error()); + return AssetCreationResult::Failure(); + } + auto lightDefDependencies = std::move(*lightDefDependenciesResult); + + const auto* clipMap = clipMapDependency->Asset(); + if (auto result = PopulateWorldIndices(*world, *bsp, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldVertices(*world, *bsp, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldSurfaces(*world, *bsp, lightmapLayout, materialDependencies, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + + PopulateWorldMaterialMemory(*world, m_memory); + + if (auto result = PopulateWorldVertexLayerData(*world, *bsp, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldModels(*world, *bsp, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldCells(*world, *bsp, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldSurfaceOrganization(*world, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldDpvsPlanes(*world, clipMap, *bsp, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldPortals(*world, *bsp, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldPrimaryLights(*world, *bsp, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldShadowGeometry(*world, m_memory)) + { + con::error("Could not create GfxWorld \"{}\" from {}: could not populate shadow geometry", assetName, bsp->m_file_name); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldLightGrid(*world, *bsp, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldLightRegions(*world, *bsp, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldStaticModels(*world, *comWorldDependency->Asset(), clipMap, staticModelBlocks, staticModelDependencies, m_memory); + !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldReflectionProbes(*world, *bsp, context, registration, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + + PopulateWorldStaticModelReflectionProbes(*world); + + if (auto result = PopulateWorldStaticModelAabbTrees(*world, *comWorldDependency->Asset(), m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldDynamicEntities(*world, clipMap, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + auto lightmapsResult = PopulateWorldLightmaps(*world, *bsp, lightmapLayout, lightDefDependencies, m_search_path, context, registration, m_memory); + if (!lightmapsResult) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, lightmapsResult.error()); + return AssetCreationResult::Failure(); + } + + PopulateWorldRuntimeData(*world, m_memory); + if (auto result = PopulateWorldSkySurfaces(*world, context, registration, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + if (auto result = PopulateWorldOutdoorData(*world, *bsp, context, registration, m_memory); !result) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); + return AssetCreationResult::Failure(); + } + + return AssetCreationResult::Success(context.AddAsset(std::move(registration))); + } + + private: + MemoryManager& m_memory; + ISearchPath& m_search_path; + }; + + class ComWorldLoader final : public AssetCreator + { + public: + ComWorldLoader(MemoryManager& memory, ISearchPath& searchPath) + : m_memory(memory), + m_search_path(searchPath) + { + } + + AssetCreationResult CreateAsset(const std::string& assetName, AssetCreationContext& context) override + { + const auto* bsp = GetBspForAsset(assetName, m_search_path, context); + if (!bsp) + return BspWasInvalid(assetName, m_search_path, context) ? AssetCreationResult::Failure() : AssetCreationResult::NoAction(); + + const auto* primaryLightsLump = bsp->GetLump(LUMP_PRIMARY_LIGHTS); + const auto primaryLightsSize = primaryLightsLump ? primaryLightsLump->data.size() : 0uz; + if (primaryLightsSize % IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE != 0uz) + { + con::error("Could not create ComWorld \"{}\" from {}: primary-light lump has invalid size {}", assetName, bsp->m_file_name, primaryLightsSize); + return AssetCreationResult::Failure(); + } + + const auto primaryLightCount = primaryLightsSize / IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE; + if (primaryLightCount > static_cast(std::numeric_limits::max())) + { + con::error("Could not create ComWorld \"{}\" from {}: too many primary lights", assetName, bsp->m_file_name); + return AssetCreationResult::Failure(); + } + + auto* comWorld = m_memory.Alloc(); + comWorld->name = m_memory.Dup(assetName.c_str()); + comWorld->isInUse = 1; + comWorld->primaryLightCount = static_cast(primaryLightCount); + comWorld->primaryLights = nullptr; + + if (primaryLightCount > 0uz) + { + comWorld->primaryLights = m_memory.Alloc(primaryLightCount); + for (auto lightIndex = 0uz; lightIndex < primaryLightCount; lightIndex++) + { + const auto* record = primaryLightsLump->data.data() + lightIndex * IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE; + ParsePrimaryLightRecord(record, comWorld->primaryLights[lightIndex], m_memory); + } + } + + return AssetCreationResult::Success(context.AddAsset(assetName, comWorld)); + } + + private: + MemoryManager& m_memory; + ISearchPath& m_search_path; + }; +} // namespace + +namespace map_d3dbsp +{ + std::unique_ptr> CreateClipMapPvsLoaderIW3(MemoryManager& memory, ISearchPath& searchPath) + { + return std::make_unique(memory, searchPath); + } + + std::unique_ptr> CreateMapEntsLoaderIW3(MemoryManager& memory, ISearchPath& searchPath) + { + return std::make_unique(memory, searchPath); + } + + std::unique_ptr> CreateComWorldLoaderIW3(MemoryManager& memory, ISearchPath& searchPath) + { + return std::make_unique(memory, searchPath); + } + + std::unique_ptr> CreateGameWorldSpLoaderIW3(MemoryManager& memory, ISearchPath& searchPath) + { + return std::make_unique(memory, searchPath); + } + + std::unique_ptr> CreateGameWorldMpLoaderIW3(MemoryManager& memory, ISearchPath& searchPath) + { + return std::make_unique(memory, searchPath); + } + + std::unique_ptr> CreateGfxWorldLoaderIW3(MemoryManager& memory, ISearchPath& searchPath) + { + return std::make_unique(memory, searchPath); + } +} // namespace map_d3dbsp diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.h b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.h new file mode 100644 index 000000000..be8d61628 --- /dev/null +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.h @@ -0,0 +1,16 @@ +#pragma once + +#include "Asset/IAssetCreator.h" +#include "Game/IW3/IW3.h" + +#include + +namespace map_d3dbsp +{ + std::unique_ptr> CreateClipMapPvsLoaderIW3(MemoryManager& memory, ISearchPath& searchPath); + std::unique_ptr> CreateMapEntsLoaderIW3(MemoryManager& memory, ISearchPath& searchPath); + std::unique_ptr> CreateComWorldLoaderIW3(MemoryManager& memory, ISearchPath& searchPath); + std::unique_ptr> CreateGameWorldSpLoaderIW3(MemoryManager& memory, ISearchPath& searchPath); + std::unique_ptr> CreateGameWorldMpLoaderIW3(MemoryManager& memory, ISearchPath& searchPath); + std::unique_ptr> CreateGfxWorldLoaderIW3(MemoryManager& memory, ISearchPath& searchPath); +} // namespace map_d3dbsp diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspReaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspReaderIW3.cpp new file mode 100644 index 000000000..e43b1d429 --- /dev/null +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspReaderIW3.cpp @@ -0,0 +1,164 @@ +#include "D3DBspReaderIW3.h" + +#include +#include +#include +#include +#include + +using namespace IW3::d3dbsp; + +namespace +{ + constexpr auto BSP_HEADER_SIZE = 12uz; + constexpr auto BSP_LUMP_ENTRY_SIZE = 8uz; + + // IW3 v22 uses a typed chunk table after the IBSP/version header. The stock + // linker/Radiant path rejects chunk counts above this limit. + constexpr auto MAX_CHUNK_COUNT = 100u; + + [[nodiscard]] size_t Align4(const size_t value) + { + return (value + 3uz) & ~3uz; + } + + [[nodiscard]] bool ReadExact(std::istream& stream, std::vector& out, const int64_t length) + { + if (length < 0 || static_cast(length) > static_cast(std::numeric_limits::max())) + return false; + + out.resize(static_cast(length)); + if (out.empty()) + return true; + + stream.read(reinterpret_cast(out.data()), length); + return stream.gcount() == length; + } + + template [[nodiscard]] bool ReadValue(const std::vector& data, size_t& offset, T& value) + { + if (offset > data.size() || sizeof(T) > data.size() - offset) + return false; + + std::memcpy(&value, data.data() + offset, sizeof(T)); + offset += sizeof(T); + return true; + } + + [[nodiscard]] LoadResult InvalidResult(std::string message) + { + LoadResult result; + result.status = LoadStatus::Invalid; + result.message = std::move(message); + return result; + } + + struct LumpEntry + { + uint32_t id; + uint32_t size; + }; + + [[nodiscard]] LoadResult ParseBsp(std::vector&& data, std::string fileName) + { + if (data.size() < BSP_HEADER_SIZE) + return InvalidResult(std::format("{} is too small to be an IW3 d3dbsp", fileName)); + + if (!std::equal(BSP_MAGIC.begin(), BSP_MAGIC.end(), reinterpret_cast(data.data()))) + return InvalidResult(std::format("{} does not start with IBSP", fileName)); + + size_t offset = BSP_MAGIC.size(); + + uint32_t version = 0; + if (!ReadValue(data, offset, version)) + return InvalidResult(std::format("{} has a truncated BSP version", fileName)); + + if (version != BSP_VERSION) + return InvalidResult(std::format("{} is BSP version {}, expected {}", fileName, version, BSP_VERSION)); + + uint32_t chunkCount = 0; + if (!ReadValue(data, offset, chunkCount)) + return InvalidResult(std::format("{} has a truncated BSP chunk count", fileName)); + + if (chunkCount > MAX_CHUNK_COUNT) + return InvalidResult(std::format("{} has an unreasonable BSP chunk count {}", fileName, chunkCount)); + + const auto tableSize = static_cast(chunkCount) * BSP_LUMP_ENTRY_SIZE; + if (offset > data.size() || tableSize > data.size() - offset) + return InvalidResult(std::format("{} has a truncated BSP chunk table", fileName)); + + std::vector entries; + entries.reserve(chunkCount); + + for (auto i = 0u; i < chunkCount; i++) + { + LumpEntry entry{}; + if (!ReadValue(data, offset, entry.id) || !ReadValue(data, offset, entry.size)) + return InvalidResult(std::format("{} has a truncated BSP chunk table entry", fileName)); + + entries.emplace_back(entry); + } + + auto parsed = std::make_unique(std::move(fileName)); + parsed->m_lumps.reserve(entries.size()); + + for (const auto& entry : entries) + { + const auto lumpSize = static_cast(entry.size); + if (offset > data.size() || lumpSize > data.size() - offset) + return InvalidResult(std::format("{} lump {} extends past end of file", parsed->m_file_name, entry.id)); + + Lump lump; + lump.id = static_cast(entry.id); + lump.data.resize(lumpSize); + if (lumpSize > 0) + std::memcpy(lump.data.data(), data.data() + offset, lumpSize); + + parsed->m_lumps.emplace_back(std::move(lump)); + + const auto alignedSize = Align4(lumpSize); + if (alignedSize > data.size() - offset) + return InvalidResult(std::format("{} lump {} padding extends past end of file", parsed->m_file_name, entry.id)); + + offset += alignedSize; + } + + LoadResult result; + result.status = LoadStatus::Loaded; + result.file = std::move(parsed); + return result; + } +} // namespace + +File::File(std::string fileName) + : m_file_name(std::move(fileName)) +{ +} + +const Lump* File::GetLump(const LumpType id) const +{ + for (const auto& lump : m_lumps) + { + if (lump.id == id) + return &lump; + } + + return nullptr; +} + +LoadResult IW3::d3dbsp::LoadFromSearchPath(const std::string& assetName, ISearchPath& searchPath) +{ + auto openFile = searchPath.Open(assetName); + if (!openFile.IsOpen()) + { + LoadResult result; + result.status = LoadStatus::NotFound; + return result; + } + + std::vector data; + if (!ReadExact(*openFile.m_stream, data, openFile.m_length)) + return InvalidResult(std::format("Could not read d3dbsp file {}", assetName)); + + return ParseBsp(std::move(data), assetName); +} diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspReaderIW3.h b/src/ObjLoading/Game/IW3/Maps/D3DBspReaderIW3.h new file mode 100644 index 000000000..c8fcfa19c --- /dev/null +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspReaderIW3.h @@ -0,0 +1,45 @@ +#pragma once + +#include "Game/IW3/Maps/D3DBspCommonIW3.h" +#include "SearchPath/ISearchPath.h" + +#include +#include +#include +#include + +namespace IW3::d3dbsp +{ + enum class LoadStatus + { + Loaded, + NotFound, + Invalid, + }; + + struct Lump + { + LumpType id; + std::vector data; + }; + + class File + { + public: + explicit File(std::string fileName); + + [[nodiscard]] const Lump* GetLump(LumpType id) const; + + std::string m_file_name; + std::vector m_lumps; + }; + + struct LoadResult + { + LoadStatus status = LoadStatus::NotFound; + std::unique_ptr file; + std::string message; + }; + + [[nodiscard]] LoadResult LoadFromSearchPath(const std::string& assetName, ISearchPath& searchPath); +} // namespace IW3::d3dbsp diff --git a/src/ObjLoading/Game/IW3/ObjLoaderIW3.cpp b/src/ObjLoading/Game/IW3/ObjLoaderIW3.cpp index 2a7fca490..7e690d29b 100644 --- a/src/ObjLoading/Game/IW3/ObjLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/ObjLoaderIW3.cpp @@ -14,6 +14,7 @@ #include "Game/IW3/XModel/LoaderXModelIW3.h" #include "LightDef/LightDefLoaderIW3.h" #include "Localize/AssetLoaderLocalizeIW3.h" +#include "Maps/D3DBspLoaderIW3.h" #include "Material/LoaderMaterialIW3.h" #include "ObjLoading.h" #include "PhysPreset/GdtLoaderPhysPresetIW3.h" @@ -113,12 +114,12 @@ namespace // collection.AddAssetCreator(std::make_unique(memory)); collection.AddAssetCreator(sound_curve::CreateLoaderIW3(memory, searchPath)); // collection.AddAssetCreator(std::make_unique(memory)); - // collection.AddAssetCreator(std::make_unique(memory)); - // collection.AddAssetCreator(std::make_unique(memory)); - // collection.AddAssetCreator(std::make_unique(memory)); - // collection.AddAssetCreator(std::make_unique(memory)); - // collection.AddAssetCreator(std::make_unique(memory)); - // collection.AddAssetCreator(std::make_unique(memory)); + collection.AddAssetCreator(map_d3dbsp::CreateClipMapPvsLoaderIW3(memory, searchPath)); + collection.AddAssetCreator(map_d3dbsp::CreateComWorldLoaderIW3(memory, searchPath)); + collection.AddAssetCreator(map_d3dbsp::CreateGameWorldSpLoaderIW3(memory, searchPath)); + collection.AddAssetCreator(map_d3dbsp::CreateGameWorldMpLoaderIW3(memory, searchPath)); + collection.AddAssetCreator(map_d3dbsp::CreateMapEntsLoaderIW3(memory, searchPath)); + collection.AddAssetCreator(map_d3dbsp::CreateGfxWorldLoaderIW3(memory, searchPath)); collection.AddAssetCreator(light_def::CreateLoaderIW3(memory, searchPath)); collection.AddAssetCreator(font::CreateLoaderIW3(memory, searchPath)); // collection.AddAssetCreator(std::make_unique(memory)); diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp new file mode 100644 index 000000000..64be23132 --- /dev/null +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -0,0 +1,2125 @@ +#include "D3DBspDumperIW3.h" + +#include "Game/IW3/CommonIW3.h" +#include "Game/IW3/Maps/D3DBspCommonIW3.h" +#include "Utils/StreamUtils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace IW3; + +namespace +{ + using enum IW3::d3dbsp::LumpType; + + struct BspLump + { + IW3::d3dbsp::LumpType id; + std::vector data; + }; + + struct IntBounds + { + int32_t mins[3]; + int32_t maxs[3]; + }; + + struct ReflectionProbeColorCorrection + { + double blackLevel; + double whiteLevel; + double gamma; + double saturation; + }; + + constexpr auto REFLECTION_PROBE_SIZE = 64uz; + constexpr auto REFLECTION_PROBE_PIXEL_SIZE = 4uz; + constexpr auto REFLECTION_PROBE_MIP_COUNT = 7uz; + constexpr auto REFLECTION_PROBE_RAW_DATA_SIZE = 0x1FFF8uz; + constexpr auto REFLECTION_PROBE_NAME_SIZE = 64uz; + constexpr auto LIGHTMAP_PRIMARY_RAW_PAGE_SIZE = 0x100000uz; + constexpr auto LIGHTMAP_SECONDARY_RAW_PAGE_SIZE = 0x200000uz; + constexpr auto LIGHTMAP_PRIMARY_RAW_WIDTH = 1024u; + constexpr auto LIGHTMAP_PRIMARY_RAW_HEIGHT = 1024u; + constexpr auto LIGHTMAP_SECONDARY_RAW_WIDTH = 512u; + constexpr auto LIGHTMAP_SECONDARY_RAW_HEIGHT = 1024u; + constexpr auto LIGHTMAP_SECONDARY_HALF_HEIGHT = LIGHTMAP_SECONDARY_RAW_HEIGHT / 2u; + constexpr auto LIGHTMAP_SECONDARY_PIXEL_SIZE = 4u; + constexpr auto SKY_LIGHTMAP_INDEX = 31u; + constexpr auto PI = 3.14159265358979323846; + constexpr auto INVALID_STATIC_MODEL_INDEX = std::numeric_limits::max(); + + // Matches the stock raw/reflections/reflections.csv "default" row. + constexpr auto DEFAULT_REFLECTION_PROBE_CORRECTION = ReflectionProbeColorCorrection{0.3, 0.7, 1.2, 0.5}; + + struct LightmapPageLayout + { + std::vector firstRawPage; + std::vector pageCount; + std::vector wideCount; + std::vector highCount; + std::vector> rawPageForPackedSlot; + std::vector> packedSlotForRawPage; + unsigned rawPageCount; + }; + + struct SurfaceLightmapRemap + { + uint8_t rawLightmapIndex; + unsigned lightmapIndex; + unsigned packedSlot; + unsigned wideCount; + unsigned highCount; + }; + + struct VertexLightmapRemap + { + unsigned packedSlot; + unsigned wideCount; + unsigned highCount; + bool valid; + }; + + [[nodiscard]] size_t PositiveCount(const int value) + { + return value > 0 ? static_cast(value) : 0uz; + } + + [[nodiscard]] size_t PositiveCount(const unsigned value) + { + return static_cast(value); + } + + [[nodiscard]] std::string_view BaseName(const char* value) + { + if (!value) + return {}; + + auto result = std::string_view(value); + const auto lastSlash = result.find_last_of("/\\"); + if (lastSlash != std::string_view::npos) + result.remove_prefix(lastSlash + 1uz); + + // A leading comma marks a fastfile reference asset. Raw BSP material + // names store the target name, so ",$default" must match "$default". + if (result.starts_with(',')) + result.remove_prefix(1uz); + + return result; + } + + [[nodiscard]] bool MaterialNameMatches(const std::string_view rawName, const std::string_view assetName) + { + if (rawName == assetName) + return true; + + // Runtime default material assets use explicit names, but raw d3dbsp stores the shared "$default" material. + return rawName == "$default" && (assetName == "$default2d" || assetName == "$default3d"); + } + + template void Append(std::vector& out, const T& value) + { + const auto* bytes = reinterpret_cast(&value); + out.insert(out.end(), bytes, bytes + sizeof(T)); + } + + [[nodiscard]] uint8_t ClampToByte(const int value) + { + return static_cast(std::clamp(value, 0, 255)); + } + + [[nodiscard]] uint8_t Byte(const char value) + { + return static_cast(static_cast(value)); + } + + void AppendBytes(std::vector& out, const void* data, const size_t size) + { + if (!data || size == 0) + return; + + const auto* bytes = static_cast(data); + out.insert(out.end(), bytes, bytes + size); + } + + [[nodiscard]] std::vector RawBytes(const void* data, const size_t size) + { + std::vector out; + if (data && size > 0) + out.reserve(size); + + AppendBytes(out, data, size); + return out; + } + + void WriteBytesAt(std::vector& out, const size_t offset, const void* data, const size_t size) + { + if (!data || size == 0) + return; + + assert(offset + size <= out.size()); + const auto* bytes = static_cast(data); + std::copy_n(bytes, size, out.data() + offset); + } + + template void WriteAt(std::vector& out, const size_t offset, const T& value) + { + WriteBytesAt(out, offset, &value, sizeof(T)); + } + + void MarkMaterialUsedByBrush(std::vector& usedMaterials, const unsigned materialIndex) + { + if (materialIndex < usedMaterials.size()) + usedMaterials[materialIndex] = 1u; + } + + void MarkMaterialUsedByBrush(std::vector& usedMaterials, const int materialIndex) + { + if (materialIndex >= 0) + MarkMaterialUsedByBrush(usedMaterials, static_cast(materialIndex)); + } + + [[nodiscard]] std::vector BuildBrushMaterialUsage(const clipMap_t& clipMap) + { + std::vector usedMaterials(PositiveCount(clipMap.numMaterials), 0u); + + for (auto brushIndex = 0uz; brushIndex < clipMap.numBrushes; brushIndex++) + { + const auto& brush = clipMap.brushes[brushIndex]; + + for (auto axis = 0uz; axis < 3uz; axis++) + { + for (auto side = 0uz; side < 2uz; side++) + MarkMaterialUsedByBrush(usedMaterials, brush.axialMaterialNum[side][axis]); + } + + for (auto sideIndex = 0uz; sideIndex < brush.numsides; sideIndex++) + MarkMaterialUsedByBrush(usedMaterials, brush.sides[sideIndex].materialNum); + } + + return usedMaterials; + } + + [[nodiscard]] int ReconstructRawMaterialContents(const int contents, const bool usedByBrush) + { + if (!usedByBrush || contents == 0) + return contents; + + // linker_pc loads BSP material contents as rawContents & 0xdffffffb. + // That also strips a raw decal bit (0x4), but the important collision + // bit here is the raw-only 0x20000000 brush marker. If we write the + // already-masked runtime value back out, relinking the dumped BSP loses + // solid/clip contents because brush contents are recomputed from this + // material table. + return static_cast(static_cast(contents) | IW3::d3dbsp::RAW_BSP_BRUSH_CONTENT_MARKER); + } + + [[nodiscard]] const GfxImageLoadDef* LoadDefForTexture(const GfxTexture* textures, const size_t index) + { + return textures ? textures[index].loadDef : nullptr; + } + + [[nodiscard]] const GfxImageLoadDef* LoadDefForImage(const GfxImage* image) + { + return image ? image->texture.loadDef : nullptr; + } + + [[nodiscard]] const char* NameOf(const XModel* value) + { + if (!value || !value->name) + return nullptr; + + // A leading comma marks a fastfile reference asset. Raw map entity text + // needs the real xmodel name, otherwise Radiant looks for ",model". + return value->name[0] == ',' ? &value->name[1] : value->name; + } + + [[nodiscard]] const char* NameOf(const PhysPreset* value) + { + if (!value || !value->name) + return nullptr; + + return value->name[0] == ',' ? &value->name[1] : value->name; + } + + template [[nodiscard]] bool TryPointerIndex(const T* base, const size_t count, const T* value, size_t& index) + { + if (!base || !value || count == 0) + return false; + + const auto baseAddress = reinterpret_cast(base); + const auto valueAddress = reinterpret_cast(value); + const auto endAddress = baseAddress + count * sizeof(T); + + if (valueAddress < baseAddress || valueAddress >= endAddress) + return false; + + const auto offset = valueAddress - baseAddress; + if (offset % sizeof(T) != 0) + return false; + + index = offset / sizeof(T); + return true; + } + + template [[nodiscard]] size_t PointerIndex(const T* base, const size_t count, const T* value) + { + auto index = 0uz; + return TryPointerIndex(base, count, value, index) ? index : 0uz; + } + + [[nodiscard]] size_t FindLeafBrushRange( + const LeafBrush* leafBrushes, const size_t leafBrushCount, const LeafBrush* nodeBrushes, const size_t nodeBrushCount, const size_t searchStart) + { + if (!leafBrushes || !nodeBrushes || nodeBrushCount == 0 || nodeBrushCount > leafBrushCount) + return leafBrushCount; + + const auto matchAt = [leafBrushes, nodeBrushes, nodeBrushCount](const size_t index) + { + return std::equal(nodeBrushes, nodeBrushes + nodeBrushCount, leafBrushes + index); + }; + + for (auto i = std::min(searchStart, leafBrushCount); i + nodeBrushCount <= leafBrushCount; i++) + { + if (matchAt(i)) + return i; + } + + // Fall back to a full scan for unusual cases where the raw leafbrush + // ranges are not monotonic. The normal linker layout is monotonic, so + // starting at the running cursor avoids picking earlier duplicate + // one-brush ranges when OAT-built fastfiles contain copied node arrays. + for (auto i = 0uz; i < std::min(searchStart, leafBrushCount); i++) + { + if (i + nodeBrushCount <= leafBrushCount && matchAt(i)) + return i; + } + + return leafBrushCount; + } + + struct LeafBrushRange + { + size_t first = std::numeric_limits::max(); + size_t end = 0uz; + bool valid = false; + }; + + void IncludeLeafBrushRange(LeafBrushRange& range, const size_t first, const size_t count) + { + if (count == 0uz) + return; + + range.first = std::min(range.first, first); + range.end = std::max(range.end, first + count); + range.valid = true; + } + + [[nodiscard]] bool CollectLeafBrushRange_r(const clipMap_t& clipMap, const size_t nodeIndex, LeafBrushRange& range, const size_t depth = 0uz) + { + if (!clipMap.leafbrushNodes || nodeIndex >= clipMap.leafbrushNodesCount || depth > clipMap.leafbrushNodesCount) + return false; + + const auto& node = clipMap.leafbrushNodes[nodeIndex]; + if (node.leafBrushCount > 0) + { + auto brushesIndex = 0uz; + if (!TryPointerIndex(clipMap.leafbrushes, clipMap.numLeafBrushes, node.data.leaf.brushes, brushesIndex)) + return false; + + IncludeLeafBrushRange(range, brushesIndex, static_cast(node.leafBrushCount)); + return true; + } + + if (node.leafBrushCount < 0 && !CollectLeafBrushRange_r(clipMap, nodeIndex + 1uz, range, depth + 1uz)) + return false; + + for (auto side = 0uz; side < 2uz; side++) + { + const auto childOffset = node.data.children.childOffset[side]; + if (childOffset == 0u || !CollectLeafBrushRange_r(clipMap, nodeIndex + childOffset, range, depth + 1uz)) + return false; + } + + return true; + } + + [[nodiscard]] bool CollectLeafBrushes_r(const clipMap_t& clipMap, const size_t nodeIndex, std::vector& brushes, const size_t depth = 0uz) + { + if (!clipMap.leafbrushNodes || nodeIndex >= clipMap.leafbrushNodesCount || depth > clipMap.leafbrushNodesCount) + return false; + + const auto& node = clipMap.leafbrushNodes[nodeIndex]; + if (node.leafBrushCount > 0) + { + if (!node.data.leaf.brushes) + return false; + + brushes.insert(brushes.end(), node.data.leaf.brushes, node.data.leaf.brushes + node.leafBrushCount); + return true; + } + + if (node.leafBrushCount < 0 && !CollectLeafBrushes_r(clipMap, nodeIndex + 1uz, brushes, depth + 1uz)) + return false; + + for (auto side = 0uz; side < 2uz; side++) + { + const auto childOffset = node.data.children.childOffset[side]; + if (childOffset == 0u || !CollectLeafBrushes_r(clipMap, nodeIndex + childOffset, brushes, depth + 1uz)) + return false; + } + + return true; + } + + [[nodiscard]] std::vector BuildMaterials(const clipMap_t& clipMap) + { + std::vector out; + out.reserve(static_cast(clipMap.numMaterials) * 72uz); + const auto brushMaterialUsage = BuildBrushMaterialUsage(clipMap); + + for (auto i = 0uz; i < clipMap.numMaterials; i++) + { + const auto& material = clipMap.materials[i]; + const auto contentFlags = ReconstructRawMaterialContents(material.contentFlags, brushMaterialUsage[i] != 0u); + AppendBytes(out, material.material, sizeof(material.material)); + Append(out, material.surfaceFlags); + Append(out, contentFlags); + } + + return out; + } + + [[nodiscard]] std::vector BuildPlanes(const clipMap_t& clipMap) + { + std::vector out; + out.reserve(PositiveCount(clipMap.planeCount) * 16uz); + + for (auto i = 0uz; i < PositiveCount(clipMap.planeCount); i++) + { + const auto& plane = clipMap.planes[i]; + AppendBytes(out, plane.normal, sizeof(plane.normal)); + Append(out, plane.dist); + } + + return out; + } + + [[nodiscard]] uint32_t PlaneIndex(const clipMap_t& clipMap, const cplane_s* plane) + { + return static_cast(PointerIndex(clipMap.planes, PositiveCount(clipMap.planeCount), plane)); + } + + [[nodiscard]] std::vector BuildBrushSideData(const clipMap_t& clipMap) + { + std::vector out; + + for (auto brushIndex = 0uz; brushIndex < clipMap.numBrushes; brushIndex++) + { + const auto& brush = clipMap.brushes[brushIndex]; + + for (auto axis = 0uz; axis < 3uz; axis++) + { + for (auto side = 0uz; side < 2uz; side++) + { + const auto dist = side == 0uz ? brush.mins[axis] : brush.maxs[axis]; + const auto materialIndex = static_cast(brush.axialMaterialNum[side][axis]); + Append(out, dist); + Append(out, materialIndex); + } + } + + for (auto sideIndex = 0uz; sideIndex < brush.numsides; sideIndex++) + { + const auto& side = brush.sides[sideIndex]; + Append(out, PlaneIndex(clipMap, side.plane)); + Append(out, side.materialNum); + } + } + + return out; + } + + [[nodiscard]] std::vector BuildBrushEdgeCounts(const clipMap_t& clipMap) + { + std::vector out; + + for (auto brushIndex = 0uz; brushIndex < clipMap.numBrushes; brushIndex++) + { + const auto& brush = clipMap.brushes[brushIndex]; + + for (auto axis = 0uz; axis < 3uz; axis++) + { + for (auto side = 0uz; side < 2uz; side++) + Append(out, brush.edgeCount[side][axis]); + } + + for (auto sideIndex = 0uz; sideIndex < brush.numsides; sideIndex++) + Append(out, brush.sides[sideIndex].edgeCount); + } + + return out; + } + + [[nodiscard]] std::vector BuildBrushEdges(const clipMap_t& clipMap) + { + return RawBytes(clipMap.brushEdges, static_cast(clipMap.numBrushEdges) * sizeof(cbrushedge_t)); + } + + [[nodiscard]] std::vector BuildBrushHeaders(const clipMap_t& clipMap) + { + std::vector out; + out.reserve(static_cast(clipMap.numBrushes) * 4uz); + + for (auto brushIndex = 0uz; brushIndex < clipMap.numBrushes; brushIndex++) + { + const auto& brush = clipMap.brushes[brushIndex]; + const auto sideCount = static_cast(brush.numsides + 6u); + const auto materialIndex = static_cast(brush.axialMaterialNum[0][0]); + Append(out, sideCount); + Append(out, materialIndex); + } + + return out; + } + + [[nodiscard]] std::vector BuildClipLeafs(const clipMap_t& clipMap) + { + std::vector out; + out.reserve(static_cast(clipMap.numLeafs) * 24uz); + + auto runningFirstLeafBrush = 0; + + for (auto leafIndex = 0uz; leafIndex < clipMap.numLeafs; leafIndex++) + { + const auto& leaf = clipMap.leafs[leafIndex]; + const auto* leafBrushNode = leaf.leafBrushNode >= 0 && static_cast(leaf.leafBrushNode) < clipMap.leafbrushNodesCount + ? &clipMap.leafbrushNodes[leaf.leafBrushNode] + : nullptr; + auto leafBrushCount = 0; + auto firstLeafBrush = runningFirstLeafBrush; + if (leafBrushNode) + { + auto recoveredRange = LeafBrushRange{}; + + // linker_pc partitions each raw leaf brush range into a small + // runtime tree. The raw BSP still wants the original contiguous + // range, so flatten terminal leaf nodes back to their min/max + // span inside clipMap.leafbrushes. + if (CollectLeafBrushRange_r(clipMap, static_cast(leaf.leafBrushNode), recoveredRange) && recoveredRange.valid) + { + firstLeafBrush = static_cast(recoveredRange.first); + leafBrushCount = static_cast(recoveredRange.end - recoveredRange.first); + } + else + { + std::vector recoveredBrushes; + if (CollectLeafBrushes_r(clipMap, static_cast(leaf.leafBrushNode), recoveredBrushes) && !recoveredBrushes.empty()) + { + const auto brushesIndex = FindLeafBrushRange(clipMap.leafbrushes, + clipMap.numLeafBrushes, + recoveredBrushes.data(), + recoveredBrushes.size(), + static_cast(runningFirstLeafBrush)); + + if (brushesIndex < clipMap.numLeafBrushes) + { + firstLeafBrush = static_cast(brushesIndex); + leafBrushCount = static_cast(recoveredBrushes.size()); + } + } + } + } + + // The raw leaf record carries the cluster/cell assignment even for + // leaves that also reference brushes. Recomputing this from brush + // count makes valid source leaves look solid to Radiant. + const auto cluster = static_cast(leaf.cluster); + const auto cellIndex = cluster >= 0 ? 0 : -1; + + Append(out, cluster); + Append(out, static_cast(leaf.firstCollAabbIndex)); + Append(out, static_cast(leaf.collAabbCount)); + Append(out, firstLeafBrush); + Append(out, leafBrushCount); + Append(out, cellIndex); + + runningFirstLeafBrush = firstLeafBrush + leafBrushCount; + } + + return out; + } + + [[nodiscard]] int32_t BoundsMinToInt(const float value) + { + return static_cast(std::floor(value)) - 8; + } + + [[nodiscard]] int32_t BoundsMaxToInt(const float value) + { + return static_cast(std::ceil(value)) + 8; + } + + [[nodiscard]] IntBounds RootClipNodeBounds(const clipMap_t& clipMap, const GfxWorld& world) + { + const auto* mins = world.modelCount > 0 && world.models ? world.models[0].bounds[0] + : clipMap.numSubModels > 0 && clipMap.cmodels ? clipMap.cmodels[0].mins + : nullptr; + const auto* maxs = world.modelCount > 0 && world.models ? world.models[0].bounds[1] + : clipMap.numSubModels > 0 && clipMap.cmodels ? clipMap.cmodels[0].maxs + : nullptr; + + IntBounds result{}; + for (auto axis = 0uz; axis < 3uz; axis++) + { + result.mins[axis] = mins ? BoundsMinToInt(mins[axis]) : 0; + result.maxs[axis] = maxs ? BoundsMaxToInt(maxs[axis]) : 0; + } + + return result; + } + + [[nodiscard]] unsigned PlaneAxis(const cplane_s& plane) + { + if (plane.type <= 2) + return plane.type; + + auto axis = 0u; + auto largestComponent = std::abs(plane.normal[0]); + for (auto i = 1u; i < 3u; i++) + { + const auto component = std::abs(plane.normal[i]); + if (component > largestComponent) + { + largestComponent = component; + axis = i; + } + } + + return axis; + } + + void AssignClipNodeBounds( + const clipMap_t& clipMap, std::vector& bounds, std::vector& visited, const int nodeIndex, const IntBounds& nodeBounds) + { + if (nodeIndex < 0 || static_cast(nodeIndex) >= bounds.size() || visited[nodeIndex]) + return; + + visited[nodeIndex] = true; + bounds[nodeIndex] = nodeBounds; + + const auto& node = clipMap.nodes[nodeIndex]; + const auto planeIndex = PointerIndex(clipMap.planes, PositiveCount(clipMap.planeCount), node.plane); + if (planeIndex >= PositiveCount(clipMap.planeCount)) + return; + + const auto& plane = clipMap.planes[planeIndex]; + const auto axis = PlaneAxis(plane); + const auto dist = static_cast(std::lround(plane.dist)); + + auto frontBounds = nodeBounds; + auto backBounds = nodeBounds; + frontBounds.mins[axis] = dist; + backBounds.maxs[axis] = dist; + + AssignClipNodeBounds(clipMap, bounds, visited, node.children[0], frontBounds); + AssignClipNodeBounds(clipMap, bounds, visited, node.children[1], backBounds); + } + + [[nodiscard]] std::vector BuildClipNodes(const clipMap_t& clipMap, const GfxWorld& world) + { + const auto nodeCount = PositiveCount(clipMap.numNodes); + std::vector bounds(nodeCount); + std::vector visited(nodeCount); + + if (nodeCount > 0) + AssignClipNodeBounds(clipMap, bounds, visited, 0, RootClipNodeBounds(clipMap, world)); + + std::vector out; + out.reserve(nodeCount * 36uz); + + for (auto nodeIndex = 0uz; nodeIndex < nodeCount; nodeIndex++) + { + const auto& node = clipMap.nodes[nodeIndex]; + const auto planeIndex = static_cast(PointerIndex(clipMap.planes, PositiveCount(clipMap.planeCount), node.plane)); + Append(out, planeIndex); + Append(out, static_cast(node.children[0])); + Append(out, static_cast(node.children[1])); + AppendBytes(out, bounds[nodeIndex].mins, sizeof(bounds[nodeIndex].mins)); + AppendBytes(out, bounds[nodeIndex].maxs, sizeof(bounds[nodeIndex].maxs)); + } + + return out; + } + + [[nodiscard]] std::vector BuildLeafBrushes(const clipMap_t& clipMap) + { + std::vector out; + out.reserve(static_cast(clipMap.numLeafBrushes) * sizeof(uint32_t)); + + for (auto i = 0uz; i < clipMap.numLeafBrushes; i++) + { + const auto value = static_cast(clipMap.leafbrushes[i]); + Append(out, value); + } + + return out; + } + + [[nodiscard]] std::vector BuildCollisionVerts(const clipMap_t& clipMap) + { + return RawBytes(clipMap.verts, static_cast(clipMap.vertCount) * sizeof(vec3_t)); + } + + [[nodiscard]] std::vector BuildCollisionTriIndices(const clipMap_t& clipMap) + { + return RawBytes(clipMap.triIndices, PositiveCount(clipMap.triCount) * 3uz * sizeof(uint16_t)); + } + + [[nodiscard]] std::vector BuildCollisionTriEdgeIsWalkable(const clipMap_t& clipMap) + { + const auto size = ((PositiveCount(clipMap.triCount) * 3uz + 31uz) / 32uz) * sizeof(uint32_t); + return RawBytes(clipMap.triEdgeIsWalkable, size); + } + + [[nodiscard]] std::vector BuildCollisionBorders(const clipMap_t& clipMap) + { + return RawBytes(clipMap.borders, PositiveCount(clipMap.borderCount) * sizeof(CollisionBorder)); + } + + [[nodiscard]] std::vector BuildCollisionPartitions(const clipMap_t& clipMap) + { + std::vector out; + out.reserve(PositiveCount(clipMap.partitionCount) * 12uz); + + for (auto i = 0uz; i < PositiveCount(clipMap.partitionCount); i++) + { + const auto& partition = clipMap.partitions[i]; + const uint8_t padding[2]{}; + const auto firstTri = partition.firstTri; + const auto borderIndex = static_cast(PointerIndex(clipMap.borders, PositiveCount(clipMap.borderCount), partition.borders)); + + AppendBytes(out, padding, sizeof(padding)); + Append(out, partition.triCount); + Append(out, partition.borderCount); + Append(out, firstTri); + Append(out, borderIndex); + } + + return out; + } + + [[nodiscard]] std::vector BuildCollisionAabbTrees(const clipMap_t& clipMap) + { + return RawBytes(clipMap.aabbTrees, PositiveCount(clipMap.aabbTreeCount) * sizeof(CollisionAabbTree)); + } + + [[nodiscard]] size_t LightGridRowCount(const GfxLightGrid& lightGrid) + { + if (lightGrid.rowAxis >= 3u || lightGrid.maxs[lightGrid.rowAxis] < lightGrid.mins[lightGrid.rowAxis]) + return 0uz; + + return static_cast(lightGrid.maxs[lightGrid.rowAxis] - lightGrid.mins[lightGrid.rowAxis] + 1u); + } + + [[nodiscard]] std::vector BuildLightGridHeader(const GfxWorld& world) + { + const auto& lightGrid = world.lightGrid; + std::vector out; + + AppendBytes(out, lightGrid.mins, sizeof(lightGrid.mins)); + AppendBytes(out, lightGrid.maxs, sizeof(lightGrid.maxs)); + Append(out, lightGrid.rowAxis); + Append(out, lightGrid.colAxis); + AppendBytes(out, lightGrid.rowDataStart, LightGridRowCount(lightGrid) * sizeof(uint16_t)); + + return out; + } + + [[nodiscard]] std::vector BuildLightGridEntries(const GfxWorld& world) + { + return RawBytes(world.lightGrid.entries, static_cast(world.lightGrid.entryCount) * sizeof(GfxLightGridEntry)); + } + + [[nodiscard]] std::vector BuildLightGridColors(const GfxWorld& world) + { + std::vector out; + auto colorCount = static_cast(world.lightGrid.colorCount); + + // The linker appends a runtime fallback color set. It is not present in stock test.d3dbsp. + if (colorCount > 0) + colorCount--; + + AppendBytes(out, world.lightGrid.colors, colorCount * sizeof(GfxLightGridColors)); + return out; + } + + [[nodiscard]] std::vector BuildLightGridRawRows(const GfxWorld& world) + { + return RawBytes(world.lightGrid.rawRowData, world.lightGrid.rawRowDataSize); + } + + [[nodiscard]] uint16_t SurfaceMaterialIndex(const clipMap_t* clipMap, const GfxSurface& surface) + { + const auto* materialName = surface.material && surface.material->info.name ? surface.material->info.name : nullptr; + const auto materialBaseName = BaseName(materialName); + + if (!clipMap || !clipMap->materials) + return 0u; + + for (auto i = 0uz; i < clipMap->numMaterials; i++) + { + const auto rawName = BaseName(clipMap->materials[i].material); + if (MaterialNameMatches(rawName, materialBaseName)) + return static_cast(i); + } + + return 0u; + } + + [[nodiscard]] uint8_t RawPrimaryLightIndex(const GfxSurface& surface) + { + // Sky surfaces in test.d3dbsp store 0 here; the linker normalizes them to the sun primary light at runtime. + if (static_cast(surface.lightmapIndex) == SKY_LIGHTMAP_INDEX) + return 0u; + + return static_cast(surface.primaryLightIndex); + } + + [[nodiscard]] unsigned LightmapRawPageCount(const GfxImageLoadDef* primary, const GfxImageLoadDef* secondary) + { + if (!primary || !secondary || primary->resourceSize == 0u || secondary->resourceSize == 0u) + return 1u; + + const auto primaryPages = (primary->resourceSize + LIGHTMAP_PRIMARY_RAW_PAGE_SIZE - 1uz) / LIGHTMAP_PRIMARY_RAW_PAGE_SIZE; + const auto secondaryPages = (secondary->resourceSize + LIGHTMAP_SECONDARY_RAW_PAGE_SIZE - 1uz) / LIGHTMAP_SECONDARY_RAW_PAGE_SIZE; + return static_cast(std::max(1uz, std::max(primaryPages, secondaryPages))); + } + + [[nodiscard]] std::pair LightmapAtlasGrid(const GfxImageLoadDef* primary, const GfxImageLoadDef* secondary) + { + const auto resourcePageCount = LightmapRawPageCount(primary, secondary); + auto wideCount = primary && primary->dimensions[0] >= LIGHTMAP_PRIMARY_RAW_WIDTH ? primary->dimensions[0] / LIGHTMAP_PRIMARY_RAW_WIDTH : 1u; + auto highCount = + primary && primary->dimensions[1] >= LIGHTMAP_PRIMARY_RAW_HEIGHT ? primary->dimensions[1] / LIGHTMAP_PRIMARY_RAW_HEIGHT : resourcePageCount; + + wideCount = std::max(1u, wideCount); + highCount = std::max(1u, highCount); + + if (wideCount * highCount != resourcePageCount) + { + wideCount = 1u; + highCount = resourcePageCount; + } + + return std::make_pair(wideCount, highCount); + } + + [[nodiscard]] std::vector LinkerRawPageOrder(const unsigned pageCount) + { + std::vector result; + result.reserve(pageCount); + + if (pageCount >= 2u) + { + // The linker's packing pass seeds each multi-page group with the selected pair in this order. + // For a two-page runtime lightmap that means raw page 1 is packed into the first atlas slot and raw page 0 into the second. + result.emplace_back(1u); + result.emplace_back(0u); + } + else if (pageCount == 1u) + { + result.emplace_back(0u); + } + + for (auto pageIndex = 2u; pageIndex < pageCount; pageIndex++) + result.emplace_back(pageIndex); + + return result; + } + + [[nodiscard]] std::vector InvertRawPageOrder(const std::vector& rawPageForPackedSlot) + { + std::vector result(rawPageForPackedSlot.size()); + + for (auto packedSlot = 0uz; packedSlot < rawPageForPackedSlot.size(); packedSlot++) + { + const auto rawPage = rawPageForPackedSlot[packedSlot]; + if (rawPage < result.size()) + result[rawPage] = static_cast(packedSlot); + } + + return result; + } + + void AppendZeros(std::vector& out, const size_t size) + { + out.resize(out.size() + size, std::byte{}); + } + + void PadToSize(std::vector& out, const size_t size) + { + if (out.size() < size) + AppendZeros(out, size - out.size()); + } + + void AppendPrimaryLightmapRawPage( + std::vector& out, const GfxImageLoadDef& primary, const unsigned wideCount, const unsigned highCount, const unsigned packedSlot) + { + const auto sourceWidth = static_cast(primary.dimensions[0]); + const auto sourceHeight = static_cast(primary.dimensions[1]); + const auto slotX = packedSlot % wideCount; + const auto slotY = packedSlot / wideCount; + + if (sourceWidth < wideCount * LIGHTMAP_PRIMARY_RAW_WIDTH || sourceHeight < highCount * LIGHTMAP_PRIMARY_RAW_HEIGHT) + { + AppendZeros(out, LIGHTMAP_PRIMARY_RAW_PAGE_SIZE); + return; + } + + const auto sourceStride = static_cast(sourceWidth); + const auto sourceX = static_cast(slotX) * LIGHTMAP_PRIMARY_RAW_WIDTH; + const auto sourceY = static_cast(slotY) * LIGHTMAP_PRIMARY_RAW_HEIGHT; + + for (auto row = 0u; row < LIGHTMAP_PRIMARY_RAW_HEIGHT; row++) + { + const auto sourceOffset = (sourceY + row) * sourceStride + sourceX; + if (sourceOffset + LIGHTMAP_PRIMARY_RAW_WIDTH <= primary.resourceSize) + AppendBytes(out, primary.data + sourceOffset, LIGHTMAP_PRIMARY_RAW_WIDTH); + else + AppendZeros(out, LIGHTMAP_PRIMARY_RAW_WIDTH); + } + } + + void AppendSecondaryLightmapRawPage( + std::vector& out, const GfxImageLoadDef& secondary, const unsigned wideCount, const unsigned highCount, const unsigned packedSlot) + { + const auto sourceWidth = static_cast(secondary.dimensions[0]); + const auto sourceHeight = static_cast(secondary.dimensions[1]); + const auto slotX = packedSlot % wideCount; + const auto slotY = packedSlot / wideCount; + + if (sourceWidth < wideCount * LIGHTMAP_SECONDARY_RAW_WIDTH || sourceHeight < highCount * LIGHTMAP_SECONDARY_RAW_HEIGHT) + { + AppendZeros(out, LIGHTMAP_SECONDARY_RAW_PAGE_SIZE); + return; + } + + const auto sourceStride = static_cast(sourceWidth) * LIGHTMAP_SECONDARY_PIXEL_SIZE; + const auto sourceX = static_cast(slotX) * LIGHTMAP_SECONDARY_RAW_WIDTH * LIGHTMAP_SECONDARY_PIXEL_SIZE; + + for (auto row = 0u; row < LIGHTMAP_SECONDARY_HALF_HEIGHT; row++) + { + const auto sourceY = static_cast(slotY) * LIGHTMAP_SECONDARY_HALF_HEIGHT + row; + const auto sourceOffset = sourceY * sourceStride + sourceX; + if (sourceOffset + LIGHTMAP_SECONDARY_RAW_WIDTH * LIGHTMAP_SECONDARY_PIXEL_SIZE <= secondary.resourceSize) + AppendBytes(out, secondary.data + sourceOffset, LIGHTMAP_SECONDARY_RAW_WIDTH * LIGHTMAP_SECONDARY_PIXEL_SIZE); + else + AppendZeros(out, LIGHTMAP_SECONDARY_RAW_WIDTH * LIGHTMAP_SECONDARY_PIXEL_SIZE); + } + + for (auto row = 0u; row < LIGHTMAP_SECONDARY_HALF_HEIGHT; row++) + { + const auto sourceY = static_cast(highCount + slotY) * LIGHTMAP_SECONDARY_HALF_HEIGHT + row; + const auto sourceOffset = sourceY * sourceStride + sourceX; + if (sourceOffset + LIGHTMAP_SECONDARY_RAW_WIDTH * LIGHTMAP_SECONDARY_PIXEL_SIZE <= secondary.resourceSize) + AppendBytes(out, secondary.data + sourceOffset, LIGHTMAP_SECONDARY_RAW_WIDTH * LIGHTMAP_SECONDARY_PIXEL_SIZE); + else + AppendZeros(out, LIGHTMAP_SECONDARY_RAW_WIDTH * LIGHTMAP_SECONDARY_PIXEL_SIZE); + } + } + + [[nodiscard]] LightmapPageLayout BuildLightmapPageLayout(const GfxWorld& world) + { + LightmapPageLayout layout; + layout.firstRawPage.reserve(PositiveCount(world.lightmapCount)); + layout.pageCount.reserve(PositiveCount(world.lightmapCount)); + layout.wideCount.reserve(PositiveCount(world.lightmapCount)); + layout.highCount.reserve(PositiveCount(world.lightmapCount)); + layout.rawPageForPackedSlot.reserve(PositiveCount(world.lightmapCount)); + layout.packedSlotForRawPage.reserve(PositiveCount(world.lightmapCount)); + layout.rawPageCount = 0u; + + for (auto lightmapIndex = 0uz; lightmapIndex < PositiveCount(world.lightmapCount); lightmapIndex++) + { + auto* primary = world.lightmaps ? LoadDefForImage(world.lightmaps[lightmapIndex].primary) : nullptr; + auto* secondary = world.lightmaps ? LoadDefForImage(world.lightmaps[lightmapIndex].secondary) : nullptr; + if (!primary) + primary = LoadDefForTexture(world.lightmapPrimaryTextures, lightmapIndex); + if (!secondary) + secondary = LoadDefForTexture(world.lightmapSecondaryTextures, lightmapIndex); + + const auto [wideCount, highCount] = LightmapAtlasGrid(primary, secondary); + const auto pageCount = wideCount * highCount; + const auto rawPageForPackedSlot = LinkerRawPageOrder(pageCount); + layout.firstRawPage.emplace_back(layout.rawPageCount); + layout.pageCount.emplace_back(pageCount); + layout.wideCount.emplace_back(wideCount); + layout.highCount.emplace_back(highCount); + layout.rawPageForPackedSlot.emplace_back(rawPageForPackedSlot); + layout.packedSlotForRawPage.emplace_back(InvertRawPageOrder(rawPageForPackedSlot)); + layout.rawPageCount += pageCount; + } + + return layout; + } + + [[nodiscard]] unsigned SurfacePackedLightmapSlot(const GfxWorld& world, const GfxSurface& surface, const unsigned wideCount, const unsigned highCount) + { + if (wideCount * highCount <= 1u || !world.indices || !world.vd.vertices || world.vertexCount == 0u) + return 0u; + + const auto firstIndex = surface.tris.baseIndex; + const auto indexCount = static_cast(surface.tris.triCount) * 3; + if (firstIndex < 0 || indexCount <= 0 || firstIndex + indexCount > world.indexCount) + return 0u; + + auto minU = std::numeric_limits::max(); + auto maxU = std::numeric_limits::lowest(); + auto minV = std::numeric_limits::max(); + auto maxV = std::numeric_limits::lowest(); + auto foundVertex = false; + + for (auto indexOffset = 0; indexOffset < indexCount; indexOffset++) + { + const auto vertexIndex = surface.tris.firstVertex + world.indices[firstIndex + indexOffset]; + if (vertexIndex < 0 || static_cast(vertexIndex) >= world.vertexCount) + continue; + + const auto u = world.vd.vertices[vertexIndex].lmapCoord[0]; + const auto v = world.vd.vertices[vertexIndex].lmapCoord[1]; + minU = std::min(minU, u); + maxU = std::max(maxU, u); + minV = std::min(minV, v); + maxV = std::max(maxV, v); + foundVertex = true; + } + + if (!foundVertex) + return 0u; + + const auto midpointU = (static_cast(minU) + static_cast(maxU)) * 0.5; + const auto midpointV = (static_cast(minV) + static_cast(maxV)) * 0.5; + const auto slotX = std::clamp(static_cast(std::floor(midpointU * wideCount)), 0, static_cast(wideCount - 1u)); + const auto slotY = std::clamp(static_cast(std::floor(midpointV * highCount)), 0, static_cast(highCount - 1u)); + + return static_cast(slotY) * wideCount + static_cast(slotX); + } + + [[nodiscard]] std::vector BuildSurfaceLightmapRemaps(const GfxWorld& world, const LightmapPageLayout& layout) + { + std::vector remaps; + remaps.reserve(PositiveCount(world.surfaceCount)); + + for (auto surfaceIndex = 0uz; surfaceIndex < PositiveCount(world.surfaceCount); surfaceIndex++) + { + const auto& surface = world.dpvs.surfaces[surfaceIndex]; + const auto lightmapIndex = static_cast(surface.lightmapIndex); + if (lightmapIndex == SKY_LIGHTMAP_INDEX || lightmapIndex >= layout.pageCount.size()) + { + remaps.emplace_back(SurfaceLightmapRemap{static_cast(lightmapIndex), lightmapIndex, 0u, 1u, 1u}); + continue; + } + + const auto wideCount = layout.wideCount[lightmapIndex]; + const auto highCount = layout.highCount[lightmapIndex]; + const auto packedSlot = SurfacePackedLightmapSlot(world, surface, wideCount, highCount); + const auto rawPage = + packedSlot < layout.rawPageForPackedSlot[lightmapIndex].size() ? layout.rawPageForPackedSlot[lightmapIndex][packedSlot] : packedSlot; + const auto rawLightmapIndex = layout.firstRawPage[lightmapIndex] + rawPage; + remaps.emplace_back(SurfaceLightmapRemap{static_cast(rawLightmapIndex), lightmapIndex, packedSlot, wideCount, highCount}); + } + + return remaps; + } + + [[nodiscard]] std::vector BuildVertexLightmapRemaps(const GfxWorld& world, const std::vector& surfaceRemaps) + { + std::vector remaps(world.vertexCount, VertexLightmapRemap{0u, 1u, 1u, false}); + if (!world.indices || !world.vd.vertices || !world.dpvs.surfaces) + return remaps; + + for (auto surfaceIndex = 0uz; surfaceIndex < PositiveCount(world.surfaceCount) && surfaceIndex < surfaceRemaps.size(); surfaceIndex++) + { + const auto& surface = world.dpvs.surfaces[surfaceIndex]; + const auto lightmapIndex = static_cast(surface.lightmapIndex); + if (lightmapIndex == SKY_LIGHTMAP_INDEX) + continue; + + const auto firstIndex = surface.tris.baseIndex; + const auto indexCount = static_cast(surface.tris.triCount) * 3; + if (firstIndex < 0 || indexCount <= 0 || firstIndex + indexCount > world.indexCount) + continue; + + const auto& surfaceRemap = surfaceRemaps[surfaceIndex]; + for (auto indexOffset = 0; indexOffset < indexCount; indexOffset++) + { + const auto vertexIndex = surface.tris.firstVertex + world.indices[firstIndex + indexOffset]; + if (vertexIndex < 0 || static_cast(vertexIndex) >= world.vertexCount) + continue; + + remaps[vertexIndex] = VertexLightmapRemap{surfaceRemap.packedSlot, surfaceRemap.wideCount, surfaceRemap.highCount, true}; + } + } + + return remaps; + } + + [[nodiscard]] std::vector BuildSurfaces(const clipMap_t* clipMap, const GfxWorld& world, const std::vector& lightmapRemaps) + { + std::vector out; + out.reserve(PositiveCount(world.surfaceCount) * 24uz); + + // Surface order is part of the BSP's index space. Brush models and AABB + // ranges point into this same list, so reordering here requires remapping + // every dependent range as well. + for (auto surfaceIndex = 0uz; surfaceIndex < PositiveCount(world.surfaceCount); surfaceIndex++) + { + const auto& surface = world.dpvs.surfaces[surfaceIndex]; + const auto materialIndex = SurfaceMaterialIndex(clipMap, surface); + const auto lightmapIndex = + surfaceIndex < lightmapRemaps.size() ? lightmapRemaps[surfaceIndex].rawLightmapIndex : static_cast(surface.lightmapIndex); + const auto reflectionProbeIndex = static_cast(surface.reflectionProbeIndex); + const auto primaryLightIndex = RawPrimaryLightIndex(surface); + const auto flags = static_cast(surface.flags); + const uint16_t padding = 0u; + const auto vertexLayerData = surface.tris.vertexLayerData; + const auto firstVertex = surface.tris.firstVertex; + const auto vertexCount = surface.tris.vertexCount; + const auto indexCount = static_cast(surface.tris.triCount * 3u); + const auto baseIndex = surface.tris.baseIndex; + + Append(out, materialIndex); + Append(out, lightmapIndex); + Append(out, reflectionProbeIndex); + Append(out, primaryLightIndex); + Append(out, flags); + Append(out, padding); + Append(out, vertexLayerData); + Append(out, firstVertex); + Append(out, vertexCount); + Append(out, indexCount); + Append(out, baseIndex); + } + + return out; + } + + void CrossProduct(const float (&left)[3], const float (&right)[3], float (&out)[3]) + { + out[0] = left[1] * right[2] - left[2] * right[1]; + out[1] = left[2] * right[0] - left[0] * right[2]; + out[2] = left[0] * right[1] - left[1] * right[0]; + } + + [[nodiscard]] std::vector BuildVertices(const GfxWorld& world, const std::vector& lightmapRemaps) + { + std::vector out; + out.reserve(static_cast(world.vertexCount) * 68uz); + + for (auto vertexIndex = 0uz; vertexIndex < world.vertexCount; vertexIndex++) + { + const auto& vertex = world.vd.vertices[vertexIndex]; + float normal[3]{}; + float tangent[3]{}; + float binormal[3]{}; + float lmapCoord[2]{vertex.lmapCoord[0], vertex.lmapCoord[1]}; + Common::Vec3UnpackUnitVec(vertex.normal, normal); + Common::Vec3UnpackUnitVec(vertex.tangent, tangent); + CrossProduct(normal, tangent, binormal); + + for (auto& component : binormal) + component *= vertex.binormalSign; + + if (vertexIndex < lightmapRemaps.size() && lightmapRemaps[vertexIndex].valid + && lightmapRemaps[vertexIndex].wideCount * lightmapRemaps[vertexIndex].highCount > 1u) + { + const auto& remap = lightmapRemaps[vertexIndex]; + const auto slotX = remap.packedSlot % remap.wideCount; + const auto slotY = remap.packedSlot / remap.wideCount; + lmapCoord[0] = lmapCoord[0] * static_cast(remap.wideCount) - static_cast(slotX); + lmapCoord[1] = lmapCoord[1] * static_cast(remap.highCount) - static_cast(slotY); + } + + AppendBytes(out, vertex.xyz, sizeof(vertex.xyz)); + AppendBytes(out, normal, sizeof(normal)); + Append(out, vertex.color.packed); + AppendBytes(out, vertex.texCoord, sizeof(vertex.texCoord)); + AppendBytes(out, lmapCoord, sizeof(lmapCoord)); + AppendBytes(out, tangent, sizeof(tangent)); + AppendBytes(out, binormal, sizeof(binormal)); + } + + return out; + } + + [[nodiscard]] std::vector BuildIndices(const GfxWorld& world) + { + return RawBytes(world.indices, PositiveCount(world.indexCount) * sizeof(uint16_t)); + } + + [[nodiscard]] std::vector BuildLightmapImages(const GfxWorld& world, const LightmapPageLayout& layout) + { + if (world.lightmapCount <= 0) + return {}; + + std::vector out; + for (auto lightmapIndex = 0uz; lightmapIndex < PositiveCount(world.lightmapCount); lightmapIndex++) + { + auto* primary = world.lightmaps ? LoadDefForImage(world.lightmaps[lightmapIndex].primary) : nullptr; + auto* secondary = world.lightmaps ? LoadDefForImage(world.lightmaps[lightmapIndex].secondary) : nullptr; + if (!primary) + primary = LoadDefForTexture(world.lightmapPrimaryTextures, lightmapIndex); + if (!secondary) + secondary = LoadDefForTexture(world.lightmapSecondaryTextures, lightmapIndex); + if (!primary || !secondary) + return {}; + + const auto pageCount = lightmapIndex < layout.pageCount.size() ? layout.pageCount[lightmapIndex] : 1u; + const auto wideCount = lightmapIndex < layout.wideCount.size() ? layout.wideCount[lightmapIndex] : 1u; + const auto highCount = lightmapIndex < layout.highCount.size() ? layout.highCount[lightmapIndex] : pageCount; + for (auto rawPage = 0u; rawPage < pageCount; rawPage++) + { + // Raw d3dbsp stores fixed-height lightmap pages as secondary texture data first, then primary. + // The linker packs raw pages into a runtime atlas and applies the matching UV scale/offset when loading surfaces. + const auto packedSlot = lightmapIndex < layout.packedSlotForRawPage.size() && rawPage < layout.packedSlotForRawPage[lightmapIndex].size() + ? layout.packedSlotForRawPage[lightmapIndex][rawPage] + : rawPage; + AppendSecondaryLightmapRawPage(out, *secondary, wideCount, highCount, packedSlot); + AppendPrimaryLightmapRawPage(out, *primary, wideCount, highCount, packedSlot); + } + } + + return out; + } + + [[nodiscard]] uint32_t PackRgba(const uint8_t r, const uint8_t g, const uint8_t b, const uint8_t a) + { + return static_cast(r) | (static_cast(g) << 8u) | (static_cast(b) << 16u) | (static_cast(a) << 24u); + } + + [[nodiscard]] uint32_t TransformReflectionProbeColor(const uint8_t r, const uint8_t g, const uint8_t b) + { + constexpr double LUMA_R = 0.1140000000596046; + constexpr double LUMA_G = 0.5870000123977661; + constexpr double LUMA_B = 0.2989999949932098; + + const auto& correction = DEFAULT_REFLECTION_PROBE_CORRECTION; + const auto range = correction.whiteLevel - correction.blackLevel; + + double color[3]{ + std::pow(std::max(0.0, (static_cast(r) / 255.0 - correction.blackLevel) / range), correction.gamma), + std::pow(std::max(0.0, (static_cast(g) / 255.0 - correction.blackLevel) / range), correction.gamma), + std::pow(std::max(0.0, (static_cast(b) / 255.0 - correction.blackLevel) / range), correction.gamma), + }; + + const auto luma = LUMA_R * color[0] + LUMA_G * color[1] + LUMA_B * color[2]; + for (auto& channel : color) + channel = correction.saturation * channel + (1.0 - correction.saturation) * luma; + + const auto scale = std::max({0.1, color[0], color[1], color[2]}); + return PackRgba(ClampToByte(static_cast(color[0] / scale * 255.0)), + ClampToByte(static_cast(color[1] / scale * 255.0)), + ClampToByte(static_cast(color[2] / scale * 255.0)), + ClampToByte(static_cast(255.0 * (scale * 0.25)))); + } + + [[nodiscard]] uint32_t EstimateReflectionProbeSourceColor(const uint32_t targetColor, const double scale) + { + constexpr double LUMA_R = 0.1140000000596046; + constexpr double LUMA_G = 0.5870000123977661; + constexpr double LUMA_B = 0.2989999949932098; + + const auto& correction = DEFAULT_REFLECTION_PROBE_CORRECTION; + const auto r = static_cast(targetColor & 0xFFu); + const auto g = static_cast((targetColor >> 8u) & 0xFFu); + const auto b = static_cast((targetColor >> 16u) & 0xFFu); + + const double corrected[3]{ + ((r + 0.5) / 255.0) * scale, + ((g + 0.5) / 255.0) * scale, + ((b + 0.5) / 255.0) * scale, + }; + + const auto luma = LUMA_R * corrected[0] + LUMA_G * corrected[1] + LUMA_B * corrected[2]; + const auto range = correction.whiteLevel - correction.blackLevel; + + uint8_t source[3]{}; + for (auto i = 0uz; i < 3uz; i++) + { + const auto unsaturated = std::max(0.0, (corrected[i] - (1.0 - correction.saturation) * luma) / correction.saturation); + const auto linear = std::pow(unsaturated, 1.0 / correction.gamma) * range + correction.blackLevel; + source[i] = ClampToByte(static_cast(std::round(linear * 255.0))); + } + + return PackRgba(source[0], source[1], source[2], 0u); + } + + [[nodiscard]] uint32_t InvertReflectionProbeColor(const uint32_t targetColor, std::unordered_map& cache) + { + const auto cached = cache.find(targetColor); + if (cached != cache.end()) + return cached->second; + + const auto alpha = static_cast((targetColor >> 24u) & 0xFFu); + const auto minScale = alpha / 63.75; + const auto maxScale = (alpha + 1.0) / 63.75; + const auto estimated = EstimateReflectionProbeSourceColor(targetColor, (minScale + maxScale) * 0.5); + + const int base[3]{ + static_cast(estimated & 0xFFu), + static_cast((estimated >> 8u) & 0xFFu), + static_cast((estimated >> 16u) & 0xFFu), + }; + + constexpr auto SEARCH_RADIUS = 5; + for (auto r = std::max(0, base[0] - SEARCH_RADIUS); r <= std::min(255, base[0] + SEARCH_RADIUS); r++) + { + for (auto g = std::max(0, base[1] - SEARCH_RADIUS); g <= std::min(255, base[1] + SEARCH_RADIUS); g++) + { + for (auto b = std::max(0, base[2] - SEARCH_RADIUS); b <= std::min(255, base[2] + SEARCH_RADIUS); b++) + { + if (TransformReflectionProbeColor(static_cast(r), static_cast(g), static_cast(b)) == targetColor) + { + const auto sourceColor = PackRgba(static_cast(r), static_cast(g), static_cast(b), 0u); + cache.emplace(targetColor, sourceColor); + return sourceColor; + } + } + } + } + + // Some transformed colors may not have an exact source-byte preimage under the default correction. + cache.emplace(targetColor, estimated); + return estimated; + } + + [[nodiscard]] size_t ReflectionProbeMipSize(const size_t mipLevel) + { + const auto size = REFLECTION_PROBE_SIZE >> mipLevel; + return size * size * REFLECTION_PROBE_PIXEL_SIZE; + } + + [[nodiscard]] size_t ReflectionProbeTextureOffset(const size_t face, const size_t mipLevel) + { + auto offset = 0uz; + for (auto currentMip = 0uz; currentMip < mipLevel; currentMip++) + offset += ReflectionProbeMipSize(currentMip) * 6uz; + + return offset + ReflectionProbeMipSize(mipLevel) * face; + } + + void AppendInvertedReflectionProbePixels(std::vector& out, const char* imageData, std::unordered_map& colorCache) + { + const auto* bytes = reinterpret_cast(imageData); + const auto appendFaceMip = [&out, bytes, &colorCache](const size_t face, const size_t mipLevel) + { + const auto sourceOffset = ReflectionProbeTextureOffset(face, mipLevel); + const auto pixelCount = ReflectionProbeMipSize(mipLevel) / REFLECTION_PROBE_PIXEL_SIZE; + for (auto pixelIndex = 0uz; pixelIndex < pixelCount; pixelIndex++) + { + const auto pixelOffset = sourceOffset + pixelIndex * REFLECTION_PROBE_PIXEL_SIZE; + const auto targetColor = PackRgba(bytes[pixelOffset], bytes[pixelOffset + 1uz], bytes[pixelOffset + 2uz], bytes[pixelOffset + 3uz]); + const auto sourceColor = InvertReflectionProbeColor(targetColor, colorCache); + Append(out, sourceColor); + } + }; + + for (auto face = 0uz; face < 6uz; face++) + appendFaceMip(face, 0uz); + + for (auto face = 0uz; face < 6uz; face++) + { + for (auto mipLevel = 1uz; mipLevel < REFLECTION_PROBE_MIP_COUNT; mipLevel++) + appendFaceMip(face, mipLevel); + } + } + + [[nodiscard]] std::vector BuildReflectionProbeRecords(const GfxWorld& world) + { + if (world.reflectionProbeCount <= 1u || !world.reflectionProbes) + return {}; + + std::vector out; + std::unordered_map colorCache; + // The linker discards the source color-correction name after baking the probe image. + // Emit a canonical empty/default record and invert pixels so relinking applies the same default transform. + const std::array correctionName{}; + + for (auto probeIndex = 1uz; probeIndex < world.reflectionProbeCount; probeIndex++) + { + const auto& probe = world.reflectionProbes[probeIndex]; + auto* loadDef = LoadDefForImage(probe.reflectionImage); + if (!loadDef) + loadDef = LoadDefForTexture(world.reflectionProbeTextures, probeIndex); + + if (!loadDef || loadDef->resourceSize != REFLECTION_PROBE_RAW_DATA_SIZE) + return {}; + + AppendBytes(out, probe.origin, sizeof(probe.origin)); + AppendBytes(out, correctionName.data(), correctionName.size()); + AppendInvertedReflectionProbePixels(out, loadDef->data, colorCache); + } + + return out; + } + + [[nodiscard]] std::vector BuildCellHeader(const GfxWorld& world) + { + constexpr auto CELL_HEADER_SIZE = 112uz; + constexpr auto REFLECTION_PROBE_LIST_OFFSET = 44uz; + constexpr auto REFLECTION_PROBE_LIST_CAPACITY = CELL_HEADER_SIZE - REFLECTION_PROBE_LIST_OFFSET - 1uz; + + std::vector out; + out.reserve(CELL_HEADER_SIZE); + + if (world.dpvsPlanes.cellCount <= 0 || !world.cells) + { + out.resize(CELL_HEADER_SIZE, std::byte{}); + return out; + } + + const auto& cell = world.cells[0]; + AppendBytes(out, cell.mins, sizeof(cell.mins)); + AppendBytes(out, cell.maxs, sizeof(cell.maxs)); + + // The fixed-size cell header stores the cell's reflection probe list at byte 44. + PadToSize(out, REFLECTION_PROBE_LIST_OFFSET); + out.emplace_back(static_cast(cell.reflectionProbeCount)); + for (auto i = 0uz; i < static_cast(cell.reflectionProbeCount) && i < REFLECTION_PROBE_LIST_CAPACITY; i++) + out.emplace_back(static_cast(cell.reflectionProbes[i])); + + PadToSize(out, CELL_HEADER_SIZE); + return out; + } + + [[nodiscard]] std::vector BuildBrushModels(const clipMap_t* clipMap, const GfxWorld& world) + { + std::vector out; + out.reserve(PositiveCount(world.modelCount) * 48uz); + + for (auto modelIndex = 0uz; modelIndex < PositiveCount(world.modelCount); modelIndex++) + { + const auto& model = world.models[modelIndex]; + const auto startSurfIndex = static_cast(model.startSurfIndex); + const auto surfaceCount = model.surfaceCount; + const auto surfaceCountNoDecal = model.surfaceCountNoDecal; + const auto firstTriSoup = surfaceCount > 0u ? startSurfIndex : std::numeric_limits::max(); + const auto firstTriSoupNoDecal = surfaceCountNoDecal > 0u ? startSurfIndex : std::numeric_limits::max(); + auto firstCollAabbIndex = 0u; + auto collAabbCount = 0u; + auto firstBrush = 0u; + auto brushCount = modelIndex == 0uz && clipMap ? static_cast(clipMap->numBrushes) : 0u; + + if (clipMap && modelIndex > 0uz && modelIndex < clipMap->numSubModels) + { + const auto& cmodel = clipMap->cmodels[modelIndex]; + firstCollAabbIndex = static_cast(cmodel.leaf.firstCollAabbIndex); + collAabbCount = static_cast(cmodel.leaf.collAabbCount); + + // linker_pc derives submodel collision leaves from these raw + // brush ranges. Runtime clipMap only keeps the partitioned + // leafbrush tree, so flatten it back to a contiguous range. + std::vector modelBrushes; + if (cmodel.leaf.leafBrushNode > 0 && CollectLeafBrushes_r(*clipMap, static_cast(cmodel.leaf.leafBrushNode), modelBrushes) + && !modelBrushes.empty()) + { + const auto [minBrush, maxBrush] = std::minmax_element(modelBrushes.begin(), modelBrushes.end()); + firstBrush = *minBrush; + brushCount = static_cast(*maxBrush - *minBrush + 1u); + } + } + + AppendBytes(out, model.bounds[0], sizeof(model.bounds[0])); + AppendBytes(out, model.bounds[1], sizeof(model.bounds[1])); + // Raw v22 stores firstTriSoup[2] and triSoupCount[2]. R_LoadSubmodels + // maps an empty tri-soup range to startSurfIndex 0xffff. + Append(out, firstTriSoupNoDecal); + Append(out, firstTriSoup); + Append(out, surfaceCountNoDecal); + Append(out, surfaceCount); + // The render fields above are 16-bit, but the following collision + // and brush ranges are 32-bit in the raw model record. + Append(out, firstCollAabbIndex); + Append(out, collAabbCount); + Append(out, firstBrush); + Append(out, brushCount); + } + + return out; + } + + [[nodiscard]] std::vector BuildAabbSurfaceRanges(const GfxWorld& world) + { + std::vector out; + + if (world.dpvs.staticSurfaceCount != world.dpvs.staticSurfaceCountNoDecal) + { + const uint32_t startSurfIndex = 0u; + const auto surfaceCount = + world.modelCount > 0 && world.models ? static_cast(world.models[0].surfaceCount) : static_cast(world.surfaceCount); + const uint32_t childCount = 0u; + Append(out, startSurfIndex); + Append(out, surfaceCount); + Append(out, childCount); + + // The loader does not yet reconstruct the original per-cell AABB + // hierarchy. For decal-split maps, emit one root leaf covering + // model0 so Radiant's no-decal compaction sees the full surface set. + // Runtime cell tree counts include static-model tree fixups and are + // not stable between linker_pc-origin and OAT-origin fastfiles, so + // do not preserve their zero-padding footprint in the raw lump. + + return out; + } + + if (world.dpvsPlanes.cellCount <= 0 || !world.cells) + return out; + + for (auto cellIndex = 0; cellIndex < world.dpvsPlanes.cellCount; cellIndex++) + { + const auto& cell = world.cells[cellIndex]; + for (auto treeIndex = 0; treeIndex < cell.aabbTreeCount; treeIndex++) + { + const auto& tree = cell.aabbTree[treeIndex]; + const auto startSurfIndex = static_cast(tree.startSurfIndex); + const auto surfaceCount = static_cast(tree.surfaceCount); + const auto childCount = static_cast(tree.childCount); + + Append(out, startSurfIndex); + Append(out, surfaceCount); + Append(out, childCount); + } + } + + return out; + } + + [[nodiscard]] bool CanReuseAabbSurfaceRangesForLegacyLump(const GfxWorld& world) + { + // The old render path only matches the new AABB surface ranges when there is no decal split. + // Maps with decal-split surfaces need the original legacy ranges, which are not recovered yet. + if (world.dpvs.staticSurfaceCount != world.dpvs.staticSurfaceCountNoDecal) + return false; + + return true; + } + + [[nodiscard]] std::string FormatFloat(const float value) + { + auto result = std::format("{:.9g}", value); + if (result == "-0") + return "0"; + + return result; + } + + [[nodiscard]] float StaticModelScale(const cStaticModel_s& staticModel) + { + const auto invScale = + std::sqrt(staticModel.invScaledAxis[0][0] * staticModel.invScaledAxis[0][0] + staticModel.invScaledAxis[0][1] * staticModel.invScaledAxis[0][1] + + staticModel.invScaledAxis[0][2] * staticModel.invScaledAxis[0][2]); + if (invScale <= std::numeric_limits::epsilon()) + return 1.0f; + + return 1.0f / invScale; + } + + void StaticModelAxis(const cStaticModel_s& staticModel, float (&axis)[3][3]) + { + const auto scale = StaticModelScale(staticModel); + for (auto row = 0uz; row < 3uz; row++) + { + for (auto column = 0uz; column < 3uz; column++) + axis[row][column] = staticModel.invScaledAxis[column][row] * scale; + } + } + + [[nodiscard]] std::array AxisToAngles(const float (&axis)[3][3]) + { + const auto forwardLength = std::sqrt(axis[0][0] * axis[0][0] + axis[0][1] * axis[0][1]); + return {static_cast(std::atan2(-axis[0][2], forwardLength) * 180.0 / PI), + static_cast(std::atan2(axis[0][1], axis[0][0]) * 180.0 / PI), + static_cast(std::atan2(axis[1][2], axis[2][2]) * 180.0 / PI)}; + } + + [[nodiscard]] std::array StaticModelAngles(const cStaticModel_s& staticModel) + { + float axis[3][3]{}; + StaticModelAxis(staticModel, axis); + + return AxisToAngles(axis); + } + + void QuatToAxis(const float (&quat)[4], float (&axis)[3][3]) + { + const auto x = quat[0]; + const auto y = quat[1]; + const auto z = quat[2]; + const auto w = quat[3]; + const auto x2 = x + x; + const auto y2 = y + y; + const auto z2 = z + z; + const auto xx = x * x2; + const auto xy = x * y2; + const auto xz = x * z2; + const auto yy = y * y2; + const auto yz = y * z2; + const auto zz = z * z2; + const auto wx = w * x2; + const auto wy = w * y2; + const auto wz = w * z2; + + // dyn_model entities are loaded as angles -> AnglesToAxis -> MatrixToQuat. + // Rebuild that IW3 axis convention from the runtime quat before writing angles. + axis[0][0] = 1.0f - yy - zz; + axis[0][1] = xy + wz; + axis[0][2] = xz - wy; + axis[1][0] = xy - wz; + axis[1][1] = 1.0f - xx - zz; + axis[1][2] = yz + wx; + axis[2][0] = xz + wy; + axis[2][1] = yz - wx; + axis[2][2] = 1.0f - xx - yy; + } + + [[nodiscard]] std::array DynEntityAngles(const DynEntityDef& dynEnt) + { + float axis[3][3]{}; + QuatToAxis(dynEnt.pose.quat, axis); + return AxisToAngles(axis); + } + + [[nodiscard]] std::string_view DynEntityTypeName(const DynEntityType type) + { + switch (type) + { + case DYNENT_TYPE_CLUTTER: + return "clutter"; + + case DYNENT_TYPE_DESTRUCT: + return "destruct"; + + default: + return {}; + } + } + + [[nodiscard]] bool HasNonZeroVector(const float (&value)[3]) + { + return value[0] != 0.0f || value[1] != 0.0f || value[2] != 0.0f; + } + + void AppendFloat3Field(std::string& out, const std::string_view fieldName, const float (&value)[3]) + { + out += std::format("\"{}\" \"{} {} {}\"\n", fieldName, FormatFloat(value[0]), FormatFloat(value[1]), FormatFloat(value[2])); + } + + [[nodiscard]] bool AlmostEqual(const float a, const float b) + { + return std::abs(a - b) <= 0.001f; + } + + [[nodiscard]] bool StaticModelOriginMatches(const cStaticModel_s& staticModel, const GfxStaticModelDrawInst& drawInst) + { + return AlmostEqual(staticModel.origin[0], drawInst.placement.origin[0]) && AlmostEqual(staticModel.origin[1], drawInst.placement.origin[1]) + && AlmostEqual(staticModel.origin[2], drawInst.placement.origin[2]); + } + + [[nodiscard]] bool StaticModelNameMatches(const cStaticModel_s& staticModel, const GfxStaticModelDrawInst& drawInst) + { + const auto* staticModelName = NameOf(staticModel.xmodel); + const auto* drawModelName = NameOf(drawInst.model); + return staticModelName && drawModelName && std::strcmp(staticModelName, drawModelName) == 0; + } + + [[nodiscard]] size_t FindStaticModelDrawIndex(const GfxWorld* world, const cStaticModel_s& staticModel, const std::vector& usedDrawInsts) + { + if (!world || !world->dpvs.smodelDrawInsts) + return INVALID_STATIC_MODEL_INDEX; + + for (auto drawIndex = 0uz; drawIndex < PositiveCount(world->dpvs.smodelCount); drawIndex++) + { + if (drawIndex < usedDrawInsts.size() && usedDrawInsts[drawIndex]) + continue; + + const auto& drawInst = world->dpvs.smodelDrawInsts[drawIndex]; + if (StaticModelNameMatches(staticModel, drawInst) && StaticModelOriginMatches(staticModel, drawInst)) + return drawIndex; + } + + return INVALID_STATIC_MODEL_INDEX; + } + + [[nodiscard]] std::string StaticModelGroundLighting(const GfxStaticModelInst& inst, const GfxStaticModelDrawInst& drawInst) + { + // The linker parses gndLt with "%02x%02x%02x%02x%02x" and stores the + // first four fields into groundLighting as B, G, R, alpha. For v22 the + // fifth field is stored directly as smodelDrawInst->primaryLightIndex. + const auto& color = inst.groundLighting.array; + return std::format("{:02x}{:02x}{:02x}{:02x}{:02x}", + static_cast(Byte(color[2])), + static_cast(Byte(color[1])), + static_cast(Byte(color[0])), + static_cast(Byte(color[3])), + static_cast(Byte(drawInst.primaryLightIndex))); + } + + void AppendStaticModelGroundLighting(std::string& out, const GfxWorld* world, const size_t staticModelIndex) + { + if (!world || staticModelIndex == INVALID_STATIC_MODEL_INDEX || !world->dpvs.smodelInsts || !world->dpvs.smodelDrawInsts + || staticModelIndex >= PositiveCount(world->dpvs.smodelCount)) + return; + + const auto& inst = world->dpvs.smodelInsts[staticModelIndex]; + const auto& drawInst = world->dpvs.smodelDrawInsts[staticModelIndex]; + // The stock caller only keeps parsed gndLt when the packed ground + // lighting color is non-zero; otherwise it recomputes model lighting. + if (inst.groundLighting.packed == 0u) + return; + + out += std::format("\"gndLt\" \"{}\"\n", StaticModelGroundLighting(inst, drawInst)); + } + + void AppendStaticModelSpawnFlags(std::string& out, const GfxWorld* world, const size_t staticModelIndex) + { + if (!world || staticModelIndex == INVALID_STATIC_MODEL_INDEX || !world->dpvs.smodelDrawInsts + || staticModelIndex >= PositiveCount(world->dpvs.smodelCount)) + return; + + // The linker only reads misc_model spawnflags bit 2 for this field: + // smodelDrawInst->flags = (spawnflags & 2) != 0. + if (Byte(world->dpvs.smodelDrawInsts[staticModelIndex].flags) != 0u) + out += "\"spawnflags\" \"2\"\n"; + } + + void AppendStaticModelEntity(std::string& out, const cStaticModel_s& staticModel, const GfxWorld* world, const size_t staticModelIndex) + { + const auto* modelName = NameOf(staticModel.xmodel); + if (!modelName || !*modelName) + return; + + const auto angles = StaticModelAngles(staticModel); + out += "{\n"; + out += std::format("\"modelscale\" \"{}\"\n", FormatFloat(StaticModelScale(staticModel))); + out += std::format( + "\"origin\" \"{} {} {}\"\n", FormatFloat(staticModel.origin[0]), FormatFloat(staticModel.origin[1]), FormatFloat(staticModel.origin[2])); + out += std::format("\"angles\" \"{} {} {}\"\n", FormatFloat(angles[0]), FormatFloat(angles[1]), FormatFloat(angles[2])); + AppendStaticModelSpawnFlags(out, world, staticModelIndex); + AppendStaticModelGroundLighting(out, world, staticModelIndex); + out += std::format("\"model\" \"{}\"\n", modelName); + out += "\"classname\" \"misc_model\"\n"; + out += "}\n"; + } + + void AppendStaticModelEntities(std::string& out, const clipMap_t* clipMap, const GfxWorld* world) + { + if (!clipMap || !clipMap->staticModelList) + return; + + if (!out.empty() && out.back() != '\n') + out += '\n'; + + std::vector usedDrawInsts(world ? PositiveCount(world->dpvs.smodelCount) : 0uz); + + // Static models originate as misc_model entity text in raw d3dbsp, but + // linking moves placement/collision data into clipMap.staticModelList + // and lighting/render data into GfxWorld DPVS arrays. The order can + // differ, so match the DPVS entry by model name and origin. + for (auto staticModelIndex = 0uz; staticModelIndex < clipMap->numStaticModels; staticModelIndex++) + { + const auto drawIndex = FindStaticModelDrawIndex(world, clipMap->staticModelList[staticModelIndex], usedDrawInsts); + if (drawIndex != INVALID_STATIC_MODEL_INDEX && drawIndex < usedDrawInsts.size()) + usedDrawInsts[drawIndex] = true; + + AppendStaticModelEntity(out, clipMap->staticModelList[staticModelIndex], world, drawIndex); + } + } + + void AppendDynEntityMassFields(std::string& out, const DynEntityDef& dynEnt) + { + if (HasNonZeroVector(dynEnt.mass.centerOfMass)) + AppendFloat3Field(out, "centerofmass", dynEnt.mass.centerOfMass); + + if (HasNonZeroVector(dynEnt.mass.momentsOfInertia)) + AppendFloat3Field(out, "momofinertia", dynEnt.mass.momentsOfInertia); + + if (HasNonZeroVector(dynEnt.mass.productsOfInertia)) + AppendFloat3Field(out, "prodofinertia", dynEnt.mass.productsOfInertia); + } + + void AppendDynModelPhysPreset(std::string& out, const DynEntityDef& dynEnt) + { + if (!dynEnt.physPreset || (dynEnt.xModel && dynEnt.xModel->physPreset == dynEnt.physPreset)) + return; + + const auto* physPresetName = NameOf(dynEnt.physPreset); + if (physPresetName && *physPresetName) + out += std::format("\"physPreset\" \"{}\"\n", physPresetName); + } + + void AppendDynModelEntity(std::string& out, const DynEntityDef& dynEnt) + { + const auto* modelName = NameOf(dynEnt.xModel); + const auto typeName = DynEntityTypeName(dynEnt.type); + if (!modelName || !*modelName || typeName.empty()) + return; + + const auto angles = DynEntityAngles(dynEnt); + out += "{\n"; + AppendDynModelPhysPreset(out, dynEnt); + AppendFloat3Field(out, "origin", dynEnt.pose.origin); + out += std::format("\"type\" \"{}\"\n", typeName); + out += std::format("\"angles\" \"{} {} {}\"\n", FormatFloat(angles[0]), FormatFloat(angles[1]), FormatFloat(angles[2])); + if (dynEnt.health != 0) + out += std::format("\"health\" \"{}\"\n", dynEnt.health); + AppendDynEntityMassFields(out, dynEnt); + out += std::format("\"model\" \"{}\"\n", modelName); + out += "\"classname\" \"dyn_model\"\n"; + out += "}\n"; + } + + void AppendDynModelEntities(std::string& out, const clipMap_t* clipMap) + { + if (!clipMap || !clipMap->dynEntDefList[0]) + return; + + if (!out.empty() && out.back() != '\n') + out += '\n'; + + // dyn_model source entities are consumed into clipMap dynEnt arrays by + // linker_pc. Reconstruct the source-side entity blocks from the runtime + // model dynents so dumped BSPs remain useful in Radiant and relinking. + for (auto dynEntIndex = 0uz; dynEntIndex < clipMap->dynEntCount[0]; dynEntIndex++) + AppendDynModelEntity(out, clipMap->dynEntDefList[0][dynEntIndex]); + } + + [[nodiscard]] std::vector BuildEntities(const MapEnts& mapEnts, const clipMap_t* clipMap, const GfxWorld* world) + { + auto entityCharCount = PositiveCount(mapEnts.numEntityChars); + if (entityCharCount > 0uz && mapEnts.entityString && mapEnts.entityString[entityCharCount - 1uz] == '\0') + entityCharCount--; + + std::string entities; + if (mapEnts.entityString && entityCharCount > 0uz) + entities.assign(mapEnts.entityString, entityCharCount); + + AppendStaticModelEntities(entities, clipMap, world); + AppendDynModelEntities(entities, clipMap); + if (!entities.empty() && entities.back() != '\n') + entities += '\n'; + + std::vector out; + AppendBytes(out, entities.data(), entities.size()); + out.emplace_back(std::byte{0}); + return out; + } + + void AppendLiteralRle(std::vector& out, const char* data, const size_t size) + { + auto offset = 0uz; + while (offset < size) + { + const auto chunkSize = std::min(127uz, size - offset); + const auto marker = static_cast(~chunkSize); + Append(out, marker); + AppendBytes(out, data + offset, chunkSize); + offset += chunkSize; + } + } + + [[nodiscard]] std::vector BuildGameWorldSpPath(const GameWorldSp& gameWorld) + { + std::vector out; + const uint32_t version = 8u; + const auto nodeCount = static_cast(std::min(gameWorld.path.nodeCount, static_cast(std::numeric_limits::max()))); + + Append(out, version); + Append(out, nodeCount); + + for (auto nodeIndex = 0uz; nodeIndex < nodeCount; nodeIndex++) + { + const auto& node = gameWorld.path.nodes[nodeIndex]; + const auto linkCount = node.constant.totalLinkCount; + Append(out, linkCount); + + for (auto linkIndex = 0uz; linkIndex < linkCount; linkIndex++) + { + const auto& link = node.constant.Links[linkIndex]; + Append(out, link.nodeNum); + Append(out, link.fDist); + } + } + + AppendLiteralRle(out, gameWorld.path.pathVis, PositiveCount(gameWorld.path.visBytes)); + return out; + } + + void AppendPrimaryLight(std::vector& out, const ComPrimaryLight& light) + { + constexpr auto TYPE_OFFSET = 0uz; + constexpr auto CAN_USE_SHADOW_MAP_OFFSET = 1uz; + constexpr auto UNUSED_OFFSET = 2uz; + constexpr auto COLOR_OFFSET = 4uz; + constexpr auto DIR_OFFSET = 16uz; + constexpr auto ORIGIN_OFFSET = 28uz; + constexpr auto RADIUS_OFFSET = 40uz; + constexpr auto COS_HALF_FOV_OUTER_OFFSET = 44uz; + constexpr auto COS_HALF_FOV_INNER_OFFSET = 48uz; + constexpr auto EXPONENT_OFFSET = 52uz; + constexpr auto ROTATION_LIMIT_OFFSET = 56uz; + constexpr auto TRANSLATION_LIMIT_OFFSET = 60uz; + constexpr auto DEF_NAME_OFFSET = 64uz; + constexpr auto DEF_NAME_SIZE = 64uz; + + const auto baseOffset = out.size(); + out.resize(out.size() + IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE, std::byte{}); + + out[baseOffset + TYPE_OFFSET] = static_cast(light.type); + out[baseOffset + CAN_USE_SHADOW_MAP_OFFSET] = static_cast(light.canUseShadowMap); + out[baseOffset + UNUSED_OFFSET] = static_cast(light.unused); + out[baseOffset + UNUSED_OFFSET + 1uz] = std::byte{}; + + // The v22 BSP stores DiskPrimaryLight, not the runtime ComPrimaryLight + // layout. cosHalfFovExpanded is derived by the linker from outer FOV and + // rotationLimit, while exponent is stored as a 32-bit disk field. + WriteBytesAt(out, baseOffset + COLOR_OFFSET, light.color, sizeof(light.color)); + WriteBytesAt(out, baseOffset + DIR_OFFSET, light.dir, sizeof(light.dir)); + WriteBytesAt(out, baseOffset + ORIGIN_OFFSET, light.origin, sizeof(light.origin)); + WriteAt(out, baseOffset + RADIUS_OFFSET, light.radius); + WriteAt(out, baseOffset + COS_HALF_FOV_OUTER_OFFSET, light.cosHalfFovOuter); + WriteAt(out, baseOffset + COS_HALF_FOV_INNER_OFFSET, light.cosHalfFovInner); + const auto exponent = static_cast(static_cast(light.exponent)); + WriteAt(out, baseOffset + EXPONENT_OFFSET, exponent); + WriteAt(out, baseOffset + ROTATION_LIMIT_OFFSET, light.rotationLimit); + WriteAt(out, baseOffset + TRANSLATION_LIMIT_OFFSET, light.translationLimit); + if (light.defName) + { + const auto defNameLength = std::min(std::strlen(light.defName), DEF_NAME_SIZE - 1uz); + WriteBytesAt(out, baseOffset + DEF_NAME_OFFSET, light.defName, defNameLength); + } + } + + [[nodiscard]] std::vector BuildPrimaryLights(const ComWorld& comWorld) + { + if (comWorld.primaryLightCount <= 0u || !comWorld.primaryLights) + return {}; + + std::vector out; + out.reserve(static_cast(comWorld.primaryLightCount) * IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE); + for (auto lightIndex = 0uz; lightIndex < comWorld.primaryLightCount; lightIndex++) + AppendPrimaryLight(out, comWorld.primaryLights[lightIndex]); + return out; + } + + [[nodiscard]] std::vector BuildLightRegionCounts(const GfxWorld& world, const unsigned primaryLightCount) + { + std::vector out; + out.reserve(primaryLightCount); + + for (auto lightIndex = 0uz; lightIndex < primaryLightCount; lightIndex++) + { + const auto hullCount = + world.lightRegion && lightIndex < world.primaryLightCount && world.lightRegion[lightIndex].hulls ? world.lightRegion[lightIndex].hullCount : 0u; + out.emplace_back(static_cast(std::min(hullCount, 0xFFu))); + } + + return out; + } + + [[nodiscard]] std::vector BuildLightRegionHulls(const GfxWorld& world, const unsigned primaryLightCount) + { + std::vector out; + if (!world.lightRegion) + return out; + + for (auto lightIndex = 0uz; lightIndex < primaryLightCount && lightIndex < world.primaryLightCount; lightIndex++) + { + const auto& region = world.lightRegion[lightIndex]; + const auto hullCount = region.hulls ? std::min(region.hullCount, 0xFFu) : 0u; + for (auto hullIndex = 0uz; hullIndex < hullCount; hullIndex++) + { + const auto& hull = region.hulls[hullIndex]; + const auto axisCount = hull.axis ? hull.axisCount : 0u; + AppendBytes(out, hull.kdopMidPoint, sizeof(hull.kdopMidPoint)); + AppendBytes(out, hull.kdopHalfSize, sizeof(hull.kdopHalfSize)); + Append(out, axisCount); + } + } + + return out; + } + + [[nodiscard]] std::vector BuildLightRegionAxes(const GfxWorld& world, const unsigned primaryLightCount) + { + std::vector out; + if (!world.lightRegion) + return out; + + for (auto lightIndex = 0uz; lightIndex < primaryLightCount && lightIndex < world.primaryLightCount; lightIndex++) + { + const auto& region = world.lightRegion[lightIndex]; + const auto hullCount = region.hulls ? std::min(region.hullCount, 0xFFu) : 0u; + for (auto hullIndex = 0uz; hullIndex < hullCount; hullIndex++) + { + const auto& hull = region.hulls[hullIndex]; + if (hull.axis) + AppendBytes(out, hull.axis, static_cast(hull.axisCount) * sizeof(GfxLightRegionAxis)); + } + } + + return out; + } + + void AddLump(std::vector& lumps, const IW3::d3dbsp::LumpType id, std::vector&& data) + { + if (!data.empty()) + lumps.emplace_back(id, std::move(data)); + } + + [[nodiscard]] size_t LumpOrderIndex(const IW3::d3dbsp::LumpType id) + { + for (auto i = 0uz; i < IW3::d3dbsp::LUMP_WRITE_ORDER.size(); i++) + { + if (IW3::d3dbsp::LUMP_WRITE_ORDER[i] == id) + return i; + } + + return IW3::d3dbsp::LUMP_WRITE_ORDER.size(); + } + + void SortLumps(std::vector& lumps) + { + std::stable_sort(lumps.begin(), + lumps.end(), + [](const BspLump& left, const BspLump& right) + { + return LumpOrderIndex(left.id) < LumpOrderIndex(right.id); + }); + } + + void WriteBsp(std::ostream& stream, std::vector lumps) + { + SortLumps(lumps); + + const auto lumpCount = static_cast(lumps.size()); + stream::Write(stream, IW3::d3dbsp::BSP_MAGIC.data(), IW3::d3dbsp::BSP_MAGIC.size()); + stream::WriteValue(stream, IW3::d3dbsp::BSP_VERSION); + stream::WriteValue(stream, lumpCount); + + for (const auto& lump : lumps) + { + const auto id = std::to_underlying(lump.id); + const auto size = static_cast(lump.data.size()); + stream::WriteValue(stream, id); + stream::WriteValue(stream, size); + } + + const std::byte padding[3]{}; + for (const auto& lump : lumps) + { + stream::Write(stream, lump.data.data(), lump.data.size()); + + const auto paddingSize = (4uz - lump.data.size() % 4uz) % 4uz; + if (paddingSize > 0) + stream::Write(stream, padding, paddingSize); + } + } +} // namespace + +namespace map_d3dbsp +{ + void DumperIW3::DumpAsset(AssetDumpingContext& context, const XAssetInfo& asset) + { + const auto* world = asset.Asset(); + const auto* clipMapInfo = context.m_zone.m_pools.GetAsset(asset.m_name); + const auto* comWorldInfo = context.m_zone.m_pools.GetAsset(asset.m_name); + const auto* mapEntsInfo = context.m_zone.m_pools.GetAsset(asset.m_name); + const auto* gameWorldSpInfo = context.m_zone.m_pools.GetAsset(asset.m_name); + const auto* clipMap = clipMapInfo ? clipMapInfo->Asset() : nullptr; + const auto* comWorld = comWorldInfo ? comWorldInfo->Asset() : nullptr; + const auto* mapEnts = mapEntsInfo ? mapEntsInfo->Asset() : nullptr; + const auto* gameWorldSp = gameWorldSpInfo ? gameWorldSpInfo->Asset() : nullptr; + + // A raw d3dbsp is reconstructed from several loaded map assets. GfxWorld + // alone does not contain enough data. + assert(world); + assert(clipMap); + assert(comWorld); + assert(mapEnts); + if (!world || !clipMap || !comWorld || !mapEnts) + return; + + // Lump 43 is ComWorld primary-light data. Light-region lumps are sized + // by GfxWorld::primaryLightCount, which can be smaller than the ComWorld + // record count in stock BSPs. + const auto worldPrimaryLightCount = world->primaryLightCount; + const auto lightmapPageLayout = BuildLightmapPageLayout(*world); + const auto surfaceLightmapRemaps = BuildSurfaceLightmapRemaps(*world, lightmapPageLayout); + const auto vertexLightmapRemaps = BuildVertexLightmapRemaps(*world, surfaceLightmapRemaps); + + std::vector lumps; + + AddLump(lumps, LUMP_MATERIALS, BuildMaterials(*clipMap)); + AddLump(lumps, LUMP_PLANES, BuildPlanes(*clipMap)); + AddLump(lumps, LUMP_BRUSHSIDES, BuildBrushSideData(*clipMap)); + AddLump(lumps, LUMP_BRUSHSIDE_EDGE_COUNTS, BuildBrushEdgeCounts(*clipMap)); + AddLump(lumps, LUMP_BRUSHEDGES, BuildBrushEdges(*clipMap)); + AddLump(lumps, LUMP_BRUSHES, BuildBrushHeaders(*clipMap)); + AddLump(lumps, LUMP_NODES, BuildClipNodes(*clipMap, *world)); + AddLump(lumps, LUMP_LEAFS, BuildClipLeafs(*clipMap)); + AddLump(lumps, LUMP_LEAFBRUSHES, BuildLeafBrushes(*clipMap)); + AddLump(lumps, LUMP_COLLISIONVERTS, BuildCollisionVerts(*clipMap)); + AddLump(lumps, LUMP_COLLISIONTRIS, BuildCollisionTriIndices(*clipMap)); + AddLump(lumps, LUMP_COLLISION_EDGE_WALKABLE, BuildCollisionTriEdgeIsWalkable(*clipMap)); + AddLump(lumps, LUMP_COLLISIONBORDERS, BuildCollisionBorders(*clipMap)); + AddLump(lumps, LUMP_COLLISIONPARTITIONS, BuildCollisionPartitions(*clipMap)); + AddLump(lumps, LUMP_COLLISIONAABBS, BuildCollisionAabbTrees(*clipMap)); + + AddLump(lumps, LUMP_LIGHTGRID_HEADER, BuildLightGridHeader(*world)); + AddLump(lumps, LUMP_LIGHTGRID_ROWS, BuildLightGridRawRows(*world)); + AddLump(lumps, LUMP_LIGHTMAPS, BuildLightmapImages(*world, lightmapPageLayout)); + AddLump(lumps, LUMP_LIGHTGRID_ENTRIES, BuildLightGridEntries(*world)); + AddLump(lumps, LUMP_LIGHTGRID_COLORS, BuildLightGridColors(*world)); + AddLump(lumps, LUMP_LAYERED_TRI_SOUPS, BuildSurfaces(clipMap, *world, surfaceLightmapRemaps)); + AddLump(lumps, LUMP_LAYERED_VERTS, BuildVertices(*world, vertexLightmapRemaps)); + AddLump(lumps, LUMP_LAYERED_INDICES, BuildIndices(*world)); + if (CanReuseAabbSurfaceRangesForLegacyLump(*world)) + AddLump(lumps, LUMP_LAYERED_AABBTREES, BuildAabbSurfaceRanges(*world)); + AddLump(lumps, LUMP_CELLS, BuildCellHeader(*world)); + AddLump(lumps, LUMP_MODELS, BuildBrushModels(clipMap, *world)); + AddLump(lumps, LUMP_SIMPLE_TRI_SOUPS, BuildSurfaces(clipMap, *world, surfaceLightmapRemaps)); + AddLump(lumps, LUMP_SIMPLE_VERTS, BuildVertices(*world, vertexLightmapRemaps)); + AddLump(lumps, LUMP_SIMPLE_INDICES, BuildIndices(*world)); + AddLump(lumps, LUMP_SIMPLE_AABBTREES, BuildAabbSurfaceRanges(*world)); + AddLump(lumps, LUMP_REFLECTION_PROBES, BuildReflectionProbeRecords(*world)); + + AddLump(lumps, LUMP_ENTITIES, BuildEntities(*mapEnts, clipMap, world)); + AddLump(lumps, LUMP_PRIMARY_LIGHTS, BuildPrimaryLights(*comWorld)); + + AddLump(lumps, LUMP_LIGHT_REGION_COUNTS, BuildLightRegionCounts(*world, worldPrimaryLightCount)); + AddLump(lumps, LUMP_LIGHT_REGION_HULLS, BuildLightRegionHulls(*world, worldPrimaryLightCount)); + AddLump(lumps, LUMP_LIGHT_REGION_AXES, BuildLightRegionAxes(*world, worldPrimaryLightCount)); + + if (gameWorldSp) + AddLump(lumps, LUMP_PATHCONNECTIONS, BuildGameWorldSpPath(*gameWorldSp)); + + const auto assetFile = context.OpenAssetFile(asset.m_name); + if (assetFile) + WriteBsp(*assetFile, std::move(lumps)); + } +} // namespace map_d3dbsp diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.h b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.h new file mode 100644 index 000000000..d11fa1332 --- /dev/null +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.h @@ -0,0 +1,13 @@ +#pragma once + +#include "Dumping/AbstractAssetDumper.h" +#include "Game/IW3/IW3.h" + +namespace map_d3dbsp +{ + class DumperIW3 final : public AbstractAssetDumper + { + protected: + void DumpAsset(AssetDumpingContext& context, const XAssetInfo& asset) override; + }; +} // namespace map_d3dbsp diff --git a/src/ObjWriting/Game/IW3/ObjWriterIW3.cpp b/src/ObjWriting/Game/IW3/ObjWriterIW3.cpp index 2c7af7a98..cfa7f421d 100644 --- a/src/ObjWriting/Game/IW3/ObjWriterIW3.cpp +++ b/src/ObjWriting/Game/IW3/ObjWriterIW3.cpp @@ -9,6 +9,7 @@ #include "Game/IW3/XModel/XModelDumperIW3.h" #include "LightDef/LightDefDumperIW3.h" #include "Localize/LocalizeDumperIW3.h" +#include "Maps/D3DBspDumperIW3.h" #include "PhysPreset/PhysPresetInfoStringDumperIW3.h" #include "RawFile/RawFileDumperIW3.h" #include "Sound/LoadedSoundDumperIW3.h" @@ -40,7 +41,7 @@ void ObjWriter::RegisterAssetDumpers(AssetDumpingContext& context) // REGISTER_DUMPER(AssetDumperGameWorldSp) // REGISTER_DUMPER(AssetDumperGameWorldMp) RegisterAssetDumper(std::make_unique()); - // REGISTER_DUMPER(AssetDumperGfxWorld) + RegisterAssetDumper(std::make_unique()); RegisterAssetDumper(std::make_unique()); RegisterAssetDumper(std::make_unique()); // REGISTER_DUMPER(AssetDumperMenuList)