From a202151c39e4c23f0df58da9a7116fcb9f66ce43 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Mon, 8 Jun 2026 14:06:57 +0100 Subject: [PATCH 01/35] feat: IW3 `.d3dbsp` dumper --- .../Game/IW3/Maps/D3DBspDumperIW3.cpp | 1899 +++++++++++++++++ .../Game/IW3/Maps/D3DBspDumperIW3.h | 13 + src/ObjWriting/Game/IW3/ObjWriterIW3.cpp | 3 +- 3 files changed, 1914 insertions(+), 1 deletion(-) create mode 100644 src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp create mode 100644 src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.h diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp new file mode 100644 index 000000000..2b790f552 --- /dev/null +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -0,0 +1,1899 @@ +#include "D3DBspDumperIW3.h" + +#include "Game/IW3/CommonIW3.h" +#include "Utils/StreamUtils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace IW3; + +namespace +{ + constexpr auto BSP_MAGIC = std::array{'I', 'B', 'S', 'P'}; + constexpr auto BSP_VERSION = 22u; + + // 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 BspLumpType : unsigned + { + 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, + LUMP_PATHCONNECTIONS = 40, // SP path data; absent for many 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, + }; + + // IW3 v22 d3dbsp files use this order in the stock tools output. + constexpr auto LUMP_ORDER = std::array{ + LUMP_MATERIALS, + LUMP_LIGHTMAPS, + LUMP_LIGHTGRID_HEADER, + LUMP_LIGHTGRID_ROWS, + LUMP_LIGHTGRID_ENTRIES, + LUMP_LIGHTGRID_COLORS, + LUMP_PLANES, + LUMP_BRUSHSIDES, + LUMP_BRUSHSIDE_EDGE_COUNTS, + LUMP_BRUSHEDGES, + LUMP_BRUSHES, + LUMP_LAYERED_TRI_SOUPS, + LUMP_LAYERED_VERTS, + LUMP_VERTEX_LAYER_DATA, + LUMP_LAYERED_INDICES, + LUMP_CULLGROUPS, + LUMP_CULLGROUP_INDICES, + LUMP_PORTALVERTS, + LUMP_LAYERED_AABBTREES, + LUMP_CELLS, + LUMP_PORTALS, + LUMP_NODES, + LUMP_LEAFS, + LUMP_LEAFBRUSHES, + LUMP_LEAFSURFACES, + LUMP_COLLISIONVERTS, + LUMP_COLLISIONTRIS, + LUMP_COLLISION_EDGE_WALKABLE, + LUMP_COLLISIONBORDERS, + LUMP_COLLISIONPARTITIONS, + LUMP_COLLISIONAABBS, + LUMP_MODELS, + LUMP_VISIBILITY, + LUMP_ENTITIES, + LUMP_PRIMARY_LIGHTS, + LUMP_LIGHT_REGION_COUNTS, + LUMP_LIGHT_REGION_HULLS, + LUMP_LIGHT_REGION_AXES, + LUMP_SIMPLE_TRI_SOUPS, + LUMP_SIMPLE_VERTS, + LUMP_SIMPLE_INDICES, + LUMP_SIMPLE_CULLGROUPS, + LUMP_SIMPLE_AABBTREES, + LUMP_PATHCONNECTIONS, + LUMP_REFLECTION_PROBES, + }; + + struct BspLump + { + BspLumpType 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); + + 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"); + } + + [[nodiscard]] std::string GetPartialBspFileName(const std::string& assetName) + { + constexpr auto EXTENSION = std::string_view(".d3dbsp"); + + if (assetName.ends_with(EXTENSION)) + return std::format("{}.partial{}", assetName.substr(0, assetName.size() - EXTENSION.size()), EXTENSION); + + return assetName + ".partial.d3dbsp"; + } + + 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]] 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; + } + + template [[nodiscard]] size_t PointerIndex(const T* base, const size_t count, const T* value) + { + if (!base || !value || count == 0) + return 0uz; + + 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 0uz; + + const auto offset = valueAddress - baseAddress; + if (offset % sizeof(T) != 0) + return 0uz; + + return offset / sizeof(T); + } + + [[nodiscard]] std::vector BuildMaterials(const clipMap_t& clipMap) + { + std::vector out; + out.reserve(static_cast(clipMap.numMaterials) * 72uz); + + for (auto i = 0uz; i < clipMap.numMaterials; i++) + { + const auto& material = clipMap.materials[i]; + AppendBytes(out, material.material, sizeof(material.material)); + Append(out, material.surfaceFlags); + Append(out, material.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) + { + std::vector out; + AppendBytes(out, clipMap.brushEdges, static_cast(clipMap.numBrushEdges) * sizeof(cbrushedge_t)); + return out; + } + + [[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 nextCluster = 0; + 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; + const auto leafBrushCount = leafBrushNode && leafBrushNode->leafBrushCount > 0 ? static_cast(leafBrushNode->leafBrushCount) : 0; + const auto firstLeafBrush = leafBrushCount > 0 + ? static_cast(PointerIndex(clipMap.leafbrushes, clipMap.numLeafBrushes, leafBrushNode->data.leaf.brushes)) + : runningFirstLeafBrush; + + // Leaf 0 is a dummy leaf. Real empty-space clusters start at the next non-solid leaf. + const auto cluster = leafBrushCount > 0 ? -1 : leafIndex == 0uz ? 0 : nextCluster++; + 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) + { + std::vector out; + AppendBytes(out, clipMap.verts, static_cast(clipMap.vertCount) * sizeof(vec3_t)); + return out; + } + + [[nodiscard]] std::vector BuildCollisionTriIndices(const clipMap_t& clipMap) + { + std::vector out; + AppendBytes(out, clipMap.triIndices, PositiveCount(clipMap.triCount) * 3uz * sizeof(uint16_t)); + return out; + } + + [[nodiscard]] std::vector BuildCollisionTriEdgeIsWalkable(const clipMap_t& clipMap) + { + std::vector out; + const auto size = ((PositiveCount(clipMap.triCount) * 3uz + 31uz) / 32uz) * sizeof(uint32_t); + AppendBytes(out, clipMap.triEdgeIsWalkable, size); + return out; + } + + [[nodiscard]] std::vector BuildCollisionBorders(const clipMap_t& clipMap) + { + std::vector out; + AppendBytes(out, clipMap.borders, PositiveCount(clipMap.borderCount) * sizeof(CollisionBorder)); + return out; + } + + [[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) + { + std::vector out; + AppendBytes(out, clipMap.aabbTrees, PositiveCount(clipMap.aabbTreeCount) * sizeof(CollisionAabbTree)); + return out; + } + + [[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) + { + std::vector out; + AppendBytes(out, world.lightGrid.entries, static_cast(world.lightGrid.entryCount) * sizeof(GfxLightGridEntry)); + return out; + } + + [[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) + { + std::vector out; + AppendBytes(out, world.lightGrid.rawRowData, world.lightGrid.rawRowDataSize); + return out; + } + + [[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 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 DiskOrderedSurfaces(const clipMap_t* clipMap, const GfxWorld& world) + { + std::vector surfaces; + surfaces.reserve(PositiveCount(world.surfaceCount)); + + for (auto i = 0uz; i < PositiveCount(world.surfaceCount); i++) + surfaces.emplace_back(&world.dpvs.surfaces[i]); + + std::stable_sort(surfaces.begin(), + surfaces.end(), + [clipMap](const GfxSurface* left, const GfxSurface* right) + { + const auto leftMaterial = SurfaceMaterialIndex(clipMap, *left); + const auto rightMaterial = SurfaceMaterialIndex(clipMap, *right); + if (leftMaterial != rightMaterial) + return leftMaterial < rightMaterial; + + const auto leftPrimary = RawPrimaryLightIndex(*left); + const auto rightPrimary = RawPrimaryLightIndex(*right); + if (leftPrimary != rightPrimary) + return leftPrimary < rightPrimary; + + return left->tris.baseIndex < right->tris.baseIndex; + }); + + return surfaces; + } + + [[nodiscard]] std::vector BuildSurfaces(const clipMap_t* clipMap, const GfxWorld& world, const std::vector& lightmapRemaps) + { + std::vector out; + out.reserve(PositiveCount(world.surfaceCount) * 24uz); + + for (const auto* surface : DiskOrderedSurfaces(clipMap, world)) + { + const auto surfaceIndex = PointerIndex(world.dpvs.surfaces, PositiveCount(world.surfaceCount), surface); + 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) + { + std::vector out; + AppendBytes(out, world.indices, PositiveCount(world.indexCount) * sizeof(uint16_t)); + return out; + } + + [[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) + { + std::vector out(112uz); + + if (world.dpvsPlanes.cellCount <= 0 || !world.cells) + return out; + + const auto& cell = world.cells[0]; + auto offset = 0uz; + std::copy_n(reinterpret_cast(cell.mins), sizeof(cell.mins), out.data() + offset); + offset += sizeof(cell.mins); + std::copy_n(reinterpret_cast(cell.maxs), sizeof(cell.maxs), out.data() + offset); + + // The fixed-size cell header stores the cell's reflection probe list at byte 44. + constexpr auto REFLECTION_PROBE_LIST_OFFSET = 44uz; + out[REFLECTION_PROBE_LIST_OFFSET] = static_cast(cell.reflectionProbeCount); + for (auto i = 0uz; i < static_cast(cell.reflectionProbeCount) && i < 67uz; i++) + out[REFLECTION_PROBE_LIST_OFFSET + 1uz + i] = static_cast(cell.reflectionProbes[i]); + + 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 uint16_t zeroShort = 0u; + const uint32_t zero = 0u; + const auto brushCount = modelIndex == 0uz && clipMap ? static_cast(clipMap->numBrushes) : 0u; + + AppendBytes(out, model.bounds[0], sizeof(model.bounds[0])); + AppendBytes(out, model.bounds[1], sizeof(model.bounds[1])); + // v22 reads start/count from the second pair; older paths read the first pair. + // The no-decal count is recomputed by the raw loader from surface/AABB data. + Append(out, startSurfIndex); + Append(out, startSurfIndex); + Append(out, surfaceCount); + Append(out, surfaceCount); + Append(out, zeroShort); + Append(out, zeroShort); + Append(out, zero); + Append(out, zero); + Append(out, brushCount); + } + + return out; + } + + [[nodiscard]] std::vector BuildAabbSurfaceRanges(const GfxWorld& world) + { + std::vector out; + + if (world.dpvs.staticSurfaceCount != world.dpvs.staticSurfaceCountNoDecal) + { + auto totalAabbTreeCount = 0uz; + if (world.dpvsPlanes.cellCount > 0 && world.cells) + { + for (auto cellIndex = 0; cellIndex < world.dpvsPlanes.cellCount; cellIndex++) + totalAabbTreeCount += static_cast(std::max(world.cells[cellIndex].aabbTreeCount, 0)); + } + + 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); + + // Keep the original AABB lump footprint where possible; some Radiant paths are sensitive to later lump positions. + for (auto treeIndex = 1uz; treeIndex < totalAabbTreeCount; treeIndex++) + { + Append(out, 0u); + Append(out, 0u); + Append(out, 0u); + } + + 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 StaticModelAngles(const cStaticModel_s& staticModel) + { + float axis[3][3]{}; + StaticModelAxis(staticModel, axis); + + 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]] 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); + } + } + + [[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); + 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(UINT16_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 RAW_PRIMARY_LIGHT_SIZE = 0x80uz; + constexpr auto TYPE_OFFSET = 0uz; + constexpr auto CAN_USE_SHADOW_MAP_OFFSET = 1uz; + 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 DEF_NAME_OFFSET = 56uz; + constexpr auto DEF_NAME_SIZE = RAW_PRIMARY_LIGHT_SIZE - DEF_NAME_OFFSET; + + const auto baseOffset = out.size(); + out.resize(out.size() + 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); + + const auto exponent = static_cast(static_cast(light.exponent)); + std::copy_n(reinterpret_cast(light.color), sizeof(light.color), out.data() + baseOffset + COLOR_OFFSET); + std::copy_n(reinterpret_cast(light.dir), sizeof(light.dir), out.data() + baseOffset + DIR_OFFSET); + std::copy_n(reinterpret_cast(light.origin), sizeof(light.origin), out.data() + baseOffset + ORIGIN_OFFSET); + std::copy_n(reinterpret_cast(&light.radius), sizeof(light.radius), out.data() + baseOffset + RADIUS_OFFSET); + std::copy_n( + reinterpret_cast(&light.cosHalfFovOuter), sizeof(light.cosHalfFovOuter), out.data() + baseOffset + COS_HALF_FOV_OUTER_OFFSET); + std::copy_n( + reinterpret_cast(&light.cosHalfFovInner), sizeof(light.cosHalfFovInner), out.data() + baseOffset + COS_HALF_FOV_INNER_OFFSET); + std::copy_n(reinterpret_cast(&exponent), sizeof(exponent), out.data() + baseOffset + EXPONENT_OFFSET); + + if (light.type >= 2 && light.defName) + std::copy_n(reinterpret_cast(light.defName), + std::min(std::strlen(light.defName), DEF_NAME_SIZE - 1uz), + out.data() + baseOffset + DEF_NAME_OFFSET); + } + + [[nodiscard]] std::vector BuildPrimaryLights(const ComWorld& comWorld) + { + if (comWorld.primaryLightCount <= 0u || !comWorld.primaryLights) + return {}; + + std::vector out; + out.reserve(static_cast(comWorld.primaryLightCount) * 0x80uz); + 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 BspLumpType id, std::vector&& data) + { + if (!data.empty()) + lumps.emplace_back(id, std::move(data)); + } + + [[nodiscard]] size_t LumpOrderIndex(const BspLumpType id) + { + for (auto i = 0uz; i < LUMP_ORDER.size(); i++) + { + if (LUMP_ORDER[i] == id) + return i; + } + + return LUMP_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, BSP_MAGIC.data(), BSP_MAGIC.size()); + stream::WriteValue(stream, BSP_VERSION); + stream::WriteValue(stream, lumpCount); + + for (const auto& lump : lumps) + { + const auto id = static_cast(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 for collision, entities, or primary lights. + assert(world); + assert(clipMap); + assert(comWorld); + assert(mapEnts); + if (!world || !clipMap || !comWorld || !mapEnts) + return; + + const auto primaryLightCount = comWorld->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, primaryLightCount)); + AddLump(lumps, LUMP_LIGHT_REGION_HULLS, BuildLightRegionHulls(*world, primaryLightCount)); + AddLump(lumps, LUMP_LIGHT_REGION_AXES, BuildLightRegionAxes(*world, primaryLightCount)); + + if (gameWorldSp) + AddLump(lumps, LUMP_PATHCONNECTIONS, BuildGameWorldSpPath(*gameWorldSp)); + + const auto assetFile = context.OpenAssetFile(GetPartialBspFileName(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) From e6dbd177bc3f6cfcd4ae33c4169e78c95b1076e5 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Tue, 9 Jun 2026 08:52:22 +0100 Subject: [PATCH 02/35] wip: checkpoint progress on bsp loading OAT linked + unlinked `.d3dbsp` can be read by IW3xRadiant viewer --- src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h | 133 + .../IW3/Maps/D3DBspAssetCreationStateIW3.cpp | 16 + .../IW3/Maps/D3DBspAssetCreationStateIW3.h | 19 + .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 2849 +++++++++++++++++ .../Game/IW3/Maps/D3DBspLoaderIW3.h | 16 + .../Game/IW3/Maps/D3DBspReaderIW3.cpp | 164 + .../Game/IW3/Maps/D3DBspReaderIW3.h | 45 + src/ObjLoading/Game/IW3/ObjLoaderIW3.cpp | 13 +- .../Game/IW3/Maps/D3DBspDumperIW3.cpp | 268 +- 9 files changed, 3315 insertions(+), 208 deletions(-) create mode 100644 src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h create mode 100644 src/ObjLoading/Game/IW3/Maps/D3DBspAssetCreationStateIW3.cpp create mode 100644 src/ObjLoading/Game/IW3/Maps/D3DBspAssetCreationStateIW3.h create mode 100644 src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp create mode 100644 src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.h create mode 100644 src/ObjLoading/Game/IW3/Maps/D3DBspReaderIW3.cpp create mode 100644 src/ObjLoading/Game/IW3/Maps/D3DBspReaderIW3.h diff --git a/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h b/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h new file mode 100644 index 000000000..e52ad24ce --- /dev/null +++ b/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h @@ -0,0 +1,133 @@ +#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; + + // Lump 43 stores ComPrimaryLight fields up to translationLimit. The runtime + // defName pointer is not part of the raw BSP record. + inline constexpr size_t RAW_PRIMARY_LIGHT_SIZE = 64uz; + + // 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, + }; + + // 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..0459c94c3 --- /dev/null +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -0,0 +1,2849 @@ +#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/IwiTypes.h" +#include "Utils/Logging/Log.h" + +#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; + + constexpr auto RAW_LIGHT_TYPE_OFFSET = 0uz; + constexpr auto RAW_LIGHT_CAN_USE_SHADOW_MAP_OFFSET = 1uz; + constexpr auto RAW_LIGHT_EXPONENT_OFFSET = 2uz; + constexpr auto RAW_LIGHT_UNUSED_OFFSET = 3uz; + 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_COS_HALF_FOV_EXPANDED_OFFSET = 52uz; + constexpr auto RAW_LIGHT_ROTATION_LIMIT_OFFSET = 56uz; + constexpr auto RAW_LIGHT_TRANSLATION_LIMIT_OFFSET = 60uz; + 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_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_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 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 DEFAULT_MATERIAL_NAME = "$default"; + constexpr auto DEFAULT_MATERIAL_REFERENCE_NAME = ",$default"; + constexpr auto SKY_LIGHTMAP_INDEX = 31u; + constexpr auto PATHCONNECTIONS_VERSION = 8u; + constexpr auto DEG_TO_RAD = 3.14159265358979323846f / 180.0f; + + [[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]] bool ValidateRecordLump( + const IW3::d3dbsp::File& bsp, const IW3::d3dbsp::Lump* lump, const IW3::d3dbsp::LumpType type, const size_t recordSize, std::string& error) + { + if (!lump) + { + error = std::format("missing lump {}", std::to_underlying(type)); + return false; + } + + if (recordSize == 0uz || lump->data.size() % recordSize != 0uz) + { + error = std::format("{} lump {} has funny size {}", bsp.m_file_name, std::to_underlying(type), lump->data.size()); + return false; + } + + return true; + } + + [[nodiscard]] size_t RecordCount(const IW3::d3dbsp::Lump& lump, const size_t recordSize) + { + return recordSize > 0uz ? lump.data.size() / recordSize : 0uz; + } + + 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 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::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; + } + + 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]] 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; + }; + + void ParsePrimaryLightRecord(const std::byte* record, ComPrimaryLight& light) + { + light = {}; + light.type = ReadRawByte(record, RAW_LIGHT_TYPE_OFFSET); + light.canUseShadowMap = ReadRawByte(record, RAW_LIGHT_CAN_USE_SHADOW_MAP_OFFSET); + light.exponent = ReadRawByte(record, RAW_LIGHT_EXPONENT_OFFSET); + light.unused = ReadRawByte(record, RAW_LIGHT_UNUSED_OFFSET); + + 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_COS_HALF_FOV_EXPANDED_OFFSET, light.cosHalfFovExpanded); + CopyUnaligned(record + RAW_LIGHT_ROTATION_LIMIT_OFFSET, light.rotationLimit); + CopyUnaligned(record + RAW_LIGHT_TRANSLATION_LIMIT_OFFSET, light.translationLimit); + + // The raw 64-byte BSP record stops before the runtime defName pointer. + light.defName = nullptr; + } + + [[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 {}; + + return existingField->second; + } + + [[nodiscard]] bool ParseEntityBlocks(const std::vector& lump, std::vector& blocks, std::string& error) + { + auto textLen = lump.size(); + while (textLen > 0uz && lump[textLen - 1uz] == std::byte{}) + textLen--; + + const std::string text(reinterpret_cast(lump.data()), textLen); + auto offset = 0uz; + + while (offset < text.size()) + { + while (offset < text.size() && text[offset] != '{') + { + if (!std::isspace(static_cast(text[offset]))) + { + error = "unexpected non-whitespace before entity block"; + return false; + } + 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) + { + error = "unterminated entity block"; + return false; + } + } + + return true; + } + + [[nodiscard]] std::vector CompileMapEntsEntityString(const std::vector& blocks) + { + std::string out; + for (const auto& block : blocks) + { + // The original linker consumes misc_model while building static-model + // world data and does not retain those editor-only blocks in MapEnts. + if (block.classname == "misc_model") + 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]] float StaticModelScale(const EntityBlock& block) + { + const auto parsedScale = ParseFloat(EntityField(block, "modelscale")); + if (!parsedScale || *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 = std::sin(pitch); + const auto cp = std::cos(pitch); + const auto sy = std::sin(yaw); + const auto cy = std::cos(yaw); + const auto sr = std::sin(roll); + const auto cr = std::cos(roll); + + axis[0][0] = cp * cy; + axis[0][1] = cp * sy; + axis[0][2] = -sp; + axis[1][0] = sr * sp * cy - cr * sy; + axis[1][1] = sr * sp * sy + cr * cy; + axis[1][2] = sr * cp; + axis[2][0] = cr * sp * cy + sr * sy; + axis[2][1] = cr * sp * sy - sr * cy; + axis[2][2] = cr * cp; + } + + 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; + } + + 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 = ParseFloat3(EntityField(*block, "origin")).value_or(std::array{}); + const auto angles = ParseFloat3(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. + for (auto row = 0uz; row < 3uz; row++) + { + for (auto column = 0uz; column < 3uz; column++) + staticModel.invScaledAxis[column][row] = axis[row][column] / scale; + } + + BuildStaticModelBounds(*model, axis, origin, scale, staticModel); + } + } + + [[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); + } + + void SetLeafBoundsFromCollisionAabbs(const clipMap_t& clipMap, cLeaf_t& leaf) + { + if (!clipMap.aabbTrees || leaf.collAabbCount == 0u || leaf.firstCollAabbIndex >= clipMap.aabbTreeCount) + return; + + for (auto axis = 0uz; axis < 3uz; axis++) + { + leaf.mins[axis] = std::numeric_limits::max(); + leaf.maxs[axis] = std::numeric_limits::lowest(); + } + + 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& aabb = clipMap.aabbTrees[aabbIndex]; + for (auto axis = 0uz; axis < 3uz; axis++) + { + leaf.mins[axis] = std::min(leaf.mins[axis], aabb.origin[axis] - aabb.halfSize[axis]); + leaf.maxs[axis] = std::max(leaf.maxs[axis], aabb.origin[axis] + aabb.halfSize[axis]); + } + } + } + + [[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; + } + + [[nodiscard]] bool PopulateClipMapMaterials(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* materials = bsp.GetLump(LUMP_MATERIALS); + if (!ValidateRecordLump(bsp, materials, LUMP_MATERIALS, RAW_MATERIAL_SIZE, error)) + return false; + + const auto count = RecordCount(*materials, RAW_MATERIAL_SIZE); + if (!FitsUnsigned(count)) + { + error = "too many material records"; + return false; + } + + 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); + clipMap.materials[i].contentFlags = ReadI32(record, 68uz); + } + + return true; + } + + [[nodiscard]] bool PopulateClipMapPlanes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* planes = bsp.GetLump(LUMP_PLANES); + if (!ValidateRecordLump(bsp, planes, LUMP_PLANES, RAW_PLANE_SIZE, error)) + return false; + + const auto count = RecordCount(*planes, RAW_PLANE_SIZE); + if (!FitsInt(count)) + { + error = "too many plane records"; + return false; + } + + 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 true; + } + + [[nodiscard]] bool PopulateClipMapBrushes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* brushHeaders = bsp.GetLump(LUMP_BRUSHES); + const auto* brushSides = bsp.GetLump(LUMP_BRUSHSIDES); + const auto* edgeCounts = bsp.GetLump(LUMP_BRUSHSIDE_EDGE_COUNTS); + const auto* brushEdges = bsp.GetLump(LUMP_BRUSHEDGES); + + if (!ValidateRecordLump(bsp, brushHeaders, LUMP_BRUSHES, RAW_BRUSH_HEADER_SIZE, error) + || !ValidateRecordLump(bsp, brushSides, LUMP_BRUSHSIDES, RAW_BRUSHSIDE_SIZE, error) || !edgeCounts) + return false; + + const auto brushCount = RecordCount(*brushHeaders, RAW_BRUSH_HEADER_SIZE); + if (!FitsUint16(brushCount)) + { + error = "too many brush records"; + return false; + } + + 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) + { + error = "brush has fewer than six axial sides"; + return false; + } + + totalSideCount += sideCount; + nonAxialSideCount += sideCount - 6u; + } + + if (brushSides->data.size() != totalSideCount * RAW_BRUSHSIDE_SIZE || edgeCounts->data.size() != totalSideCount) + { + error = "brush side/edge-count lumps do not match brush headers"; + return false; + } + + if (!FitsUnsigned(nonAxialSideCount)) + { + error = "too many non-axial brush sides"; + return false; + } + + clipMap.numBrushes = static_cast(brushCount); + clipMap.brushes = AllocZeroed(memory, brushCount); + 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))) + { + error = "brush side references an invalid plane"; + return false; + } + + 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 true; + } + + [[nodiscard]] bool PopulateClipMapNodes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* nodes = bsp.GetLump(LUMP_NODES); + if (!nodes) + return true; + + if (nodes->data.size() % RAW_CLIP_NODE_SIZE != 0uz) + { + error = "node lump has funny size"; + return false; + } + + const auto nodeCount = RecordCount(*nodes, RAW_CLIP_NODE_SIZE); + if (!FitsUnsigned(nodeCount)) + { + error = "too many node records"; + return false; + } + + 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)) + { + error = "node record references invalid plane or child"; + return false; + } + + clipMap.nodes[nodeIndex].plane = &clipMap.planes[planeIndex]; + clipMap.nodes[nodeIndex].children[0] = static_cast(child0); + clipMap.nodes[nodeIndex].children[1] = static_cast(child1); + } + + return true; + } + + [[nodiscard]] bool PopulateClipMapLeafBrushes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* leafBrushes = bsp.GetLump(LUMP_LEAFBRUSHES); + if (!leafBrushes) + return true; + + if (leafBrushes->data.size() % RAW_LEAF_BRUSH_SIZE != 0uz) + { + error = "leafbrush lump has funny size"; + return false; + } + + const auto leafBrushCount = RecordCount(*leafBrushes, RAW_LEAF_BRUSH_SIZE); + if (!FitsUnsigned(leafBrushCount)) + { + error = "too many leafbrush records"; + return false; + } + + clipMap.numLeafBrushes = static_cast(leafBrushCount); + clipMap.leafbrushes = AllocZeroed(memory, leafBrushCount); + + 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()) + { + error = "leafbrush index exceeds runtime range"; + return false; + } + + clipMap.leafbrushes[i] = static_cast(brushIndex); + } + + return true; + } + + [[nodiscard]] bool PopulateClipMapCollision(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* verts = bsp.GetLump(LUMP_COLLISIONVERTS); + if (verts) + { + if (verts->data.size() % RAW_VEC3_SIZE != 0uz || !FitsUnsigned(RecordCount(*verts, RAW_VEC3_SIZE))) + { + error = "collision vert lump has funny size"; + return false; + } + + 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))) + { + error = "collision tri lump has funny size"; + return false; + } + + 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))) + { + error = "collision border lump has funny size"; + return false; + } + + 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))) + { + error = "collision partition lump has funny size"; + return false; + } + + const auto partitionCount = RecordCount(*partitions, RAW_COLLISION_PARTITION_SIZE); + clipMap.partitionCount = static_cast(partitionCount); + clipMap.partitions = AllocZeroed(memory, partitionCount); + 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 + borderCount > static_cast(std::max(clipMap.borderCount, 0))) + { + error = "collision partition references invalid border"; + return false; + } + + clipMap.partitions[i].triCount = static_cast(std::to_integer(record[2])); + clipMap.partitions[i].borderCount = borderCount; + clipMap.partitions[i].firstTri = ReadI32(record, 4uz); + clipMap.partitions[i].borders = clipMap.borders && borderCount > 0u ? &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))) + { + error = "collision AABB lump has funny size"; + return false; + } + + clipMap.aabbTreeCount = static_cast(RecordCount(*aabbs, RAW_COLLISION_AABB_SIZE)); + clipMap.aabbTrees = AllocCopy(memory, aabbs->data); + } + + return true; + } + + [[nodiscard]] bool PopulateClipMapLeafs(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* leafs = bsp.GetLump(LUMP_LEAFS); + if (!leafs) + return true; + + if (leafs->data.size() % RAW_LEAF_SIZE != 0uz || !FitsUnsigned(RecordCount(*leafs, RAW_LEAF_SIZE))) + { + error = "leaf lump has funny size"; + return false; + } + + const auto leafCount = RecordCount(*leafs, RAW_LEAF_SIZE); + auto leafBrushNodeCount = 0uz; + for (auto leafIndex = 0uz; leafIndex < leafCount; leafIndex++) + { + const auto* record = leafs->data.data() + leafIndex * RAW_LEAF_SIZE; + if (ReadI32(record, 16uz) > 0) + leafBrushNodeCount++; + } + + clipMap.numLeafs = static_cast(leafCount); + clipMap.leafs = AllocZeroed(memory, leafCount); + clipMap.leafbrushNodesCount = static_cast(leafBrushNodeCount); + clipMap.leafbrushNodes = leafBrushNodeCount > 0uz ? AllocZeroed(memory, leafBrushNodeCount) : nullptr; + + auto nextLeafBrushNode = 0uz; + 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); + const auto firstLeafBrush = ReadI32(record, 12uz); + const auto leafBrushCount = ReadI32(record, 16uz); + + if (firstCollAabbIndex < 0 || collAabbCount < 0 || firstLeafBrush < 0 || leafBrushCount < 0) + { + error = "leaf contains negative runtime count/index"; + return false; + } + + 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 = -1; + SetLeafBoundsFromCollisionAabbs(clipMap, leaf); + + if (cluster >= 0) + maxCluster = std::max(maxCluster, cluster); + + if (leafBrushCount > 0) + { + if (static_cast(firstLeafBrush + leafBrushCount) > clipMap.numLeafBrushes || nextLeafBrushNode >= leafBrushNodeCount) + { + error = "leaf references invalid leafbrush range"; + return false; + } + + auto& node = clipMap.leafbrushNodes[nextLeafBrushNode]; + node.axis = -1; + node.leafBrushCount = static_cast(std::min(leafBrushCount, static_cast(std::numeric_limits::max()))); + node.data.leaf.brushes = &clipMap.leafbrushes[firstLeafBrush]; + for (auto brushOffset = 0; brushOffset < leafBrushCount; brushOffset++) + { + const auto brushIndex = clipMap.leafbrushes[firstLeafBrush + brushOffset]; + if (brushIndex < clipMap.numBrushes) + node.contents |= clipMap.brushes[brushIndex].contents; + } + + leaf.brushContents = node.contents; + leaf.leafBrushNode = static_cast(nextLeafBrushNode); + nextLeafBrushNode++; + } + } + + clipMap.numClusters = maxCluster + 1; + return true; + } + + [[nodiscard]] bool PopulateClipMapModels(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* models = bsp.GetLump(LUMP_MODELS); + if (!models) + return true; + + if (models->data.size() % RAW_MODEL_SIZE != 0uz || !FitsUnsigned(RecordCount(*models, RAW_MODEL_SIZE))) + { + error = "model lump has funny size"; + return false; + } + + 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]; + CopyFloat3(record, model.mins); + CopyFloat3(record + 12uz, model.maxs); + model.radius = RadiusFromBounds(model.mins, model.maxs); + if (clipMap.numLeafs > 0u && clipMap.leafs) + model.leaf = clipMap.leafs[0]; + } + + return true; + } + + [[nodiscard]] bool PopulateClipMapVisibility(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* visibility = bsp.GetLump(LUMP_VISIBILITY); + if (!visibility || visibility->data.empty()) + return true; + + clipMap.visibility = AllocCopy(memory, visibility->data); + clipMap.vised = 1; + + if (clipMap.numClusters > 0) + { + if (visibility->data.size() % static_cast(clipMap.numClusters) != 0uz) + { + error = "visibility lump does not divide by cluster count"; + return false; + } + + clipMap.clusterBytes = static_cast(visibility->data.size() / static_cast(clipMap.numClusters)); + } + else + { + clipMap.clusterBytes = static_cast(visibility->data.size()); + } + + return true; + } + + [[nodiscard]] bool PopulateClipMapLeafSurfaces(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* leafSurfaces = bsp.GetLump(LUMP_LEAFSURFACES); + if (!leafSurfaces) + return true; + + if (leafSurfaces->data.size() % sizeof(uint32_t) != 0uz || !FitsUnsigned(leafSurfaces->data.size() / sizeof(uint32_t))) + { + error = "leafsurface lump has funny size"; + return false; + } + + clipMap.numLeafSurfaces = static_cast(leafSurfaces->data.size() / sizeof(uint32_t)); + clipMap.leafsurfaces = AllocCopy(memory, leafSurfaces->data); + return true; + } + + [[nodiscard]] const IW3::d3dbsp::Lump* + SelectWorldLump(const IW3::d3dbsp::File& bsp, const IW3::d3dbsp::LumpType preferred, const IW3::d3dbsp::LumpType fallback) + { + const auto* preferredLump = bsp.GetLump(preferred); + if (preferredLump && !preferredLump->data.empty()) + return preferredLump; + + return bsp.GetLump(fallback); + } + + [[nodiscard]] const IW3::d3dbsp::Lump* + SelectSimpleWorldLump(const IW3::d3dbsp::File& bsp, const IW3::d3dbsp::LumpType simple, const IW3::d3dbsp::LumpType layered) + { + // IW3 v22 BSPs can contain both simple and layered render geometry. The + // layered surface records reference generated material names such as + // "*1n_2n"; linker_pc parses those names and synthesizes layered + // materials from their source material indices. Until this loader + // implements that synthesis, prefer the simple representation because it + // references regular world materials directly. + return SelectWorldLump(bsp, simple, layered); + } + + [[nodiscard]] std::vector WorldMaterialNameCandidates(const std::string& rawMaterialName) + { + std::vector result; + + if (rawMaterialName.empty()) + 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); + + if (rawMaterialName == "$default") + { + result.emplace_back("$default3d"); + result.emplace_back("$default2d"); + } + + 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::vector WorldSurfaceMaterialUsage(const IW3::d3dbsp::File& bsp, const size_t materialCount, std::string& error) + { + std::vector result(materialCount); + const auto* surfaces = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_TRI_SOUPS, LUMP_LAYERED_TRI_SOUPS); + if (!surfaces) + return result; + + if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) + { + error = "world surface lump has funny size"; + return {}; + } + + 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) + { + error = std::format("world surface {} references invalid material index {}", surfaceIndex, materialIndex); + return {}; + } + + result[materialIndex] = true; + } + + return result; + } + + [[nodiscard]] std::vector*> + LoadWorldMaterials(const IW3::d3dbsp::File& bsp, AssetCreationContext& context, MemoryManager& memory, std::string& error) + { + const auto* materials = bsp.GetLump(LUMP_MATERIALS); + if (!ValidateRecordLump(bsp, materials, LUMP_MATERIALS, RAW_MATERIAL_SIZE, error)) + return {}; + + std::vector*> result; + const auto materialCount = RecordCount(*materials, RAW_MATERIAL_SIZE); + const auto renderMaterialUsage = WorldSurfaceMaterialUsage(bsp, materialCount, error); + if (!error.empty()) + return {}; + + 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]) + { + error = std::format("world surface references unnamed material index {}", materialIndex); + return {}; + } + + 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; + if (materialName == DEFAULT_MATERIAL_NAME) + { + dependency = GetOrCreateDefaultMaterialReference(context, memory); + } + else + { + for (const auto& candidateName : WorldMaterialNameCandidates(materialName)) + { + dependency = TryLoadWorldMaterialDependency(context, candidateName); + if (dependency) + break; + } + } + + if (!dependency) + { + error = std::format("missing render material \"{}\"", materialName); + return {}; + } + + result.emplace_back(dependency); + } + + 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]); + } + } + } + + [[nodiscard]] bool PopulateWorldIndices(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* indices = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_INDICES, LUMP_LAYERED_INDICES); + if (!indices) + return true; + + if (indices->data.size() % sizeof(uint16_t) != 0uz || !FitsInt(indices->data.size() / sizeof(uint16_t))) + { + error = "world index lump has funny size"; + return false; + } + + world.indexCount = static_cast(indices->data.size() / sizeof(uint16_t)); + world.indices = AllocCopy(memory, indices->data); + return true; + } + + [[nodiscard]] bool PopulateWorldVertices(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* verts = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_VERTS, LUMP_LAYERED_VERTS); + if (!verts) + return true; + + if (verts->data.size() % RAW_WORLD_VERTEX_SIZE != 0uz || !FitsUnsigned(RecordCount(*verts, RAW_WORLD_VERTEX_SIZE))) + { + error = "world vertex lump has funny size"; + return false; + } + + 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 = Common::Vec3PackUnitVec(normal); + vertex.tangent = Common::Vec3PackUnitVec(tangent); + } + + SetWorldBoundsFromVertices(world); + return true; + } + + 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]; + } + } + } + + [[nodiscard]] bool PopulateWorldSurfaces( + GfxWorld& world, + const IW3::d3dbsp::File& bsp, + const std::vector*>& materialDependencies, + MemoryManager& memory, + std::string& error) + { + const auto* surfaces = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_TRI_SOUPS, LUMP_LAYERED_TRI_SOUPS); + if (!surfaces) + return true; + + if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz || !FitsInt(RecordCount(*surfaces, RAW_WORLD_SURFACE_SIZE))) + { + error = "world surface lump has funny size"; + return false; + } + + 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); + + 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]) + { + error = std::format("world surface {} references missing render material index {}", surfaceIndex, materialIndex); + return false; + } + + surface.material = materialDependencies[materialIndex]->Asset(); + + surface.lightmapIndex = static_cast(std::to_integer(record[2])); + 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 = ReadI32(record, 20uz); + PopulateSurfaceBounds(world, surface); + } + + if (world.surfaceCount > 0) + { + const auto sortedSurfIndexCount = static_cast(world.dpvs.staticSurfaceCount + world.dpvs.staticSurfaceCountNoDecal); + world.dpvs.sortedSurfIndex = AllocZeroed(memory, sortedSurfIndexCount); + for (auto i = 0uz; i < sortedSurfIndexCount; i++) + world.dpvs.sortedSurfIndex[i] = static_cast(std::min(i % static_cast(world.surfaceCount), static_cast(UINT16_MAX))); + } + + return true; + } + + [[nodiscard]] bool PopulateWorldVertexLayerData(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* vertexLayerData = bsp.GetLump(LUMP_VERTEX_LAYER_DATA); + if (!vertexLayerData) + return true; + + if (!FitsUnsigned(vertexLayerData->data.size())) + { + error = "vertex layer data lump is too large"; + return false; + } + + world.vertexLayerDataSize = static_cast(vertexLayerData->data.size()); + world.vld.data = AllocCopy(memory, vertexLayerData->data); + return true; + } + + [[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]] bool PopulateWorldLightmaps( + GfxWorld& world, + const std::string& assetName, + const IW3::d3dbsp::File& bsp, + AssetCreationContext& context, + AssetRegistration& registration, + MemoryManager& memory, + std::string& error) + { + const auto* lightmaps = bsp.GetLump(LUMP_LIGHTMAPS); + if (!lightmaps || lightmaps->data.empty()) + return true; + + if (lightmaps->data.size() % LIGHTMAP_RAW_PAGE_SIZE != 0uz || !FitsInt(lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE)) + { + error = "lightmap lump has funny size"; + return false; + } + + const auto pageCount = lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE; + world.lightmapCount = static_cast(pageCount); + world.lightmaps = AllocZeroed(memory, pageCount); + + for (auto pageIndex = 0uz; pageIndex < pageCount; pageIndex++) + { + const auto* page = lightmaps->data.data() + pageIndex * LIGHTMAP_RAW_PAGE_SIZE; + const auto* secondaryData = page; + const auto* primaryData = page + LIGHTMAP_SECONDARY_RAW_PAGE_SIZE; + + const auto primaryName = GeneratedImageName(assetName, "lightmap_primary", pageIndex); + const auto secondaryName = GeneratedImageName(assetName, "lightmap_secondary", pageIndex); + constexpr auto lightmapFlags = static_cast(image::iwi6::IMG_FLAG_NOMIPMAPS); + + auto* primary = CreateGeneratedImage(memory, + primaryName, + MAPTYPE_2D, + TS_COLOR_MAP, + IMG_CATEGORY_LIGHTMAP, + LIGHTMAP_PRIMARY_RAW_WIDTH, + LIGHTMAP_PRIMARY_RAW_HEIGHT, + 1u, + oat::D3DFMT_A8, + lightmapFlags, + primaryData, + LIGHTMAP_PRIMARY_RAW_PAGE_SIZE); + auto* secondary = CreateGeneratedImage(memory, + secondaryName, + MAPTYPE_2D, + TS_COLOR_MAP, + IMG_CATEGORY_LIGHTMAP, + LIGHTMAP_SECONDARY_RAW_WIDTH, + LIGHTMAP_SECONDARY_RAW_HEIGHT, + 1u, + oat::D3DFMT_A8R8G8B8, + lightmapFlags, + secondaryData, + LIGHTMAP_SECONDARY_RAW_PAGE_SIZE); + + auto* primaryInfo = AddGeneratedImage(context, registration, primaryName, primary); + auto* secondaryInfo = AddGeneratedImage(context, registration, secondaryName, secondary); + if (!primaryInfo || !secondaryInfo) + { + error = "could not register generated lightmap image"; + return false; + } + + world.lightmaps[pageIndex].primary = primaryInfo->Asset(); + world.lightmaps[pageIndex].secondary = secondaryInfo->Asset(); + } + + return true; + } + + void CopyTransformedReflectionProbePixels(char* out, const std::byte* source) + { + 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]] bool PopulateWorldReflectionProbes( + GfxWorld& world, + const std::string& assetName, + const IW3::d3dbsp::File& bsp, + AssetCreationContext& context, + AssetRegistration& registration, + MemoryManager& memory, + std::string& error) + { + const auto* reflectionProbes = bsp.GetLump(LUMP_REFLECTION_PROBES); + if (!reflectionProbes || reflectionProbes->data.empty()) + { + world.reflectionProbeCount = 1u; + world.reflectionProbes = AllocZeroed(memory, 1uz); + return true; + } + + if (reflectionProbes->data.size() % REFLECTION_PROBE_RECORD_SIZE != 0uz || !FitsUnsigned(reflectionProbes->data.size() / REFLECTION_PROBE_RECORD_SIZE + 1uz)) + { + error = "reflection-probe lump has funny size"; + return false; + } + + const auto rawProbeCount = reflectionProbes->data.size() / REFLECTION_PROBE_RECORD_SIZE; + world.reflectionProbeCount = static_cast(rawProbeCount + 1uz); + world.reflectionProbes = AllocZeroed(memory, world.reflectionProbeCount); + + 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 = GeneratedImageName(assetName, "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) + { + error = "could not register generated reflection probe image"; + return false; + } + + probe.reflectionImage = imageInfo->Asset(); + } + + return true; + } + + [[nodiscard]] bool PopulateWorldLightGrid(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* header = bsp.GetLump(LUMP_LIGHTGRID_HEADER); + if (header) + { + if (header->data.size() < 20uz || (header->data.size() - 20uz) % sizeof(uint16_t) != 0uz) + { + error = "lightgrid header lump has funny size"; + return false; + } + + 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())) + { + error = "lightgrid raw row lump is too large"; + return false; + } + + 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))) + { + error = "lightgrid entry lump has funny size"; + return false; + } + + 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)) + { + error = "lightgrid color lump has funny size"; + return false; + } + + 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 true; + } + + [[nodiscard]] GfxAabbTree* BuildWorldAabbTrees(const GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, int& treeCount, std::string& error) + { + const auto* aabbTrees = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_AABBTREES, LUMP_LAYERED_AABBTREES); + if (!aabbTrees || aabbTrees->data.empty()) + { + treeCount = world.surfaceCount > 0 ? 1 : 0; + if (treeCount == 0) + return nullptr; + + 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 result; + } + + if (aabbTrees->data.size() % RAW_WORLD_AABB_TREE_SIZE != 0uz || !FitsInt(RecordCount(*aabbTrees, RAW_WORLD_AABB_TREE_SIZE))) + { + error = "world AABB tree lump has funny size"; + return nullptr; + } + + 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]; + std::memcpy(tree.mins, world.mins, sizeof(tree.mins)); + std::memcpy(tree.maxs, world.maxs, sizeof(tree.maxs)); + tree.startSurfIndex = static_cast(std::min(ReadU32(record), static_cast(UINT16_MAX))); + tree.surfaceCount = static_cast(std::min(ReadU32(record, 4uz), static_cast(UINT16_MAX))); + tree.childCount = static_cast(std::min(ReadU32(record, 8uz), static_cast(UINT16_MAX))); + tree.startSurfIndexNoDecal = tree.startSurfIndex; + tree.surfaceCountNoDecal = tree.surfaceCount; + } + + return result; + } + + [[nodiscard]] bool PopulateWorldCells(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + int aabbTreeCount = 0; + auto* aabbTrees = BuildWorldAabbTrees(world, bsp, memory, aabbTreeCount, error); + if (!error.empty()) + return false; + + 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) + { + error = "cell lump has funny size"; + return false; + } + + if (!FitsInt(cellCount)) + { + error = "too many cell records"; + return false; + } + + world.dpvsPlanes.cellCount = static_cast(cellCount); + world.cellBitsCount = static_cast((cellCount + 31uz) / 32uz); + 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 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)); + } + } + + // The canonical OAT BSP currently stores AABB surface ranges without a + // full per-cell hierarchy. Assign them to the first cell, which mirrors + // the single-cell BSPs produced by the dumper. + if (cellCount > 0uz) + { + world.cells[0].aabbTreeCount = aabbTreeCount; + world.cells[0].aabbTree = aabbTrees; + } + + return true; + } + + [[nodiscard]] bool PopulateWorldModels(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* models = bsp.GetLump(LUMP_MODELS); + if (!models) + return true; + + if (models->data.size() % RAW_MODEL_SIZE != 0uz || !FitsInt(RecordCount(*models, RAW_MODEL_SIZE))) + { + error = "world model lump has funny size"; + return false; + } + + 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 true; + } + + 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] = 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); + light.exponent = static_cast(std::to_integer(record[RAW_LIGHT_EXPONENT_OFFSET])); + } + + [[nodiscard]] bool InferWorldPrimaryLightCount(const IW3::d3dbsp::File& bsp, const size_t rawPrimaryLightCount, unsigned& primaryLightCount, std::string& error) + { + const auto* regionCounts = bsp.GetLump(LUMP_LIGHT_REGION_COUNTS); + if (regionCounts && !regionCounts->data.empty()) + { + if (!FitsUnsigned(regionCounts->data.size())) + { + error = "light-region count lump is too large"; + return false; + } + + // The primary-light lump is ComWorld data. GfxWorld primary-light + // arrays are sized by the light-region count lump when it exists. + primaryLightCount = static_cast(regionCounts->data.size()); + return true; + } + + const auto* surfaces = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_TRI_SOUPS, LUMP_LAYERED_TRI_SOUPS); + auto maxPrimaryLightIndex = 0u; + auto foundSurface = false; + + if (surfaces && !surfaces->data.empty()) + { + if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) + { + error = "world surface lump has funny size"; + return false; + } + + 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; + } + } + + primaryLightCount = foundSurface ? maxPrimaryLightIndex + 1u : static_cast(std::min(rawPrimaryLightCount, static_cast(UINT32_MAX))); + return true; + } + + [[nodiscard]] bool PopulateWorldPrimaryLights(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + 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)) + { + error = "primary-light lump has funny size"; + return false; + } + + const auto rawPrimaryLightCount = size / IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE; + if (!InferWorldPrimaryLightCount(bsp, rawPrimaryLightCount, world.primaryLightCount, error)) + return false; + + if (world.primaryLightCount > rawPrimaryLightCount) + { + error = std::format("GfxWorld primary light count {} exceeds raw primary-light record count {}", world.primaryLightCount, rawPrimaryLightCount); + return false; + } + + if (rawPrimaryLightCount == 0uz) + return true; + + world.sunPrimaryLightIndex = 0u; + world.sunLight = AllocZeroed(memory); + ParseGfxLightRecord(primaryLights->data.data(), *world.sunLight); + std::memcpy(world.sunColorFromBsp, world.sunLight->color, sizeof(world.sunColorFromBsp)); + world.lightGrid.sunPrimaryLightIndex = world.sunPrimaryLightIndex; + return true; + } + + [[nodiscard]] bool PopulateWorldLightRegions(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + if (world.primaryLightCount == 0u) + return true; + + const auto* counts = bsp.GetLump(LUMP_LIGHT_REGION_COUNTS); + if (!counts || counts->data.empty()) + return true; + + 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()) + { + error = "light-region hull lump is truncated"; + return false; + } + + 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()) + { + error = "light-region axis lump is truncated"; + return false; + } + + 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 true; + } + + void PopulateStaticModelGroundLighting(const EntityBlock& block, GfxStaticModelInst& inst, GfxStaticModelDrawInst& drawInst) + { + const auto gndLt = EntityField(block, "gndLt"); + if (gndLt.size() < 10uz) + return; + + const auto b = ParseHexByte(gndLt, 0uz); + const auto g = ParseHexByte(gndLt, 2uz); + const auto r = ParseHexByte(gndLt, 4uz); + const auto a = ParseHexByte(gndLt, 6uz); + const auto primaryLightIndex = ParseHexByte(gndLt, 8uz); + if (!b || !g || !r || !a || !primaryLightIndex) + 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); + drawInst.primaryLightIndex = static_cast(*primaryLightIndex); + } + + [[nodiscard]] bool PopulateWorldStaticModels( + GfxWorld& world, + const clipMap_t* clipMap, + const std::vector& staticModelBlocks, + const std::vector*>& staticModelDependencies, + MemoryManager& memory, + std::string& error) + { + 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())) + { + error = "too many static model records"; + return false; + } + + world.dpvs.smodelCount = static_cast(validStaticModels.size()); + if (validStaticModels.empty()) + return true; + + 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 = ParseFloat3(EntityField(*block, "origin")).value_or(std::array{}); + const auto angles = ParseFloat3(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)); + drawInst.smodelCacheIndex[0] = std::numeric_limits::max(); + drawInst.smodelCacheIndex[1] = std::numeric_limits::max(); + drawInst.smodelCacheIndex[2] = std::numeric_limits::max(); + drawInst.smodelCacheIndex[3] = std::numeric_limits::max(); + + 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(*block, inst, drawInst); + } + + return true; + } + + [[nodiscard]] bool PopulateWorldDpvsPlanes(GfxWorld& world, const clipMap_t* clipMap, MemoryManager& memory, std::string& error) + { + if (!clipMap) + return true; + + 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)); + } + + if (clipMap->numNodes > 0u && !FitsInt(clipMap->numNodes)) + { + error = "too many clipmap nodes for gfxworld"; + return false; + } + + return true; + } + + 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; + + std::string error; + if (!PopulateClipMapMaterials(*clipMap, *bsp, m_memory, error) || !PopulateClipMapPlanes(*clipMap, *bsp, m_memory, error) + || !PopulateClipMapBrushes(*clipMap, *bsp, m_memory, error) || !PopulateClipMapNodes(*clipMap, *bsp, m_memory, error) + || !PopulateClipMapLeafBrushes(*clipMap, *bsp, m_memory, error) || !PopulateClipMapCollision(*clipMap, *bsp, m_memory, error) + || !PopulateClipMapLeafs(*clipMap, *bsp, m_memory, error) || !PopulateClipMapModels(*clipMap, *bsp, m_memory, error) + || !PopulateClipMapVisibility(*clipMap, *bsp, m_memory, error) || !PopulateClipMapLeafSurfaces(*clipMap, *bsp, m_memory, error)) + { + con::error("Could not create clipmap \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + + std::vector entityBlocks; + const auto* entityLump = bsp->GetLump(LUMP_ENTITIES); + if (entityLump) + { + std::string parseError; + if (!ParseEntityBlocks(entityLump->data, entityBlocks, parseError)) + { + con::error("Could not create clipmap \"{}\" from {}: {}", assetName, bsp->m_file_name, parseError); + return AssetCreationResult::Failure(); + } + } + + const auto staticModelBlocks = StaticModelEntityBlocks(entityBlocks); + const auto staticModelDependencies = LoadStaticModelDependencies(staticModelBlocks, context); + PopulateStaticModels(*clipMap, staticModelBlocks, staticModelDependencies, 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); + } + + 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(); + } + + std::vector entityBlocks; + std::string parseError; + if (!ParseEntityBlocks(entities->data, entityBlocks, parseError)) + { + con::error("Could not create MapEnts \"{}\" from {}: {}", assetName, bsp->m_file_name, parseError); + 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]] bool DecodePathVisRle( + const std::vector& data, size_t& offset, const size_t expectedSize, MemoryManager& memory, char*& pathVis, std::string& error) + { + pathVis = expectedSize > 0uz ? AllocZeroed(memory, expectedSize) : nullptr; + auto outOffset = 0uz; + + while (outOffset < expectedSize) + { + if (offset >= data.size()) + { + error = "path visibility RLE ended early"; + return false; + } + + 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()) + { + error = "path visibility zero run exceeds expected size"; + return false; + } + + 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) + { + error = "path visibility literal run exceeds expected size"; + return false; + } + + std::memcpy(pathVis + outOffset, data.data() + offset, literalCount); + outOffset += literalCount; + offset += literalCount; + } + } + + return true; + } + + 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); + + std::string error; + if (!DecodePathVisRle(data, offset, visBytes, m_memory, gameWorld->path.pathVis, error)) + { + con::error("Could not create GameWorldSp \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + + 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; + }; + + void PopulateWorldSkySurfaces(GfxWorld& world, MemoryManager& memory) + { + if (!world.dpvs.surfaces || world.surfaceCount <= 0) + return; + + std::vector skySurfaces; + for (auto surfaceIndex = 0; surfaceIndex < world.surfaceCount; surfaceIndex++) + { + if (static_cast(world.dpvs.surfaces[surfaceIndex].lightmapIndex) == SKY_LIGHTMAP_INDEX) + skySurfaces.emplace_back(surfaceIndex); + } + + if (skySurfaces.empty()) + return; + + world.skySurfCount = static_cast(skySurfaces.size()); + world.skyStartSurfs = AllocZeroed(memory, skySurfaces.size()); + std::memcpy(world.skyStartSurfs, skySurfaces.data(), skySurfaces.size() * sizeof(int)); + } + + 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; + } + } + + 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) + { + std::string parseError; + if (!ParseEntityBlocks(entityLump->data, entityBlocks, parseError)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, parseError); + return AssetCreationResult::Failure(); + } + } + + std::string error; + const auto materialDependencies = LoadWorldMaterials(*bsp, context, m_memory, error); + if (!error.empty()) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + + const auto staticModelBlocks = StaticModelEntityBlocks(entityBlocks); + const auto staticModelDependencies = LoadStaticModelDependencies(staticModelBlocks, context); + + auto* world = AllocZeroed(m_memory); + world->name = m_memory.Dup(assetName.c_str()); + world->baseName = world->name; + 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); + } + + const auto* clipMap = clipMapDependency->Asset(); + if (!PopulateWorldDpvsPlanes(*world, clipMap, m_memory, error) || !PopulateWorldIndices(*world, *bsp, m_memory, error) + || !PopulateWorldVertices(*world, *bsp, m_memory, error) + || !PopulateWorldSurfaces(*world, *bsp, materialDependencies, m_memory, error) || !PopulateWorldVertexLayerData(*world, *bsp, m_memory, error) + || !PopulateWorldModels(*world, *bsp, m_memory, error) || !PopulateWorldCells(*world, *bsp, m_memory, error) + || !PopulateWorldPrimaryLights(*world, *bsp, m_memory, error) || !PopulateWorldLightGrid(*world, *bsp, m_memory, error) + || !PopulateWorldLightRegions(*world, *bsp, m_memory, error) + || !PopulateWorldStaticModels(*world, clipMap, staticModelBlocks, staticModelDependencies, m_memory, error) + || !PopulateWorldLightmaps(*world, assetName, *bsp, context, registration, m_memory, error) + || !PopulateWorldReflectionProbes(*world, assetName, *bsp, context, registration, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + + PopulateWorldSkySurfaces(*world, m_memory); + 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]); + } + } + + 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 index 2b790f552..0fff152c4 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -1,6 +1,7 @@ #include "D3DBspDumperIW3.h" #include "Game/IW3/CommonIW3.h" +#include "Game/IW3/Maps/D3DBspCommonIW3.h" #include "Utils/StreamUtils.h" #include @@ -23,130 +24,11 @@ using namespace IW3; namespace { - constexpr auto BSP_MAGIC = std::array{'I', 'B', 'S', 'P'}; - constexpr auto BSP_VERSION = 22u; - - // 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 BspLumpType : unsigned - { - 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, - LUMP_PATHCONNECTIONS = 40, // SP path data; absent for many 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, - }; - - // IW3 v22 d3dbsp files use this order in the stock tools output. - constexpr auto LUMP_ORDER = std::array{ - LUMP_MATERIALS, - LUMP_LIGHTMAPS, - LUMP_LIGHTGRID_HEADER, - LUMP_LIGHTGRID_ROWS, - LUMP_LIGHTGRID_ENTRIES, - LUMP_LIGHTGRID_COLORS, - LUMP_PLANES, - LUMP_BRUSHSIDES, - LUMP_BRUSHSIDE_EDGE_COUNTS, - LUMP_BRUSHEDGES, - LUMP_BRUSHES, - LUMP_LAYERED_TRI_SOUPS, - LUMP_LAYERED_VERTS, - LUMP_VERTEX_LAYER_DATA, - LUMP_LAYERED_INDICES, - LUMP_CULLGROUPS, - LUMP_CULLGROUP_INDICES, - LUMP_PORTALVERTS, - LUMP_LAYERED_AABBTREES, - LUMP_CELLS, - LUMP_PORTALS, - LUMP_NODES, - LUMP_LEAFS, - LUMP_LEAFBRUSHES, - LUMP_LEAFSURFACES, - LUMP_COLLISIONVERTS, - LUMP_COLLISIONTRIS, - LUMP_COLLISION_EDGE_WALKABLE, - LUMP_COLLISIONBORDERS, - LUMP_COLLISIONPARTITIONS, - LUMP_COLLISIONAABBS, - LUMP_MODELS, - LUMP_VISIBILITY, - LUMP_ENTITIES, - LUMP_PRIMARY_LIGHTS, - LUMP_LIGHT_REGION_COUNTS, - LUMP_LIGHT_REGION_HULLS, - LUMP_LIGHT_REGION_AXES, - LUMP_SIMPLE_TRI_SOUPS, - LUMP_SIMPLE_VERTS, - LUMP_SIMPLE_INDICES, - LUMP_SIMPLE_CULLGROUPS, - LUMP_SIMPLE_AABBTREES, - LUMP_PATHCONNECTIONS, - LUMP_REFLECTION_PROBES, - }; + using enum IW3::d3dbsp::LumpType; struct BspLump { - BspLumpType id; + IW3::d3dbsp::LumpType id; std::vector data; }; @@ -232,6 +114,11 @@ namespace 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; } @@ -244,16 +131,6 @@ namespace return rawName == "$default" && (assetName == "$default2d" || assetName == "$default3d"); } - [[nodiscard]] std::string GetPartialBspFileName(const std::string& assetName) - { - constexpr auto EXTENSION = std::string_view(".d3dbsp"); - - if (assetName.ends_with(EXTENSION)) - return std::format("{}.partial{}", assetName.substr(0, assetName.size() - EXTENSION.size()), EXTENSION); - - return assetName + ".partial.d3dbsp"; - } - template void Append(std::vector& out, const T& value) { const auto* bytes = reinterpret_cast(&value); @@ -988,54 +865,29 @@ namespace return remaps; } - [[nodiscard]] std::vector DiskOrderedSurfaces(const clipMap_t* clipMap, const GfxWorld& world) - { - std::vector surfaces; - surfaces.reserve(PositiveCount(world.surfaceCount)); - - for (auto i = 0uz; i < PositiveCount(world.surfaceCount); i++) - surfaces.emplace_back(&world.dpvs.surfaces[i]); - - std::stable_sort(surfaces.begin(), - surfaces.end(), - [clipMap](const GfxSurface* left, const GfxSurface* right) - { - const auto leftMaterial = SurfaceMaterialIndex(clipMap, *left); - const auto rightMaterial = SurfaceMaterialIndex(clipMap, *right); - if (leftMaterial != rightMaterial) - return leftMaterial < rightMaterial; - - const auto leftPrimary = RawPrimaryLightIndex(*left); - const auto rightPrimary = RawPrimaryLightIndex(*right); - if (leftPrimary != rightPrimary) - return leftPrimary < rightPrimary; - - return left->tris.baseIndex < right->tris.baseIndex; - }); - - return surfaces; - } - [[nodiscard]] std::vector BuildSurfaces(const clipMap_t* clipMap, const GfxWorld& world, const std::vector& lightmapRemaps) { std::vector out; out.reserve(PositiveCount(world.surfaceCount) * 24uz); - for (const auto* surface : DiskOrderedSurfaces(clipMap, world)) + // 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 surfaceIndex = PointerIndex(world.dpvs.surfaces, PositiveCount(world.surfaceCount), surface); - const auto materialIndex = SurfaceMaterialIndex(clipMap, *surface); + 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); + 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; + 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); @@ -1346,17 +1198,18 @@ namespace const auto& model = world.models[modelIndex]; const auto startSurfIndex = static_cast(model.startSurfIndex); const auto surfaceCount = model.surfaceCount; + const auto surfaceCountNoDecal = model.surfaceCountNoDecal; const uint16_t zeroShort = 0u; const uint32_t zero = 0u; const auto brushCount = modelIndex == 0uz && clipMap ? static_cast(clipMap->numBrushes) : 0u; AppendBytes(out, model.bounds[0], sizeof(model.bounds[0])); AppendBytes(out, model.bounds[1], sizeof(model.bounds[1])); - // v22 reads start/count from the second pair; older paths read the first pair. - // The no-decal count is recomputed by the raw loader from surface/AABB data. + // Raw v22 stores the start index twice for older/newer loader paths, + // followed by the precomputed no-decal split and the total count. Append(out, startSurfIndex); Append(out, startSurfIndex); - Append(out, surfaceCount); + Append(out, surfaceCountNoDecal); Append(out, surfaceCount); Append(out, zeroShort); Append(out, zeroShort); @@ -1389,7 +1242,11 @@ namespace Append(out, surfaceCount); Append(out, childCount); - // Keep the original AABB lump footprint where possible; some Radiant paths are sensitive to later lump positions. + // 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. + // Keep the original lump footprint where possible; some Radiant + // paths are sensitive to later lump positions. for (auto treeIndex = 1uz; treeIndex < totalAabbTreeCount; treeIndex++) { Append(out, 0u); @@ -1655,26 +1512,30 @@ namespace void AppendPrimaryLight(std::vector& out, const ComPrimaryLight& light) { - constexpr auto RAW_PRIMARY_LIGHT_SIZE = 0x80uz; constexpr auto TYPE_OFFSET = 0uz; constexpr auto CAN_USE_SHADOW_MAP_OFFSET = 1uz; + constexpr auto EXPONENT_OFFSET = 2uz; + constexpr auto UNUSED_OFFSET = 3uz; 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 DEF_NAME_OFFSET = 56uz; - constexpr auto DEF_NAME_SIZE = RAW_PRIMARY_LIGHT_SIZE - DEF_NAME_OFFSET; + constexpr auto COS_HALF_FOV_EXPANDED_OFFSET = 52uz; + constexpr auto ROTATION_LIMIT_OFFSET = 56uz; + constexpr auto TRANSLATION_LIMIT_OFFSET = 60uz; const auto baseOffset = out.size(); - out.resize(out.size() + RAW_PRIMARY_LIGHT_SIZE, std::byte{}); + 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 + EXPONENT_OFFSET] = static_cast(light.exponent); + out[baseOffset + UNUSED_OFFSET] = static_cast(light.unused); - const auto exponent = static_cast(static_cast(light.exponent)); + // Raw BSP primary lights are the runtime ComPrimaryLight fields through + // translationLimit only. The runtime defName pointer is not present on disk. std::copy_n(reinterpret_cast(light.color), sizeof(light.color), out.data() + baseOffset + COLOR_OFFSET); std::copy_n(reinterpret_cast(light.dir), sizeof(light.dir), out.data() + baseOffset + DIR_OFFSET); std::copy_n(reinterpret_cast(light.origin), sizeof(light.origin), out.data() + baseOffset + ORIGIN_OFFSET); @@ -1683,12 +1544,12 @@ namespace reinterpret_cast(&light.cosHalfFovOuter), sizeof(light.cosHalfFovOuter), out.data() + baseOffset + COS_HALF_FOV_OUTER_OFFSET); std::copy_n( reinterpret_cast(&light.cosHalfFovInner), sizeof(light.cosHalfFovInner), out.data() + baseOffset + COS_HALF_FOV_INNER_OFFSET); - std::copy_n(reinterpret_cast(&exponent), sizeof(exponent), out.data() + baseOffset + EXPONENT_OFFSET); - - if (light.type >= 2 && light.defName) - std::copy_n(reinterpret_cast(light.defName), - std::min(std::strlen(light.defName), DEF_NAME_SIZE - 1uz), - out.data() + baseOffset + DEF_NAME_OFFSET); + std::copy_n(reinterpret_cast(&light.cosHalfFovExpanded), + sizeof(light.cosHalfFovExpanded), + out.data() + baseOffset + COS_HALF_FOV_EXPANDED_OFFSET); + std::copy_n(reinterpret_cast(&light.rotationLimit), sizeof(light.rotationLimit), out.data() + baseOffset + ROTATION_LIMIT_OFFSET); + std::copy_n( + reinterpret_cast(&light.translationLimit), sizeof(light.translationLimit), out.data() + baseOffset + TRANSLATION_LIMIT_OFFSET); } [[nodiscard]] std::vector BuildPrimaryLights(const ComWorld& comWorld) @@ -1697,7 +1558,7 @@ namespace return {}; std::vector out; - out.reserve(static_cast(comWorld.primaryLightCount) * 0x80uz); + 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; @@ -1762,21 +1623,21 @@ namespace return out; } - void AddLump(std::vector& lumps, const BspLumpType id, std::vector&& data) + 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 BspLumpType id) + [[nodiscard]] size_t LumpOrderIndex(const IW3::d3dbsp::LumpType id) { - for (auto i = 0uz; i < LUMP_ORDER.size(); i++) + for (auto i = 0uz; i < IW3::d3dbsp::LUMP_WRITE_ORDER.size(); i++) { - if (LUMP_ORDER[i] == id) + if (IW3::d3dbsp::LUMP_WRITE_ORDER[i] == id) return i; } - return LUMP_ORDER.size(); + return IW3::d3dbsp::LUMP_WRITE_ORDER.size(); } void SortLumps(std::vector& lumps) @@ -1794,13 +1655,13 @@ namespace SortLumps(lumps); const auto lumpCount = static_cast(lumps.size()); - stream::Write(stream, BSP_MAGIC.data(), BSP_MAGIC.size()); - stream::WriteValue(stream, BSP_VERSION); + 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 = static_cast(lump.id); + const auto id = std::to_underlying(lump.id); const auto size = static_cast(lump.data.size()); stream::WriteValue(stream, id); stream::WriteValue(stream, size); @@ -1833,7 +1694,7 @@ namespace map_d3dbsp const auto* gameWorldSp = gameWorldSpInfo ? gameWorldSpInfo->Asset() : nullptr; // A raw d3dbsp is reconstructed from several loaded map assets. GfxWorld - // alone does not contain enough data for collision, entities, or primary lights. + // alone does not contain enough data. assert(world); assert(clipMap); assert(comWorld); @@ -1841,7 +1702,10 @@ namespace map_d3dbsp if (!world || !clipMap || !comWorld || !mapEnts) return; - const auto primaryLightCount = comWorld->primaryLightCount; + // 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); @@ -1885,14 +1749,14 @@ namespace map_d3dbsp AddLump(lumps, LUMP_ENTITIES, BuildEntities(*mapEnts, clipMap, world)); AddLump(lumps, LUMP_PRIMARY_LIGHTS, BuildPrimaryLights(*comWorld)); - AddLump(lumps, LUMP_LIGHT_REGION_COUNTS, BuildLightRegionCounts(*world, primaryLightCount)); - AddLump(lumps, LUMP_LIGHT_REGION_HULLS, BuildLightRegionHulls(*world, primaryLightCount)); - AddLump(lumps, LUMP_LIGHT_REGION_AXES, BuildLightRegionAxes(*world, primaryLightCount)); + 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(GetPartialBspFileName(asset.m_name)); + const auto assetFile = context.OpenAssetFile(asset.m_name); if (assetFile) WriteBsp(*assetFile, std::move(lumps)); } From e6b142574608362652163abaf6666a2cebdb4982 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Tue, 9 Jun 2026 10:43:52 +0100 Subject: [PATCH 03/35] fix: remove linker consumed map entities from entityString --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 0459c94c3..cb308bce6 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -562,14 +562,32 @@ namespace return true; } + [[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) { - // The original linker consumes misc_model while building static-model - // world data and does not retain those editor-only blocks in MapEnts. - if (block.classname == "misc_model") + if (IsLinkerConsumedMapEntity(block)) continue; out += block.text; From d5e045c5d6b84f6231e15ce81bf7bcfd8be87f30 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Tue, 9 Jun 2026 11:28:30 +0100 Subject: [PATCH 04/35] fix: load d3dbsp primary lights This preserves lightdef names, reads the disk exponent field, derives cosHalfFovExpanded like the linker, and uses the stock sun-light index convention. --- src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h | 7 ++- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 62 +++++++++++++++---- .../Game/IW3/Maps/D3DBspDumperIW3.cpp | 24 ++++--- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h b/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h index e52ad24ce..6e0caeceb 100644 --- a/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h +++ b/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h @@ -9,9 +9,10 @@ namespace IW3::d3dbsp inline constexpr std::array BSP_MAGIC{'I', 'B', 'S', 'P'}; inline constexpr uint32_t BSP_VERSION = 22u; - // Lump 43 stores ComPrimaryLight fields up to translationLimit. The runtime - // defName pointer is not part of the raw BSP record. - inline constexpr size_t RAW_PRIMARY_LIGHT_SIZE = 64uz; + // 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 diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index cb308bce6..56f90cfa3 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -33,17 +33,18 @@ namespace constexpr auto RAW_LIGHT_TYPE_OFFSET = 0uz; constexpr auto RAW_LIGHT_CAN_USE_SHADOW_MAP_OFFSET = 1uz; - constexpr auto RAW_LIGHT_EXPONENT_OFFSET = 2uz; - constexpr auto RAW_LIGHT_UNUSED_OFFSET = 3uz; + 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_COS_HALF_FOV_EXPANDED_OFFSET = 52uz; + 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; @@ -402,13 +403,18 @@ namespace std::unordered_map fields; }; - void ParsePrimaryLightRecord(const std::byte* record, ComPrimaryLight& light) + [[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 = ReadRawByte(record, RAW_LIGHT_EXPONENT_OFFSET); - light.unused = ReadRawByte(record, RAW_LIGHT_UNUSED_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); @@ -416,12 +422,32 @@ namespace 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_COS_HALF_FOV_EXPANDED_OFFSET, light.cosHalfFovExpanded); CopyUnaligned(record + RAW_LIGHT_ROTATION_LIMIT_OFFSET, light.rotationLimit); CopyUnaligned(record + RAW_LIGHT_TRANSLATION_LIMIT_OFFSET, light.translationLimit); - // The raw 64-byte BSP record stops before the runtime defName pointer. - light.defName = nullptr; + // 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::vector QuotedEntityTokens(const std::string& block) @@ -2095,14 +2121,14 @@ namespace 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] = 0; + 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 = static_cast(std::to_integer(record[RAW_LIGHT_EXPONENT_OFFSET])); + light.exponent = ReadI32(record, RAW_LIGHT_EXPONENT_OFFSET); } [[nodiscard]] bool InferWorldPrimaryLightCount(const IW3::d3dbsp::File& bsp, const size_t rawPrimaryLightCount, unsigned& primaryLightCount, std::string& error) @@ -2170,9 +2196,19 @@ namespace if (rawPrimaryLightCount == 0uz) return true; + // 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(), *world.sunLight); + 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 true; @@ -2820,7 +2856,7 @@ namespace 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]); + ParsePrimaryLightRecord(record, comWorld->primaryLights[lightIndex], m_memory); } } diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp index 0fff152c4..014dcd1dd 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -1514,28 +1514,30 @@ namespace { constexpr auto TYPE_OFFSET = 0uz; constexpr auto CAN_USE_SHADOW_MAP_OFFSET = 1uz; - constexpr auto EXPONENT_OFFSET = 2uz; - constexpr auto UNUSED_OFFSET = 3uz; + 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 COS_HALF_FOV_EXPANDED_OFFSET = 52uz; + 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 + EXPONENT_OFFSET] = static_cast(light.exponent); out[baseOffset + UNUSED_OFFSET] = static_cast(light.unused); + out[baseOffset + UNUSED_OFFSET + 1uz] = std::byte{}; - // Raw BSP primary lights are the runtime ComPrimaryLight fields through - // translationLimit only. The runtime defName pointer is not present on disk. + // 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. std::copy_n(reinterpret_cast(light.color), sizeof(light.color), out.data() + baseOffset + COLOR_OFFSET); std::copy_n(reinterpret_cast(light.dir), sizeof(light.dir), out.data() + baseOffset + DIR_OFFSET); std::copy_n(reinterpret_cast(light.origin), sizeof(light.origin), out.data() + baseOffset + ORIGIN_OFFSET); @@ -1544,12 +1546,16 @@ namespace reinterpret_cast(&light.cosHalfFovOuter), sizeof(light.cosHalfFovOuter), out.data() + baseOffset + COS_HALF_FOV_OUTER_OFFSET); std::copy_n( reinterpret_cast(&light.cosHalfFovInner), sizeof(light.cosHalfFovInner), out.data() + baseOffset + COS_HALF_FOV_INNER_OFFSET); - std::copy_n(reinterpret_cast(&light.cosHalfFovExpanded), - sizeof(light.cosHalfFovExpanded), - out.data() + baseOffset + COS_HALF_FOV_EXPANDED_OFFSET); + const auto exponent = static_cast(static_cast(light.exponent)); + std::copy_n(reinterpret_cast(&exponent), sizeof(exponent), out.data() + baseOffset + EXPONENT_OFFSET); std::copy_n(reinterpret_cast(&light.rotationLimit), sizeof(light.rotationLimit), out.data() + baseOffset + ROTATION_LIMIT_OFFSET); std::copy_n( reinterpret_cast(&light.translationLimit), sizeof(light.translationLimit), out.data() + baseOffset + TRANSLATION_LIMIT_OFFSET); + if (light.defName) + { + const auto defNameLength = std::min(std::strlen(light.defName), DEF_NAME_SIZE - 1uz); + std::copy_n(reinterpret_cast(light.defName), defNameLength, out.data() + baseOffset + DEF_NAME_OFFSET); + } } [[nodiscard]] std::vector BuildPrimaryLights(const ComWorld& comWorld) From e16fe2937a424d4709f51b4e05f0ae1f8a9475f8 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Tue, 9 Jun 2026 13:02:16 +0100 Subject: [PATCH 05/35] feat: load d3dbsp dynamic model entities Also mirrors linker_pc angle/quaternion conversion behaviour for dynamic entity poses. --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 353 +++++++++++++++++- 1 file changed, 338 insertions(+), 15 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 56f90cfa3..0ac72a1d5 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -82,7 +82,9 @@ namespace constexpr auto DEFAULT_MATERIAL_REFERENCE_NAME = ",$default"; constexpr auto SKY_LIGHTMAP_INDEX = 31u; constexpr auto PATHCONNECTIONS_VERSION = 8u; - constexpr auto DEG_TO_RAD = 3.14159265358979323846f / 180.0f; + // 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) { @@ -125,6 +127,52 @@ namespace 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]] 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]] bool ValidateRecordLump( const IW3::d3dbsp::File& bsp, const IW3::d3dbsp::Lump* lump, const IW3::d3dbsp::LumpType type, const size_t recordSize, std::string& error) { @@ -637,6 +685,22 @@ namespace 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 parsedScale = ParseFloat(EntityField(block, "modelscale")); @@ -652,22 +716,72 @@ namespace const auto yaw = angles[1] * DEG_TO_RAD; const auto roll = angles[2] * DEG_TO_RAD; - const auto sp = std::sin(pitch); - const auto cp = std::cos(pitch); - const auto sy = std::sin(yaw); - const auto cy = std::cos(yaw); - const auto sr = std::sin(roll); - const auto cr = std::cos(roll); + 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] = cp * cy; - axis[0][1] = cp * sy; + axis[0][0] = LinkerFloat(static_cast(cp) * cy); + axis[0][1] = LinkerFloat(static_cast(cp) * sy); axis[0][2] = -sp; - axis[1][0] = sr * sp * cy - cr * sy; - axis[1][1] = sr * sp * sy + cr * cy; - axis[1][2] = sr * cp; - axis[2][0] = cr * sp * cy + sr * sy; - axis[2][1] = cr * sp * sy - sr * cy; - axis[2][2] = cr * cp; + + const auto srSp = LinkerFloat(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 = LinkerFloat(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( @@ -734,6 +848,73 @@ namespace 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, @@ -785,6 +966,83 @@ namespace } } + 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++) @@ -2390,6 +2648,60 @@ namespace return true; } + [[nodiscard]] bool PopulateWorldDynamicEntities(GfxWorld& world, const clipMap_t* clipMap, MemoryManager& memory, std::string& error) + { + if (!clipMap) + return true; + + if (world.dpvsPlanes.cellCount < 0) + { + error = "negative world cell count"; + return false; + } + + 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 true; + } + [[nodiscard]] bool PopulateWorldDpvsPlanes(GfxWorld& world, const clipMap_t* clipMap, MemoryManager& memory, std::string& error) { if (!clipMap) @@ -2455,7 +2767,10 @@ namespace 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) @@ -2469,6 +2784,13 @@ namespace 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))); } @@ -2798,6 +3120,7 @@ namespace || !PopulateWorldPrimaryLights(*world, *bsp, m_memory, error) || !PopulateWorldLightGrid(*world, *bsp, m_memory, error) || !PopulateWorldLightRegions(*world, *bsp, m_memory, error) || !PopulateWorldStaticModels(*world, clipMap, staticModelBlocks, staticModelDependencies, m_memory, error) + || !PopulateWorldDynamicEntities(*world, clipMap, m_memory, error) || !PopulateWorldLightmaps(*world, assetName, *bsp, context, registration, m_memory, error) || !PopulateWorldReflectionProbes(*world, assetName, *bsp, context, registration, m_memory, error)) { From 89cce69aedb6266ce1a4f81616c2a14e87282674 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Tue, 9 Jun 2026 14:11:22 +0100 Subject: [PATCH 06/35] fix: preserve d3dbsp leaf records from OAT-built fastfiles Use the loaded leaf cluster value directly and recover firstLeafBrush from the packed leafbrush cursor when OAT-built fastfiles contain copied per-node leafbrush arrays. This keeps static model visibility intact when dumping BSPs from OAT-built fastfiles. --- .../Game/IW3/Maps/D3DBspDumperIW3.cpp | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp index 014dcd1dd..642ed4c46 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -311,7 +311,6 @@ namespace std::vector out; out.reserve(static_cast(clipMap.numLeafs) * 24uz); - auto nextCluster = 0; auto runningFirstLeafBrush = 0; for (auto leafIndex = 0uz; leafIndex < clipMap.numLeafs; leafIndex++) @@ -321,12 +320,23 @@ namespace ? &clipMap.leafbrushNodes[leaf.leafBrushNode] : nullptr; const auto leafBrushCount = leafBrushNode && leafBrushNode->leafBrushCount > 0 ? static_cast(leafBrushNode->leafBrushCount) : 0; - const auto firstLeafBrush = leafBrushCount > 0 - ? static_cast(PointerIndex(clipMap.leafbrushes, clipMap.numLeafBrushes, leafBrushNode->data.leaf.brushes)) - : runningFirstLeafBrush; + auto firstLeafBrush = runningFirstLeafBrush; + if (leafBrushCount > 0) + { + const auto brushesIndex = PointerIndex(clipMap.leafbrushes, clipMap.numLeafBrushes, leafBrushNode->data.leaf.brushes); + + // Official linker-built assets keep leaf nodes pointing into + // clipMap.leafbrushes. OAT-built fastfiles can contain copied + // per-node brush arrays instead, so recover the raw leaf range + // from the monotonically packed leafbrush cursor in that case. + if (brushesIndex < PositiveCount(clipMap.numLeafBrushes)) + firstLeafBrush = static_cast(brushesIndex); + } - // Leaf 0 is a dummy leaf. Real empty-space clusters start at the next non-solid leaf. - const auto cluster = leafBrushCount > 0 ? -1 : leafIndex == 0uz ? 0 : nextCluster++; + // 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); From 5ed889b61d080174aad1a4bfe25cfbf5d4fa913c Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Tue, 9 Jun 2026 15:13:55 +0100 Subject: [PATCH 07/35] fix(iw3): recover d3dbsp leafbrush ranges for OAT-built fastfiles Recover raw leafbrush offsets when leaf brush nodes contain copied per-node arrays instead of pointers into the shared leafbrush list, preserving byte-identical BSP dumps after OAT round trips. --- .../Game/IW3/Maps/D3DBspDumperIW3.cpp | 68 +++++++++++++++++-- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp index 642ed4c46..e41601abb 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -176,23 +176,64 @@ namespace return value->name[0] == ',' ? &value->name[1] : value->name; } - template [[nodiscard]] size_t PointerIndex(const T* base, const size_t count, const T* value) + template [[nodiscard]] bool TryPointerIndex(const T* base, const size_t count, const T* value, size_t& index) { if (!base || !value || count == 0) - return 0uz; + 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 0uz; + return false; const auto offset = valueAddress - baseAddress; if (offset % sizeof(T) != 0) - return 0uz; + return false; + + index = offset / sizeof(T); + return true; + } - return offset / sizeof(T); + 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; } [[nodiscard]] std::vector BuildMaterials(const clipMap_t& clipMap) @@ -323,14 +364,27 @@ namespace auto firstLeafBrush = runningFirstLeafBrush; if (leafBrushCount > 0) { - const auto brushesIndex = PointerIndex(clipMap.leafbrushes, clipMap.numLeafBrushes, leafBrushNode->data.leaf.brushes); + auto brushesIndex = 0uz; // Official linker-built assets keep leaf nodes pointing into // clipMap.leafbrushes. OAT-built fastfiles can contain copied // per-node brush arrays instead, so recover the raw leaf range // from the monotonically packed leafbrush cursor in that case. - if (brushesIndex < PositiveCount(clipMap.numLeafBrushes)) + if (TryPointerIndex(clipMap.leafbrushes, clipMap.numLeafBrushes, leafBrushNode->data.leaf.brushes, brushesIndex)) + { firstLeafBrush = static_cast(brushesIndex); + } + else + { + brushesIndex = FindLeafBrushRange(clipMap.leafbrushes, + clipMap.numLeafBrushes, + leafBrushNode->data.leaf.brushes, + static_cast(leafBrushCount), + static_cast(runningFirstLeafBrush)); + + if (brushesIndex < clipMap.numLeafBrushes) + firstLeafBrush = static_cast(brushesIndex); + } } // The raw leaf record carries the cluster/cell assignment even for From b15f9114474610f159608d6246eb73c282f7340d Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 07:06:33 +0100 Subject: [PATCH 08/35] fix: improve IW3 d3dbsp clipmap collision parity Rebuild clipmap leafbrush nodes, box hull data, visibility fallback, material content masking, and collision partition pointers from raw BSP data. Also improve d3dbsp dumping of raw material contents, leafbrush ranges, and submodel brush/AABB ranges for more stable linker_pc-compatible round trips. --- src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h | 2 + .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 626 ++++++++++++++++-- .../Game/IW3/Maps/D3DBspDumperIW3.cpp | 199 +++++- 3 files changed, 732 insertions(+), 95 deletions(-) diff --git a/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h b/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h index 6e0caeceb..4c8e81ea8 100644 --- a/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h +++ b/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h @@ -8,6 +8,8 @@ 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 diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 0ac72a1d5..3070bddc3 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -215,6 +215,12 @@ namespace 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)); @@ -320,6 +326,18 @@ namespace 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]; @@ -1088,27 +1106,21 @@ namespace return std::sqrt(radiusSquared); } - void SetLeafBoundsFromCollisionAabbs(const clipMap_t& clipMap, cLeaf_t& leaf) + [[nodiscard]] int LeafTerrainContents(const clipMap_t& clipMap, const cLeaf_t& leaf) { - if (!clipMap.aabbTrees || leaf.collAabbCount == 0u || leaf.firstCollAabbIndex >= clipMap.aabbTreeCount) - return; - - for (auto axis = 0uz; axis < 3uz; axis++) - { - leaf.mins[axis] = std::numeric_limits::max(); - leaf.maxs[axis] = std::numeric_limits::lowest(); - } + 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& aabb = clipMap.aabbTrees[aabbIndex]; - for (auto axis = 0uz; axis < 3uz; axis++) - { - leaf.mins[axis] = std::min(leaf.mins[axis], aabb.origin[axis] - aabb.halfSize[axis]); - leaf.maxs[axis] = std::max(leaf.maxs[axis], aabb.origin[axis] + aabb.halfSize[axis]); - } + 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) @@ -1134,6 +1146,280 @@ namespace 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]] std::optional PartitionLeafBrushes_r( + const clipMap_t& clipMap, + std::vector& nodes, + LeafBrush* leafBrushes, + const int leafBrushCount, + const float (&mins)[3], + const float (&maxs)[3], + std::string& error) + { + if (leafBrushCount <= 0) + { + error = "cannot partition an empty leafbrush range"; + return std::nullopt; + } + + 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, error); + if (!childNodeIndex) + return std::nullopt; + + 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) + { + error = "leafbrush partition produced an empty child"; + return std::nullopt; + } + + 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, error); + if (!childNodeIndex) + return std::nullopt; + + const auto childOffset = *childNodeIndex - nodeIndex; + if (childOffset > std::numeric_limits::max()) + { + error = "leafbrush partition child offset exceeded uint16 range"; + return std::nullopt; + } + + 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()) + { + error = "leafbrush partition leaf count exceeded int16 range"; + return std::nullopt; + } + + 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) + { + error = "leafbrush partition produced a leaf with no contents"; + return std::nullopt; + } + + nodes[nodeIndex].data.leaf.brushes = leafBrushes; + return nodeIndex; + } + + [[nodiscard]] bool PartitionLeafBrushes( + const clipMap_t& clipMap, + std::vector& nodes, + LeafBrush* leafBrushes, + const int leafBrushCount, + cLeaf_t& leaf, + std::string& error) + { + leaf.brushContents = 0; + leaf.terrainContents = LeafTerrainContents(clipMap, leaf); + leaf.leafBrushNode = 0; + + if (leafBrushCount <= 0) + return true; + + 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) + { + error = "leafbrush references invalid brush"; + return false; + } + + 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, error); + if (!nodeIndex) + return false; + + leaf.leafBrushNode = static_cast(*nodeIndex); + return true; + } + [[nodiscard]] bool PopulateClipMapMaterials(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) { const auto* materials = bsp.GetLump(LUMP_MATERIALS); @@ -1155,7 +1441,12 @@ namespace 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); - clipMap.materials[i].contentFlags = ReadI32(record, 68uz); + // 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 true; @@ -1235,7 +1526,10 @@ namespace } clipMap.numBrushes = static_cast(brushCount); - clipMap.brushes = AllocZeroed(memory, 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; @@ -1353,7 +1647,11 @@ namespace { const auto* leafBrushes = bsp.GetLump(LUMP_LEAFBRUSHES); if (!leafBrushes) + { + clipMap.numLeafBrushes = 0u; + clipMap.leafbrushes = AllocZeroed(memory, 1uz); return true; + } if (leafBrushes->data.size() % RAW_LEAF_BRUSH_SIZE != 0uz) { @@ -1369,7 +1667,10 @@ namespace } clipMap.numLeafBrushes = static_cast(leafBrushCount); - clipMap.leafbrushes = AllocZeroed(memory, 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++) { @@ -1443,12 +1744,13 @@ namespace 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 + borderCount > static_cast(std::max(clipMap.borderCount, 0))) + if (borderCount > 0u && (borderIndex > runtimeBorderCount || borderCount > runtimeBorderCount - borderIndex)) { error = "collision partition references invalid border"; return false; @@ -1457,7 +1759,11 @@ namespace clipMap.partitions[i].triCount = static_cast(std::to_integer(record[2])); clipMap.partitions[i].borderCount = borderCount; clipMap.partitions[i].firstTri = ReadI32(record, 4uz); - clipMap.partitions[i].borders = clipMap.borders && borderCount > 0u ? &clipMap.borders[borderIndex] : nullptr; + // 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; } } @@ -1490,20 +1796,9 @@ namespace } const auto leafCount = RecordCount(*leafs, RAW_LEAF_SIZE); - auto leafBrushNodeCount = 0uz; - for (auto leafIndex = 0uz; leafIndex < leafCount; leafIndex++) - { - const auto* record = leafs->data.data() + leafIndex * RAW_LEAF_SIZE; - if (ReadI32(record, 16uz) > 0) - leafBrushNodeCount++; - } - clipMap.numLeafs = static_cast(leafCount); clipMap.leafs = AllocZeroed(memory, leafCount); - clipMap.leafbrushNodesCount = static_cast(leafBrushNodeCount); - clipMap.leafbrushNodes = leafBrushNodeCount > 0uz ? AllocZeroed(memory, leafBrushNodeCount) : nullptr; - auto nextLeafBrushNode = 0uz; auto maxCluster = -1; for (auto leafIndex = 0uz; leafIndex < leafCount; leafIndex++) { @@ -1512,10 +1807,8 @@ namespace const auto cluster = ReadI32(record); const auto firstCollAabbIndex = ReadI32(record, 4uz); const auto collAabbCount = ReadI32(record, 8uz); - const auto firstLeafBrush = ReadI32(record, 12uz); - const auto leafBrushCount = ReadI32(record, 16uz); - if (firstCollAabbIndex < 0 || collAabbCount < 0 || firstLeafBrush < 0 || leafBrushCount < 0) + if (firstCollAabbIndex < 0 || collAabbCount < 0) { error = "leaf contains negative runtime count/index"; return false; @@ -1525,38 +1818,116 @@ namespace 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 = -1; - SetLeafBoundsFromCollisionAabbs(clipMap, leaf); + leaf.leafBrushNode = 0; if (cluster >= 0) maxCluster = std::max(maxCluster, cluster); + } + + clipMap.numClusters = maxCluster + 1; + return true; + } + + [[nodiscard]] bool PopulateClipMapLeafBrushNodes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + const auto* leafs = bsp.GetLump(LUMP_LEAFS); + if (!leafs || !clipMap.leafs) + return true; + + if (leafs->data.size() % RAW_LEAF_SIZE != 0uz || RecordCount(*leafs, RAW_LEAF_SIZE) != clipMap.numLeafs) + { + error = "leaf lump changed before leafbrush node build"; + return false; + } - if (leafBrushCount > 0) + // 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) + { + error = "leaf contains negative leafbrush range"; + return false; + } + + if (static_cast(firstLeafBrush + leafBrushCount) > clipMap.numLeafBrushes) + { + error = "leaf references invalid leafbrush range"; + return false; + } + + if (!PartitionLeafBrushes(clipMap, nodes, &clipMap.leafbrushes[firstLeafBrush], leafBrushCount, clipMap.leafs[leafIndex], error)) + return false; + } + + 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) { - if (static_cast(firstLeafBrush + leafBrushCount) > clipMap.numLeafBrushes || nextLeafBrushNode >= leafBrushNodeCount) + error = "model lump changed before leafbrush node build"; + return false; + } + + 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) { - error = "leaf references invalid leafbrush range"; + error = "model references invalid brush range"; return false; } - auto& node = clipMap.leafbrushNodes[nextLeafBrushNode]; - node.axis = -1; - node.leafBrushCount = static_cast(std::min(leafBrushCount, static_cast(std::numeric_limits::max()))); - node.data.leaf.brushes = &clipMap.leafbrushes[firstLeafBrush]; - for (auto brushOffset = 0; brushOffset < leafBrushCount; brushOffset++) - { - const auto brushIndex = clipMap.leafbrushes[firstLeafBrush + brushOffset]; - if (brushIndex < clipMap.numBrushes) - node.contents |= clipMap.brushes[brushIndex].contents; - } + auto* modelLeafBrushes = AllocZeroed(memory, brushCount); + for (auto brushOffset = 0u; brushOffset < brushCount; brushOffset++) + modelLeafBrushes[brushOffset] = static_cast(firstBrush + brushOffset); - leaf.brushContents = node.contents; - leaf.leafBrushNode = static_cast(nextLeafBrushNode); - nextLeafBrushNode++; + if (!PartitionLeafBrushes(clipMap, nodes, modelLeafBrushes, static_cast(brushCount), clipMap.cmodels[modelIndex].leaf, error)) + return false; } } - clipMap.numClusters = maxCluster + 1; + 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 true; } @@ -1580,40 +1951,73 @@ namespace { const auto* record = models->data.data() + modelIndex * RAW_MODEL_SIZE; auto& model = clipMap.cmodels[modelIndex]; - CopyFloat3(record, model.mins); - CopyFloat3(record + 12uz, model.maxs); + 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 (clipMap.numLeafs > 0u && clipMap.leafs) - model.leaf = clipMap.leafs[0]; + + 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()) + { + error = "model collision AABB range exceeded uint16"; + return false; + } + + model.leaf.firstCollAabbIndex = static_cast(firstCollAabbIndex); + model.leaf.collAabbCount = static_cast(collAabbCount); + } } - return true; + return PopulateClipMapLeafBrushNodes(clipMap, bsp, memory, error); } [[nodiscard]] bool PopulateClipMapVisibility(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) { 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 true; + } - clipMap.visibility = AllocCopy(memory, visibility->data); - clipMap.vised = 1; - - if (clipMap.numClusters > 0) + if (visibility->data.size() < 8uz) { - if (visibility->data.size() % static_cast(clipMap.numClusters) != 0uz) - { - error = "visibility lump does not divide by cluster count"; - return false; - } + error = "visibility lump has a truncated header"; + return false; + } - clipMap.clusterBytes = static_cast(visibility->data.size() / static_cast(clipMap.numClusters)); + const auto numClusters = ReadI32(visibility->data.data()); + const auto clusterBytes = ReadI32(visibility->data.data(), 4uz); + if (numClusters < 0 || clusterBytes < 0) + { + error = "visibility lump has negative dimensions"; + return false; } - else + + const auto expectedSize = 8uz + static_cast(numClusters) * static_cast(clusterBytes); + if (visibility->data.size() != expectedSize) { - clipMap.clusterBytes = static_cast(visibility->data.size()); + error = "visibility lump size does not match its header"; + return false; } + 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 true; } @@ -2285,7 +2689,9 @@ namespace } world.dpvsPlanes.cellCount = static_cast(cellCount); - world.cellBitsCount = static_cast((cellCount + 31uz) / 32uz); + // 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++) @@ -2702,6 +3108,80 @@ namespace return true; } + 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 = 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))); + } + } + } + [[nodiscard]] bool PopulateWorldDpvsPlanes(GfxWorld& world, const clipMap_t* clipMap, MemoryManager& memory, std::string& error) { if (!clipMap) @@ -3094,7 +3574,8 @@ namespace auto* world = AllocZeroed(m_memory); world->name = m_memory.Dup(assetName.c_str()); - world->baseName = world->name; + const auto baseName = BspBaseName(assetName); + world->baseName = m_memory.Dup(baseName.c_str()); SetOutdoorLookupIdentity(*world); AssetRegistration registration(assetName, world); @@ -3128,6 +3609,7 @@ namespace return AssetCreationResult::Failure(); } + PopulateWorldRuntimeData(*world, m_memory); PopulateWorldSkySurfaces(*world, m_memory); return AssetCreationResult::Success(context.AddAsset(std::move(registration))); } diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp index e41601abb..700770154 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -156,6 +156,53 @@ namespace out.insert(out.end(), bytes, bytes + size); } + 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; @@ -236,17 +283,93 @@ namespace 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, material.contentFlags); + Append(out, contentFlags); } return out; @@ -360,31 +483,40 @@ namespace const auto* leafBrushNode = leaf.leafBrushNode >= 0 && static_cast(leaf.leafBrushNode) < clipMap.leafbrushNodesCount ? &clipMap.leafbrushNodes[leaf.leafBrushNode] : nullptr; - const auto leafBrushCount = leafBrushNode && leafBrushNode->leafBrushCount > 0 ? static_cast(leafBrushNode->leafBrushCount) : 0; + auto leafBrushCount = 0; auto firstLeafBrush = runningFirstLeafBrush; - if (leafBrushCount > 0) + if (leafBrushNode) { - auto brushesIndex = 0uz; + auto recoveredRange = LeafBrushRange{}; - // Official linker-built assets keep leaf nodes pointing into - // clipMap.leafbrushes. OAT-built fastfiles can contain copied - // per-node brush arrays instead, so recover the raw leaf range - // from the monotonically packed leafbrush cursor in that case. - if (TryPointerIndex(clipMap.leafbrushes, clipMap.numLeafBrushes, leafBrushNode->data.leaf.brushes, brushesIndex)) + // 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(brushesIndex); + firstLeafBrush = static_cast(recoveredRange.first); + leafBrushCount = static_cast(recoveredRange.end - recoveredRange.first); } else { - brushesIndex = FindLeafBrushRange(clipMap.leafbrushes, - clipMap.numLeafBrushes, - leafBrushNode->data.leaf.brushes, - static_cast(leafBrushCount), - static_cast(runningFirstLeafBrush)); - - if (brushesIndex < clipMap.numLeafBrushes) - firstLeafBrush = static_cast(brushesIndex); + 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 @@ -1263,9 +1395,30 @@ namespace const auto startSurfIndex = static_cast(model.startSurfIndex); const auto surfaceCount = model.surfaceCount; const auto surfaceCountNoDecal = model.surfaceCountNoDecal; - const uint16_t zeroShort = 0u; const uint32_t zero = 0u; - const auto brushCount = modelIndex == 0uz && clipMap ? static_cast(clipMap->numBrushes) : 0u; + 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])); @@ -1275,10 +1428,10 @@ namespace Append(out, startSurfIndex); Append(out, surfaceCountNoDecal); Append(out, surfaceCount); - Append(out, zeroShort); - Append(out, zeroShort); - Append(out, zero); + Append(out, static_cast(firstCollAabbIndex)); + Append(out, static_cast(collAabbCount)); Append(out, zero); + Append(out, firstBrush); Append(out, brushCount); } From 75133c4f7d54d1facef11121cae9cc35c6d4fe35 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 08:50:53 +0100 Subject: [PATCH 09/35] fix: merge d3dbsp lightmaps like linker_pc --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 478 +++++++++++++++++- 1 file changed, 451 insertions(+), 27 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 3070bddc3..39745f9d7 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -73,6 +73,8 @@ namespace 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; @@ -81,6 +83,7 @@ namespace constexpr auto DEFAULT_MATERIAL_NAME = "$default"; constexpr auto DEFAULT_MATERIAL_REFERENCE_NAME = ",$default"; 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. @@ -2060,6 +2063,284 @@ namespace return SelectWorldLump(bsp, simple, layered); } + 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]] 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]] bool BuildLightmapCouplingMatrix( + const IW3::d3dbsp::File& bsp, + const unsigned rawPageCount, + std::array& coupling, + std::string& error) + { + const auto* surfaces = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_TRI_SOUPS, LUMP_LAYERED_TRI_SOUPS); + if (!surfaces) + return true; + + if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) + { + error = "world surface lump has funny size"; + return false; + } + + 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) + { + error = std::format("world surface {} references missing lightmap page {}", surfaceIndex, lightmapIndex); + return false; + } + + 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 true; + } + + [[nodiscard]] bool ReferencedLightmapPageCount(const IW3::d3dbsp::File& bsp, unsigned& pageCount, std::string& error) + { + pageCount = 0u; + const auto* surfaces = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_TRI_SOUPS, LUMP_LAYERED_TRI_SOUPS); + if (!surfaces) + return true; + + if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) + { + error = "world surface lump has funny size"; + return false; + } + + 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) + { + error = std::format("world surface {} has invalid lightmap page {}", surfaceIndex, lightmapIndex); + return false; + } + + pageCount = std::max(pageCount, lightmapIndex + 1u); + } + + return true; + } + + [[nodiscard]] bool BuildLightmapAtlasLayout(const IW3::d3dbsp::File& bsp, LightmapAtlasLayout& layout, std::string& error) + { + const auto* lightmaps = bsp.GetLump(LUMP_LIGHTMAPS); + if (!lightmaps || lightmaps->data.empty()) + return true; + + if (lightmaps->data.size() % LIGHTMAP_RAW_PAGE_SIZE != 0uz || !FitsUnsigned(lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE)) + { + error = "lightmap lump has funny size"; + return false; + } + + layout.rawPageCount = static_cast(lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE); + if (layout.rawPageCount > MAX_LIGHTMAP_PAGE_COUNT) + { + error = std::format("lightmap lump has too many pages: {}", layout.rawPageCount); + return false; + } + + unsigned referencedPageCount = 0u; + if (!ReferencedLightmapPageCount(bsp, referencedPageCount, error)) + return false; + + // 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) + { + error = std::format("lightmap page count {} does not match surface references {}", layout.rawPageCount, referencedPageCount); + return false; + } + + std::array coupling{}; + if (!BuildLightmapCouplingMatrix(bsp, layout.rawPageCount, coupling, error)) + return false; + + 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) + { + error = "could not pair lightmap pages"; + return false; + } + + // 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) + { + error = "could not extend lightmap atlas group"; + return false; + } + + 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 true; + } + [[nodiscard]] std::vector WorldMaterialNameCandidates(const std::string& rawMaterialName) { std::vector result; @@ -2321,9 +2602,52 @@ namespace } } + 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]] bool PopulateWorldSurfaces( GfxWorld& world, const IW3::d3dbsp::File& bsp, + const LightmapAtlasLayout& lightmapLayout, const std::vector*>& materialDependencies, MemoryManager& memory, std::string& error) @@ -2344,6 +2668,7 @@ namespace world.dpvs.litSurfsBegin = 0u; world.dpvs.litSurfsEnd = static_cast(world.surfaceCount); world.dpvs.surfaces = AllocZeroed(memory, world.surfaceCount); + std::vector vertexLightmapRemaps(world.vertexCount, -1); for (auto surfaceIndex = 0uz; surfaceIndex < static_cast(world.surfaceCount); surfaceIndex++) { @@ -2358,7 +2683,8 @@ namespace surface.material = materialDependencies[materialIndex]->Asset(); - surface.lightmapIndex = static_cast(std::to_integer(record[2])); + 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])); @@ -2367,6 +2693,21 @@ namespace surface.tris.vertexCount = ReadU16(record, 16uz); surface.tris.triCount = static_cast(ReadU16(record, 18uz) / 3u); surface.tris.baseIndex = ReadI32(record, 20uz); + + 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); + } + } + PopulateSurfaceBounds(world, surface); } @@ -2407,10 +2748,81 @@ namespace 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]] std::pair, std::vector> + BuildLightmapAtlasImages(const IW3::d3dbsp::Lump& lightmaps, const LightmapAtlasGroup& group) + { + 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)); + } + + return std::make_pair(std::move(primary), std::move(secondary)); + } + [[nodiscard]] bool PopulateWorldLightmaps( GfxWorld& world, - const std::string& assetName, const IW3::d3dbsp::File& bsp, + const LightmapAtlasLayout& lightmapLayout, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory, @@ -2426,44 +2838,48 @@ namespace return false; } - const auto pageCount = lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE; - world.lightmapCount = static_cast(pageCount); - world.lightmaps = AllocZeroed(memory, pageCount); - - for (auto pageIndex = 0uz; pageIndex < pageCount; pageIndex++) + const auto pageCount = static_cast(lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE); + if (pageCount != lightmapLayout.rawPageCount) { - const auto* page = lightmaps->data.data() + pageIndex * LIGHTMAP_RAW_PAGE_SIZE; - const auto* secondaryData = page; - const auto* primaryData = page + LIGHTMAP_SECONDARY_RAW_PAGE_SIZE; + error = "lightmap atlas layout does not match lightmap lump"; + return false; + } + + world.lightmapCount = static_cast(lightmapLayout.groups.size()); + world.lightmaps = AllocZeroed(memory, lightmapLayout.groups.size()); - const auto primaryName = GeneratedImageName(assetName, "lightmap_primary", pageIndex); - const auto secondaryName = GeneratedImageName(assetName, "lightmap_secondary", pageIndex); + for (auto lightmapIndex = 0uz; lightmapIndex < lightmapLayout.groups.size(); lightmapIndex++) + { + const auto& group = lightmapLayout.groups[lightmapIndex]; + const auto [primaryPixels, secondaryPixels] = BuildLightmapAtlasImages(*lightmaps, group); + 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_COLOR_MAP, + TS_FUNCTION, IMG_CATEGORY_LIGHTMAP, - LIGHTMAP_PRIMARY_RAW_WIDTH, - LIGHTMAP_PRIMARY_RAW_HEIGHT, + static_cast(group.wideCount * LIGHTMAP_PRIMARY_RAW_WIDTH), + static_cast(group.highCount * LIGHTMAP_PRIMARY_RAW_HEIGHT), 1u, - oat::D3DFMT_A8, + oat::D3DFMT_L8, lightmapFlags, - primaryData, - LIGHTMAP_PRIMARY_RAW_PAGE_SIZE); + primaryPixels.data(), + primaryPixels.size()); auto* secondary = CreateGeneratedImage(memory, secondaryName, MAPTYPE_2D, - TS_COLOR_MAP, + TS_FUNCTION, IMG_CATEGORY_LIGHTMAP, - LIGHTMAP_SECONDARY_RAW_WIDTH, - LIGHTMAP_SECONDARY_RAW_HEIGHT, + static_cast(group.wideCount * LIGHTMAP_SECONDARY_RAW_WIDTH), + static_cast(group.highCount * LIGHTMAP_SECONDARY_RAW_HEIGHT), 1u, oat::D3DFMT_A8R8G8B8, lightmapFlags, - secondaryData, - LIGHTMAP_SECONDARY_RAW_PAGE_SIZE); + secondaryPixels.data(), + secondaryPixels.size()); auto* primaryInfo = AddGeneratedImage(context, registration, primaryName, primary); auto* secondaryInfo = AddGeneratedImage(context, registration, secondaryName, secondary); @@ -2473,8 +2889,8 @@ namespace return false; } - world.lightmaps[pageIndex].primary = primaryInfo->Asset(); - world.lightmaps[pageIndex].secondary = secondaryInfo->Asset(); + world.lightmaps[lightmapIndex].primary = primaryInfo->Asset(); + world.lightmaps[lightmapIndex].secondary = secondaryInfo->Asset(); } return true; @@ -3572,6 +3988,13 @@ namespace const auto staticModelBlocks = StaticModelEntityBlocks(entityBlocks); const auto staticModelDependencies = LoadStaticModelDependencies(staticModelBlocks, context); + LightmapAtlasLayout lightmapLayout; + if (!BuildLightmapAtlasLayout(*bsp, lightmapLayout, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + auto* world = AllocZeroed(m_memory); world->name = m_memory.Dup(assetName.c_str()); const auto baseName = BspBaseName(assetName); @@ -3596,13 +4019,14 @@ namespace const auto* clipMap = clipMapDependency->Asset(); if (!PopulateWorldDpvsPlanes(*world, clipMap, m_memory, error) || !PopulateWorldIndices(*world, *bsp, m_memory, error) || !PopulateWorldVertices(*world, *bsp, m_memory, error) - || !PopulateWorldSurfaces(*world, *bsp, materialDependencies, m_memory, error) || !PopulateWorldVertexLayerData(*world, *bsp, m_memory, error) + || !PopulateWorldSurfaces(*world, *bsp, lightmapLayout, materialDependencies, m_memory, error) + || !PopulateWorldVertexLayerData(*world, *bsp, m_memory, error) || !PopulateWorldModels(*world, *bsp, m_memory, error) || !PopulateWorldCells(*world, *bsp, m_memory, error) || !PopulateWorldPrimaryLights(*world, *bsp, m_memory, error) || !PopulateWorldLightGrid(*world, *bsp, m_memory, error) || !PopulateWorldLightRegions(*world, *bsp, m_memory, error) || !PopulateWorldStaticModels(*world, clipMap, staticModelBlocks, staticModelDependencies, m_memory, error) || !PopulateWorldDynamicEntities(*world, clipMap, m_memory, error) - || !PopulateWorldLightmaps(*world, assetName, *bsp, context, registration, m_memory, error) + || !PopulateWorldLightmaps(*world, *bsp, lightmapLayout, context, registration, m_memory, error) || !PopulateWorldReflectionProbes(*world, assetName, *bsp, context, registration, m_memory, error)) { con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); From af6baa67847dbeba5b540be09e4b3f32d438a6f2 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 09:41:28 +0100 Subject: [PATCH 10/35] fix: preserve d3dbsp gndLt only for lit static models --- src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 39745f9d7..64877e7f7 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -3368,13 +3368,21 @@ namespace if (!b || !g || !r || !a || !primaryLightIndex) return; + drawInst.primaryLightIndex = static_cast(*primaryLightIndex); + + // 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. + // In the drop path it recomputes primaryLightIndex from the model + // bounds/light regions; keep the parsed index until that lookup exists. + if (!drawInst.model || (static_cast(drawInst.model->flags) & 1u) == 0u || (*r == 0u && *g == 0u && *b == 0u && *a == 0u)) + 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); - drawInst.primaryLightIndex = static_cast(*primaryLightIndex); } [[nodiscard]] bool PopulateWorldStaticModels( From 5c0f0898885e18feee745ce215064f426777d8f6 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 10:06:34 +0100 Subject: [PATCH 11/35] fix: write d3dbsp model collision ranges as u32 --- src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp index 700770154..f271e7a34 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -1395,7 +1395,6 @@ namespace const auto startSurfIndex = static_cast(model.startSurfIndex); const auto surfaceCount = model.surfaceCount; const auto surfaceCountNoDecal = model.surfaceCountNoDecal; - const uint32_t zero = 0u; auto firstCollAabbIndex = 0u; auto collAabbCount = 0u; auto firstBrush = 0u; @@ -1428,9 +1427,10 @@ namespace Append(out, startSurfIndex); Append(out, surfaceCountNoDecal); Append(out, surfaceCount); - Append(out, static_cast(firstCollAabbIndex)); - Append(out, static_cast(collAabbCount)); - Append(out, zero); + // 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); } From bbe9e370a0872f1bc5cb0093f95c39fee0c30068 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 11:03:24 +0100 Subject: [PATCH 12/35] fix: populate d3dbsp GfxWorld runtime buffers OAT-built fastfiles from raw d3dbsp data now load into game with no crashes. World is not rendering at this time. --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 281 +++++++++++++++++- 1 file changed, 265 insertions(+), 16 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 64877e7f7..a02785450 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -51,6 +51,7 @@ namespace 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; @@ -2847,6 +2848,11 @@ namespace 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++) { @@ -2909,9 +2915,42 @@ namespace } } + [[nodiscard]] bool CreateDefaultReflectionProbe( + GfxWorld& world, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory, std::string& error) + { + 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) + { + error = "could not register generated default reflection probe image"; + return false; + } + + world.reflectionProbes[0].reflectionImage = imageInfo->Asset(); + return true; + } + [[nodiscard]] bool PopulateWorldReflectionProbes( GfxWorld& world, - const std::string& assetName, const IW3::d3dbsp::File& bsp, AssetCreationContext& context, AssetRegistration& registration, @@ -2923,7 +2962,8 @@ namespace { world.reflectionProbeCount = 1u; world.reflectionProbes = AllocZeroed(memory, 1uz); - return true; + world.reflectionProbeTextures = AllocZeroed(memory, 1uz); + return CreateDefaultReflectionProbe(world, context, registration, memory, error); } if (reflectionProbes->data.size() % REFLECTION_PROBE_RECORD_SIZE != 0uz || !FitsUnsigned(reflectionProbes->data.size() / REFLECTION_PROBE_RECORD_SIZE + 1uz)) @@ -2935,6 +2975,13 @@ namespace 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); + + if (!CreateDefaultReflectionProbe(world, context, registration, memory, error)) + return false; for (auto rawProbeIndex = 0uz; rawProbeIndex < rawProbeCount; rawProbeIndex++) { @@ -2943,7 +2990,7 @@ namespace auto& probe = world.reflectionProbes[probeIndex]; CopyFloat3(record, probe.origin); - const auto imageName = GeneratedImageName(assetName, "reflection_probe", probeIndex); + const auto imageName = std::format("*reflection_probe{}", probeIndex); auto* image = CreateGeneratedImage(memory, imageName, MAPTYPE_CUBE, @@ -3354,6 +3401,19 @@ namespace return true; } + [[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; + } + void PopulateStaticModelGroundLighting(const EntityBlock& block, GfxStaticModelInst& inst, GfxStaticModelDrawInst& drawInst) { const auto gndLt = EntityField(block, "gndLt"); @@ -3606,27 +3666,216 @@ namespace } } - [[nodiscard]] bool PopulateWorldDpvsPlanes(GfxWorld& world, const clipMap_t* clipMap, MemoryManager& memory, std::string& error) + struct DpvsNodeLoad { - if (!clipMap) + 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 (clipMap->planeCount > 0 && clipMap->planes) + 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)) { - 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 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]] bool PopulateWorldDpvsNodes(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + 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 true; + + // 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 true; + } + + if (rawNodes->data.size() % RAW_CLIP_NODE_SIZE != 0uz || rawLeafs->data.size() % RAW_LEAF_SIZE != 0uz) + { + error = "world node/leaf lump has funny size"; + return false; + } + + const auto rawNodeCount = RecordCount(*rawNodes, RAW_CLIP_NODE_SIZE); + const auto rawLeafCount = RecordCount(*rawLeafs, RAW_LEAF_SIZE); + if (rawNodeCount == 0uz || rawLeafCount == 0uz) + { + error = "world node tree is empty"; + return false; + } + + if (!FitsInt(rawNodeCount) || rawLeafCount > std::numeric_limits::max() - rawNodeCount || !FitsInt(rawNodeCount + rawLeafCount)) + { + error = "world node tree is too large"; + return false; + } + + 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) + { + error = "world node references invalid plane"; + return false; + } + + 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()) + { + error = "world node references invalid child"; + return false; + } + + 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) + { + error = "world leaf references invalid cell"; + return false; + } + + nodes[rawNodeCount + leafIndex].cellIndex = cellIndex; + } + + std::vector visitState(rawNodeCount); + if (!SetDpvsNodeCells_r(nodes, visitState, 0uz, rawNodeCount)) + { + error = "world node tree is cyclic or invalid"; + return false; } - if (clipMap->numNodes > 0u && !FitsInt(clipMap->numNodes)) + auto streamCount = 0uz; + if (!CountDpvsNodeStream_r(nodes, 0uz, streamCount) || !FitsInt(streamCount)) { - error = "too many clipmap nodes for gfxworld"; + error = "world node stream is too large"; + return false; + } + + 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) + { + error = "world node stream could not be packed"; return false; } return true; } + [[nodiscard]] bool PopulateWorldDpvsPlanes( + GfxWorld& world, const clipMap_t* clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + if (!clipMap) + return true; + + 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, error); + } + class ClipMapPvsLoader final : public AssetCreator { public: @@ -4025,17 +4274,17 @@ namespace } const auto* clipMap = clipMapDependency->Asset(); - if (!PopulateWorldDpvsPlanes(*world, clipMap, m_memory, error) || !PopulateWorldIndices(*world, *bsp, m_memory, error) - || !PopulateWorldVertices(*world, *bsp, m_memory, error) + if (!PopulateWorldIndices(*world, *bsp, m_memory, error) || !PopulateWorldVertices(*world, *bsp, m_memory, error) || !PopulateWorldSurfaces(*world, *bsp, lightmapLayout, materialDependencies, m_memory, error) || !PopulateWorldVertexLayerData(*world, *bsp, m_memory, error) || !PopulateWorldModels(*world, *bsp, m_memory, error) || !PopulateWorldCells(*world, *bsp, m_memory, error) - || !PopulateWorldPrimaryLights(*world, *bsp, m_memory, error) || !PopulateWorldLightGrid(*world, *bsp, m_memory, error) - || !PopulateWorldLightRegions(*world, *bsp, m_memory, error) + || !PopulateWorldDpvsPlanes(*world, clipMap, *bsp, m_memory, error) + || !PopulateWorldPrimaryLights(*world, *bsp, m_memory, error) || !PopulateWorldShadowGeometry(*world, m_memory) + || !PopulateWorldLightGrid(*world, *bsp, m_memory, error) || !PopulateWorldLightRegions(*world, *bsp, m_memory, error) || !PopulateWorldStaticModels(*world, clipMap, staticModelBlocks, staticModelDependencies, m_memory, error) || !PopulateWorldDynamicEntities(*world, clipMap, m_memory, error) || !PopulateWorldLightmaps(*world, *bsp, lightmapLayout, context, registration, m_memory, error) - || !PopulateWorldReflectionProbes(*world, assetName, *bsp, context, registration, m_memory, error)) + || !PopulateWorldReflectionProbes(*world, *bsp, context, registration, m_memory, error)) { con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); return AssetCreationResult::Failure(); From 15bdb5d7f95015fad5fc34fc4e660beb70dc87fa Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 13:18:36 +0100 Subject: [PATCH 13/35] fix: runtime GfxPortal/cell portal reconstruction from the raw BSP portal lumps --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 906 +++++++++++++++++- 1 file changed, 879 insertions(+), 27 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index a02785450..663eb76ae 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -63,6 +63,7 @@ namespace 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; @@ -177,6 +178,41 @@ namespace return world.primaryLightCount - world.sunPrimaryLightIndex - 1u; } + [[nodiscard]] unsigned char U8(const char value) + { + return static_cast(value); + } + + [[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]] 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]] bool ValidateRecordLump( const IW3::d3dbsp::File& bsp, const IW3::d3dbsp::Lump* lump, const IW3::d3dbsp::LumpType type, const size_t recordSize, std::string& error) { @@ -354,6 +390,44 @@ namespace 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)); @@ -421,6 +495,11 @@ namespace return std::format("{}_{}_{}", assetName, kind, index); } + [[nodiscard]] std::string OutdoorImageName() + { + return "$outdoor"; + } + [[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) { @@ -2505,6 +2584,24 @@ namespace } } + 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]] bool PopulateWorldIndices(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) { const auto* indices = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_INDICES, LUMP_LAYERED_INDICES); @@ -2712,15 +2809,67 @@ namespace PopulateSurfaceBounds(world, surface); } - if (world.surfaceCount > 0) + return true; + } + + void PopulateWorldMaterialMemory(GfxWorld& world, MemoryManager& memory) + { + struct MaterialUsage { - const auto sortedSurfIndexCount = static_cast(world.dpvs.staticSurfaceCount + world.dpvs.staticSurfaceCountNoDecal); - world.dpvs.sortedSurfIndex = AllocZeroed(memory, sortedSurfIndexCount); - for (auto i = 0uz; i < sortedSurfIndexCount; i++) - world.dpvs.sortedSurfIndex[i] = static_cast(std::min(i % static_cast(world.surfaceCount), static_cast(UINT16_MAX))); + Material* material = nullptr; + int memory = 0; + std::vector firstVertices; + }; + + std::array usages; + 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; + if (!material || material->info.hashIndex >= usages.size()) + continue; + + auto& usage = usages[material->info.hashIndex]; + 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; + } } - return true; + 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]] bool PopulateWorldVertexLayerData(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) @@ -3089,6 +3238,84 @@ namespace return true; } + [[nodiscard]] std::optional + FinishWorldAabbTree_r(const GfxWorld& world, GfxAabbTree* trees, const size_t treeIndex, size_t totalTreesUsed, const size_t treeCount, std::string& error) + { + 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) + { + error = std::format("AABB tree {} children extend past tree count", treeIndex); + return std::nullopt; + } + + // 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, error); + if (!nextTree) + return std::nullopt; + + 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)) + { + error = std::format("AABB tree {} surface range is outside world surfaces", treeIndex); + return std::nullopt; + } + + 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]] bool FinishWorldAabbTrees(const GfxWorld& world, GfxAabbTree* trees, const size_t treeCount, std::string& error) + { + auto treeIndex = 0uz; + while (treeIndex < treeCount) + { + const auto nextTree = FinishWorldAabbTree_r(world, trees, treeIndex, treeIndex + 1uz, treeCount, error); + if (!nextTree) + return false; + + treeIndex = *nextTree; + } + + return true; + } + + [[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* BuildWorldAabbTrees(const GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, int& treeCount, std::string& error) { const auto* aabbTrees = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_AABBTREES, LUMP_LAYERED_AABBTREES); @@ -3118,15 +3345,25 @@ namespace { const auto* record = aabbTrees->data.data() + treeIndex * RAW_WORLD_AABB_TREE_SIZE; auto& tree = result[treeIndex]; - std::memcpy(tree.mins, world.mins, sizeof(tree.mins)); - std::memcpy(tree.maxs, world.maxs, sizeof(tree.maxs)); - tree.startSurfIndex = static_cast(std::min(ReadU32(record), static_cast(UINT16_MAX))); - tree.surfaceCount = static_cast(std::min(ReadU32(record, 4uz), static_cast(UINT16_MAX))); - tree.childCount = static_cast(std::min(ReadU32(record, 8uz), static_cast(UINT16_MAX))); + 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) + { + error = std::format("AABB tree {} value is out of uint16 range", treeIndex); + return nullptr; + } + + tree.startSurfIndex = static_cast(startSurface); + tree.surfaceCount = static_cast(surfaceCount); + tree.childCount = static_cast(childCount); tree.startSurfIndexNoDecal = tree.startSurfIndex; tree.surfaceCountNoDecal = tree.surfaceCount; } + if (!FinishWorldAabbTrees(world, result, static_cast(treeCount), error)) + return nullptr; + return result; } @@ -3166,6 +3403,21 @@ namespace 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)) + { + error = std::format("cell {} references invalid AABB tree {}", cellIndex, aabbTreeIndex); + return false; + } + + // 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]); @@ -3180,16 +3432,107 @@ namespace { 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)); + } } } - // The canonical OAT BSP currently stores AABB surface ranges without a - // full per-cell hierarchy. Assign them to the first cell, which mirrors - // the single-cell BSPs produced by the dumper. - if (cellCount > 0uz) + return true; + } + + [[nodiscard]] char PortalPlaneSide(const float value, const char positiveValue) + { + return value > 0.0f ? positiveValue : 0; + } + + [[nodiscard]] bool PopulateWorldPortals(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + { + 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 true; + + if (portals->data.size() % RAW_WORLD_PORTAL_SIZE != 0uz || portalVerts->data.size() % RAW_VEC3_SIZE != 0uz + || cells->data.size() % RAW_WORLD_CELL_SIZE != 0uz) + { + error = "portal, portal-vertex, or cell lump has funny size"; + return false; + } + + 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))) + { + error = "portal or cell count is invalid"; + return false; + } + + 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)) + { + error = std::format("portal {} references invalid plane {}", portalIndex, planeIndex); + return false; + } + + if (cellIndex >= static_cast(world.dpvsPlanes.cellCount)) + { + error = std::format("portal {} references invalid cell {}", portalIndex, cellIndex); + return false; + } + + if (firstVertex + vertexCount > portalVertCount) + { + error = std::format("portal {} vertex range is outside portal vertices", portalIndex); + return false; + } + + 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++) { - world.cells[0].aabbTreeCount = aabbTreeCount; - world.cells[0].aabbTree = aabbTrees; + 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) + { + error = std::format("cell {} portal range is outside portals", cellIndex); + return false; + } + + auto& cell = world.cells[cellIndex]; + cell.portalCount = static_cast(portalCountForCell); + cell.portals = portalCountForCell > 0u ? &portalData[firstPortal] : nullptr; } return true; @@ -3242,6 +3585,340 @@ namespace return true; } + [[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 + { + std::size_t operator()(const TriangleKey& key) const + { + auto result = 1469598103934665603ull; + for (const auto value : key.values) + { + result ^= value; + result *= 1099511628211ull; + } + + return static_cast(result); + } + }; + + using DecalTriangleMaterialMap = std::unordered_map; + + struct DecalTriangleData + { + unsigned firstSurfaceIndex = 0u; + DecalTriangleMaterialMap minMaterialForTriangle; + std::vector> surfaceTriangleKeys; + }; + + [[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.surfaceTriangleKeys.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& surfaceKeys = result.surfaceTriangleKeys[surfIndex - modelSurfIndexBegin]; + surfaceKeys.reserve(surface.tris.triCount); + 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; + + surfaceKeys.emplace_back(*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& surfaceKeys = decalTriangleData.surfaceTriangleKeys[surfIndex - decalTriangleData.firstSurfaceIndex]; + if (surfaceKeys.size() != surface.tris.triCount) + return false; + + const auto materialSortedIndex = static_cast(surface.material->info.drawSurf.fields.materialSortedIndex); + for (const auto& triangleKey : surfaceKeys) + { + const auto existingTriangle = decalTriangleData.minMaterialForTriangle.find(triangleKey); + if (existingTriangle == decalTriangleData.minMaterialForTriangle.end() || materialSortedIndex <= existingTriangle->second) + return false; + } + + return true; + } + + [[nodiscard]] bool CompareWorldSurfaces(const GfxSurface& left, const GfxSurface& right) + { + const auto leftHasLit = MaterialHasTechnique(left.material, TECHNIQUE_LIT_BEGIN); + const auto rightHasLit = MaterialHasTechnique(right.material, TECHNIQUE_LIT_BEGIN); + if (leftHasLit != rightHasLit) + return leftHasLit > rightHasLit; + + if (!leftHasLit) + { + const auto leftHasEmissive = MaterialHasTechnique(left.material, TECHNIQUE_EMISSIVE); + const auto rightHasEmissive = MaterialHasTechnique(right.material, TECHNIQUE_EMISSIVE); + 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 || !MaterialHasTechnique(material, TECHNIQUE_LIT_BEGIN) || 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 || !MaterialHasTechnique(material, TECHNIQUE_LIT_BEGIN)) + 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 || !MaterialHasTechnique(material, TECHNIQUE_EMISSIVE)) + break; + + surfIndex++; + } + + world.dpvs.emissiveSurfsEnd = surfIndex; + } + + [[nodiscard]] bool BuildNoDecalSubModels(GfxWorld& world, std::vector& sortedSurfIndex, unsigned& noDecalSurfaceCount, std::string& error) + { + if (!world.models || world.modelCount <= 0) + return true; + + 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)) + { + error = std::format("world model {} surface range is out of bounds", modelIndex); + return false; + } + + 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; + for (auto originalSurfIndex = 0u; originalSurfIndex < rootSurfaceCount; originalSurfIndex++) + { + const auto sortedIndex = sortedSurfIndex[originalSurfIndex]; + if ((U8(world.dpvs.surfaces[sortedIndex].flags) & 2u) == 0u) + sortedSurfIndex[writeIndex++] = sortedIndex; + } + + noDecalSurfaceCount = writeIndex - rootSurfaceCount; + if (noDecalSurfaceCount != rootModel.surfaceCountNoDecal) + { + error = std::format("no-decal surface compaction mismatch: {} vs {}", noDecalSurfaceCount, rootModel.surfaceCountNoDecal); + return false; + } + + return true; + } + + [[nodiscard]] bool PopulateWorldSurfaceOrganization(GfxWorld& world, MemoryManager& memory, std::string& error) + { + if (!world.models || world.modelCount <= 0 || !world.dpvs.surfaces) + return true; + + auto& rootModel = world.models[0]; + if (rootModel.surfaceCount == 0u) + return true; + + if (rootModel.startSurfIndex != 0u) + { + error = "root world model does not start at surface 0"; + return false; + } + + const auto surfaceCount = static_cast(rootModel.surfaceCount); + if (surfaceCount > static_cast(world.surfaceCount) || !FitsUint16(surfaceCount * 2uz)) + { + error = "root world model surface count is invalid"; + return false; + } + + 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) + { + error = "surface sort produced an invalid original surface index"; + return false; + } + + surface.tris.vertexCount = sortedSurfIndex[originalSurfIndex]; + sortedSurfIndex[originalSurfIndex] = static_cast(surfIndex); + } + + ClassifySortedSurfaceRanges(world, surfaceCount); + + unsigned noDecalSurfaceCount = 0u; + if (!BuildNoDecalSubModels(world, sortedSurfIndex, noDecalSurfaceCount, error)) + return false; + + 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 true; + } + void ParseGfxLightRecord(const std::byte* record, GfxLight& light) { light = {}; @@ -3655,7 +4332,7 @@ namespace } const auto sortedSurfIndexCount = static_cast(world.dpvs.staticSurfaceCount + world.dpvs.staticSurfaceCountNoDecal); - if (sortedSurfIndexCount > 0uz) + if (sortedSurfIndexCount > 0uz && !world.dpvs.sortedSurfIndex) { world.dpvs.sortedSurfIndex = AllocZeroed(memory, sortedSurfIndexCount); for (auto i = 0uz; i < sortedSurfIndexCount; i++) @@ -4169,24 +4846,74 @@ namespace ISearchPath& m_search_path; }; - void PopulateWorldSkySurfaces(GfxWorld& world, MemoryManager& memory) + [[nodiscard]] bool PopulateWorldSkySurfaces( + GfxWorld& world, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory, std::string& error) { if (!world.dpvs.surfaces || world.surfaceCount <= 0) - return; + return true; std::vector skySurfaces; + const Material* skyMaterial = nullptr; for (auto surfaceIndex = 0; surfaceIndex < world.surfaceCount; surfaceIndex++) { - if (static_cast(world.dpvs.surfaces[surfaceIndex].lightmapIndex) == SKY_LIGHTMAP_INDEX) - skySurfaces.emplace_back(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) + { + error = std::format("map has at least two different skies: {} and {}", surface.material->info.name, skyMaterial->info.name); + return false; + } + + skyMaterial = surface.material; + skySurfaces.emplace_back(surfaceIndex); } if (skySurfaces.empty()) - return; + return true; + + 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) + { + error = std::format("colorMap for sky material \"{}\" is not a cubemap", skyMaterial->info.name); + return false; + } + + auto* imageDependency = context.LoadDependency(image->name); + if (!imageDependency) + { + error = std::format("missing sky image \"{}\"", image->name); + return false; + } + + registration.AddDependency(imageDependency); + world.skyImage = imageDependency->Asset(); + world.skySamplerState = static_cast(SamplerStateByte(texture.samplerState)); + break; + } + + if (!world.skyImage) + { + error = std::format("sky material \"{}\" has no colorMap", skyMaterial->info.name); + return false; + } world.skySurfCount = static_cast(skySurfaces.size()); world.skyStartSurfs = AllocZeroed(memory, skySurfaces.size()); std::memcpy(world.skyStartSurfs, skySurfaces.data(), skySurfaces.size() * sizeof(int)); + return true; } void SetOutdoorLookupIdentity(GfxWorld& world) @@ -4198,6 +4925,112 @@ namespace } } + [[nodiscard]] bool PopulateWorldOutdoorData( + GfxWorld& world, + const IW3::d3dbsp::File& bsp, + AssetCreationContext& context, + AssetRegistration& registration, + MemoryManager& memory, + std::string& error) + { + const auto* materials = bsp.GetLump(LUMP_MATERIALS); + const auto* surfaces = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_TRI_SOUPS, LUMP_LAYERED_TRI_SOUPS); + if (!materials || !surfaces || world.modelCount <= 0 || !world.models || !world.dpvs.surfaces) + return true; + + if (materials->data.size() % RAW_MATERIAL_SIZE != 0uz || surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) + { + error = "could not calculate outdoor bounds from funny-sized material or surface lump"; + return false; + } + + 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)) + { + error = "root model surface range is outside the world surface lump"; + return false; + } + + 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) + { + error = std::format("world surface {} references invalid material index {}", surfaceIndex, rawMaterialIndex); + return false; + } + + 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, + OutdoorImageName(), + 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, OutdoorImageName(), image); + if (!imageInfo) + { + error = "could not register generated outdoor image"; + return false; + } + + world.outdoorImage = imageInfo->Asset(); + return true; + } + class GfxWorldLoader final : public AssetCreator { public: @@ -4275,10 +5108,19 @@ namespace const auto* clipMap = clipMapDependency->Asset(); if (!PopulateWorldIndices(*world, *bsp, m_memory, error) || !PopulateWorldVertices(*world, *bsp, m_memory, error) - || !PopulateWorldSurfaces(*world, *bsp, lightmapLayout, materialDependencies, m_memory, error) - || !PopulateWorldVertexLayerData(*world, *bsp, m_memory, error) - || !PopulateWorldModels(*world, *bsp, m_memory, error) || !PopulateWorldCells(*world, *bsp, m_memory, error) + || !PopulateWorldSurfaces(*world, *bsp, lightmapLayout, materialDependencies, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + + PopulateWorldMaterialMemory(*world, m_memory); + + if (!PopulateWorldVertexLayerData(*world, *bsp, m_memory, error) || !PopulateWorldModels(*world, *bsp, m_memory, error) + || !PopulateWorldCells(*world, *bsp, m_memory, error) + || !PopulateWorldSurfaceOrganization(*world, m_memory, error) || !PopulateWorldDpvsPlanes(*world, clipMap, *bsp, m_memory, error) + || !PopulateWorldPortals(*world, *bsp, m_memory, error) || !PopulateWorldPrimaryLights(*world, *bsp, m_memory, error) || !PopulateWorldShadowGeometry(*world, m_memory) || !PopulateWorldLightGrid(*world, *bsp, m_memory, error) || !PopulateWorldLightRegions(*world, *bsp, m_memory, error) || !PopulateWorldStaticModels(*world, clipMap, staticModelBlocks, staticModelDependencies, m_memory, error) @@ -4291,7 +5133,17 @@ namespace } PopulateWorldRuntimeData(*world, m_memory); - PopulateWorldSkySurfaces(*world, m_memory); + if (!PopulateWorldSkySurfaces(*world, context, registration, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldOutdoorData(*world, *bsp, context, registration, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + return AssetCreationResult::Success(context.AddAsset(std::move(registration))); } From c2277c7c614d80be805e1818f304bac3e3b937ae Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 14:15:23 +0100 Subject: [PATCH 14/35] fix: populate d3dbsp static model visibility --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 305 +++++++++++++++++- 1 file changed, 301 insertions(+), 4 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 663eb76ae..6974c6004 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -4168,10 +4168,9 @@ namespace drawInst.placement.scale = scale; std::copy(origin.begin(), origin.end(), drawInst.placement.origin); std::memcpy(drawInst.placement.axis, axis, sizeof(drawInst.placement.axis)); - drawInst.smodelCacheIndex[0] = std::numeric_limits::max(); - drawInst.smodelCacheIndex[1] = std::numeric_limits::max(); - drawInst.smodelCacheIndex[2] = std::numeric_limits::max(); - drawInst.smodelCacheIndex[3] = std::numeric_limits::max(); + // 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; @@ -4215,6 +4214,303 @@ namespace return true; } + [[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]] bool BoundsOverlap(const float (&leftMins)[3], const float (&leftMaxs)[3], const float (&rightMins)[3], const float (&rightMaxs)[3]) + { + return leftMins[0] <= rightMaxs[0] && leftMaxs[0] >= rightMins[0] && leftMins[1] <= rightMaxs[1] && leftMaxs[1] >= rightMins[1] + && leftMins[2] <= rightMaxs[2] && leftMaxs[2] >= rightMins[2]; + } + + [[nodiscard]] float BoundsVolume(const float (&mins)[3], const float (&maxs)[3]) + { + return std::max(maxs[0] - mins[0], 0.0f) * std::max(maxs[1] - mins[1], 0.0f) * std::max(maxs[2] - mins[2], 0.0f); + } + + [[nodiscard]] float BoundsExpansionVolumeDelta( + const float (&outerMins)[3], const float (&outerMaxs)[3], const float (&addedMins)[3], const float (&addedMaxs)[3]) + { + float expandedMins[3]{outerMins[0], outerMins[1], outerMins[2]}; + float expandedMaxs[3]{outerMaxs[0], outerMaxs[1], outerMaxs[2]}; + ExpandBounds(addedMins, addedMaxs, expandedMins, expandedMaxs); + return BoundsVolume(expandedMins, expandedMaxs) - BoundsVolume(outerMins, outerMaxs); + } + + [[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 AddStaticModelToAabbTree_r(const GfxWorld& world, StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& tree, const uint16_t staticModelIndex) + { + AddStaticModelToTreeList(staticModelIndexesByTree, tree, staticModelIndex); + + if (tree.childCount == 0u || tree.childrenOffset == 0) + return; + + const auto& smodelInst = world.dpvs.smodelInsts[staticModelIndex]; + auto* children = reinterpret_cast(reinterpret_cast(&tree) + tree.childrenOffset); + + // linker_pc first descends into an existing child that fully contains + // the static model bounds. If no child contains it, linker_pc may add a + // static-model-only child. We avoid mutating the BSP tree shape here and + // instead fall back to overlapping existing children so DPVS traversal + // still reaches the model in partially visible cells. + for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) + { + auto& child = children[childIndex]; + if (BoundsContain(child.mins, child.maxs, smodelInst.mins, smodelInst.maxs)) + { + AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex); + return; + } + } + + auto addedToChild = false; + for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) + { + auto& child = children[childIndex]; + if (BoundsOverlap(child.mins, child.maxs, smodelInst.mins, smodelInst.maxs)) + { + AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex); + addedToChild = true; + } + } + + if (addedToChild) + return; + + // Degenerate fallback for imported trees with stale bounds. Expanding + // the first non-surface child mimics the linker path that reuses an + // existing static-model-only child before allocating a new one. + 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); + AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex); + return; + } + } + + auto bestChildIndex = 0u; + auto bestExpansionDelta = std::numeric_limits::max(); + for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) + { + const auto& child = children[childIndex]; + const auto expansionDelta = BoundsExpansionVolumeDelta(child.mins, child.maxs, smodelInst.mins, smodelInst.maxs); + if (expansionDelta < bestExpansionDelta) + { + bestExpansionDelta = expansionDelta; + bestChildIndex = childIndex; + } + } + + auto& bestChild = children[bestChildIndex]; + ExpandBounds(smodelInst.mins, smodelInst.maxs, bestChild.mins, bestChild.maxs); + AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, bestChild, staticModelIndex); + } + + [[nodiscard]] bool AddStaticModelToCell( + const GfxWorld& world, StaticModelIndexLists& staticModelIndexesByTree, const uint16_t staticModelIndex, const int cellIndex, std::string& error) + { + if (cellIndex < 0 || cellIndex >= world.dpvsPlanes.cellCount || !world.cells) + { + error = "static model references invalid cell"; + return false; + } + + auto& cell = world.cells[cellIndex]; + if (!cell.aabbTree) + return true; + + const auto existing = staticModelIndexesByTree.find(cell.aabbTree); + if (existing != staticModelIndexesByTree.end() && !existing->second.empty() && existing->second.back() == staticModelIndex) + return true; + + AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, *cell.aabbTree, staticModelIndex); + return true; + } + + [[nodiscard]] bool FilterStaticModelIntoCells_r( + const GfxWorld& world, + StaticModelIndexLists& staticModelIndexesByTree, + const uint16_t staticModelIndex, + const uint16_t* node, + const float (&mins)[3], + const float (&maxs)[3], + std::string& error) + { + while (true) + { + if (!node) + { + error = "world node stream is missing"; + return false; + } + + 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, error) : true; + + if (planeIndex >= world.planeCount || !world.dpvsPlanes.planes) + { + error = "world node stream references invalid plane"; + return false; + } + + 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) + { + if (!FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, node + 2, mins, maxs, error)) + return false; + } + 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 + && !FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, node + 2, frontMins, maxs, error)) + { + return false; + } + + return FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, rightNode, mins, backMaxs, error); + } + + node = rightNode; + continue; + } + + if (boxSide == 1) + { + node += 2; + continue; + } + + if (boxSide == 2) + { + node += node[1]; + continue; + } + + error = "world node plane-side classification failed"; + return false; + } + } + + [[nodiscard]] bool CommitStaticModelAabbTreeIndexes(StaticModelIndexLists& staticModelIndexesByTree, MemoryManager& memory, std::string& error) + { + for (auto& [tree, indexes] : staticModelIndexesByTree) + { + std::sort(indexes.begin(), indexes.end()); + indexes.erase(std::unique(indexes.begin(), indexes.end()), indexes.end()); + + if (!FitsUint16(indexes.size())) + { + error = "too many static model indexes in world AABB tree"; + return false; + } + + tree->smodelIndexCount = static_cast(indexes.size()); + if (!indexes.empty()) + { + tree->smodelIndexes = AllocZeroed(memory, indexes.size()); + std::copy(indexes.begin(), indexes.end(), tree->smodelIndexes); + } + } + + return true; + } + + [[nodiscard]] bool PopulateWorldStaticModelAabbTrees(GfxWorld& world, MemoryManager& memory, std::string& error) + { + if (world.dpvs.smodelCount == 0u || !world.dpvs.smodelInsts || !world.cells || world.dpvsPlanes.cellCount <= 0) + return true; + + if (world.dpvs.smodelCount > std::numeric_limits::max()) + { + error = "too many static models for AABB tree indexes"; + return false; + } + + 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) + { + if (!FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, packedIndex, world.dpvsPlanes.nodes, smodelInst.mins, smodelInst.maxs, error)) + return false; + } + else if (!AddStaticModelToCell(world, staticModelIndexesByTree, packedIndex, 0, error)) + { + return false; + } + } + + return CommitStaticModelAabbTreeIndexes(staticModelIndexesByTree, memory, error); + } + [[nodiscard]] bool PopulateWorldDynamicEntities(GfxWorld& world, const clipMap_t* clipMap, MemoryManager& memory, std::string& error) { if (!clipMap) @@ -5124,6 +5420,7 @@ namespace || !PopulateWorldPrimaryLights(*world, *bsp, m_memory, error) || !PopulateWorldShadowGeometry(*world, m_memory) || !PopulateWorldLightGrid(*world, *bsp, m_memory, error) || !PopulateWorldLightRegions(*world, *bsp, m_memory, error) || !PopulateWorldStaticModels(*world, clipMap, staticModelBlocks, staticModelDependencies, m_memory, error) + || !PopulateWorldStaticModelAabbTrees(*world, m_memory, error) || !PopulateWorldDynamicEntities(*world, clipMap, m_memory, error) || !PopulateWorldLightmaps(*world, *bsp, lightmapLayout, context, registration, m_memory, error) || !PopulateWorldReflectionProbes(*world, *bsp, context, registration, m_memory, error)) From ac5a5a358cf539239776b8d820911b5253fa4bae Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 14:19:17 +0100 Subject: [PATCH 15/35] fix: assign d3dbsp static model reflection probes --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 6974c6004..d3de370b5 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -3167,6 +3167,108 @@ namespace return true; } + [[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]] bool PopulateWorldLightGrid(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) { const auto* header = bsp.GetLump(LUMP_LIGHTGRID_HEADER); @@ -5429,6 +5531,7 @@ namespace return AssetCreationResult::Failure(); } + PopulateWorldStaticModelReflectionProbes(*world); PopulateWorldRuntimeData(*world, m_memory); if (!PopulateWorldSkySurfaces(*world, context, registration, m_memory, error)) { From fb003f3898f3403e95b001e9a314c145107bfde5 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 15:26:20 +0100 Subject: [PATCH 16/35] fix: match linker d3dbsp gfxworld derived data --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 174 +++++++++++++++--- 1 file changed, 152 insertions(+), 22 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index d3de370b5..8ca7438a4 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -82,6 +82,8 @@ namespace 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 DEFAULT_MATERIAL_NAME = "$default"; constexpr auto DEFAULT_MATERIAL_REFERENCE_NAME = ",$default"; constexpr auto SKY_LIGHTMAP_INDEX = 31u; @@ -146,6 +148,47 @@ namespace 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 @@ -2143,6 +2186,12 @@ namespace return SelectWorldLump(bsp, simple, layered); } + [[nodiscard]] bool UsesSimpleWorldGeometry(const IW3::d3dbsp::File& bsp) + { + const auto* simpleSurfaces = bsp.GetLump(LUMP_SIMPLE_TRI_SOUPS); + return simpleSurfaces && !simpleSurfaces->data.empty(); + } + struct LightmapAtlasGroup { unsigned wideCount = 1u; @@ -2821,7 +2870,8 @@ namespace std::vector firstVertices; }; - std::array usages; + std::array usages; + std::array materialHashTable{}; if (!world.dpvs.surfaces || world.surfaceCount <= 0) return; @@ -2829,10 +2879,11 @@ namespace { const auto& surface = world.dpvs.surfaces[surfaceIndex]; auto* material = surface.material; - if (!material || material->info.hashIndex >= usages.size()) + const auto materialHashIndex = MaterialHashIndex(materialHashTable, material); + if (!materialHashIndex) continue; - auto& usage = usages[material->info.hashIndex]; + 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 @@ -2875,8 +2926,15 @@ namespace [[nodiscard]] bool PopulateWorldVertexLayerData(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) { const auto* vertexLayerData = bsp.GetLump(LUMP_VERTEX_LAYER_DATA); - if (!vertexLayerData) + 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 true; + } if (!FitsUnsigned(vertexLayerData->data.size())) { @@ -3816,18 +3874,15 @@ namespace return false; const auto& surfaceKeys = decalTriangleData.surfaceTriangleKeys[surfIndex - decalTriangleData.firstSurfaceIndex]; - if (surfaceKeys.size() != surface.tris.triCount) + if (surfaceKeys.empty()) 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); - for (const auto& triangleKey : surfaceKeys) - { - const auto existingTriangle = decalTriangleData.minMaterialForTriangle.find(triangleKey); - if (existingTriangle == decalTriangleData.minMaterialForTriangle.end() || materialSortedIndex <= existingTriangle->second) - return false; - } - - return true; + const auto existingTriangle = decalTriangleData.minMaterialForTriangle.find(surfaceKeys[0]); + return existingTriangle != decalTriangleData.minMaterialForTriangle.end() && materialSortedIndex > existingTriangle->second; } [[nodiscard]] bool CompareWorldSurfaces(const GfxSurface& left, const GfxSurface& right) @@ -3908,6 +3963,68 @@ namespace world.dpvs.emissiveSurfsEnd = surfIndex; } + [[nodiscard]] bool AppendNoDecalAabbTreeSurfaces( + const GfxWorld& world, GfxAabbTree& tree, std::vector& sortedSurfIndex, const unsigned sourceSurfaceCount, unsigned& writeIndex, std::string& error) + { + if (writeIndex > UINT16_MAX) + { + error = "no-decal AABB tree start surface index is out of uint16 range"; + return false; + } + + 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++) + { + if (!AppendNoDecalAabbTreeSurfaces(world, children[childIndex], sortedSurfIndex, sourceSurfaceCount, writeIndex, error)) + return false; + } + } + else + { + const auto firstSurfaceIndex = static_cast(tree.startSurfIndex); + const auto surfaceCount = static_cast(tree.surfaceCount); + if (firstSurfaceIndex > sourceSurfaceCount || surfaceCount > sourceSurfaceCount - firstSurfaceIndex) + { + error = "AABB tree no-decal source surface range is out of bounds"; + return false; + } + + for (auto surfaceOffset = 0u; surfaceOffset < surfaceCount; surfaceOffset++) + { + const auto surfaceIndex = sortedSurfIndex[firstSurfaceIndex + surfaceOffset]; + if (surfaceIndex >= world.dpvs.staticSurfaceCount) + { + error = "AABB tree no-decal source references invalid surface"; + return false; + } + + if ((U8(world.dpvs.surfaces[surfaceIndex].flags) & 2u) != 0u) + continue; + + if (writeIndex >= sortedSurfIndex.size()) + { + error = "too many no-decal AABB tree surfaces"; + return false; + } + + sortedSurfIndex[writeIndex++] = surfaceIndex; + } + } + + const auto surfaceCountNoDecal = writeIndex - static_cast(tree.startSurfIndexNoDecal); + if (surfaceCountNoDecal > UINT16_MAX) + { + error = "no-decal AABB tree surface count is out of uint16 range"; + return false; + } + + tree.surfaceCountNoDecal = static_cast(surfaceCountNoDecal); + return true; + } + [[nodiscard]] bool BuildNoDecalSubModels(GfxWorld& world, std::vector& sortedSurfIndex, unsigned& noDecalSurfaceCount, std::string& error) { if (!world.models || world.modelCount <= 0) @@ -3943,20 +4060,33 @@ namespace const auto& rootModel = world.models[0]; const auto rootSurfaceCount = static_cast(rootModel.surfaceCount); auto writeIndex = rootSurfaceCount; - for (auto originalSurfIndex = 0u; originalSurfIndex < rootSurfaceCount; originalSurfIndex++) + if (world.dpvsPlanes.cellCount > 0 && world.cells) { - const auto sortedIndex = sortedSurfIndex[originalSurfIndex]; - if ((U8(world.dpvs.surfaces[sortedIndex].flags) & 2u) == 0u) - sortedSurfIndex[writeIndex++] = sortedIndex; - } + // 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; - noDecalSurfaceCount = writeIndex - rootSurfaceCount; - if (noDecalSurfaceCount != rootModel.surfaceCountNoDecal) + if (!AppendNoDecalAabbTreeSurfaces(world, *cell.aabbTree, sortedSurfIndex, rootSurfaceCount, writeIndex, error)) + return false; + } + } + else { - error = std::format("no-decal surface compaction mismatch: {} vs {}", noDecalSurfaceCount, rootModel.surfaceCountNoDecal); - return false; + for (auto originalSurfIndex = 0u; originalSurfIndex < rootSurfaceCount; originalSurfIndex++) + { + const auto sortedIndex = sortedSurfIndex[originalSurfIndex]; + if ((U8(world.dpvs.surfaces[sortedIndex].flags) & 2u) == 0u) + sortedSurfIndex[writeIndex++] = sortedIndex; + } } + noDecalSurfaceCount = writeIndex - rootSurfaceCount; return true; } From 06421af5a965eb56933a423cad66380cce2926ca Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 18:18:47 +0100 Subject: [PATCH 17/35] fix: improve d3dbsp static model aabb tree generation --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 431 ++++++++++++++---- 1 file changed, 353 insertions(+), 78 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 8ca7438a4..9eadf847c 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -3476,6 +3477,30 @@ namespace 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]] bool SetAabbTreeChildrenOffset(GfxAabbTree& tree, const GfxAabbTree* children, std::string& error) + { + const auto offset = reinterpret_cast(children) - reinterpret_cast(&tree); + if (offset < static_cast(std::numeric_limits::min()) + || offset > static_cast(std::numeric_limits::max())) + { + error = "AABB tree children offset is outside int range"; + return false; + } + + tree.childrenOffset = static_cast(offset); + return true; + } + [[nodiscard]] GfxAabbTree* BuildWorldAabbTrees(const GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, int& treeCount, std::string& error) { const auto* aabbTrees = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_AABBTREES, LUMP_LAYERED_AABBTREES); @@ -4452,26 +4477,6 @@ namespace && outerMaxs[1] >= innerMaxs[1] && outerMaxs[2] >= innerMaxs[2]; } - [[nodiscard]] bool BoundsOverlap(const float (&leftMins)[3], const float (&leftMaxs)[3], const float (&rightMins)[3], const float (&rightMaxs)[3]) - { - return leftMins[0] <= rightMaxs[0] && leftMaxs[0] >= rightMins[0] && leftMins[1] <= rightMaxs[1] && leftMaxs[1] >= rightMins[1] - && leftMins[2] <= rightMaxs[2] && leftMaxs[2] >= rightMins[2]; - } - - [[nodiscard]] float BoundsVolume(const float (&mins)[3], const float (&maxs)[3]) - { - return std::max(maxs[0] - mins[0], 0.0f) * std::max(maxs[1] - mins[1], 0.0f) * std::max(maxs[2] - mins[2], 0.0f); - } - - [[nodiscard]] float BoundsExpansionVolumeDelta( - const float (&outerMins)[3], const float (&outerMaxs)[3], const float (&addedMins)[3], const float (&addedMaxs)[3]) - { - float expandedMins[3]{outerMins[0], outerMins[1], outerMins[2]}; - float expandedMaxs[3]{outerMaxs[0], outerMaxs[1], outerMaxs[2]}; - ExpandBounds(addedMins, addedMaxs, expandedMins, expandedMaxs); - return BoundsVolume(expandedMins, expandedMaxs) - BoundsVolume(outerMins, outerMaxs); - } - [[nodiscard]] int BoxOnPlaneSide(const float (&mins)[3], const float (&maxs)[3], const cplane_s& plane) { const auto dist = plane.dist; @@ -4520,79 +4525,113 @@ namespace indexes.emplace_back(staticModelIndex); } - void AddStaticModelToAabbTree_r(const GfxWorld& world, StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& tree, const uint16_t 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]] bool CopyAabbTreeToNewAddress( + StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& oldTree, GfxAabbTree& newTree, std::string& error) + { + newTree = oldTree; + MoveStaticModelTreeList(staticModelIndexesByTree, oldTree, newTree); + + if (oldTree.childCount > 0u) + return SetAabbTreeChildrenOffset(newTree, AabbTreeChildren(oldTree), error); + + newTree.childrenOffset = 0; + return true; + } + + [[nodiscard]] bool AppendStaticModelOnlyChild( + StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& tree, const GfxStaticModelInst& smodelInst, MemoryManager& memory, std::string& error) + { + if (tree.childCount == std::numeric_limits::max()) + { + error = "too many AABB tree children"; + return false; + } + + const auto oldChildCount = tree.childCount; + auto* oldChildren = AabbTreeChildren(tree); + auto* newChildren = AllocZeroed(memory, static_cast(oldChildCount) + 1uz); + for (auto childIndex = 0u; childIndex < oldChildCount; childIndex++) + { + if (!CopyAabbTreeToNewAddress(staticModelIndexesByTree, oldChildren[childIndex], newChildren[childIndex], error)) + return false; + } + + if (!SetAabbTreeChildrenOffset(tree, newChildren, error)) + return false; + + 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 true; + } + + [[nodiscard]] bool AddStaticModelToAabbTree_r( + const GfxWorld& world, + StaticModelIndexLists& staticModelIndexesByTree, + GfxAabbTree& tree, + const uint16_t staticModelIndex, + MemoryManager& memory, + std::string& error) { AddStaticModelToTreeList(staticModelIndexesByTree, tree, staticModelIndex); if (tree.childCount == 0u || tree.childrenOffset == 0) - return; + return true; const auto& smodelInst = world.dpvs.smodelInsts[staticModelIndex]; - auto* children = reinterpret_cast(reinterpret_cast(&tree) + tree.childrenOffset); + auto* children = AabbTreeChildren(tree); - // linker_pc first descends into an existing child that fully contains - // the static model bounds. If no child contains it, linker_pc may add a - // static-model-only child. We avoid mutating the BSP tree shape here and - // instead fall back to overlapping existing children so DPVS traversal - // still reaches the model in partially visible cells. + // 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)) { - AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex); - return; + return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex, memory, error); } } - auto addedToChild = false; - for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) - { - auto& child = children[childIndex]; - if (BoundsOverlap(child.mins, child.maxs, smodelInst.mins, smodelInst.maxs)) - { - AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex); - addedToChild = true; - } - } - - if (addedToChild) - return; - - // Degenerate fallback for imported trees with stale bounds. Expanding - // the first non-surface child mimics the linker path that reuses an - // existing static-model-only child before allocating a new one. 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); - AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex); - return; + return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex, memory, error); } } - auto bestChildIndex = 0u; - auto bestExpansionDelta = std::numeric_limits::max(); - for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) - { - const auto& child = children[childIndex]; - const auto expansionDelta = BoundsExpansionVolumeDelta(child.mins, child.maxs, smodelInst.mins, smodelInst.maxs); - if (expansionDelta < bestExpansionDelta) - { - bestExpansionDelta = expansionDelta; - bestChildIndex = childIndex; - } - } + if (!AppendStaticModelOnlyChild(staticModelIndexesByTree, tree, smodelInst, memory, error)) + return false; - auto& bestChild = children[bestChildIndex]; - ExpandBounds(smodelInst.mins, smodelInst.maxs, bestChild.mins, bestChild.maxs); - AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, bestChild, staticModelIndex); + children = AabbTreeChildren(tree); + return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, children[tree.childCount - 1u], staticModelIndex, memory, error); } [[nodiscard]] bool AddStaticModelToCell( - const GfxWorld& world, StaticModelIndexLists& staticModelIndexesByTree, const uint16_t staticModelIndex, const int cellIndex, std::string& error) + const GfxWorld& world, + StaticModelIndexLists& staticModelIndexesByTree, + const uint16_t staticModelIndex, + const int cellIndex, + MemoryManager& memory, + std::string& error) { if (cellIndex < 0 || cellIndex >= world.dpvsPlanes.cellCount || !world.cells) { @@ -4608,8 +4647,7 @@ namespace if (existing != staticModelIndexesByTree.end() && !existing->second.empty() && existing->second.back() == staticModelIndex) return true; - AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, *cell.aabbTree, staticModelIndex); - return true; + return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, *cell.aabbTree, staticModelIndex, memory, error); } [[nodiscard]] bool FilterStaticModelIntoCells_r( @@ -4619,6 +4657,7 @@ namespace const uint16_t* node, const float (&mins)[3], const float (&maxs)[3], + MemoryManager& memory, std::string& error) { while (true) @@ -4632,7 +4671,7 @@ namespace 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, error) : true; + return cellValue != 0 ? AddStaticModelToCell(world, staticModelIndexesByTree, staticModelIndex, cellValue - 1, memory, error) : true; if (planeIndex >= world.planeCount || !world.dpvsPlanes.planes) { @@ -4648,7 +4687,7 @@ namespace const auto planeType = static_cast(plane.type); if (planeType >= 3u) { - if (!FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, node + 2, mins, maxs, error)) + if (!FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, node + 2, mins, maxs, memory, error)) return false; } else @@ -4659,12 +4698,12 @@ namespace backMaxs[planeType] = plane.dist; if (maxs[planeType] > plane.dist - && !FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, node + 2, frontMins, maxs, error)) + && !FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, node + 2, frontMins, maxs, memory, error)) { return false; } - return FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, rightNode, mins, backMaxs, error); + return FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, rightNode, mins, backMaxs, memory, error); } node = rightNode; @@ -4693,7 +4732,6 @@ namespace for (auto& [tree, indexes] : staticModelIndexesByTree) { std::sort(indexes.begin(), indexes.end()); - indexes.erase(std::unique(indexes.begin(), indexes.end()), indexes.end()); if (!FitsUint16(indexes.size())) { @@ -4712,6 +4750,217 @@ namespace return true; } + [[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]] bool AddSortedStaticModelChild( + GfxAabbTree& tree, uint16_t*& smodelIndexes, unsigned& remainingModelCount, const unsigned childModelCount, std::string& error) + { + if (childModelCount == 0u) + return true; + + if (tree.childCount == std::numeric_limits::max()) + { + error = "too many sorted AABB tree children"; + return false; + } + + auto* children = AabbTreeChildren(tree); + auto& childTree = children[tree.childCount++]; + childTree.smodelIndexCount = static_cast(childModelCount); + childTree.smodelIndexes = smodelIndexes; + smodelIndexes += childModelCount; + remainingModelCount -= childModelCount; + return true; + } + + [[nodiscard]] bool SortGfxAabbTree(const GfxWorld& world, GfxAabbTree& tree, MemoryManager& memory, std::string& error) + { + 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++) + { + if (!SortGfxAabbTree(world, children[childIndex], memory, error)) + return false; + } + + return true; + } + + if (tree.smodelIndexCount == 0u) + return true; + + 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 true; + + 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 true; + + if (tree.surfaceCount > 0u) + childCount++; + if (remainingModelCount > 0u) + childCount++; + + auto* children = AllocZeroed(memory, childCount); + if (!SetAabbTreeChildrenOffset(tree, children, error)) + return false; + + 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) + { + if (!AddSortedStaticModelChild(tree, smodelIndexes, remainingModelCount, childModelCount, error)) + return false; + + if (childModelCount > 0u && !SortGfxAabbTree(world, children[tree.childCount - 1u], memory, error)) + return false; + } + + if (remainingModelCount > 0u) + { + if (!AddSortedStaticModelChild(tree, smodelIndexes, remainingModelCount, remainingModelCount, error)) + return false; + + if (!SortGfxAabbTree(world, children[tree.childCount - 1u], memory, error)) + return false; + } + + return true; + } + + [[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]] bool FixupGfxAabbTrees(GfxCell& cell, MemoryManager& memory, std::string& error) + { + if (!cell.aabbTree) + return true; + + const auto treeCount = AabbTreeSubtreeCount(*cell.aabbTree); + if (!FitsInt(treeCount)) + { + error = "too many AABB tree nodes after static model sort"; + return false; + } + + auto* newTree = AllocZeroed(memory, treeCount); + const auto* nextTree = MoveAabbTree_r(*cell.aabbTree, *newTree, newTree + 1); + if (nextTree != newTree + treeCount) + { + error = "AABB tree fixup produced an unexpected node count"; + return false; + } + + cell.aabbTree = newTree; + cell.aabbTreeCount = static_cast(treeCount); + return true; + } + [[nodiscard]] bool PopulateWorldStaticModelAabbTrees(GfxWorld& world, MemoryManager& memory, std::string& error) { if (world.dpvs.smodelCount == 0u || !world.dpvs.smodelInsts || !world.cells || world.dpvsPlanes.cellCount <= 0) @@ -4731,16 +4980,36 @@ namespace if (world.dpvsPlanes.nodes) { - if (!FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, packedIndex, world.dpvsPlanes.nodes, smodelInst.mins, smodelInst.maxs, error)) + if (!FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, packedIndex, world.dpvsPlanes.nodes, smodelInst.mins, smodelInst.maxs, memory, error)) return false; } - else if (!AddStaticModelToCell(world, staticModelIndexesByTree, packedIndex, 0, error)) + else if (!AddStaticModelToCell(world, staticModelIndexesByTree, packedIndex, 0, memory, error)) { return false; } } - return CommitStaticModelAabbTreeIndexes(staticModelIndexesByTree, memory, error); + if (!CommitStaticModelAabbTreeIndexes(staticModelIndexesByTree, memory, error)) + return false; + + // 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 && !SortGfxAabbTree(world, *cell.aabbTree, memory, error)) + return false; + } + + for (auto cellIndex = 0; cellIndex < world.dpvsPlanes.cellCount; cellIndex++) + { + if (!FixupGfxAabbTrees(world.cells[cellIndex], memory, error)) + return false; + } + + return true; } [[nodiscard]] bool PopulateWorldDynamicEntities(GfxWorld& world, const clipMap_t* clipMap, MemoryManager& memory, std::string& error) @@ -5652,9 +5921,6 @@ namespace || !PopulateWorldPrimaryLights(*world, *bsp, m_memory, error) || !PopulateWorldShadowGeometry(*world, m_memory) || !PopulateWorldLightGrid(*world, *bsp, m_memory, error) || !PopulateWorldLightRegions(*world, *bsp, m_memory, error) || !PopulateWorldStaticModels(*world, clipMap, staticModelBlocks, staticModelDependencies, m_memory, error) - || !PopulateWorldStaticModelAabbTrees(*world, m_memory, error) - || !PopulateWorldDynamicEntities(*world, clipMap, m_memory, error) - || !PopulateWorldLightmaps(*world, *bsp, lightmapLayout, context, registration, m_memory, error) || !PopulateWorldReflectionProbes(*world, *bsp, context, registration, m_memory, error)) { con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); @@ -5662,6 +5928,15 @@ namespace } PopulateWorldStaticModelReflectionProbes(*world); + + if (!PopulateWorldStaticModelAabbTrees(*world, m_memory, error) + || !PopulateWorldDynamicEntities(*world, clipMap, m_memory, error) + || !PopulateWorldLightmaps(*world, *bsp, lightmapLayout, context, registration, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + PopulateWorldRuntimeData(*world, m_memory); if (!PopulateWorldSkySurfaces(*world, context, registration, m_memory, error)) { From b43dd039f54fc064a484d176b4452137835e6022 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 18:49:16 +0100 Subject: [PATCH 18/35] fix: canonicalize d3dbsp model tri soup starts LUMP_MODELS = 37 is now byte identical with this change --- src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp index f271e7a34..0c5a495f0 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -1395,6 +1395,8 @@ namespace 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; @@ -1421,10 +1423,10 @@ namespace AppendBytes(out, model.bounds[0], sizeof(model.bounds[0])); AppendBytes(out, model.bounds[1], sizeof(model.bounds[1])); - // Raw v22 stores the start index twice for older/newer loader paths, - // followed by the precomputed no-decal split and the total count. - Append(out, startSurfIndex); - Append(out, startSurfIndex); + // 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 From 39d909956de3197b3247833cf8e8f7564617609f Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 21:52:26 +0100 Subject: [PATCH 19/35] fix: reproduce d3dbsp lightmap falloff overlays --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 366 +++++++++++++++++- 1 file changed, 362 insertions(+), 4 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 9eadf847c..2035912d1 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -5,7 +5,10 @@ #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 @@ -596,6 +599,14 @@ namespace 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)); @@ -643,6 +654,172 @@ namespace } } + [[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]] bool LoadLightDefAttenuationPixels(const GfxLightDef& lightDef, ISearchPath& searchPath, std::vector& pixels, std::string& error) + { + const auto* image = lightDef.attenuation.image; + if (!image || !image->name) + { + error = std::format("light def \"{}\" has no attenuation image", lightDef.name ? lightDef.name : ""); + return false; + } + + const auto imageName = AssetNameWithoutReferencePrefix(image->name); + const auto file = searchPath.Open(image::GetFileNameForAsset(imageName, ".iwi")); + if (!file.IsOpen()) + { + if (LoadStockLightDefAttenuationPixels(imageName, pixels)) + return true; + + error = std::format("missing attenuation image \"{}\" for light def \"{}\"", imageName, lightDef.name ? lightDef.name : ""); + return false; + } + + auto loadResult = image::LoadIwi(*file.m_stream); + if (!loadResult || !loadResult->m_texture) + { + error = std::format("could not load attenuation image \"{}\" for light def \"{}\"", imageName, lightDef.name ? lightDef.name : ""); + return false; + } + + const auto& texture = *loadResult->m_texture; + if (texture.GetTextureType() != image::TextureType::T_2D) + { + error = std::format("attenuation image \"{}\" for light def \"{}\" is not a 2D image", imageName, lightDef.name ? lightDef.name : ""); + return false; + } + + const auto* format = texture.GetFormat(); + if (!format || format->GetType() != image::ImageFormatType::UNSIGNED) + { + error = std::format("attenuation image \"{}\" for light def \"{}\" has unsupported format", imageName, lightDef.name ? lightDef.name : ""); + return false; + } + + 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) + { + error = std::format("attenuation image \"{}\" for light def \"{}\" has unsupported pixel size", imageName, lightDef.name ? lightDef.name : ""); + return false; + } + + const auto* buffer = texture.GetBufferForMipLevel(0); + if (!buffer) + { + error = std::format("attenuation image \"{}\" for light def \"{}\" has no pixels", imageName, lightDef.name ? lightDef.name : ""); + return false; + } + + pixels.clear(); + pixels.reserve(texture.GetWidth()); + for (auto x = 0u; x < texture.GetWidth(); x++) + pixels.emplace_back(ReadRawLightPixel(buffer, *unsignedFormat, x)); + + return true; + } + + 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; @@ -2208,6 +2385,166 @@ namespace std::vector packedSlotForRawPage; }; + [[nodiscard]] bool CopyLightDefAttenuationImage( + std::vector& secondary, + const LightmapAtlasGroup& group, + const GfxLightDef& lightDef, + ISearchPath& searchPath, + std::string& error) + { + if (lightDef.lmapLookupStart <= 0) + { + error = std::format("light def \"{}\" has invalid lightmap lookup start", lightDef.name ? lightDef.name : ""); + return false; + } + + std::vector pixels; + if (!LoadLightDefAttenuationPixels(lightDef, searchPath, pixels, error)) + return false; + + if (pixels.empty()) + return true; + + const auto zoom = group.wideCount; + const auto firstPixelOffset = static_cast(zoom) * static_cast(lightDef.lmapLookupStart - 1); + size_t pixelOffset = firstPixelOffset; + + 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 false; + + for (const auto& pixel : pixels) + { + if (!writePixel(pixel)) + return false; + } + + if (!writePixel(pixels.back())) + return false; + } + else + { + if ((zoom & (zoom - 1u)) != 0u) + { + error = "lightmap atlas zoom is not a power of two"; + return false; + } + + const auto endCount = zoom + (zoom >> 1u); + for (auto i = 0u; i < endCount; i++) + { + if (!writePixel(pixels.front())) + return false; + } + + 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 false; + } + } + + for (auto i = 0u; i < endCount; i++) + { + if (!writePixel(pixels.back())) + return false; + } + } + + return true; + } + + [[nodiscard]] bool ApplyLightDefAttenuationImages( + std::vector& secondary, + const LightmapAtlasGroup& group, + const std::vector& lightDefs, + ISearchPath& searchPath, + std::string& error) + { + // 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; + + if (!CopyLightDefAttenuationImage(secondary, group, *lightDef, searchPath, error)) + { + if (error.empty()) + error = std::format("light def \"{}\" attenuation image overflowed the secondary lightmap atlas", lightDef->name ? lightDef->name : ""); + return false; + } + } + + return true; + } + + [[nodiscard]] std::vector LoadPrimaryLightDefDependencies( + const IW3::d3dbsp::File& bsp, + AssetCreationContext& context, + AssetRegistration& registration, + std::string& error) + { + 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) + { + error = "primary-light lump has funny size"; + return lightDefs; + } + + 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) + { + error = std::format("missing light def \"{}\"", defName); + return lightDefs; + } + + auto* lightDef = dependency->Asset(); + if (!lightDef || !lightDef->attenuation.image) + { + error = std::format("light def \"{}\" has no attenuation image", defName); + return lightDefs; + } + + 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) @@ -3006,8 +3343,12 @@ namespace } } - [[nodiscard]] std::pair, std::vector> - BuildLightmapAtlasImages(const IW3::d3dbsp::Lump& lightmaps, const LightmapAtlasGroup& group) + [[nodiscard]] std::optional, std::vector>> BuildLightmapAtlasImages( + const IW3::d3dbsp::Lump& lightmaps, + const LightmapAtlasGroup& group, + const std::vector& lightDefs, + ISearchPath& searchPath, + std::string& error) { const auto primaryAtlasSize = static_cast(group.wideCount) * LIGHTMAP_PRIMARY_RAW_WIDTH * static_cast(group.highCount) * LIGHTMAP_PRIMARY_RAW_HEIGHT; @@ -3025,6 +3366,9 @@ namespace CopyPrimaryLightmapRawPageToAtlas(primary, page, group, static_cast(packedSlot)); } + if (!ApplyLightDefAttenuationImages(secondary, group, lightDefs, searchPath, error)) + return std::nullopt; + return std::make_pair(std::move(primary), std::move(secondary)); } @@ -3032,6 +3376,8 @@ namespace GfxWorld& world, const IW3::d3dbsp::File& bsp, const LightmapAtlasLayout& lightmapLayout, + const std::vector& lightDefs, + ISearchPath& searchPath, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory, @@ -3065,7 +3411,12 @@ namespace for (auto lightmapIndex = 0uz; lightmapIndex < lightmapLayout.groups.size(); lightmapIndex++) { const auto& group = lightmapLayout.groups[lightmapIndex]; - const auto [primaryPixels, secondaryPixels] = BuildLightmapAtlasImages(*lightmaps, group); + auto atlasImages = BuildLightmapAtlasImages(*lightmaps, group, lightDefs, searchPath, error); + if (!atlasImages) + return false; + + 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); @@ -5903,6 +6254,13 @@ namespace registration.AddDependency(dependency); } + auto lightDefDependencies = LoadPrimaryLightDefDependencies(*bsp, context, registration, error); + if (!error.empty()) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + const auto* clipMap = clipMapDependency->Asset(); if (!PopulateWorldIndices(*world, *bsp, m_memory, error) || !PopulateWorldVertices(*world, *bsp, m_memory, error) || !PopulateWorldSurfaces(*world, *bsp, lightmapLayout, materialDependencies, m_memory, error)) @@ -5931,7 +6289,7 @@ namespace if (!PopulateWorldStaticModelAabbTrees(*world, m_memory, error) || !PopulateWorldDynamicEntities(*world, clipMap, m_memory, error) - || !PopulateWorldLightmaps(*world, *bsp, lightmapLayout, context, registration, m_memory, error)) + || !PopulateWorldLightmaps(*world, *bsp, lightmapLayout, lightDefDependencies, m_search_path, context, registration, m_memory, error)) { con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); return AssetCreationResult::Failure(); From b8b9ab9c56315447efa777ae02ca8fa35c710273 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 10 Jun 2026 21:54:09 +0100 Subject: [PATCH 20/35] fix(: normalize placeholder d3dbsp reflection probes --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 2035912d1..344a858f7 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -3343,6 +3343,40 @@ namespace } } + [[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]] std::optional, std::vector>> BuildLightmapAtlasImages( const IW3::d3dbsp::Lump& lightmaps, const LightmapAtlasGroup& group, @@ -3463,6 +3497,16 @@ namespace 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++) { From a9e43af9ea37ef9236c20c7ac9a682b1c450eb99 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Thu, 11 Jun 2026 07:57:57 +0100 Subject: [PATCH 21/35] fix: match linker bsp unit vector packing --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 344a858f7..9a5b9da58 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -230,6 +230,24 @@ namespace 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) @@ -3040,8 +3058,8 @@ namespace CrossProduct(normal, tangent, expectedBinormal); vertex.binormalSign = DotProduct(expectedBinormal, binormal) < 0.0f ? -1.0f : 1.0f; - vertex.normal = Common::Vec3PackUnitVec(normal); - vertex.tangent = Common::Vec3PackUnitVec(tangent); + vertex.normal = PackRawBspUnitVec(normal); + vertex.tangent = PackRawBspUnitVec(tangent); } SetWorldBoundsFromVertices(world); From 1fb183ebbb1f1f25efdd344600687c54ba9dae92 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Thu, 11 Jun 2026 09:02:07 +0100 Subject: [PATCH 22/35] fix: match linker draw index ordering for d3dbsp This makes dumped d3dbsp lumps DRAWINDICES and UNLAYERED_DRAWINDICES byte exact compared to a linker_pc-built ff. --- src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h | 10 + .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 210 +++++++++++++++--- 2 files changed, 192 insertions(+), 28 deletions(-) diff --git a/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h b/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h index 4c8e81ea8..ea6c4ec60 100644 --- a/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h +++ b/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h @@ -85,6 +85,16 @@ namespace IW3::d3dbsp 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, diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 9a5b9da58..6550a0234 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -34,6 +34,7 @@ using namespace IW3; namespace { using enum IW3::d3dbsp::LumpType; + using enum IW3::d3dbsp::TrisType; constexpr auto RAW_LIGHT_TYPE_OFFSET = 0uz; constexpr auto RAW_LIGHT_CAN_USE_SHADOW_MAP_OFFSET = 1uz; @@ -2360,32 +2361,26 @@ namespace return true; } - [[nodiscard]] const IW3::d3dbsp::Lump* - SelectWorldLump(const IW3::d3dbsp::File& bsp, const IW3::d3dbsp::LumpType preferred, const IW3::d3dbsp::LumpType fallback) + [[nodiscard]] IW3::d3dbsp::TrisType ChooseTrisContextType(const IW3::d3dbsp::File& bsp) { - const auto* preferredLump = bsp.GetLump(preferred); - if (preferredLump && !preferredLump->data.empty()) - return preferredLump; - - return bsp.GetLump(fallback); + // 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* - SelectSimpleWorldLump(const IW3::d3dbsp::File& bsp, const IW3::d3dbsp::LumpType simple, const IW3::d3dbsp::LumpType layered) + [[nodiscard]] const IW3::d3dbsp::Lump* SelectWorldLumpForTrisType( + const IW3::d3dbsp::File& bsp, + const IW3::d3dbsp::LumpType layered, + const IW3::d3dbsp::LumpType unlayered) { - // IW3 v22 BSPs can contain both simple and layered render geometry. The - // layered surface records reference generated material names such as - // "*1n_2n"; linker_pc parses those names and synthesizes layered - // materials from their source material indices. Until this loader - // implements that synthesis, prefer the simple representation because it - // references regular world materials directly. - return SelectWorldLump(bsp, simple, layered); + return bsp.GetLump(ChooseTrisContextType(bsp) == TRIS_TYPE_SIMPLE ? unlayered : layered); } [[nodiscard]] bool UsesSimpleWorldGeometry(const IW3::d3dbsp::File& bsp) { - const auto* simpleSurfaces = bsp.GetLump(LUMP_SIMPLE_TRI_SOUPS); - return simpleSurfaces && !simpleSurfaces->data.empty(); + return ChooseTrisContextType(bsp) == TRIS_TYPE_SIMPLE; } struct LightmapAtlasGroup @@ -2577,7 +2572,7 @@ namespace std::array& coupling, std::string& error) { - const auto* surfaces = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_TRI_SOUPS, LUMP_LAYERED_TRI_SOUPS); + const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); if (!surfaces) return true; @@ -2639,7 +2634,7 @@ namespace [[nodiscard]] bool ReferencedLightmapPageCount(const IW3::d3dbsp::File& bsp, unsigned& pageCount, std::string& error) { pageCount = 0u; - const auto* surfaces = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_TRI_SOUPS, LUMP_LAYERED_TRI_SOUPS); + const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); if (!surfaces) return true; @@ -2873,7 +2868,7 @@ namespace [[nodiscard]] std::vector WorldSurfaceMaterialUsage(const IW3::d3dbsp::File& bsp, const size_t materialCount, std::string& error) { std::vector result(materialCount); - const auto* surfaces = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_TRI_SOUPS, LUMP_LAYERED_TRI_SOUPS); + const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); if (!surfaces) return result; @@ -2967,6 +2962,25 @@ namespace return result; } + [[nodiscard]] std::vector RawWorldMaterialNames(const IW3::d3dbsp::File& bsp, std::string& error) + { + const auto* materials = bsp.GetLump(LUMP_MATERIALS); + if (!ValidateRecordLump(bsp, materials, LUMP_MATERIALS, RAW_MATERIAL_SIZE, error)) + return {}; + + std::vector result; + 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) @@ -3009,7 +3023,7 @@ namespace [[nodiscard]] bool PopulateWorldIndices(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) { - const auto* indices = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_INDICES, LUMP_LAYERED_INDICES); + const auto* indices = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_INDICES, LUMP_SIMPLE_INDICES); if (!indices) return true; @@ -3026,7 +3040,7 @@ namespace [[nodiscard]] bool PopulateWorldVertices(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) { - const auto* verts = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_VERTS, LUMP_LAYERED_VERTS); + const auto* verts = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_VERTS, LUMP_SIMPLE_VERTS); if (!verts) return true; @@ -3147,6 +3161,128 @@ namespace } } + 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]] bool RewriteWorldIndicesLikeLinker( + GfxWorld& world, + const std::vector& rawSurfaces, + const std::vector& rawMaterialNames, + MemoryManager& memory, + std::string& error) + { + if (rawSurfaces.empty()) + return true; + + if (!world.indices) + { + error = "world surfaces require an index lump"; + return false; + } + + auto rewrittenIndexCount = 0uz; + for (const auto& surface : rawSurfaces) + rewrittenIndexCount += surface.indexCount; + + if (!FitsInt(rewrittenIndexCount)) + { + error = "world index count is too large"; + return false; + } + + 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)) + { + error = std::format("world surface {} index range is out of bounds", surfaceIndex); + return false; + } + + if (writeIndex + rawSurface.indexCount > rewrittenIndices.size()) + { + error = "world index rewrite exceeded output size"; + return false; + } + + 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()) + { + error = "world index rewrite did not write every index"; + return false; + } + + 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 true; + } + [[nodiscard]] bool PopulateWorldSurfaces( GfxWorld& world, const IW3::d3dbsp::File& bsp, @@ -3155,7 +3291,7 @@ namespace MemoryManager& memory, std::string& error) { - const auto* surfaces = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_TRI_SOUPS, LUMP_LAYERED_TRI_SOUPS); + const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); if (!surfaces) return true; @@ -3172,6 +3308,10 @@ namespace 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 rawMaterialNames = RawWorldMaterialNames(bsp, error); + if (!error.empty()) + return false; for (auto surfaceIndex = 0uz; surfaceIndex < static_cast(world.surfaceCount); surfaceIndex++) { @@ -3195,8 +3335,22 @@ namespace surface.tris.firstVertex = ReadI32(record, 12uz); surface.tris.vertexCount = ReadU16(record, 16uz); surface.tris.triCount = static_cast(ReadU16(record, 18uz) / 3u); - surface.tris.baseIndex = ReadI32(record, 20uz); + 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); + } + if (!RewriteWorldIndicesLikeLinker(world, rawSurfaceIndexInfo, rawMaterialNames, memory, error)) + return false; + + 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]; @@ -3916,7 +4070,7 @@ namespace [[nodiscard]] GfxAabbTree* BuildWorldAabbTrees(const GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, int& treeCount, std::string& error) { - const auto* aabbTrees = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_AABBTREES, LUMP_LAYERED_AABBTREES); + const auto* aabbTrees = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_AABBTREES, LUMP_SIMPLE_AABBTREES); if (!aabbTrees || aabbTrees->data.empty()) { treeCount = world.surfaceCount > 0 ? 1 : 0; @@ -4622,7 +4776,7 @@ namespace return true; } - const auto* surfaces = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_TRI_SOUPS, LUMP_LAYERED_TRI_SOUPS); + const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); auto maxPrimaryLightIndex = 0u; auto foundSurface = false; @@ -6144,7 +6298,7 @@ namespace std::string& error) { const auto* materials = bsp.GetLump(LUMP_MATERIALS); - const auto* surfaces = SelectSimpleWorldLump(bsp, LUMP_SIMPLE_TRI_SOUPS, LUMP_LAYERED_TRI_SOUPS); + 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 true; From 8cb9be017009a69311f554adec49d08ff16db040 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Thu, 11 Jun 2026 09:50:06 +0100 Subject: [PATCH 23/35] fix: apply magic portal vertex coords for d3dbsp This makes dumped d3dbsp lumps DRAWVERTS and SIMPLE_VERTS byte exact compared to a linker_pc-built ff. --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 6550a0234..cbda36e45 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -89,6 +89,7 @@ namespace 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 SKY_LIGHTMAP_INDEX = 31u; @@ -3161,6 +3162,114 @@ namespace } } + [[nodiscard]] bool ApplyMagicPortalVertexCoords(GfxWorld& world, const GfxSurface& surface, std::string& error) + { + if (!surface.material || (surface.material->info.gameFlags & MATERIAL_GAME_FLAG_MAGIC_PORTAL) == 0u) + return true; + + const auto triCount = static_cast(surface.tris.triCount); + const auto indexCount = triCount * 3uz; + if (triCount == 0uz) + return true; + + 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)) + { + error = "magic portal surface index range is out of bounds"; + return false; + } + + 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) + { + error = "magic portal surface references invalid vertex"; + return false; + } + + 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 true; + } + struct RawWorldSurfaceIndexInfo { uint16_t materialIndex = 0u; @@ -3365,6 +3474,9 @@ namespace } } + if (!ApplyMagicPortalVertexCoords(world, surface, error)) + return false; + PopulateSurfaceBounds(world, surface); } From f331e166a8244012618984ff758d605d1a2bb612 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Thu, 11 Jun 2026 11:27:26 +0100 Subject: [PATCH 24/35] fix: match linker_pc BSP surface material sorting Recompute linker-style drawSurf sort keys for BSP world materials before sorting surfaces, including pixel-constant tie breakers and camera-region fallbacks for referenced techsets. This makes dumped d3dbsp TRIANGLES and SIMPLE_TRI_SOUPS lumps byte-exact compared to a linker_pc-built fastfile. --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 352 ++++++++++++++++-- 1 file changed, 328 insertions(+), 24 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index cbda36e45..58d789871 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -271,6 +272,308 @@ namespace 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) @@ -2829,6 +3132,16 @@ namespace 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 @@ -2839,12 +3152,6 @@ namespace result.emplace_back(rawMaterialName); - if (rawMaterialName == "$default") - { - result.emplace_back("$default3d"); - result.emplace_back("$default2d"); - } - return result; } @@ -2937,20 +3244,16 @@ namespace } XAssetInfo* dependency = nullptr; - if (materialName == DEFAULT_MATERIAL_NAME) + for (const auto& candidateName : WorldMaterialNameCandidates(materialName)) { - dependency = GetOrCreateDefaultMaterialReference(context, memory); - } - else - { - for (const auto& candidateName : WorldMaterialNameCandidates(materialName)) - { - dependency = TryLoadWorldMaterialDependency(context, candidateName); - if (dependency) - break; - } + dependency = TryLoadWorldMaterialDependency(context, candidateName); + if (dependency) + break; } + if (!dependency && materialName == DEFAULT_MATERIAL_NAME) + dependency = GetOrCreateDefaultMaterialReference(context, memory); + if (!dependency) { error = std::format("missing render material \"{}\"", materialName); @@ -4591,15 +4894,15 @@ namespace [[nodiscard]] bool CompareWorldSurfaces(const GfxSurface& left, const GfxSurface& right) { - const auto leftHasLit = MaterialHasTechnique(left.material, TECHNIQUE_LIT_BEGIN); - const auto rightHasLit = MaterialHasTechnique(right.material, TECHNIQUE_LIT_BEGIN); + const auto leftHasLit = MaterialHasWorldLitTechnique(left.material); + const auto rightHasLit = MaterialHasWorldLitTechnique(right.material); if (leftHasLit != rightHasLit) return leftHasLit > rightHasLit; if (!leftHasLit) { - const auto leftHasEmissive = MaterialHasTechnique(left.material, TECHNIQUE_EMISSIVE); - const auto rightHasEmissive = MaterialHasTechnique(right.material, TECHNIQUE_EMISSIVE); + const auto leftHasEmissive = MaterialHasWorldEmissiveTechnique(left.material); + const auto rightHasEmissive = MaterialHasWorldEmissiveTechnique(right.material); if (leftHasEmissive != rightHasEmissive) return leftHasEmissive > rightHasEmissive; } @@ -4636,7 +4939,7 @@ namespace while (surfIndex < surfaceCount) { const auto* material = world.dpvs.surfaces[surfIndex].material; - if (!material || !material->techniqueSet || !MaterialHasTechnique(material, TECHNIQUE_LIT_BEGIN) || material->info.sortKey >= 0x18u) + if (!material || !material->techniqueSet || !MaterialHasWorldLitTechnique(material) || material->info.sortKey >= 0x18u) break; surfIndex++; @@ -4647,7 +4950,7 @@ namespace while (surfIndex < surfaceCount) { const auto* material = world.dpvs.surfaces[surfIndex].material; - if (!material || !material->techniqueSet || !MaterialHasTechnique(material, TECHNIQUE_LIT_BEGIN)) + if (!material || !material->techniqueSet || !MaterialHasWorldLitTechnique(material)) break; surfIndex++; @@ -4658,7 +4961,7 @@ namespace while (surfIndex < surfaceCount) { const auto* material = world.dpvs.surfaces[surfIndex].material; - if (!material || !material->techniqueSet || !MaterialHasTechnique(material, TECHNIQUE_EMISSIVE)) + if (!material || !material->techniqueSet || !MaterialHasWorldEmissiveTechnique(material)) break; surfIndex++; @@ -6550,6 +6853,7 @@ namespace con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); return AssetCreationResult::Failure(); } + AssignWorldMaterialDrawSurfSortKeys(materialDependencies); const auto staticModelBlocks = StaticModelEntityBlocks(entityBlocks); const auto staticModelDependencies = LoadStaticModelDependencies(staticModelBlocks, context); From 6c9ef1f7806b85f105d3f4b371f75eea75272123 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Thu, 11 Jun 2026 14:15:15 +0100 Subject: [PATCH 25/35] fix: match linker static model entity transforms Use linker_pc-compatible misc_model key lookup, vector/scale parsing, angle matrix intermediates, and inverse-scale packing. This makes dumped d3dbsp lump 39 ENTITIES byte-identical between linker_pc-built and OAT-built fastfiles for the same source BSP. --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 72 +++++++++++++++---- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 58d789871..8c5605999 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -717,6 +718,19 @@ namespace 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; @@ -1196,10 +1210,32 @@ namespace [[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 {}; + 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 existingField->second; + return {}; } [[nodiscard]] bool ParseEntityBlocks(const std::vector& lump, std::vector& blocks, std::string& error) @@ -1348,11 +1384,17 @@ namespace [[nodiscard]] float StaticModelScale(const EntityBlock& block) { - const auto parsedScale = ParseFloat(EntityField(block, "modelscale")); - if (!parsedScale || *parsedScale <= std::numeric_limits::epsilon()) + 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; + return parsedScale; } void AnglesToAxis(const std::array& angles, float (&axis)[3][3]) @@ -1372,12 +1414,15 @@ namespace axis[0][1] = LinkerFloat(static_cast(cp) * sy); axis[0][2] = -sp; - const auto srSp = LinkerFloat(static_cast(sr) * 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 = LinkerFloat(static_cast(cr) * sp); + 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); @@ -1587,8 +1632,8 @@ namespace { const auto& [block, model] = validStaticModels[modelIndex]; auto& staticModel = clipMap.staticModelList[modelIndex]; - const auto origin = ParseFloat3(EntityField(*block, "origin")).value_or(std::array{}); - const auto angles = ParseFloat3(EntityField(*block, "angles")).value_or(std::array{}); + 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]{}; @@ -1601,10 +1646,11 @@ namespace // 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] = axis[row][column] / scale; + staticModel.invScaledAxis[column][row] = LinkerFloat(static_cast(axis[row][column]) * invScale); } BuildStaticModelBounds(*model, axis, origin, scale, staticModel); @@ -5395,8 +5441,8 @@ namespace const auto& [block, model] = validStaticModels[modelIndex]; auto& drawInst = world.dpvs.smodelDrawInsts[modelIndex]; auto& inst = world.dpvs.smodelInsts[modelIndex]; - const auto origin = ParseFloat3(EntityField(*block, "origin")).value_or(std::array{}); - const auto angles = ParseFloat3(EntityField(*block, "angles")).value_or(std::array{}); + 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]{}; From e6c9fb599cbd52dda6cf8021d1bfa605c388ad1f Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Thu, 11 Jun 2026 23:19:56 +0100 Subject: [PATCH 26/35] fix(iw3): match linker_pc d3dbsp world output Reconstruct static model primary light data and AABB tree handling closely enough for OAT-built and linker_pc-built fastfiles from the same source d3dbsp to dump byte-identical raw d3dbsp output. Also emits a canonical decal-split SIMPLE_AABBTREES lump instead of preserving unstable runtime zero-padding. --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 468 ++++++++++++++++-- .../Game/IW3/Maps/D3DBspDumperIW3.cpp | 18 +- 2 files changed, 442 insertions(+), 44 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 8c5605999..4074993aa 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -5376,28 +5376,290 @@ namespace return true; } - void PopulateStaticModelGroundLighting(const EntityBlock& block, GfxStaticModelInst& inst, GfxStaticModelDrawInst& drawInst) + [[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"); - if (gndLt.size() < 10uz) - return; + auto groundLightContainsValidData = gndLt.size() >= 10uz; - const auto b = ParseHexByte(gndLt, 0uz); - const auto g = ParseHexByte(gndLt, 2uz); - const auto r = ParseHexByte(gndLt, 4uz); - const auto a = ParseHexByte(gndLt, 6uz); - const auto primaryLightIndex = ParseHexByte(gndLt, 8uz); + 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) - return; + groundLightContainsValidData = false; - drawInst.primaryLightIndex = static_cast(*primaryLightIndex); + 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. - // In the drop path it recomputes primaryLightIndex from the model - // bounds/light regions; keep the parsed index until that lookup exists. - if (!drawInst.model || (static_cast(drawInst.model->flags) & 1u) == 0u || (*r == 0u && *g == 0u && *b == 0u && *a == 0u)) + 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. @@ -5409,6 +5671,7 @@ namespace [[nodiscard]] bool PopulateWorldStaticModels( GfxWorld& world, + const ComWorld& comWorld, const clipMap_t* clipMap, const std::vector& staticModelBlocks, const std::vector*>& staticModelDependencies, @@ -5493,12 +5756,93 @@ namespace std::memcpy(inst.maxs, maxs, sizeof(inst.maxs)); } - PopulateStaticModelGroundLighting(*block, inst, drawInst); + PopulateStaticModelGroundLighting(world, comWorld, *block, inst, drawInst); } return true; } + 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] @@ -5989,7 +6333,7 @@ namespace return true; } - [[nodiscard]] bool PopulateWorldStaticModelAabbTrees(GfxWorld& world, MemoryManager& memory, std::string& error) + [[nodiscard]] bool PopulateWorldStaticModelAabbTrees(GfxWorld& world, const ComWorld& comWorld, MemoryManager& memory, std::string& error) { if (world.dpvs.smodelCount == 0u || !world.dpvs.smodelInsts || !world.cells || world.dpvsPlanes.cellCount <= 0) return true; @@ -6000,6 +6344,8 @@ namespace return false; } + SortWorldStaticModels(world, comWorld); + StaticModelIndexLists staticModelIndexesByTree; for (auto smodelIndex = 0u; smodelIndex < world.dpvs.smodelCount; smodelIndex++) { @@ -6940,8 +7286,17 @@ namespace } const auto* clipMap = clipMapDependency->Asset(); - if (!PopulateWorldIndices(*world, *bsp, m_memory, error) || !PopulateWorldVertices(*world, *bsp, m_memory, error) - || !PopulateWorldSurfaces(*world, *bsp, lightmapLayout, materialDependencies, m_memory, error)) + if (!PopulateWorldIndices(*world, *bsp, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldVertices(*world, *bsp, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldSurfaces(*world, *bsp, lightmapLayout, materialDependencies, m_memory, error)) { con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); return AssetCreationResult::Failure(); @@ -6949,15 +7304,62 @@ namespace PopulateWorldMaterialMemory(*world, m_memory); - if (!PopulateWorldVertexLayerData(*world, *bsp, m_memory, error) || !PopulateWorldModels(*world, *bsp, m_memory, error) - || !PopulateWorldCells(*world, *bsp, m_memory, error) - || !PopulateWorldSurfaceOrganization(*world, m_memory, error) - || !PopulateWorldDpvsPlanes(*world, clipMap, *bsp, m_memory, error) - || !PopulateWorldPortals(*world, *bsp, m_memory, error) - || !PopulateWorldPrimaryLights(*world, *bsp, m_memory, error) || !PopulateWorldShadowGeometry(*world, m_memory) - || !PopulateWorldLightGrid(*world, *bsp, m_memory, error) || !PopulateWorldLightRegions(*world, *bsp, m_memory, error) - || !PopulateWorldStaticModels(*world, clipMap, staticModelBlocks, staticModelDependencies, m_memory, error) - || !PopulateWorldReflectionProbes(*world, *bsp, context, registration, m_memory, error)) + if (!PopulateWorldVertexLayerData(*world, *bsp, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldModels(*world, *bsp, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldCells(*world, *bsp, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldSurfaceOrganization(*world, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldDpvsPlanes(*world, clipMap, *bsp, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldPortals(*world, *bsp, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldPrimaryLights(*world, *bsp, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldShadowGeometry(*world, m_memory)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldLightGrid(*world, *bsp, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldLightRegions(*world, *bsp, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldStaticModels(*world, *comWorldDependency->Asset(), clipMap, staticModelBlocks, staticModelDependencies, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldReflectionProbes(*world, *bsp, context, registration, m_memory, error)) { con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); return AssetCreationResult::Failure(); @@ -6965,9 +7367,17 @@ namespace PopulateWorldStaticModelReflectionProbes(*world); - if (!PopulateWorldStaticModelAabbTrees(*world, m_memory, error) - || !PopulateWorldDynamicEntities(*world, clipMap, m_memory, error) - || !PopulateWorldLightmaps(*world, *bsp, lightmapLayout, lightDefDependencies, m_search_path, context, registration, m_memory, error)) + if (!PopulateWorldStaticModelAabbTrees(*world, *comWorldDependency->Asset(), m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldDynamicEntities(*world, clipMap, m_memory, error)) + { + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + return AssetCreationResult::Failure(); + } + if (!PopulateWorldLightmaps(*world, *bsp, lightmapLayout, lightDefDependencies, m_search_path, context, registration, m_memory, error)) { con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); return AssetCreationResult::Failure(); diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp index 0c5a495f0..1c6d925f9 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -1446,13 +1446,6 @@ namespace if (world.dpvs.staticSurfaceCount != world.dpvs.staticSurfaceCountNoDecal) { - auto totalAabbTreeCount = 0uz; - if (world.dpvsPlanes.cellCount > 0 && world.cells) - { - for (auto cellIndex = 0; cellIndex < world.dpvsPlanes.cellCount; cellIndex++) - totalAabbTreeCount += static_cast(std::max(world.cells[cellIndex].aabbTreeCount, 0)); - } - const uint32_t startSurfIndex = 0u; const auto surfaceCount = world.modelCount > 0 && world.models ? static_cast(world.models[0].surfaceCount) : static_cast(world.surfaceCount); @@ -1464,14 +1457,9 @@ namespace // 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. - // Keep the original lump footprint where possible; some Radiant - // paths are sensitive to later lump positions. - for (auto treeIndex = 1uz; treeIndex < totalAabbTreeCount; treeIndex++) - { - Append(out, 0u); - Append(out, 0u); - Append(out, 0u); - } + // 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; } From 2c8185af2b6b86013c9896a80f6d970dd95e677e Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 17 Jun 2026 07:56:56 +0100 Subject: [PATCH 27/35] chore: clean up `BuildCellHeader` --- .../Game/IW3/Maps/D3DBspDumperIW3.cpp | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp index 1c6d925f9..45c9db6ed 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -859,6 +859,12 @@ namespace 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) { @@ -1364,23 +1370,30 @@ namespace [[nodiscard]] std::vector BuildCellHeader(const GfxWorld& world) { - std::vector out(112uz); + 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]; - auto offset = 0uz; - std::copy_n(reinterpret_cast(cell.mins), sizeof(cell.mins), out.data() + offset); - offset += sizeof(cell.mins); - std::copy_n(reinterpret_cast(cell.maxs), sizeof(cell.maxs), out.data() + offset); + 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. - constexpr auto REFLECTION_PROBE_LIST_OFFSET = 44uz; - out[REFLECTION_PROBE_LIST_OFFSET] = static_cast(cell.reflectionProbeCount); - for (auto i = 0uz; i < static_cast(cell.reflectionProbeCount) && i < 67uz; i++) - out[REFLECTION_PROBE_LIST_OFFSET + 1uz + i] = static_cast(cell.reflectionProbes[i]); + 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; } @@ -1694,7 +1707,8 @@ namespace { std::vector out; const uint32_t version = 8u; - const auto nodeCount = static_cast(std::min(gameWorld.path.nodeCount, static_cast(UINT16_MAX))); + const auto nodeCount = + static_cast(std::min(gameWorld.path.nodeCount, static_cast(std::numeric_limits::max()))); Append(out, version); Append(out, nodeCount); From 171903452ca84f9467e6662e52fc02956bb862da Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 17 Jun 2026 08:01:12 +0100 Subject: [PATCH 28/35] refactor: add `RawBytes` write helper --- .../Game/IW3/Maps/D3DBspDumperIW3.cpp | 46 ++++++++----------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp index 45c9db6ed..3cd35e908 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -156,6 +156,16 @@ namespace 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 MarkMaterialUsedByBrush(std::vector& usedMaterials, const unsigned materialIndex) { if (materialIndex < usedMaterials.size()) @@ -448,9 +458,7 @@ namespace [[nodiscard]] std::vector BuildBrushEdges(const clipMap_t& clipMap) { - std::vector out; - AppendBytes(out, clipMap.brushEdges, static_cast(clipMap.numBrushEdges) * sizeof(cbrushedge_t)); - return out; + return RawBytes(clipMap.brushEdges, static_cast(clipMap.numBrushEdges) * sizeof(cbrushedge_t)); } [[nodiscard]] std::vector BuildBrushHeaders(const clipMap_t& clipMap) @@ -656,31 +664,23 @@ namespace [[nodiscard]] std::vector BuildCollisionVerts(const clipMap_t& clipMap) { - std::vector out; - AppendBytes(out, clipMap.verts, static_cast(clipMap.vertCount) * sizeof(vec3_t)); - return out; + return RawBytes(clipMap.verts, static_cast(clipMap.vertCount) * sizeof(vec3_t)); } [[nodiscard]] std::vector BuildCollisionTriIndices(const clipMap_t& clipMap) { - std::vector out; - AppendBytes(out, clipMap.triIndices, PositiveCount(clipMap.triCount) * 3uz * sizeof(uint16_t)); - return out; + return RawBytes(clipMap.triIndices, PositiveCount(clipMap.triCount) * 3uz * sizeof(uint16_t)); } [[nodiscard]] std::vector BuildCollisionTriEdgeIsWalkable(const clipMap_t& clipMap) { - std::vector out; const auto size = ((PositiveCount(clipMap.triCount) * 3uz + 31uz) / 32uz) * sizeof(uint32_t); - AppendBytes(out, clipMap.triEdgeIsWalkable, size); - return out; + return RawBytes(clipMap.triEdgeIsWalkable, size); } [[nodiscard]] std::vector BuildCollisionBorders(const clipMap_t& clipMap) { - std::vector out; - AppendBytes(out, clipMap.borders, PositiveCount(clipMap.borderCount) * sizeof(CollisionBorder)); - return out; + return RawBytes(clipMap.borders, PositiveCount(clipMap.borderCount) * sizeof(CollisionBorder)); } [[nodiscard]] std::vector BuildCollisionPartitions(const clipMap_t& clipMap) @@ -707,9 +707,7 @@ namespace [[nodiscard]] std::vector BuildCollisionAabbTrees(const clipMap_t& clipMap) { - std::vector out; - AppendBytes(out, clipMap.aabbTrees, PositiveCount(clipMap.aabbTreeCount) * sizeof(CollisionAabbTree)); - return out; + return RawBytes(clipMap.aabbTrees, PositiveCount(clipMap.aabbTreeCount) * sizeof(CollisionAabbTree)); } [[nodiscard]] size_t LightGridRowCount(const GfxLightGrid& lightGrid) @@ -736,9 +734,7 @@ namespace [[nodiscard]] std::vector BuildLightGridEntries(const GfxWorld& world) { - std::vector out; - AppendBytes(out, world.lightGrid.entries, static_cast(world.lightGrid.entryCount) * sizeof(GfxLightGridEntry)); - return out; + return RawBytes(world.lightGrid.entries, static_cast(world.lightGrid.entryCount) * sizeof(GfxLightGridEntry)); } [[nodiscard]] std::vector BuildLightGridColors(const GfxWorld& world) @@ -756,9 +752,7 @@ namespace [[nodiscard]] std::vector BuildLightGridRawRows(const GfxWorld& world) { - std::vector out; - AppendBytes(out, world.lightGrid.rawRowData, world.lightGrid.rawRowDataSize); - return out; + return RawBytes(world.lightGrid.rawRowData, world.lightGrid.rawRowDataSize); } [[nodiscard]] uint16_t SurfaceMaterialIndex(const clipMap_t* clipMap, const GfxSurface& surface) @@ -1157,9 +1151,7 @@ namespace [[nodiscard]] std::vector BuildIndices(const GfxWorld& world) { - std::vector out; - AppendBytes(out, world.indices, PositiveCount(world.indexCount) * sizeof(uint16_t)); - return out; + return RawBytes(world.indices, PositiveCount(world.indexCount) * sizeof(uint16_t)); } [[nodiscard]] std::vector BuildLightmapImages(const GfxWorld& world, const LightmapPageLayout& layout) From aee563baf6ff53528b3dc6a9767f28c79b20b456 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 17 Jun 2026 08:04:34 +0100 Subject: [PATCH 29/35] refactor: add `WriteAt` helper --- .../Game/IW3/Maps/D3DBspDumperIW3.cpp | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp index 3cd35e908..337812edc 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -166,6 +166,21 @@ namespace 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()) @@ -1751,23 +1766,20 @@ namespace // 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. - std::copy_n(reinterpret_cast(light.color), sizeof(light.color), out.data() + baseOffset + COLOR_OFFSET); - std::copy_n(reinterpret_cast(light.dir), sizeof(light.dir), out.data() + baseOffset + DIR_OFFSET); - std::copy_n(reinterpret_cast(light.origin), sizeof(light.origin), out.data() + baseOffset + ORIGIN_OFFSET); - std::copy_n(reinterpret_cast(&light.radius), sizeof(light.radius), out.data() + baseOffset + RADIUS_OFFSET); - std::copy_n( - reinterpret_cast(&light.cosHalfFovOuter), sizeof(light.cosHalfFovOuter), out.data() + baseOffset + COS_HALF_FOV_OUTER_OFFSET); - std::copy_n( - reinterpret_cast(&light.cosHalfFovInner), sizeof(light.cosHalfFovInner), out.data() + baseOffset + COS_HALF_FOV_INNER_OFFSET); + 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)); - std::copy_n(reinterpret_cast(&exponent), sizeof(exponent), out.data() + baseOffset + EXPONENT_OFFSET); - std::copy_n(reinterpret_cast(&light.rotationLimit), sizeof(light.rotationLimit), out.data() + baseOffset + ROTATION_LIMIT_OFFSET); - std::copy_n( - reinterpret_cast(&light.translationLimit), sizeof(light.translationLimit), out.data() + baseOffset + TRANSLATION_LIMIT_OFFSET); + 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); - std::copy_n(reinterpret_cast(light.defName), defNameLength, out.data() + baseOffset + DEF_NAME_OFFSET); + WriteBytesAt(out, baseOffset + DEF_NAME_OFFSET, light.defName, defNameLength); } } From ffa649edb383671628134eb15ff75f7936783537 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 17 Jun 2026 08:34:18 +0100 Subject: [PATCH 30/35] refactor: use `std::expected` for entity parsing --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 4074993aa..60fc2396f 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -1238,13 +1239,14 @@ namespace return {}; } - [[nodiscard]] bool ParseEntityBlocks(const std::vector& lump, std::vector& blocks, std::string& error) + [[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()) @@ -1252,10 +1254,7 @@ namespace while (offset < text.size() && text[offset] != '{') { if (!std::isspace(static_cast(text[offset]))) - { - error = "unexpected non-whitespace before entity block"; - return false; - } + return std::unexpected("unexpected non-whitespace before entity block"); offset++; } @@ -1308,13 +1307,10 @@ namespace } if (depth != 0) - { - error = "unterminated entity block"; - return false; - } + return std::unexpected("unterminated entity block"); } - return true; + return blocks; } [[nodiscard]] bool IsLinkerConsumedMapEntity(const EntityBlock& block) @@ -6758,12 +6754,13 @@ namespace const auto* entityLump = bsp->GetLump(LUMP_ENTITIES); if (entityLump) { - std::string parseError; - if (!ParseEntityBlocks(entityLump->data, entityBlocks, parseError)) + auto parsedEntityBlocks = ParseEntityBlocks(entityLump->data); + if (!parsedEntityBlocks) { - con::error("Could not create clipmap \"{}\" from {}: {}", assetName, bsp->m_file_name, parseError); + 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); @@ -6823,15 +6820,14 @@ namespace return AssetCreationResult::Failure(); } - std::vector entityBlocks; - std::string parseError; - if (!ParseEntityBlocks(entities->data, entityBlocks, parseError)) + auto entityBlocks = ParseEntityBlocks(entities->data); + if (!entityBlocks) { - con::error("Could not create MapEnts \"{}\" from {}: {}", assetName, bsp->m_file_name, parseError); + con::error("Could not create MapEnts \"{}\" from {}: {}", assetName, bsp->m_file_name, entityBlocks.error()); return AssetCreationResult::Failure(); } - const auto compiledEntityString = CompileMapEntsEntityString(entityBlocks); + const auto compiledEntityString = CompileMapEntsEntityString(*entityBlocks); const auto entityCharCount = compiledEntityString.size(); if (!FitsInt(entityCharCount)) { @@ -7230,12 +7226,13 @@ namespace const auto* entityLump = bsp->GetLump(LUMP_ENTITIES); if (entityLump) { - std::string parseError; - if (!ParseEntityBlocks(entityLump->data, entityBlocks, parseError)) + auto parsedEntityBlocks = ParseEntityBlocks(entityLump->data); + if (!parsedEntityBlocks) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, parseError); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, parsedEntityBlocks.error()); return AssetCreationResult::Failure(); } + entityBlocks = std::move(*parsedEntityBlocks); } std::string error; From 303fc9eeb8240e070415ab7b5b9f330440b80675 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 17 Jun 2026 09:33:18 +0100 Subject: [PATCH 31/35] refactor: use `std::expected` style everywhere for error results --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 1654 +++++++---------- 1 file changed, 641 insertions(+), 1013 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 60fc2396f..b6341207a 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -39,6 +39,10 @@ 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; @@ -585,22 +589,16 @@ namespace | (samplerState.clampW ? SAMPLER_CLAMP_W : 0)); } - [[nodiscard]] bool ValidateRecordLump( - const IW3::d3dbsp::File& bsp, const IW3::d3dbsp::Lump* lump, const IW3::d3dbsp::LumpType type, const size_t recordSize, std::string& error) + [[nodiscard]] BspLoadResult ValidateRecordLump( + const IW3::d3dbsp::File& bsp, const IW3::d3dbsp::Lump* lump, const IW3::d3dbsp::LumpType type, const size_t recordSize) { if (!lump) - { - error = std::format("missing lump {}", std::to_underlying(type)); - return false; - } + return std::unexpected(std::format("missing lump {}", std::to_underlying(type))); if (recordSize == 0uz || lump->data.size() % recordSize != 0uz) - { - error = std::format("{} lump {} has funny size {}", bsp.m_file_name, std::to_underlying(type), lump->data.size()); - return false; - } + return std::unexpected(std::format("{} lump {} has funny size {}", bsp.m_file_name, std::to_underlying(type), lump->data.size())); - return true; + return {}; } [[nodiscard]] size_t RecordCount(const IW3::d3dbsp::Lump& lump, const size_t recordSize) @@ -608,6 +606,16 @@ namespace 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); @@ -1067,68 +1075,53 @@ namespace return true; } - [[nodiscard]] bool LoadLightDefAttenuationPixels(const GfxLightDef& lightDef, ISearchPath& searchPath, std::vector& pixels, std::string& error) + [[nodiscard]] BspLoadValue> LoadLightDefAttenuationPixels(const GfxLightDef& lightDef, ISearchPath& searchPath) { const auto* image = lightDef.attenuation.image; if (!image || !image->name) - { - error = std::format("light def \"{}\" has no attenuation image", lightDef.name ? lightDef.name : ""); - return false; - } + 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 true; + return pixels; - error = std::format("missing attenuation image \"{}\" for light def \"{}\"", imageName, lightDef.name ? lightDef.name : ""); - return false; + 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) - { - error = std::format("could not load attenuation image \"{}\" for light def \"{}\"", imageName, lightDef.name ? lightDef.name : ""); - return false; - } + 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) - { - error = std::format("attenuation image \"{}\" for light def \"{}\" is not a 2D image", imageName, lightDef.name ? lightDef.name : ""); - return false; - } + 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) - { - error = std::format("attenuation image \"{}\" for light def \"{}\" has unsupported format", imageName, lightDef.name ? lightDef.name : ""); - return false; - } + 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) - { - error = std::format("attenuation image \"{}\" for light def \"{}\" has unsupported pixel size", imageName, lightDef.name ? lightDef.name : ""); - return false; - } + 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) - { - error = std::format("attenuation image \"{}\" for light def \"{}\" has no pixels", imageName, lightDef.name ? lightDef.name : ""); - return false; - } + 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 true; + return pixels; } void WriteSecondaryLightmapPixel(std::vector& out, const size_t pixelOffset, const RawLightPixel& pixel) @@ -1877,20 +1870,16 @@ namespace return static_cast(scoreBrushCount) * std::min(max - mins[axis], maxs[axis] - min); } - [[nodiscard]] std::optional PartitionLeafBrushes_r( + [[nodiscard]] BspLoadValue PartitionLeafBrushes_r( const clipMap_t& clipMap, std::vector& nodes, LeafBrush* leafBrushes, const int leafBrushCount, const float (&mins)[3], - const float (&maxs)[3], - std::string& error) + const float (&maxs)[3]) { if (leafBrushCount <= 0) - { - error = "cannot partition an empty leafbrush range"; - return std::nullopt; - } + return std::unexpected("cannot partition an empty leafbrush range"); const auto nodeIndex = AllocLeafBrushNode(nodes); auto bestScore = 0.0f; @@ -1938,9 +1927,9 @@ namespace if (centerBrushCount > 0) { - const auto childNodeIndex = PartitionLeafBrushes_r(clipMap, nodes, childLeafBrushes, centerBrushCount, mins, maxs, error); + const auto childNodeIndex = PartitionLeafBrushes_r(clipMap, nodes, childLeafBrushes, centerBrushCount, mins, maxs); if (!childNodeIndex) - return std::nullopt; + return std::unexpected(std::move(childNodeIndex.error())); nodes[nodeIndex].leafBrushCount = -1; nodes[nodeIndex].contents = nodes[*childNodeIndex].contents; @@ -1976,10 +1965,7 @@ namespace } if (childBrushCount <= 0) - { - error = "leafbrush partition produced an empty child"; - return std::nullopt; - } + 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]}; @@ -1988,16 +1974,13 @@ namespace else childMins[axis] = dist + range; - const auto childNodeIndex = PartitionLeafBrushes_r(clipMap, nodes, childLeafBrushes, childBrushCount, childMins, childMaxs, error); + const auto childNodeIndex = PartitionLeafBrushes_r(clipMap, nodes, childLeafBrushes, childBrushCount, childMins, childMaxs); if (!childNodeIndex) - return std::nullopt; + return std::unexpected(std::move(childNodeIndex.error())); const auto childOffset = *childNodeIndex - nodeIndex; if (childOffset > std::numeric_limits::max()) - { - error = "leafbrush partition child offset exceeded uint16 range"; - return std::nullopt; - } + 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; @@ -2009,10 +1992,7 @@ namespace } if (leafBrushCount > std::numeric_limits::max()) - { - error = "leafbrush partition leaf count exceeded int16 range"; - return std::nullopt; - } + return std::unexpected("leafbrush partition leaf count exceeded int16 range"); nodes[nodeIndex].leafBrushCount = static_cast(leafBrushCount); for (auto brushOffset = 0; brushOffset < leafBrushCount; brushOffset++) @@ -2022,29 +2002,25 @@ namespace } if (nodes[nodeIndex].contents == 0) - { - error = "leafbrush partition produced a leaf with no contents"; - return std::nullopt; - } + return std::unexpected("leafbrush partition produced a leaf with no contents"); nodes[nodeIndex].data.leaf.brushes = leafBrushes; return nodeIndex; } - [[nodiscard]] bool PartitionLeafBrushes( + [[nodiscard]] BspLoadResult PartitionLeafBrushes( const clipMap_t& clipMap, std::vector& nodes, LeafBrush* leafBrushes, const int leafBrushCount, - cLeaf_t& leaf, - std::string& error) + cLeaf_t& leaf) { leaf.brushContents = 0; leaf.terrainContents = LeafTerrainContents(clipMap, leaf); leaf.leafBrushNode = 0; if (leafBrushCount <= 0) - return true; + return {}; float mins[3]{ std::numeric_limits::max(), @@ -2061,10 +2037,7 @@ namespace { const auto brushIndex = leafBrushes[brushOffset]; if (brushIndex >= clipMap.numBrushes) - { - error = "leafbrush references invalid brush"; - return false; - } + return std::unexpected("leafbrush references invalid brush"); const auto& brush = clipMap.brushes[brushIndex]; leaf.brushContents |= brush.contents; @@ -2081,33 +2054,30 @@ namespace leaf.maxs[axis] = maxs[axis] + 0.125f; } - const auto nodeIndex = PartitionLeafBrushes_r(clipMap, nodes, leafBrushes, leafBrushCount, mins, maxs, error); + const auto nodeIndex = PartitionLeafBrushes_r(clipMap, nodes, leafBrushes, leafBrushCount, mins, maxs); if (!nodeIndex) - return false; + return std::unexpected(std::move(nodeIndex.error())); leaf.leafBrushNode = static_cast(*nodeIndex); - return true; + return {}; } - [[nodiscard]] bool PopulateClipMapMaterials(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateClipMapMaterials(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) { - const auto* materials = bsp.GetLump(LUMP_MATERIALS); - if (!ValidateRecordLump(bsp, materials, LUMP_MATERIALS, RAW_MATERIAL_SIZE, error)) - return false; + 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); + const auto count = RecordCount(**materials, RAW_MATERIAL_SIZE); if (!FitsUnsigned(count)) - { - error = "too many material records"; - return false; - } + 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; + 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 @@ -2118,81 +2088,71 @@ namespace static_cast(static_cast(ReadI32(record, 68uz)) & IW3::d3dbsp::RUNTIME_MATERIAL_CONTENT_MASK); } - return true; + return {}; } - [[nodiscard]] bool PopulateClipMapPlanes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateClipMapPlanes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) { - const auto* planes = bsp.GetLump(LUMP_PLANES); - if (!ValidateRecordLump(bsp, planes, LUMP_PLANES, RAW_PLANE_SIZE, error)) - return false; + 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); + const auto count = RecordCount(**planes, RAW_PLANE_SIZE); if (!FitsInt(count)) - { - error = "too many plane records"; - return false; - } + 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; + 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 true; + return {}; } - [[nodiscard]] bool PopulateClipMapBrushes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateClipMapBrushes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) { - const auto* brushHeaders = bsp.GetLump(LUMP_BRUSHES); - const auto* brushSides = bsp.GetLump(LUMP_BRUSHSIDES); + 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 (!ValidateRecordLump(bsp, brushHeaders, LUMP_BRUSHES, RAW_BRUSH_HEADER_SIZE, error) - || !ValidateRecordLump(bsp, brushSides, LUMP_BRUSHSIDES, RAW_BRUSHSIDE_SIZE, error) || !edgeCounts) - return false; + 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); + const auto brushCount = RecordCount(**brushHeaders, RAW_BRUSH_HEADER_SIZE); if (!FitsUint16(brushCount)) - { - error = "too many brush records"; - return false; - } + 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); + const auto sideCount = ReadU16((*brushHeaders)->data.data() + brushIndex * RAW_BRUSH_HEADER_SIZE); if (sideCount < 6u) - { - error = "brush has fewer than six axial sides"; - return false; - } + 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) - { - error = "brush side/edge-count lumps do not match brush headers"; - return false; - } + 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)) - { - error = "too many non-axial brush sides"; - return false; - } + 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 @@ -2211,7 +2171,7 @@ namespace 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* header = (*brushHeaders)->data.data() + brushIndex * RAW_BRUSH_HEADER_SIZE; const auto sideCount = ReadU16(header); const auto nonAxialCount = static_cast(sideCount - 6u); @@ -2224,7 +2184,7 @@ namespace { for (auto side = 0uz; side < 2uz; side++) { - const auto* rawSide = brushSides->data.data() + rawSideIndex * RAW_BRUSHSIDE_SIZE; + 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]); @@ -2243,14 +2203,11 @@ namespace for (auto sideIndex = 0uz; sideIndex < nonAxialCount; sideIndex++) { - const auto* rawSide = brushSides->data.data() + rawSideIndex * RAW_BRUSHSIDE_SIZE; + 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))) - { - error = "brush side references an invalid plane"; - return false; - } + return std::unexpected("brush side references an invalid plane"); auto& side = brush.sides[sideIndex]; side.plane = &clipMap.planes[planeIndex]; @@ -2266,27 +2223,21 @@ namespace brush.contents = BrushContents(clipMap, brush); } - return true; + return {}; } - [[nodiscard]] bool PopulateClipMapNodes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateClipMapNodes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) { const auto* nodes = bsp.GetLump(LUMP_NODES); if (!nodes) - return true; + return {}; if (nodes->data.size() % RAW_CLIP_NODE_SIZE != 0uz) - { - error = "node lump has funny size"; - return false; - } + return std::unexpected("node lump has funny size"); const auto nodeCount = RecordCount(*nodes, RAW_CLIP_NODE_SIZE); if (!FitsUnsigned(nodeCount)) - { - error = "too many node records"; - return false; - } + return std::unexpected("too many node records"); clipMap.numNodes = static_cast(nodeCount); clipMap.nodes = AllocZeroed(memory, nodeCount); @@ -2299,41 +2250,32 @@ namespace const auto child1 = ReadI32(record, 8uz); if (planeIndex < 0 || planeIndex >= clipMap.planeCount || !FitsInt16(child0) || !FitsInt16(child1)) - { - error = "node record references invalid plane or child"; - return false; - } + 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 true; + return {}; } - [[nodiscard]] bool PopulateClipMapLeafBrushes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[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 true; + return {}; } if (leafBrushes->data.size() % RAW_LEAF_BRUSH_SIZE != 0uz) - { - error = "leafbrush lump has funny size"; - return false; - } + return std::unexpected("leafbrush lump has funny size"); const auto leafBrushCount = RecordCount(*leafBrushes, RAW_LEAF_BRUSH_SIZE); if (!FitsUnsigned(leafBrushCount)) - { - error = "too many leafbrush records"; - return false; - } + return std::unexpected("too many leafbrush records"); clipMap.numLeafBrushes = static_cast(leafBrushCount); // CM_InitBoxHull writes one extra entry at leafbrushes[numLeafBrushes] @@ -2345,27 +2287,21 @@ namespace { const auto brushIndex = ReadU32(leafBrushes->data.data() + i * RAW_LEAF_BRUSH_SIZE); if (brushIndex > std::numeric_limits::max()) - { - error = "leafbrush index exceeds runtime range"; - return false; - } + return std::unexpected("leafbrush index exceeds runtime range"); clipMap.leafbrushes[i] = static_cast(brushIndex); } - return true; + return {}; } - [[nodiscard]] bool PopulateClipMapCollision(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[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))) - { - error = "collision vert lump has funny size"; - return false; - } + return std::unexpected("collision vert lump has funny size"); clipMap.vertCount = static_cast(RecordCount(*verts, RAW_VEC3_SIZE)); clipMap.verts = AllocCopy(memory, verts->data); @@ -2375,10 +2311,7 @@ namespace if (tris) { if (tris->data.size() % RAW_TRI_INDICES_SIZE != 0uz || !FitsInt(RecordCount(*tris, RAW_TRI_INDICES_SIZE))) - { - error = "collision tri lump has funny size"; - return false; - } + 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); @@ -2392,10 +2325,7 @@ namespace if (borders) { if (borders->data.size() % RAW_COLLISION_BORDER_SIZE != 0uz || !FitsInt(RecordCount(*borders, RAW_COLLISION_BORDER_SIZE))) - { - error = "collision border lump has funny size"; - return false; - } + 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); @@ -2405,10 +2335,7 @@ namespace if (partitions) { if (partitions->data.size() % RAW_COLLISION_PARTITION_SIZE != 0uz || !FitsInt(RecordCount(*partitions, RAW_COLLISION_PARTITION_SIZE))) - { - error = "collision partition lump has funny size"; - return false; - } + return std::unexpected("collision partition lump has funny size"); const auto partitionCount = RecordCount(*partitions, RAW_COLLISION_PARTITION_SIZE); clipMap.partitionCount = static_cast(partitionCount); @@ -2420,10 +2347,7 @@ namespace const auto borderIndex = ReadU32(record, 8uz); const auto borderCount = static_cast(std::to_integer(record[3])); if (borderCount > 0u && (borderIndex > runtimeBorderCount || borderCount > runtimeBorderCount - borderIndex)) - { - error = "collision partition references invalid border"; - return false; - } + return std::unexpected("collision partition references invalid border"); clipMap.partitions[i].triCount = static_cast(std::to_integer(record[2])); clipMap.partitions[i].borderCount = borderCount; @@ -2440,29 +2364,23 @@ namespace if (aabbs) { if (aabbs->data.size() % RAW_COLLISION_AABB_SIZE != 0uz || !FitsInt(RecordCount(*aabbs, RAW_COLLISION_AABB_SIZE))) - { - error = "collision AABB lump has funny size"; - return false; - } + 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 true; + return {}; } - [[nodiscard]] bool PopulateClipMapLeafs(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateClipMapLeafs(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) { const auto* leafs = bsp.GetLump(LUMP_LEAFS); if (!leafs) - return true; + return {}; if (leafs->data.size() % RAW_LEAF_SIZE != 0uz || !FitsUnsigned(RecordCount(*leafs, RAW_LEAF_SIZE))) - { - error = "leaf lump has funny size"; - return false; - } + return std::unexpected("leaf lump has funny size"); const auto leafCount = RecordCount(*leafs, RAW_LEAF_SIZE); clipMap.numLeafs = static_cast(leafCount); @@ -2478,10 +2396,7 @@ namespace const auto collAabbCount = ReadI32(record, 8uz); if (firstCollAabbIndex < 0 || collAabbCount < 0) - { - error = "leaf contains negative runtime count/index"; - return false; - } + 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()))); @@ -2494,20 +2409,17 @@ namespace } clipMap.numClusters = maxCluster + 1; - return true; + return {}; } - [[nodiscard]] bool PopulateClipMapLeafBrushNodes(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[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 true; + return {}; if (leafs->data.size() % RAW_LEAF_SIZE != 0uz || RecordCount(*leafs, RAW_LEAF_SIZE) != clipMap.numLeafs) - { - error = "leaf lump changed before leafbrush node build"; - return false; - } + 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, @@ -2523,29 +2435,21 @@ namespace const auto firstLeafBrush = ReadI32(record, 12uz); const auto leafBrushCount = ReadI32(record, 16uz); if (firstLeafBrush < 0 || leafBrushCount < 0) - { - error = "leaf contains negative leafbrush range"; - return false; - } + return std::unexpected("leaf contains negative leafbrush range"); if (static_cast(firstLeafBrush + leafBrushCount) > clipMap.numLeafBrushes) - { - error = "leaf references invalid leafbrush range"; - return false; - } + return std::unexpected("leaf references invalid leafbrush range"); - if (!PartitionLeafBrushes(clipMap, nodes, &clipMap.leafbrushes[firstLeafBrush], leafBrushCount, clipMap.leafs[leafIndex], error)) - return false; + 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) - { - error = "model lump changed before leafbrush node build"; - return false; - } + return std::unexpected("model lump changed before leafbrush node build"); for (auto modelIndex = 1uz; modelIndex < clipMap.numSubModels; modelIndex++) { @@ -2556,17 +2460,15 @@ namespace continue; if (firstBrush + brushCount > clipMap.numBrushes) - { - error = "model references invalid brush range"; - return false; - } + 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); - if (!PartitionLeafBrushes(clipMap, nodes, modelLeafBrushes, static_cast(brushCount), clipMap.cmodels[modelIndex].leaf, error)) - return false; + auto partitionResult = PartitionLeafBrushes(clipMap, nodes, modelLeafBrushes, static_cast(brushCount), clipMap.cmodels[modelIndex].leaf); + if (!partitionResult) + return std::unexpected(std::move(partitionResult.error())); } } @@ -2597,20 +2499,17 @@ namespace clipMap.leafbrushNodesCount = static_cast(nodes.size()); clipMap.leafbrushNodes = AllocZeroed(memory, nodes.size()); std::copy(nodes.begin(), nodes.end(), clipMap.leafbrushNodes); - return true; + return {}; } - [[nodiscard]] bool PopulateClipMapModels(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateClipMapModels(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) { const auto* models = bsp.GetLump(LUMP_MODELS); if (!models) - return true; + return {}; if (models->data.size() % RAW_MODEL_SIZE != 0uz || !FitsUnsigned(RecordCount(*models, RAW_MODEL_SIZE))) - { - error = "model lump has funny size"; - return false; - } + return std::unexpected("model lump has funny size"); const auto modelCount = RecordCount(*models, RAW_MODEL_SIZE); clipMap.numSubModels = static_cast(modelCount); @@ -2632,20 +2531,17 @@ namespace const auto firstCollAabbIndex = ReadU32(record, 32uz); const auto collAabbCount = ReadU32(record, 36uz); if (firstCollAabbIndex > std::numeric_limits::max() || collAabbCount > std::numeric_limits::max()) - { - error = "model collision AABB range exceeded uint16"; - return false; - } + 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, error); + return PopulateClipMapLeafBrushNodes(clipMap, bsp, memory); } - [[nodiscard]] bool PopulateClipMapVisibility(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[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()) @@ -2657,29 +2553,20 @@ namespace 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 true; + return {}; } if (visibility->data.size() < 8uz) - { - error = "visibility lump has a truncated header"; - return false; - } + 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) - { - error = "visibility lump has negative dimensions"; - return false; - } + return std::unexpected("visibility lump has negative dimensions"); const auto expectedSize = 8uz + static_cast(numClusters) * static_cast(clusterBytes); if (visibility->data.size() != expectedSize) - { - error = "visibility lump size does not match its header"; - return false; - } + return std::unexpected("visibility lump size does not match its header"); clipMap.numClusters = numClusters; clipMap.clusterBytes = clusterBytes; @@ -2687,24 +2574,45 @@ namespace if (clipMap.visibility) std::memcpy(clipMap.visibility, visibility->data.data() + 8uz, visibility->data.size() - 8uz); clipMap.vised = 1; - return true; + return {}; } - [[nodiscard]] bool PopulateClipMapLeafSurfaces(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateClipMapLeafSurfaces(clipMap_t& clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) { const auto* leafSurfaces = bsp.GetLump(LUMP_LEAFSURFACES); if (!leafSurfaces) - return true; + return {}; if (leafSurfaces->data.size() % sizeof(uint32_t) != 0uz || !FitsUnsigned(leafSurfaces->data.size() / sizeof(uint32_t))) - { - error = "leafsurface lump has funny size"; - return false; - } + 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 true; + 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) @@ -2744,29 +2652,28 @@ namespace std::vector packedSlotForRawPage; }; - [[nodiscard]] bool CopyLightDefAttenuationImage( - std::vector& secondary, - const LightmapAtlasGroup& group, - const GfxLightDef& lightDef, - ISearchPath& searchPath, - std::string& error) + [[nodiscard]] BspLoadResult + CopyLightDefAttenuationImage(std::vector& secondary, const LightmapAtlasGroup& group, const GfxLightDef& lightDef, ISearchPath& searchPath) { if (lightDef.lmapLookupStart <= 0) - { - error = std::format("light def \"{}\" has invalid lightmap lookup start", lightDef.name ? lightDef.name : ""); - return false; - } + return std::unexpected(std::format("light def \"{}\" has invalid lightmap lookup start", lightDef.name ? lightDef.name : "")); - std::vector pixels; - if (!LoadLightDefAttenuationPixels(lightDef, searchPath, pixels, error)) - return false; + auto pixelsResult = LoadLightDefAttenuationPixels(lightDef, searchPath); + if (!pixelsResult) + return std::unexpected(std::move(pixelsResult.error())); + + auto pixels = std::move(*pixelsResult); if (pixels.empty()) - return true; + 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) { @@ -2781,30 +2688,27 @@ namespace if (zoom == 1u) { if (!writePixel(pixels.front())) - return false; + return std::unexpected(overflowError()); for (const auto& pixel : pixels) { if (!writePixel(pixel)) - return false; + return std::unexpected(overflowError()); } if (!writePixel(pixels.back())) - return false; + return std::unexpected(overflowError()); } else { if ((zoom & (zoom - 1u)) != 0u) - { - error = "lightmap atlas zoom is not a power of two"; - return false; - } + 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 false; + return std::unexpected(overflowError()); } for (auto pixelIndex = 0uz; pixelIndex + 1uz < pixels.size(); pixelIndex++) @@ -2812,26 +2716,25 @@ namespace for (auto lerp = 1u; lerp <= 2u * zoom; lerp += 2u) { if (!writePixel(LerpLightPixel(pixels[pixelIndex], pixels[pixelIndex + 1uz], zoom, lerp))) - return false; + return std::unexpected(overflowError()); } } for (auto i = 0u; i < endCount; i++) { if (!writePixel(pixels.back())) - return false; + return std::unexpected(overflowError()); } } - return true; + return {}; } - [[nodiscard]] bool ApplyLightDefAttenuationImages( + [[nodiscard]] BspLoadResult ApplyLightDefAttenuationImages( std::vector& secondary, const LightmapAtlasGroup& group, const std::vector& lightDefs, - ISearchPath& searchPath, - std::string& error) + ISearchPath& searchPath) { // linker_pc overlays loaded lightdef falloff images into each generated // secondary lightmap atlas. These bytes are not authored in the raw BSP @@ -2841,22 +2744,18 @@ namespace if (!lightDef) continue; - if (!CopyLightDefAttenuationImage(secondary, group, *lightDef, searchPath, error)) - { - if (error.empty()) - error = std::format("light def \"{}\" attenuation image overflowed the secondary lightmap atlas", lightDef->name ? lightDef->name : ""); - return false; - } + auto copyResult = CopyLightDefAttenuationImage(secondary, group, *lightDef, searchPath); + if (!copyResult) + return std::unexpected(std::move(copyResult.error())); } - return true; + return {}; } - [[nodiscard]] std::vector LoadPrimaryLightDefDependencies( + [[nodiscard]] std::expected, std::string> LoadPrimaryLightDefDependencies( const IW3::d3dbsp::File& bsp, AssetCreationContext& context, - AssetRegistration& registration, - std::string& error) + AssetRegistration& registration) { std::vector lightDefs; const auto* primaryLights = bsp.GetLump(LUMP_PRIMARY_LIGHTS); @@ -2864,10 +2763,7 @@ namespace return lightDefs; if (primaryLights->data.size() % IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE != 0uz) - { - error = "primary-light lump has funny size"; - return lightDefs; - } + return std::unexpected("primary-light lump has funny size"); std::unordered_map loadedLightDefs; const auto primaryLightCount = RecordCount(*primaryLights, IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE); @@ -2885,17 +2781,11 @@ namespace loadedLightDefs.emplace(lookupName, true); auto* dependency = context.LoadDependency(defName); if (!dependency) - { - error = std::format("missing light def \"{}\"", defName); - return lightDefs; - } + return std::unexpected(std::format("missing light def \"{}\"", defName)); auto* lightDef = dependency->Asset(); if (!lightDef || !lightDef->attenuation.image) - { - error = std::format("light def \"{}\" has no attenuation image", defName); - return lightDefs; - } + return std::unexpected(std::format("light def \"{}\" has no attenuation image", defName)); registration.AddDependency(dependency); lightDefs.emplace_back(lightDef); @@ -2912,21 +2802,17 @@ namespace return left + right; } - [[nodiscard]] bool BuildLightmapCouplingMatrix( + [[nodiscard]] BspLoadResult BuildLightmapCouplingMatrix( const IW3::d3dbsp::File& bsp, const unsigned rawPageCount, - std::array& coupling, - std::string& error) + std::array& coupling) { const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); if (!surfaces) - return true; + return {}; if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) - { - error = "world surface lump has funny size"; - return false; - } + 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; @@ -2947,10 +2833,7 @@ namespace continue; if (lightmapIndex >= rawPageCount) - { - error = std::format("world surface {} references missing lightmap page {}", surfaceIndex, lightmapIndex); - return false; - } + return std::unexpected(std::format("world surface {} references missing lightmap page {}", surfaceIndex, lightmapIndex)); vertexCountByLightmap[lightmapIndex] = SaturatingAdd(vertexCountByLightmap[lightmapIndex], static_cast(ReadU16(record, 16uz))); @@ -2974,21 +2857,18 @@ namespace } } - return true; + return {}; } - [[nodiscard]] bool ReferencedLightmapPageCount(const IW3::d3dbsp::File& bsp, unsigned& pageCount, std::string& error) + [[nodiscard]] BspLoadValue ReferencedLightmapPageCount(const IW3::d3dbsp::File& bsp) { - pageCount = 0u; + auto pageCount = 0u; const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); if (!surfaces) - return true; + return pageCount; if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) - { - error = "world surface lump has funny size"; - return false; - } + 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++) @@ -2999,51 +2879,41 @@ namespace continue; if (lightmapIndex >= MAX_LIGHTMAP_PAGE_COUNT) - { - error = std::format("world surface {} has invalid lightmap page {}", surfaceIndex, lightmapIndex); - return false; - } + return std::unexpected(std::format("world surface {} has invalid lightmap page {}", surfaceIndex, lightmapIndex)); pageCount = std::max(pageCount, lightmapIndex + 1u); } - return true; + return pageCount; } - [[nodiscard]] bool BuildLightmapAtlasLayout(const IW3::d3dbsp::File& bsp, LightmapAtlasLayout& layout, std::string& error) + [[nodiscard]] BspLoadValue BuildLightmapAtlasLayout(const IW3::d3dbsp::File& bsp) { + LightmapAtlasLayout layout; const auto* lightmaps = bsp.GetLump(LUMP_LIGHTMAPS); if (!lightmaps || lightmaps->data.empty()) - return true; + return layout; if (lightmaps->data.size() % LIGHTMAP_RAW_PAGE_SIZE != 0uz || !FitsUnsigned(lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE)) - { - error = "lightmap lump has funny size"; - return false; - } + 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) - { - error = std::format("lightmap lump has too many pages: {}", layout.rawPageCount); - return false; - } + return std::unexpected(std::format("lightmap lump has too many pages: {}", layout.rawPageCount)); - unsigned referencedPageCount = 0u; - if (!ReferencedLightmapPageCount(bsp, referencedPageCount, error)) - return false; + 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) - { - error = std::format("lightmap page count {} does not match surface references {}", layout.rawPageCount, referencedPageCount); - return false; - } + if (*referencedPageCount != layout.rawPageCount) + return std::unexpected(std::format("lightmap page count {} does not match surface references {}", layout.rawPageCount, *referencedPageCount)); std::array coupling{}; - if (!BuildLightmapCouplingMatrix(bsp, layout.rawPageCount, coupling, error)) - return false; + 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); @@ -3104,10 +2974,7 @@ namespace } if (bestLeft == SKY_LIGHTMAP_INDEX || bestRight == SKY_LIGHTMAP_INDEX) - { - error = "could not pair lightmap pages"; - return false; - } + 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 @@ -3141,10 +3008,7 @@ namespace } if (bestNext == SKY_LIGHTMAP_INDEX) - { - error = "could not extend lightmap atlas group"; - return false; - } + return std::unexpected("could not extend lightmap atlas group"); group.rawPageForPackedSlot.emplace_back(bestNext); used[bestNext] = true; @@ -3164,7 +3028,7 @@ namespace layout.groups.emplace_back(std::move(group)); } - return true; + return layout; } [[nodiscard]] std::vector WorldMaterialNameCandidates(const std::string& rawMaterialName) @@ -3215,7 +3079,7 @@ namespace return context.AddAsset(DEFAULT_MATERIAL_REFERENCE_NAME, material); } - [[nodiscard]] std::vector WorldSurfaceMaterialUsage(const IW3::d3dbsp::File& bsp, const size_t materialCount, std::string& error) + [[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); @@ -3223,10 +3087,7 @@ namespace return result; if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) - { - error = "world surface lump has funny size"; - return {}; - } + 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++) @@ -3234,10 +3095,7 @@ namespace const auto* record = surfaces->data.data() + surfaceIndex * RAW_WORLD_SURFACE_SIZE; const auto materialIndex = static_cast(ReadU16(record)); if (materialIndex >= materialCount) - { - error = std::format("world surface {} references invalid material index {}", surfaceIndex, materialIndex); - return {}; - } + return std::unexpected(std::format("world surface {} references invalid material index {}", surfaceIndex, materialIndex)); result[materialIndex] = true; } @@ -3245,18 +3103,19 @@ namespace return result; } - [[nodiscard]] std::vector*> - LoadWorldMaterials(const IW3::d3dbsp::File& bsp, AssetCreationContext& context, MemoryManager& memory, std::string& error) + [[nodiscard]] std::expected*>, std::string> + LoadWorldMaterials(const IW3::d3dbsp::File& bsp, AssetCreationContext& context, MemoryManager& memory) { - const auto* materials = bsp.GetLump(LUMP_MATERIALS); - if (!ValidateRecordLump(bsp, materials, LUMP_MATERIALS, RAW_MATERIAL_SIZE, error)) - return {}; + 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); - const auto renderMaterialUsage = WorldSurfaceMaterialUsage(bsp, materialCount, error); - if (!error.empty()) - return {}; + auto renderMaterialUsage = WorldSurfaceMaterialUsage(bsp, materialCount); + if (!renderMaterialUsage) + return std::unexpected(std::move(renderMaterialUsage.error())); result.reserve(materialCount); @@ -3266,11 +3125,8 @@ namespace auto materialName = RawMaterialName(record); if (materialName.empty()) { - if (renderMaterialUsage[materialIndex]) - { - error = std::format("world surface references unnamed material index {}", materialIndex); - return {}; - } + if ((*renderMaterialUsage)[materialIndex]) + return std::unexpected(std::format("world surface references unnamed material index {}", materialIndex)); result.emplace_back(nullptr); continue; @@ -3279,7 +3135,7 @@ namespace // 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]) + if (!(*renderMaterialUsage)[materialIndex]) { result.emplace_back(nullptr); continue; @@ -3297,10 +3153,7 @@ namespace dependency = GetOrCreateDefaultMaterialReference(context, memory); if (!dependency) - { - error = std::format("missing render material \"{}\"", materialName); - return {}; - } + return std::unexpected(std::format("missing render material \"{}\"", materialName)); result.emplace_back(dependency); } @@ -3308,13 +3161,14 @@ namespace return result; } - [[nodiscard]] std::vector RawWorldMaterialNames(const IW3::d3dbsp::File& bsp, std::string& error) + [[nodiscard]] std::expected, std::string> RawWorldMaterialNames(const IW3::d3dbsp::File& bsp) { - const auto* materials = bsp.GetLump(LUMP_MATERIALS); - if (!ValidateRecordLump(bsp, materials, LUMP_MATERIALS, RAW_MATERIAL_SIZE, error)) - return {}; + 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); @@ -3367,34 +3221,28 @@ namespace } } - [[nodiscard]] bool PopulateWorldIndices(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[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 true; + return {}; if (indices->data.size() % sizeof(uint16_t) != 0uz || !FitsInt(indices->data.size() / sizeof(uint16_t))) - { - error = "world index lump has funny size"; - return false; - } + 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 true; + return {}; } - [[nodiscard]] bool PopulateWorldVertices(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[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 true; + return {}; if (verts->data.size() % RAW_WORLD_VERTEX_SIZE != 0uz || !FitsUnsigned(RecordCount(*verts, RAW_WORLD_VERTEX_SIZE))) - { - error = "world vertex lump has funny size"; - return false; - } + 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); @@ -3423,7 +3271,7 @@ namespace } SetWorldBoundsFromVertices(world); - return true; + return {}; } void PopulateSurfaceBounds(GfxWorld& world, GfxSurface& surface) @@ -3507,22 +3355,19 @@ namespace } } - [[nodiscard]] bool ApplyMagicPortalVertexCoords(GfxWorld& world, const GfxSurface& surface, std::string& error) + [[nodiscard]] BspLoadResult ApplyMagicPortalVertexCoords(GfxWorld& world, const GfxSurface& surface) { if (!surface.material || (surface.material->info.gameFlags & MATERIAL_GAME_FLAG_MAGIC_PORTAL) == 0u) - return true; + return {}; const auto triCount = static_cast(surface.tris.triCount); const auto indexCount = triCount * 3uz; if (triCount == 0uz) - return true; + 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)) - { - error = "magic portal surface index range is out of bounds"; - return false; - } + return std::unexpected("magic portal surface index range is out of bounds"); std::vector fillId(triCount); std::vector> centerAccum(triCount); @@ -3576,10 +3421,7 @@ namespace { const auto vertexIndex = surface.tris.firstVertex + world.indices[surface.tris.baseIndex + static_cast(triIndex * 3uz + triVertex)]; if (vertexIndex < 0 || static_cast(vertexIndex) >= world.vertexCount) - { - error = "magic portal surface references invalid vertex"; - return false; - } + return std::unexpected("magic portal surface references invalid vertex"); const auto& vertex = world.vd.vertices[vertexIndex]; for (auto axis = 0uz; axis < 3uz; axis++) @@ -3612,7 +3454,7 @@ namespace } } - return true; + return {}; } struct RawWorldSurfaceIndexInfo @@ -3655,31 +3497,24 @@ namespace && left.lightmapIndex == right.lightmapIndex; } - [[nodiscard]] bool RewriteWorldIndicesLikeLinker( + [[nodiscard]] BspLoadResult RewriteWorldIndicesLikeLinker( GfxWorld& world, const std::vector& rawSurfaces, const std::vector& rawMaterialNames, - MemoryManager& memory, - std::string& error) + MemoryManager& memory) { if (rawSurfaces.empty()) - return true; + return {}; if (!world.indices) - { - error = "world surfaces require an index lump"; - return false; - } + return std::unexpected("world surfaces require an index lump"); auto rewrittenIndexCount = 0uz; for (const auto& surface : rawSurfaces) rewrittenIndexCount += surface.indexCount; if (!FitsInt(rewrittenIndexCount)) - { - error = "world index count is too large"; - return false; - } + return std::unexpected("world index count is too large"); std::vector rewrittenIndices(rewrittenIndexCount); std::vector assigned(rawSurfaces.size()); @@ -3705,16 +3540,10 @@ namespace 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)) - { - error = std::format("world surface {} index range is out of bounds", surfaceIndex); - return false; - } + return std::unexpected(std::format("world surface {} index range is out of bounds", surfaceIndex)); if (writeIndex + rawSurface.indexCount > rewrittenIndices.size()) - { - error = "world index rewrite exceeded output size"; - return false; - } + 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); @@ -3724,36 +3553,29 @@ namespace } if (writeIndex != rewrittenIndices.size()) - { - error = "world index rewrite did not write every index"; - return false; - } + 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 true; + return {}; } - [[nodiscard]] bool PopulateWorldSurfaces( + [[nodiscard]] BspLoadResult PopulateWorldSurfaces( GfxWorld& world, const IW3::d3dbsp::File& bsp, const LightmapAtlasLayout& lightmapLayout, const std::vector*>& materialDependencies, - MemoryManager& memory, - std::string& error) + MemoryManager& memory) { const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); if (!surfaces) - return true; + return {}; if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz || !FitsInt(RecordCount(*surfaces, RAW_WORLD_SURFACE_SIZE))) - { - error = "world surface lump has funny size"; - return false; - } + 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); @@ -3763,9 +3585,10 @@ namespace world.dpvs.surfaces = AllocZeroed(memory, world.surfaceCount); std::vector vertexLightmapRemaps(world.vertexCount, -1); std::vector rawSurfaceIndexInfo(static_cast(world.surfaceCount)); - auto rawMaterialNames = RawWorldMaterialNames(bsp, error); - if (!error.empty()) - return false; + 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++) { @@ -3773,10 +3596,7 @@ namespace auto& surface = world.dpvs.surfaces[surfaceIndex]; const auto materialIndex = ReadU16(record); if (materialIndex >= materialDependencies.size() || !materialDependencies[materialIndex]) - { - error = std::format("world surface {} references missing render material index {}", surfaceIndex, materialIndex); - return false; - } + return std::unexpected(std::format("world surface {} references missing render material index {}", surfaceIndex, materialIndex)); surface.material = materialDependencies[materialIndex]->Asset(); @@ -3798,8 +3618,9 @@ namespace rawSurfaceIndexInfo[surfaceIndex].indexCount = ReadU16(record, 18uz); } - if (!RewriteWorldIndicesLikeLinker(world, rawSurfaceIndexInfo, rawMaterialNames, memory, error)) - return false; + 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++) { @@ -3819,13 +3640,14 @@ namespace } } - if (!ApplyMagicPortalVertexCoords(world, surface, error)) - return false; + auto portalResult = ApplyMagicPortalVertexCoords(world, surface); + if (!portalResult) + return std::unexpected(std::move(portalResult.error())); PopulateSurfaceBounds(world, surface); } - return true; + return {}; } void PopulateWorldMaterialMemory(GfxWorld& world, MemoryManager& memory) @@ -3890,7 +3712,7 @@ namespace } } - [[nodiscard]] bool PopulateWorldVertexLayerData(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[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()) @@ -3900,18 +3722,15 @@ namespace // layer buffer even when the raw file still contains lump 42. world.vertexLayerDataSize = 4u; world.vld.data = AllocZeroed(memory, world.vertexLayerDataSize); - return true; + return {}; } if (!FitsUnsigned(vertexLayerData->data.size())) - { - error = "vertex layer data lump is too large"; - return false; - } + 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 true; + return {}; } [[nodiscard]] XAssetInfo* @@ -4006,12 +3825,11 @@ namespace } } - [[nodiscard]] std::optional, std::vector>> BuildLightmapAtlasImages( + [[nodiscard]] BspLoadValue, std::vector>> BuildLightmapAtlasImages( const IW3::d3dbsp::Lump& lightmaps, const LightmapAtlasGroup& group, const std::vector& lightDefs, - ISearchPath& searchPath, - std::string& error) + ISearchPath& searchPath) { const auto primaryAtlasSize = static_cast(group.wideCount) * LIGHTMAP_PRIMARY_RAW_WIDTH * static_cast(group.highCount) * LIGHTMAP_PRIMARY_RAW_HEIGHT; @@ -4029,13 +3847,14 @@ namespace CopyPrimaryLightmapRawPageToAtlas(primary, page, group, static_cast(packedSlot)); } - if (!ApplyLightDefAttenuationImages(secondary, group, lightDefs, searchPath, error)) - return std::nullopt; + 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]] bool PopulateWorldLightmaps( + [[nodiscard]] BspLoadResult PopulateWorldLightmaps( GfxWorld& world, const IW3::d3dbsp::File& bsp, const LightmapAtlasLayout& lightmapLayout, @@ -4043,25 +3862,18 @@ namespace ISearchPath& searchPath, AssetCreationContext& context, AssetRegistration& registration, - MemoryManager& memory, - std::string& error) + MemoryManager& memory) { const auto* lightmaps = bsp.GetLump(LUMP_LIGHTMAPS); if (!lightmaps || lightmaps->data.empty()) - return true; + return {}; if (lightmaps->data.size() % LIGHTMAP_RAW_PAGE_SIZE != 0uz || !FitsInt(lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE)) - { - error = "lightmap lump has funny size"; - return false; - } + return std::unexpected("lightmap lump has funny size"); const auto pageCount = static_cast(lightmaps->data.size() / LIGHTMAP_RAW_PAGE_SIZE); if (pageCount != lightmapLayout.rawPageCount) - { - error = "lightmap atlas layout does not match lightmap lump"; - return false; - } + 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()); @@ -4074,9 +3886,9 @@ namespace for (auto lightmapIndex = 0uz; lightmapIndex < lightmapLayout.groups.size(); lightmapIndex++) { const auto& group = lightmapLayout.groups[lightmapIndex]; - auto atlasImages = BuildLightmapAtlasImages(*lightmaps, group, lightDefs, searchPath, error); + auto atlasImages = BuildLightmapAtlasImages(*lightmaps, group, lightDefs, searchPath); if (!atlasImages) - return false; + return std::unexpected(std::move(atlasImages.error())); const auto& primaryPixels = atlasImages->first; const auto& secondaryPixels = atlasImages->second; @@ -4112,16 +3924,13 @@ namespace auto* primaryInfo = AddGeneratedImage(context, registration, primaryName, primary); auto* secondaryInfo = AddGeneratedImage(context, registration, secondaryName, secondary); if (!primaryInfo || !secondaryInfo) - { - error = "could not register generated lightmap image"; - return false; - } + return std::unexpected("could not register generated lightmap image"); world.lightmaps[lightmapIndex].primary = primaryInfo->Asset(); world.lightmaps[lightmapIndex].secondary = secondaryInfo->Asset(); } - return true; + return {}; } void CopyTransformedReflectionProbePixels(char* out, const std::byte* source) @@ -4147,8 +3956,8 @@ namespace } } - [[nodiscard]] bool CreateDefaultReflectionProbe( - GfxWorld& world, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult + CreateDefaultReflectionProbe(GfxWorld& world, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory) { auto* image = CreateGeneratedImage(memory, "*reflection_probe0", @@ -4172,22 +3981,18 @@ namespace auto* imageInfo = AddGeneratedImage(context, registration, image->name, image); if (!imageInfo) - { - error = "could not register generated default reflection probe image"; - return false; - } + return std::unexpected("could not register generated default reflection probe image"); world.reflectionProbes[0].reflectionImage = imageInfo->Asset(); - return true; + return {}; } - [[nodiscard]] bool PopulateWorldReflectionProbes( + [[nodiscard]] BspLoadResult PopulateWorldReflectionProbes( GfxWorld& world, const IW3::d3dbsp::File& bsp, AssetCreationContext& context, AssetRegistration& registration, - MemoryManager& memory, - std::string& error) + MemoryManager& memory) { const auto* reflectionProbes = bsp.GetLump(LUMP_REFLECTION_PROBES); if (!reflectionProbes || reflectionProbes->data.empty()) @@ -4195,14 +4000,11 @@ namespace world.reflectionProbeCount = 1u; world.reflectionProbes = AllocZeroed(memory, 1uz); world.reflectionProbeTextures = AllocZeroed(memory, 1uz); - return CreateDefaultReflectionProbe(world, context, registration, memory, error); + return CreateDefaultReflectionProbe(world, context, registration, memory); } if (reflectionProbes->data.size() % REFLECTION_PROBE_RECORD_SIZE != 0uz || !FitsUnsigned(reflectionProbes->data.size() / REFLECTION_PROBE_RECORD_SIZE + 1uz)) - { - error = "reflection-probe lump has funny size"; - return false; - } + 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); @@ -4212,8 +4014,9 @@ namespace // probe image's basemap after the world has been loaded. world.reflectionProbeTextures = AllocZeroed(memory, world.reflectionProbeCount); - if (!CreateDefaultReflectionProbe(world, context, registration, memory, error)) - return false; + auto defaultProbeResult = CreateDefaultReflectionProbe(world, context, registration, memory); + if (!defaultProbeResult) + return std::unexpected(std::move(defaultProbeResult.error())); for (auto rawProbeIndex = 0uz; rawProbeIndex < rawProbeCount; rawProbeIndex++) { @@ -4239,15 +4042,12 @@ namespace auto* imageInfo = AddGeneratedImage(context, registration, imageName, image); if (!imageInfo) - { - error = "could not register generated reflection probe image"; - return false; - } + return std::unexpected("could not register generated reflection probe image"); probe.reflectionImage = imageInfo->Asset(); } - return true; + return {}; } [[nodiscard]] int CellForPoint(const GfxWorld& world, const float (&origin)[3]) @@ -4352,16 +4152,13 @@ namespace } } - [[nodiscard]] bool PopulateWorldLightGrid(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[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) - { - error = "lightgrid header lump has funny size"; - return false; - } + return std::unexpected("lightgrid header lump has funny size"); auto& lightGrid = world.lightGrid; std::memcpy(lightGrid.mins, header->data.data(), sizeof(lightGrid.mins)); @@ -4379,10 +4176,7 @@ namespace if (rawRows) { if (!FitsUnsigned(rawRows->data.size())) - { - error = "lightgrid raw row lump is too large"; - return false; - } + 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); @@ -4392,10 +4186,7 @@ namespace if (entries) { if (entries->data.size() % RAW_LIGHTGRID_ENTRY_SIZE != 0uz || !FitsUnsigned(RecordCount(*entries, RAW_LIGHTGRID_ENTRY_SIZE))) - { - error = "lightgrid entry lump has funny size"; - return false; - } + 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); @@ -4405,10 +4196,7 @@ namespace if (colors) { if (colors->data.size() % RAW_LIGHTGRID_COLOR_SIZE != 0uz || !FitsUnsigned(RecordCount(*colors, RAW_LIGHTGRID_COLOR_SIZE) + 1uz)) - { - error = "lightgrid color lump has funny size"; - return false; - } + 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 @@ -4420,11 +4208,21 @@ namespace std::memcpy(world.lightGrid.colors, colors->data.data(), rawColorCount * RAW_LIGHTGRID_COLOR_SIZE); } - return true; + return {}; } - [[nodiscard]] std::optional - FinishWorldAabbTree_r(const GfxWorld& world, GfxAabbTree* trees, const size_t treeIndex, size_t totalTreesUsed, const size_t treeCount, std::string& error) + 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); @@ -4434,10 +4232,7 @@ namespace const auto childStart = totalTreesUsed; const auto childCount = static_cast(tree.childCount); if (childStart + childCount > treeCount) - { - error = std::format("AABB tree {} children extend past tree count", treeIndex); - return std::nullopt; - } + 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; @@ -4448,9 +4243,9 @@ namespace for (auto childIndex = 0uz; childIndex < childCount; childIndex++) { const auto childTreeIndex = childStart + childIndex; - const auto nextTree = FinishWorldAabbTree_r(world, trees, childTreeIndex, totalTreesUsed, treeCount, error); + const auto nextTree = FinishWorldAabbTree_r(world, trees, childTreeIndex, totalTreesUsed, treeCount); if (!nextTree) - return std::nullopt; + return std::unexpected(std::move(nextTree.error())); totalTreesUsed = *nextTree; ExpandBounds(trees[childTreeIndex].mins, trees[childTreeIndex].maxs, tree.mins, tree.maxs); @@ -4461,10 +4256,7 @@ namespace const auto startSurface = static_cast(tree.startSurfIndex); const auto surfaceCount = static_cast(tree.surfaceCount); if (startSurface + surfaceCount > static_cast(world.surfaceCount)) - { - error = std::format("AABB tree {} surface range is outside world surfaces", treeIndex); - return std::nullopt; - } + 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], @@ -4476,19 +4268,19 @@ namespace return totalTreesUsed; } - [[nodiscard]] bool FinishWorldAabbTrees(const GfxWorld& world, GfxAabbTree* trees, const size_t treeCount, std::string& error) + [[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, error); + const auto nextTree = FinishWorldAabbTree_r(world, trees, treeIndex, treeIndex + 1uz, treeCount); if (!nextTree) - return false; + return std::unexpected(std::move(nextTree.error())); treeIndex = *nextTree; } - return true; + return {}; } [[nodiscard]] size_t AabbTreeSubtreeCount(const GfxAabbTree& tree) @@ -4511,44 +4303,38 @@ namespace return reinterpret_cast(reinterpret_cast(&tree) + tree.childrenOffset); } - [[nodiscard]] bool SetAabbTreeChildrenOffset(GfxAabbTree& tree, const GfxAabbTree* children, std::string& error) + [[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())) - { - error = "AABB tree children offset is outside int range"; - return false; - } + return std::unexpected("AABB tree children offset is outside int range"); tree.childrenOffset = static_cast(offset); - return true; + return {}; } - [[nodiscard]] GfxAabbTree* BuildWorldAabbTrees(const GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, int& treeCount, std::string& error) + [[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()) { - treeCount = world.surfaceCount > 0 ? 1 : 0; + const auto treeCount = world.surfaceCount > 0 ? 1 : 0; if (treeCount == 0) - return nullptr; + 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 result; + return WorldAabbTrees{result, treeCount}; } if (aabbTrees->data.size() % RAW_WORLD_AABB_TREE_SIZE != 0uz || !FitsInt(RecordCount(*aabbTrees, RAW_WORLD_AABB_TREE_SIZE))) - { - error = "world AABB tree lump has funny size"; - return nullptr; - } + return std::unexpected("world AABB tree lump has funny size"); - treeCount = static_cast(RecordCount(*aabbTrees, RAW_WORLD_AABB_TREE_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++) { @@ -4558,10 +4344,7 @@ namespace const auto surfaceCount = ReadU32(record, 4uz); const auto childCount = ReadU32(record, 8uz); if (startSurface > UINT16_MAX || surfaceCount > UINT16_MAX || childCount > UINT16_MAX) - { - error = std::format("AABB tree {} value is out of uint16 range", treeIndex); - return nullptr; - } + return std::unexpected(std::format("AABB tree {} value is out of uint16 range", treeIndex)); tree.startSurfIndex = static_cast(startSurface); tree.surfaceCount = static_cast(surfaceCount); @@ -4570,32 +4353,29 @@ namespace tree.surfaceCountNoDecal = tree.surfaceCount; } - if (!FinishWorldAabbTrees(world, result, static_cast(treeCount), error)) - return nullptr; + auto finishResult = FinishWorldAabbTrees(world, result, static_cast(treeCount)); + if (!finishResult) + return std::unexpected(std::move(finishResult.error())); - return result; + return WorldAabbTrees{result, treeCount}; } - [[nodiscard]] bool PopulateWorldCells(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateWorldCells(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) { - int aabbTreeCount = 0; - auto* aabbTrees = BuildWorldAabbTrees(world, bsp, memory, aabbTreeCount, error); - if (!error.empty()) - return false; + 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) - { - error = "cell lump has funny size"; - return false; - } + return std::unexpected("cell lump has funny size"); if (!FitsInt(cellCount)) - { - error = "too many cell records"; - return false; - } + 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 @@ -4615,10 +4395,7 @@ namespace 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)) - { - error = std::format("cell {} references invalid AABB tree {}", cellIndex, aabbTreeIndex); - return false; - } + 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, @@ -4649,7 +4426,7 @@ namespace } } - return true; + return {}; } [[nodiscard]] char PortalPlaneSide(const float value, const char positiveValue) @@ -4657,29 +4434,23 @@ namespace return value > 0.0f ? positiveValue : 0; } - [[nodiscard]] bool PopulateWorldPortals(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[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 true; + 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) - { - error = "portal, portal-vertex, or cell lump has funny size"; - return false; - } + 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))) - { - error = "portal or cell count is invalid"; - return false; - } + return std::unexpected("portal or cell count is invalid"); auto* vertexData = portalVertCount > 0uz ? AllocZeroed(memory, portalVertCount) : nullptr; for (auto vertexIndex = 0uz; vertexIndex < portalVertCount; vertexIndex++) @@ -4695,22 +4466,13 @@ namespace const auto vertexCount = std::to_integer(record[12]); if (planeIndex >= static_cast(world.planeCount)) - { - error = std::format("portal {} references invalid plane {}", portalIndex, planeIndex); - return false; - } + return std::unexpected(std::format("portal {} references invalid plane {}", portalIndex, planeIndex)); if (cellIndex >= static_cast(world.dpvsPlanes.cellCount)) - { - error = std::format("portal {} references invalid cell {}", portalIndex, cellIndex); - return false; - } + return std::unexpected(std::format("portal {} references invalid cell {}", portalIndex, cellIndex)); if (firstVertex + vertexCount > portalVertCount) - { - error = std::format("portal {} vertex range is outside portal vertices", portalIndex); - return false; - } + return std::unexpected(std::format("portal {} vertex range is outside portal vertices", portalIndex)); auto& portal = portalData[portalIndex]; const auto& plane = world.dpvsPlanes.planes[planeIndex]; @@ -4734,30 +4496,24 @@ namespace const auto firstPortal = static_cast(ReadU32(record, 28uz)); const auto portalCountForCell = ReadU32(record, 32uz); if (firstPortal + portalCountForCell > portalCount) - { - error = std::format("cell {} portal range is outside portals", cellIndex); - return false; - } + 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 true; + return {}; } - [[nodiscard]] bool PopulateWorldModels(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateWorldModels(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) { const auto* models = bsp.GetLump(LUMP_MODELS); if (!models) - return true; + return {}; if (models->data.size() % RAW_MODEL_SIZE != 0uz || !FitsInt(RecordCount(*models, RAW_MODEL_SIZE))) - { - error = "world model lump has funny size"; - return false; - } + return std::unexpected("world model lump has funny size"); world.modelCount = static_cast(RecordCount(*models, RAW_MODEL_SIZE)); world.models = AllocZeroed(memory, world.modelCount); @@ -4791,7 +4547,7 @@ namespace world.dpvs.staticSurfaceCountNoDecal = world.models[0].surfaceCountNoDecal; } - return true; + return {}; } [[nodiscard]] uint32_t FloatKeyBits(const float value) @@ -5012,14 +4768,11 @@ namespace world.dpvs.emissiveSurfsEnd = surfIndex; } - [[nodiscard]] bool AppendNoDecalAabbTreeSurfaces( - const GfxWorld& world, GfxAabbTree& tree, std::vector& sortedSurfIndex, const unsigned sourceSurfaceCount, unsigned& writeIndex, std::string& error) + [[nodiscard]] BspLoadResult + AppendNoDecalAabbTreeSurfaces(const GfxWorld& world, GfxAabbTree& tree, std::vector& sortedSurfIndex, const unsigned sourceSurfaceCount, unsigned& writeIndex) { if (writeIndex > UINT16_MAX) - { - error = "no-decal AABB tree start surface index is out of uint16 range"; - return false; - } + return std::unexpected("no-decal AABB tree start surface index is out of uint16 range"); tree.startSurfIndexNoDecal = static_cast(writeIndex); if (tree.childCount > 0u) @@ -5027,8 +4780,9 @@ namespace auto* children = reinterpret_cast(reinterpret_cast(&tree) + tree.childrenOffset); for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) { - if (!AppendNoDecalAabbTreeSurfaces(world, children[childIndex], sortedSurfIndex, sourceSurfaceCount, writeIndex, error)) - return false; + auto childResult = AppendNoDecalAabbTreeSurfaces(world, children[childIndex], sortedSurfIndex, sourceSurfaceCount, writeIndex); + if (!childResult) + return std::unexpected(std::move(childResult.error())); } } else @@ -5036,28 +4790,19 @@ namespace const auto firstSurfaceIndex = static_cast(tree.startSurfIndex); const auto surfaceCount = static_cast(tree.surfaceCount); if (firstSurfaceIndex > sourceSurfaceCount || surfaceCount > sourceSurfaceCount - firstSurfaceIndex) - { - error = "AABB tree no-decal source surface range is out of bounds"; - return false; - } + 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) - { - error = "AABB tree no-decal source references invalid surface"; - return false; - } + 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()) - { - error = "too many no-decal AABB tree surfaces"; - return false; - } + return std::unexpected("too many no-decal AABB tree surfaces"); sortedSurfIndex[writeIndex++] = surfaceIndex; } @@ -5065,19 +4810,16 @@ namespace const auto surfaceCountNoDecal = writeIndex - static_cast(tree.startSurfIndexNoDecal); if (surfaceCountNoDecal > UINT16_MAX) - { - error = "no-decal AABB tree surface count is out of uint16 range"; - return false; - } + return std::unexpected("no-decal AABB tree surface count is out of uint16 range"); tree.surfaceCountNoDecal = static_cast(surfaceCountNoDecal); - return true; + return {}; } - [[nodiscard]] bool BuildNoDecalSubModels(GfxWorld& world, std::vector& sortedSurfIndex, unsigned& noDecalSurfaceCount, std::string& error) + [[nodiscard]] BspLoadValue BuildNoDecalSubModels(GfxWorld& world, std::vector& sortedSurfIndex) { if (!world.models || world.modelCount <= 0) - return true; + return 0u; for (auto modelIndex = 0; modelIndex < world.modelCount; modelIndex++) { @@ -5089,10 +4831,7 @@ namespace const auto begin = static_cast(model.startSurfIndex); const auto end = begin + static_cast(model.surfaceCount); if (end > static_cast(world.surfaceCount)) - { - error = std::format("world model {} surface range is out of bounds", modelIndex); - return false; - } + 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++) @@ -5121,8 +4860,9 @@ namespace if (!cell.aabbTree) continue; - if (!AppendNoDecalAabbTreeSurfaces(world, *cell.aabbTree, sortedSurfIndex, rootSurfaceCount, writeIndex, error)) - return false; + auto appendResult = AppendNoDecalAabbTreeSurfaces(world, *cell.aabbTree, sortedSurfIndex, rootSurfaceCount, writeIndex); + if (!appendResult) + return std::unexpected(std::move(appendResult.error())); } } else @@ -5135,31 +4875,24 @@ namespace } } - noDecalSurfaceCount = writeIndex - rootSurfaceCount; - return true; + return writeIndex - rootSurfaceCount; } - [[nodiscard]] bool PopulateWorldSurfaceOrganization(GfxWorld& world, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateWorldSurfaceOrganization(GfxWorld& world, MemoryManager& memory) { if (!world.models || world.modelCount <= 0 || !world.dpvs.surfaces) - return true; + return {}; auto& rootModel = world.models[0]; if (rootModel.surfaceCount == 0u) - return true; + return {}; if (rootModel.startSurfIndex != 0u) - { - error = "root world model does not start at surface 0"; - return false; - } + 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)) - { - error = "root world model surface count is invalid"; - return false; - } + return std::unexpected("root world model surface count is invalid"); std::vector sortedSurfIndex(static_cast(surfaceCount) * 2uz); for (auto surfIndex = 0u; surfIndex < surfaceCount; surfIndex++) @@ -5176,10 +4909,7 @@ namespace auto& surface = world.dpvs.surfaces[surfIndex]; const auto originalSurfIndex = static_cast(surface.tris.vertexCount); if (originalSurfIndex >= surfaceCount) - { - error = "surface sort produced an invalid original surface index"; - return false; - } + return std::unexpected("surface sort produced an invalid original surface index"); surface.tris.vertexCount = sortedSurfIndex[originalSurfIndex]; sortedSurfIndex[originalSurfIndex] = static_cast(surfIndex); @@ -5187,17 +4917,17 @@ namespace ClassifySortedSurfaceRanges(world, surfaceCount); - unsigned noDecalSurfaceCount = 0u; - if (!BuildNoDecalSubModels(world, sortedSurfIndex, noDecalSurfaceCount, error)) - return false; + auto noDecalSurfaceCount = BuildNoDecalSubModels(world, sortedSurfIndex); + if (!noDecalSurfaceCount) + return std::unexpected(std::move(noDecalSurfaceCount.error())); - const auto finalSortedCount = static_cast(surfaceCount) + noDecalSurfaceCount; + 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 true; + world.dpvs.staticSurfaceCountNoDecal = *noDecalSurfaceCount; + rootModel.surfaceCountNoDecal = static_cast(*noDecalSurfaceCount); + return {}; } void ParseGfxLightRecord(const std::byte* record, GfxLight& light) @@ -5216,21 +4946,17 @@ namespace light.exponent = ReadI32(record, RAW_LIGHT_EXPONENT_OFFSET); } - [[nodiscard]] bool InferWorldPrimaryLightCount(const IW3::d3dbsp::File& bsp, const size_t rawPrimaryLightCount, unsigned& primaryLightCount, std::string& error) + [[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())) - { - error = "light-region count lump is too large"; - return false; - } + 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. - primaryLightCount = static_cast(regionCounts->data.size()); - return true; + return static_cast(regionCounts->data.size()); } const auto* surfaces = SelectWorldLumpForTrisType(bsp, LUMP_LAYERED_TRI_SOUPS, LUMP_SIMPLE_TRI_SOUPS); @@ -5240,10 +4966,7 @@ namespace if (surfaces && !surfaces->data.empty()) { if (surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) - { - error = "world surface lump has funny size"; - return false; - } + 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++) @@ -5254,32 +4977,27 @@ namespace } } - primaryLightCount = foundSurface ? maxPrimaryLightIndex + 1u : static_cast(std::min(rawPrimaryLightCount, static_cast(UINT32_MAX))); - return true; + return foundSurface ? maxPrimaryLightIndex + 1u : static_cast(std::min(rawPrimaryLightCount, static_cast(UINT32_MAX))); } - [[nodiscard]] bool PopulateWorldPrimaryLights(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[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)) - { - error = "primary-light lump has funny size"; - return false; - } + return std::unexpected("primary-light lump has funny size"); const auto rawPrimaryLightCount = size / IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE; - if (!InferWorldPrimaryLightCount(bsp, rawPrimaryLightCount, world.primaryLightCount, error)) - return false; + auto primaryLightCount = InferWorldPrimaryLightCount(bsp, rawPrimaryLightCount); + if (!primaryLightCount) + return std::unexpected(std::move(primaryLightCount.error())); + world.primaryLightCount = *primaryLightCount; if (world.primaryLightCount > rawPrimaryLightCount) - { - error = std::format("GfxWorld primary light count {} exceeds raw primary-light record count {}", world.primaryLightCount, rawPrimaryLightCount); - return false; - } + return std::unexpected(std::format("GfxWorld primary light count {} exceeds raw primary-light record count {}", world.primaryLightCount, rawPrimaryLightCount)); if (rawPrimaryLightCount == 0uz) - return true; + 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 @@ -5296,17 +5014,17 @@ namespace 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 true; + return {}; } - [[nodiscard]] bool PopulateWorldLightRegions(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateWorldLightRegions(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory) { if (world.primaryLightCount == 0u) - return true; + return {}; const auto* counts = bsp.GetLump(LUMP_LIGHT_REGION_COUNTS); if (!counts || counts->data.empty()) - return true; + return {}; world.lightGrid.hasLightRegions = true; world.lightRegion = AllocZeroed(memory, world.primaryLightCount); @@ -5323,10 +5041,7 @@ namespace continue; if (!hulls || hullOffset + static_cast(hullCount) * RAW_LIGHT_REGION_HULL_SIZE > hulls->data.size()) - { - error = "light-region hull lump is truncated"; - return false; - } + return std::unexpected("light-region hull lump is truncated"); auto& region = world.lightRegion[lightIndex]; region.hullCount = hullCount; @@ -5345,10 +5060,7 @@ namespace continue; if (!axes || axisOffset + static_cast(hull.axisCount) * RAW_LIGHT_REGION_AXIS_SIZE > axes->data.size()) - { - error = "light-region axis lump is truncated"; - return false; - } + 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); @@ -5356,7 +5068,7 @@ namespace } } - return true; + return {}; } [[nodiscard]] bool PopulateWorldShadowGeometry(GfxWorld& world, MemoryManager& memory) @@ -5665,14 +5377,13 @@ namespace inst.groundLighting.array[3] = static_cast(*a); } - [[nodiscard]] bool PopulateWorldStaticModels( + [[nodiscard]] BspLoadResult PopulateWorldStaticModels( GfxWorld& world, const ComWorld& comWorld, const clipMap_t* clipMap, const std::vector& staticModelBlocks, const std::vector*>& staticModelDependencies, - MemoryManager& memory, - std::string& error) + MemoryManager& memory) { std::vector> validStaticModels; validStaticModels.reserve(staticModelBlocks.size()); @@ -5683,14 +5394,11 @@ namespace } if (!FitsUnsigned(validStaticModels.size())) - { - error = "too many static model records"; - return false; - } + return std::unexpected("too many static model records"); world.dpvs.smodelCount = static_cast(validStaticModels.size()); if (validStaticModels.empty()) - return true; + return {}; world.dpvs.smodelDrawInsts = AllocZeroed(memory, validStaticModels.size()); world.dpvs.smodelInsts = AllocZeroed(memory, validStaticModels.size()); @@ -5755,7 +5463,7 @@ namespace PopulateStaticModelGroundLighting(world, comWorld, *block, inst, drawInst); } - return true; + return {}; } using StaticModelSortOrder = std::unordered_map; @@ -5905,59 +5613,55 @@ namespace targetIndexes.insert(targetIndexes.end(), std::make_move_iterator(indexes.begin()), std::make_move_iterator(indexes.end())); } - [[nodiscard]] bool CopyAabbTreeToNewAddress( - StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& oldTree, GfxAabbTree& newTree, std::string& error) + [[nodiscard]] BspLoadResult CopyAabbTreeToNewAddress(StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& oldTree, GfxAabbTree& newTree) { newTree = oldTree; MoveStaticModelTreeList(staticModelIndexesByTree, oldTree, newTree); if (oldTree.childCount > 0u) - return SetAabbTreeChildrenOffset(newTree, AabbTreeChildren(oldTree), error); + return SetAabbTreeChildrenOffset(newTree, AabbTreeChildren(oldTree)); newTree.childrenOffset = 0; - return true; + return {}; } - [[nodiscard]] bool AppendStaticModelOnlyChild( - StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& tree, const GfxStaticModelInst& smodelInst, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult AppendStaticModelOnlyChild( + StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& tree, const GfxStaticModelInst& smodelInst, MemoryManager& memory) { if (tree.childCount == std::numeric_limits::max()) - { - error = "too many AABB tree children"; - return false; - } + 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++) { - if (!CopyAabbTreeToNewAddress(staticModelIndexesByTree, oldChildren[childIndex], newChildren[childIndex], error)) - return false; + auto result = CopyAabbTreeToNewAddress(staticModelIndexesByTree, oldChildren[childIndex], newChildren[childIndex]); + if (!result) + return std::unexpected(std::move(result.error())); } - if (!SetAabbTreeChildrenOffset(tree, newChildren, error)) - return false; + 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 true; + return {}; } - [[nodiscard]] bool AddStaticModelToAabbTree_r( + [[nodiscard]] BspLoadResult AddStaticModelToAabbTree_r( const GfxWorld& world, StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& tree, const uint16_t staticModelIndex, - MemoryManager& memory, - std::string& error) + MemoryManager& memory) { AddStaticModelToTreeList(staticModelIndexesByTree, tree, staticModelIndex); if (tree.childCount == 0u || tree.childrenOffset == 0) - return true; + return {}; const auto& smodelInst = world.dpvs.smodelInsts[staticModelIndex]; auto* children = AabbTreeChildren(tree); @@ -5972,7 +5676,7 @@ namespace auto& child = children[childIndex]; if (BoundsContain(child.mins, child.maxs, smodelInst.mins, smodelInst.maxs)) { - return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex, memory, error); + return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex, memory); } } @@ -5982,70 +5686,59 @@ namespace if (child.surfaceCount == 0u) { ExpandBounds(smodelInst.mins, smodelInst.maxs, child.mins, child.maxs); - return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex, memory, error); + return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, child, staticModelIndex, memory); } } - if (!AppendStaticModelOnlyChild(staticModelIndexesByTree, tree, smodelInst, memory, error)) - return false; + 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, error); + return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, children[tree.childCount - 1u], staticModelIndex, memory); } - [[nodiscard]] bool AddStaticModelToCell( + [[nodiscard]] BspLoadResult AddStaticModelToCell( const GfxWorld& world, StaticModelIndexLists& staticModelIndexesByTree, const uint16_t staticModelIndex, const int cellIndex, - MemoryManager& memory, - std::string& error) + MemoryManager& memory) { if (cellIndex < 0 || cellIndex >= world.dpvsPlanes.cellCount || !world.cells) - { - error = "static model references invalid cell"; - return false; - } + return std::unexpected("static model references invalid cell"); auto& cell = world.cells[cellIndex]; if (!cell.aabbTree) - return true; + return {}; const auto existing = staticModelIndexesByTree.find(cell.aabbTree); if (existing != staticModelIndexesByTree.end() && !existing->second.empty() && existing->second.back() == staticModelIndex) - return true; + return {}; - return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, *cell.aabbTree, staticModelIndex, memory, error); + return AddStaticModelToAabbTree_r(world, staticModelIndexesByTree, *cell.aabbTree, staticModelIndex, memory); } - [[nodiscard]] bool FilterStaticModelIntoCells_r( + [[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, - std::string& error) + MemoryManager& memory) { while (true) { if (!node) - { - error = "world node stream is missing"; - return false; - } + 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, error) : true; + return cellValue != 0 ? AddStaticModelToCell(world, staticModelIndexesByTree, staticModelIndex, cellValue - 1, memory) : BspLoadResult{}; if (planeIndex >= world.planeCount || !world.dpvsPlanes.planes) - { - error = "world node stream references invalid plane"; - return false; - } + return std::unexpected("world node stream references invalid plane"); const auto& plane = world.dpvsPlanes.planes[planeIndex]; const auto boxSide = BoxOnPlaneSide(mins, maxs, plane); @@ -6055,8 +5748,9 @@ namespace const auto planeType = static_cast(plane.type); if (planeType >= 3u) { - if (!FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, node + 2, mins, maxs, memory, error)) - return false; + auto result = FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, node + 2, mins, maxs, memory); + if (!result) + return std::unexpected(std::move(result.error())); } else { @@ -6065,13 +5759,14 @@ namespace frontMins[planeType] = plane.dist; backMaxs[planeType] = plane.dist; - if (maxs[planeType] > plane.dist - && !FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, node + 2, frontMins, maxs, memory, error)) + if (maxs[planeType] > plane.dist) { - return false; + 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, error); + return FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, staticModelIndex, rightNode, mins, backMaxs, memory); } node = rightNode; @@ -6090,22 +5785,18 @@ namespace continue; } - error = "world node plane-side classification failed"; - return false; + return std::unexpected("world node plane-side classification failed"); } } - [[nodiscard]] bool CommitStaticModelAabbTreeIndexes(StaticModelIndexLists& staticModelIndexesByTree, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult CommitStaticModelAabbTreeIndexes(StaticModelIndexLists& staticModelIndexesByTree, MemoryManager& memory) { for (auto& [tree, indexes] : staticModelIndexesByTree) { std::sort(indexes.begin(), indexes.end()); if (!FitsUint16(indexes.size())) - { - error = "too many static model indexes in world AABB tree"; - return false; - } + return std::unexpected("too many static model indexes in world AABB tree"); tree->smodelIndexCount = static_cast(indexes.size()); if (!indexes.empty()) @@ -6115,7 +5806,7 @@ namespace } } - return true; + return {}; } [[nodiscard]] unsigned SortGfxAabbTreeChildren( @@ -6136,17 +5827,14 @@ namespace return childCount < 2u ? 0u : childCount; } - [[nodiscard]] bool AddSortedStaticModelChild( - GfxAabbTree& tree, uint16_t*& smodelIndexes, unsigned& remainingModelCount, const unsigned childModelCount, std::string& error) + [[nodiscard]] BspLoadResult AddSortedStaticModelChild( + GfxAabbTree& tree, uint16_t*& smodelIndexes, unsigned& remainingModelCount, const unsigned childModelCount) { if (childModelCount == 0u) - return true; + return {}; if (tree.childCount == std::numeric_limits::max()) - { - error = "too many sorted AABB tree children"; - return false; - } + return std::unexpected("too many sorted AABB tree children"); auto* children = AabbTreeChildren(tree); auto& childTree = children[tree.childCount++]; @@ -6154,10 +5842,10 @@ namespace childTree.smodelIndexes = smodelIndexes; smodelIndexes += childModelCount; remainingModelCount -= childModelCount; - return true; + return {}; } - [[nodiscard]] bool SortGfxAabbTree(const GfxWorld& world, GfxAabbTree& tree, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult SortGfxAabbTree(const GfxWorld& world, GfxAabbTree& tree, MemoryManager& memory) { if (tree.smodelIndexCount > 1u) std::sort(tree.smodelIndexes, tree.smodelIndexes + tree.smodelIndexCount); @@ -6167,15 +5855,16 @@ namespace auto* children = AabbTreeChildren(tree); for (auto childIndex = 0u; childIndex < tree.childCount; childIndex++) { - if (!SortGfxAabbTree(world, children[childIndex], memory, error)) - return false; + auto result = SortGfxAabbTree(world, children[childIndex], memory); + if (!result) + return std::unexpected(std::move(result.error())); } - return true; + return {}; } if (tree.smodelIndexCount == 0u) - return true; + 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()}; @@ -6192,7 +5881,7 @@ namespace } if (tree.smodelIndexCount < 8u) - return true; + 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; @@ -6240,7 +5929,7 @@ namespace childCount += childModelCount != 0u ? 1u : 0u; if (childCount == 0u) - return true; + return {}; if (tree.surfaceCount > 0u) childCount++; @@ -6248,8 +5937,8 @@ namespace childCount++; auto* children = AllocZeroed(memory, childCount); - if (!SetAabbTreeChildrenOffset(tree, children, error)) - return false; + if (auto result = SetAabbTreeChildrenOffset(tree, children); !result) + return std::unexpected(std::move(result.error())); tree.childCount = 0u; if (tree.surfaceCount > 0u) @@ -6267,23 +5956,30 @@ namespace remainingModelCount = tree.smodelIndexCount; for (const auto childModelCount : childModelCounts) { - if (!AddSortedStaticModelChild(tree, smodelIndexes, remainingModelCount, childModelCount, error)) - return false; + auto result = AddSortedStaticModelChild(tree, smodelIndexes, remainingModelCount, childModelCount); + if (!result) + return std::unexpected(std::move(result.error())); - if (childModelCount > 0u && !SortGfxAabbTree(world, children[tree.childCount - 1u], memory, error)) - return false; + if (childModelCount > 0u) + { + result = SortGfxAabbTree(world, children[tree.childCount - 1u], memory); + if (!result) + return std::unexpected(std::move(result.error())); + } } if (remainingModelCount > 0u) { - if (!AddSortedStaticModelChild(tree, smodelIndexes, remainingModelCount, remainingModelCount, error)) - return false; + auto result = AddSortedStaticModelChild(tree, smodelIndexes, remainingModelCount, remainingModelCount); + if (!result) + return std::unexpected(std::move(result.error())); - if (!SortGfxAabbTree(world, children[tree.childCount - 1u], memory, error)) - return false; + result = SortGfxAabbTree(world, children[tree.childCount - 1u], memory); + if (!result) + return std::unexpected(std::move(result.error())); } - return true; + return {}; } [[nodiscard]] GfxAabbTree* MoveAabbTree_r(GfxAabbTree& tree, GfxAabbTree& newTree, GfxAabbTree* nextChild) @@ -6304,41 +6000,32 @@ namespace return nextFreeTree; } - [[nodiscard]] bool FixupGfxAabbTrees(GfxCell& cell, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult FixupGfxAabbTrees(GfxCell& cell, MemoryManager& memory) { if (!cell.aabbTree) - return true; + return {}; const auto treeCount = AabbTreeSubtreeCount(*cell.aabbTree); if (!FitsInt(treeCount)) - { - error = "too many AABB tree nodes after static model sort"; - return false; - } + 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) - { - error = "AABB tree fixup produced an unexpected node count"; - return false; - } + return std::unexpected("AABB tree fixup produced an unexpected node count"); cell.aabbTree = newTree; cell.aabbTreeCount = static_cast(treeCount); - return true; + return {}; } - [[nodiscard]] bool PopulateWorldStaticModelAabbTrees(GfxWorld& world, const ComWorld& comWorld, MemoryManager& memory, std::string& error) + [[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 true; + return {}; if (world.dpvs.smodelCount > std::numeric_limits::max()) - { - error = "too many static models for AABB tree indexes"; - return false; - } + return std::unexpected("too many static models for AABB tree indexes"); SortWorldStaticModels(world, comWorld); @@ -6350,17 +6037,19 @@ namespace if (world.dpvsPlanes.nodes) { - if (!FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, packedIndex, world.dpvsPlanes.nodes, smodelInst.mins, smodelInst.maxs, memory, error)) - return false; + 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 (!AddStaticModelToCell(world, staticModelIndexesByTree, packedIndex, 0, memory, error)) + else if (auto result = AddStaticModelToCell(world, staticModelIndexesByTree, packedIndex, 0, memory); !result) { - return false; + return std::unexpected(std::move(result.error())); } } - if (!CommitStaticModelAabbTreeIndexes(staticModelIndexesByTree, memory, error)) - return false; + 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 @@ -6369,29 +6058,31 @@ namespace for (auto cellIndex = 0; cellIndex < world.dpvsPlanes.cellCount; cellIndex++) { auto& cell = world.cells[cellIndex]; - if (cell.aabbTree && !SortGfxAabbTree(world, *cell.aabbTree, memory, error)) - return false; + 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++) { - if (!FixupGfxAabbTrees(world.cells[cellIndex], memory, error)) - return false; + auto result = FixupGfxAabbTrees(world.cells[cellIndex], memory); + if (!result) + return std::unexpected(std::move(result.error())); } - return true; + return {}; } - [[nodiscard]] bool PopulateWorldDynamicEntities(GfxWorld& world, const clipMap_t* clipMap, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateWorldDynamicEntities(GfxWorld& world, const clipMap_t* clipMap, MemoryManager& memory) { if (!clipMap) - return true; + return {}; if (world.dpvsPlanes.cellCount < 0) - { - error = "negative world cell count"; - return false; - } + return std::unexpected("negative world cell count"); const auto nonSunLightCount = NonSunPrimaryLightCount(world); if (nonSunLightCount > 0uz) @@ -6433,7 +6124,7 @@ namespace if (world.dpvsDyn.dynEntClientCount[0] > 0u) world.nonSunPrimaryLightForModelDynEnt = AllocZeroed(memory, world.dpvsDyn.dynEntClientCount[0]); - return true; + return {}; } void PopulateWorldRuntimeData(GfxWorld& world, MemoryManager& memory) @@ -6600,42 +6291,33 @@ namespace return WriteDpvsNodeStream_r(nodes, static_cast(node.children[1]), cellCount, out); } - [[nodiscard]] bool PopulateWorldDpvsNodes(GfxWorld& world, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[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 true; + 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 true; + return {}; } if (rawNodes->data.size() % RAW_CLIP_NODE_SIZE != 0uz || rawLeafs->data.size() % RAW_LEAF_SIZE != 0uz) - { - error = "world node/leaf lump has funny size"; - return false; - } + 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) - { - error = "world node tree is empty"; - return false; - } + return std::unexpected("world node tree is empty"); if (!FitsInt(rawNodeCount) || rawLeafCount > std::numeric_limits::max() - rawNodeCount || !FitsInt(rawNodeCount + rawLeafCount)) - { - error = "world node tree is too large"; - return false; - } + return std::unexpected("world node tree is too large"); std::vector nodes(rawNodeCount + rawLeafCount); for (auto nodeIndex = 0uz; nodeIndex < rawNodeCount; nodeIndex++) @@ -6646,20 +6328,14 @@ namespace node.cellIndex = -2; if (node.planeIndex < 0 || node.planeIndex >= world.planeCount) - { - error = "world node references invalid plane"; - return false; - } + 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()) - { - error = "world node references invalid child"; - return false; - } + return std::unexpected("world node references invalid child"); node.children[childIndex] = static_cast(convertedChild); } @@ -6669,46 +6345,33 @@ namespace { const auto cellIndex = ReadI32(rawLeafs->data.data() + leafIndex * RAW_LEAF_SIZE, RAW_LEAF_CELL_INDEX_OFFSET); if (cellIndex < -1 || cellIndex >= world.dpvsPlanes.cellCount) - { - error = "world leaf references invalid cell"; - return false; - } + return std::unexpected("world leaf references invalid cell"); nodes[rawNodeCount + leafIndex].cellIndex = cellIndex; } std::vector visitState(rawNodeCount); if (!SetDpvsNodeCells_r(nodes, visitState, 0uz, rawNodeCount)) - { - error = "world node tree is cyclic or invalid"; - return false; - } + return std::unexpected("world node tree is cyclic or invalid"); auto streamCount = 0uz; if (!CountDpvsNodeStream_r(nodes, 0uz, streamCount) || !FitsInt(streamCount)) - { - error = "world node stream is too large"; - return false; - } + 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) - { - error = "world node stream could not be packed"; - return false; - } + return std::unexpected("world node stream could not be packed"); - return true; + return {}; } - [[nodiscard]] bool PopulateWorldDpvsPlanes( - GfxWorld& world, const clipMap_t* clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateWorldDpvsPlanes(GfxWorld& world, const clipMap_t* clipMap, const IW3::d3dbsp::File& bsp, MemoryManager& memory) { if (!clipMap) - return true; + return {}; if (clipMap->planeCount > 0 && clipMap->planes) { @@ -6717,7 +6380,7 @@ namespace std::memcpy(world.dpvsPlanes.planes, clipMap->planes, static_cast(clipMap->planeCount) * sizeof(cplane_s)); } - return PopulateWorldDpvsNodes(world, bsp, memory, error); + return PopulateWorldDpvsNodes(world, bsp, memory); } class ClipMapPvsLoader final : public AssetCreator @@ -6739,14 +6402,10 @@ namespace clipMap->name = m_memory.Dup(assetName.c_str()); clipMap->isInUse = 1; - std::string error; - if (!PopulateClipMapMaterials(*clipMap, *bsp, m_memory, error) || !PopulateClipMapPlanes(*clipMap, *bsp, m_memory, error) - || !PopulateClipMapBrushes(*clipMap, *bsp, m_memory, error) || !PopulateClipMapNodes(*clipMap, *bsp, m_memory, error) - || !PopulateClipMapLeafBrushes(*clipMap, *bsp, m_memory, error) || !PopulateClipMapCollision(*clipMap, *bsp, m_memory, error) - || !PopulateClipMapLeafs(*clipMap, *bsp, m_memory, error) || !PopulateClipMapModels(*clipMap, *bsp, m_memory, error) - || !PopulateClipMapVisibility(*clipMap, *bsp, m_memory, error) || !PopulateClipMapLeafSurfaces(*clipMap, *bsp, m_memory, error)) + const auto populateResult = PopulateClipMap(*clipMap, *bsp, m_memory); + if (!populateResult) { - con::error("Could not create clipmap \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create clipmap \"{}\" from {}: {}", assetName, bsp->m_file_name, populateResult.error()); return AssetCreationResult::Failure(); } @@ -6851,29 +6510,22 @@ namespace ISearchPath& m_search_path; }; - [[nodiscard]] bool DecodePathVisRle( - const std::vector& data, size_t& offset, const size_t expectedSize, MemoryManager& memory, char*& pathVis, std::string& error) + [[nodiscard]] BspLoadValue DecodePathVisRle(const std::vector& data, size_t& offset, const size_t expectedSize, MemoryManager& memory) { - pathVis = expectedSize > 0uz ? AllocZeroed(memory, expectedSize) : nullptr; + auto* pathVis = expectedSize > 0uz ? AllocZeroed(memory, expectedSize) : nullptr; auto outOffset = 0uz; while (outOffset < expectedSize) { if (offset >= data.size()) - { - error = "path visibility RLE ended early"; - return false; - } + 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()) - { - error = "path visibility zero run exceeds expected size"; - return false; - } + return std::unexpected("path visibility zero run exceeds expected size"); outOffset += zeroCount; pathVis[outOffset++] = static_cast(std::to_integer(data[offset++])); @@ -6882,10 +6534,7 @@ namespace { const auto literalCount = static_cast(static_cast(~marker)); if (literalCount > expectedSize - outOffset || literalCount > data.size() - offset) - { - error = "path visibility literal run exceeds expected size"; - return false; - } + return std::unexpected("path visibility literal run exceeds expected size"); std::memcpy(pathVis + outOffset, data.data() + offset, literalCount); outOffset += literalCount; @@ -6893,7 +6542,7 @@ namespace } } - return true; + return pathVis; } class GameWorldSpLoader final : public AssetCreator @@ -6973,12 +6622,13 @@ namespace const auto visBytes = (static_cast(nodeCount) * (static_cast(nodeCount) - 1uz) + 7uz) >> 3uz; gameWorld->path.visBytes = static_cast(visBytes); - std::string error; - if (!DecodePathVisRle(data, offset, visBytes, m_memory, gameWorld->path.pathVis, error)) + auto pathVisResult = DecodePathVisRle(data, offset, visBytes, m_memory); + if (!pathVisResult) { - con::error("Could not create GameWorldSp \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + 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)); } @@ -7013,11 +6663,11 @@ namespace ISearchPath& m_search_path; }; - [[nodiscard]] bool PopulateWorldSkySurfaces( - GfxWorld& world, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory, std::string& error) + [[nodiscard]] BspLoadResult PopulateWorldSkySurfaces( + GfxWorld& world, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory) { if (!world.dpvs.surfaces || world.surfaceCount <= 0) - return true; + return {}; std::vector skySurfaces; const Material* skyMaterial = nullptr; @@ -7032,17 +6682,14 @@ namespace // one sky material and uses that material's colorMap cubemap as // GfxWorld::skyImage. if (skyMaterial && skyMaterial != surface.material) - { - error = std::format("map has at least two different skies: {} and {}", surface.material->info.name, skyMaterial->info.name); - return false; - } + 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 true; + return {}; constexpr auto colorMapHash = Common::R_HashString("colorMap"); for (auto textureIndex = 0uz; textureIndex < skyMaterial->textureCount; textureIndex++) @@ -7053,17 +6700,11 @@ namespace const auto* image = texture.u.image; if (!image || texture.semantic == TS_WATER_MAP || image->mapType != MAPTYPE_CUBE) - { - error = std::format("colorMap for sky material \"{}\" is not a cubemap", skyMaterial->info.name); - return false; - } + return std::unexpected(std::format("colorMap for sky material \"{}\" is not a cubemap", skyMaterial->info.name)); auto* imageDependency = context.LoadDependency(image->name); if (!imageDependency) - { - error = std::format("missing sky image \"{}\"", image->name); - return false; - } + return std::unexpected(std::format("missing sky image \"{}\"", image->name)); registration.AddDependency(imageDependency); world.skyImage = imageDependency->Asset(); @@ -7072,15 +6713,12 @@ namespace } if (!world.skyImage) - { - error = std::format("sky material \"{}\" has no colorMap", skyMaterial->info.name); - return false; - } + 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 true; + return {}; } void SetOutdoorLookupIdentity(GfxWorld& world) @@ -7092,24 +6730,20 @@ namespace } } - [[nodiscard]] bool PopulateWorldOutdoorData( + [[nodiscard]] BspLoadResult PopulateWorldOutdoorData( GfxWorld& world, const IW3::d3dbsp::File& bsp, AssetCreationContext& context, AssetRegistration& registration, - MemoryManager& memory, - std::string& error) + 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 true; + return {}; if (materials->data.size() % RAW_MATERIAL_SIZE != 0uz || surfaces->data.size() % RAW_WORLD_SURFACE_SIZE != 0uz) - { - error = "could not calculate outdoor bounds from funny-sized material or surface lump"; - return false; - } + 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); @@ -7117,10 +6751,7 @@ namespace const auto rootStartSurface = static_cast(rootModel.startSurfIndex); const auto rootSurfaceCount = static_cast(rootModel.surfaceCount); if (rootStartSurface + rootSurfaceCount > rawSurfaceCount || rootStartSurface + rootSurfaceCount > static_cast(world.surfaceCount)) - { - error = "root model surface range is outside the world surface lump"; - return false; - } + 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}; @@ -7129,10 +6760,7 @@ namespace const auto* rawSurface = surfaces->data.data() + surfaceIndex * RAW_WORLD_SURFACE_SIZE; const auto rawMaterialIndex = static_cast(ReadU16(rawSurface)); if (rawMaterialIndex >= rawMaterialCount) - { - error = std::format("world surface {} references invalid material index {}", surfaceIndex, rawMaterialIndex); - return false; - } + 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); @@ -7189,13 +6817,10 @@ namespace pixels.size()); auto* imageInfo = AddGeneratedImage(context, registration, OutdoorImageName(), image); if (!imageInfo) - { - error = "could not register generated outdoor image"; - return false; - } + return std::unexpected("could not register generated outdoor image"); world.outdoorImage = imageInfo->Asset(); - return true; + return {}; } class GfxWorldLoader final : public AssetCreator @@ -7235,24 +6860,25 @@ namespace entityBlocks = std::move(*parsedEntityBlocks); } - std::string error; - const auto materialDependencies = LoadWorldMaterials(*bsp, context, m_memory, error); - if (!error.empty()) + auto materialDependenciesResult = LoadWorldMaterials(*bsp, context, m_memory); + if (!materialDependenciesResult) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + 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); - LightmapAtlasLayout lightmapLayout; - if (!BuildLightmapAtlasLayout(*bsp, lightmapLayout, error)) + auto lightmapLayoutResult = BuildLightmapAtlasLayout(*bsp); + if (!lightmapLayoutResult) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + 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()); @@ -7275,120 +6901,122 @@ namespace registration.AddDependency(dependency); } - auto lightDefDependencies = LoadPrimaryLightDefDependencies(*bsp, context, registration, error); - if (!error.empty()) + auto lightDefDependenciesResult = LoadPrimaryLightDefDependencies(*bsp, context, registration); + if (!lightDefDependenciesResult) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + 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 (!PopulateWorldIndices(*world, *bsp, m_memory, error)) + if (auto result = PopulateWorldIndices(*world, *bsp, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldVertices(*world, *bsp, m_memory, error)) + if (auto result = PopulateWorldVertices(*world, *bsp, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldSurfaces(*world, *bsp, lightmapLayout, materialDependencies, m_memory, error)) + if (auto result = PopulateWorldSurfaces(*world, *bsp, lightmapLayout, materialDependencies, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } PopulateWorldMaterialMemory(*world, m_memory); - if (!PopulateWorldVertexLayerData(*world, *bsp, m_memory, error)) + if (auto result = PopulateWorldVertexLayerData(*world, *bsp, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldModels(*world, *bsp, m_memory, error)) + if (auto result = PopulateWorldModels(*world, *bsp, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldCells(*world, *bsp, m_memory, error)) + if (auto result = PopulateWorldCells(*world, *bsp, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldSurfaceOrganization(*world, m_memory, error)) + if (auto result = PopulateWorldSurfaceOrganization(*world, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldDpvsPlanes(*world, clipMap, *bsp, m_memory, error)) + if (auto result = PopulateWorldDpvsPlanes(*world, clipMap, *bsp, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldPortals(*world, *bsp, m_memory, error)) + if (auto result = PopulateWorldPortals(*world, *bsp, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldPrimaryLights(*world, *bsp, m_memory, error)) + if (auto result = PopulateWorldPrimaryLights(*world, *bsp, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + 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 {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: could not populate shadow geometry", assetName, bsp->m_file_name); return AssetCreationResult::Failure(); } - if (!PopulateWorldLightGrid(*world, *bsp, m_memory, error)) + if (auto result = PopulateWorldLightGrid(*world, *bsp, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldLightRegions(*world, *bsp, m_memory, error)) + if (auto result = PopulateWorldLightRegions(*world, *bsp, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldStaticModels(*world, *comWorldDependency->Asset(), clipMap, staticModelBlocks, staticModelDependencies, m_memory, error)) + 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, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldReflectionProbes(*world, *bsp, context, registration, m_memory, error)) + if (auto result = PopulateWorldReflectionProbes(*world, *bsp, context, registration, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } PopulateWorldStaticModelReflectionProbes(*world); - if (!PopulateWorldStaticModelAabbTrees(*world, *comWorldDependency->Asset(), m_memory, error)) + if (auto result = PopulateWorldStaticModelAabbTrees(*world, *comWorldDependency->Asset(), m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldDynamicEntities(*world, clipMap, m_memory, error)) + if (auto result = PopulateWorldDynamicEntities(*world, clipMap, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldLightmaps(*world, *bsp, lightmapLayout, lightDefDependencies, m_search_path, context, registration, m_memory, error)) + 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, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, lightmapsResult.error()); return AssetCreationResult::Failure(); } PopulateWorldRuntimeData(*world, m_memory); - if (!PopulateWorldSkySurfaces(*world, context, registration, m_memory, error)) + if (auto result = PopulateWorldSkySurfaces(*world, context, registration, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } - if (!PopulateWorldOutdoorData(*world, *bsp, context, registration, m_memory, error)) + if (auto result = PopulateWorldOutdoorData(*world, *bsp, context, registration, m_memory); !result) { - con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, error); + con::error("Could not create GfxWorld \"{}\" from {}: {}", assetName, bsp->m_file_name, result.error()); return AssetCreationResult::Failure(); } From 082371742a70fd2595c48fe19a2fd1ffe0db3c3c Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 17 Jun 2026 11:08:51 +0100 Subject: [PATCH 32/35] fix: hugely speed up IW3 d3dbsp decal surface detection Improve the triangle key hash used for coincident surface lookup so 32-bit MSVC builds do not collapse dense map geometry into huge unordered_map buckets. Also store only the first triangle key per surface, matching the linker_pc decal classification behaviour while avoiding unnecessary per-surface key vectors. --- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index b6341207a..cf158e93f 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -4572,16 +4572,28 @@ namespace 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 { - auto result = 1469598103934665603ull; + // 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 ^= value; - result *= 1099511628211ull; + result ^= Mix(static_cast(value) + 0x9e3779b97f4a7c15ull + (result << 6u) + (result >> 2u)); } - return static_cast(result); + return static_cast(Mix(result)); } }; @@ -4591,7 +4603,7 @@ namespace { unsigned firstSurfaceIndex = 0u; DecalTriangleMaterialMap minMaterialForTriangle; - std::vector> surfaceTriangleKeys; + std::vector> firstSurfaceTriangleKey; }; [[nodiscard]] std::optional BuildTriangleKey(const GfxWorld& world, const GfxSurface& surface, const int baseIndex) @@ -4640,7 +4652,7 @@ namespace { DecalTriangleData result; result.firstSurfaceIndex = modelSurfIndexBegin; - result.surfaceTriangleKeys.resize(modelSurfIndexEnd - modelSurfIndexBegin); + result.firstSurfaceTriangleKey.resize(modelSurfIndexEnd - modelSurfIndexBegin); auto totalTriCount = 0uz; for (auto surfIndex = modelSurfIndexBegin; surfIndex < modelSurfIndexEnd; surfIndex++) @@ -4653,8 +4665,7 @@ namespace if (!surface.material) continue; - auto& surfaceKeys = result.surfaceTriangleKeys[surfIndex - modelSurfIndexBegin]; - surfaceKeys.reserve(surface.tris.triCount); + 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++) { @@ -4662,7 +4673,9 @@ namespace if (!triangleKey) continue; - surfaceKeys.emplace_back(*triangleKey); + if (!firstSurfaceTriangleKey) + firstSurfaceTriangleKey = *triangleKey; + auto [entry, inserted] = result.minMaterialForTriangle.emplace(*triangleKey, materialSortedIndex); if (!inserted) entry->second = std::min(entry->second, materialSortedIndex); @@ -4678,15 +4691,15 @@ namespace if (!surface.material || surface.tris.triCount == 0u) return false; - const auto& surfaceKeys = decalTriangleData.surfaceTriangleKeys[surfIndex - decalTriangleData.firstSurfaceIndex]; - if (surfaceKeys.empty()) + 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(surfaceKeys[0]); + const auto existingTriangle = decalTriangleData.minMaterialForTriangle.find(*firstSurfaceTriangleKey); return existingTriangle != decalTriangleData.minMaterialForTriangle.end() && materialSortedIndex > existingTriangle->second; } From 8a7475324d785e7e3bc923eff371935de40d3c49 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 17 Jun 2026 11:11:29 +0100 Subject: [PATCH 33/35] refactor: move outdoor image name to constant --- src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index cf158e93f..6403e5c17 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -99,6 +99,7 @@ namespace 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; @@ -888,11 +889,6 @@ namespace return std::format("{}_{}_{}", assetName, kind, index); } - [[nodiscard]] std::string OutdoorImageName() - { - return "$outdoor"; - } - [[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) { @@ -6817,7 +6813,7 @@ namespace // exact texel-generation pass can be added once the collision trace // path is complete. auto* image = CreateGeneratedImage(memory, - OutdoorImageName(), + OUTDOOR_IMAGE_NAME, MAPTYPE_2D, TS_FUNCTION, IMG_CATEGORY_AUTO_GENERATED, @@ -6828,7 +6824,7 @@ namespace static_cast(image::iwi6::IMG_FLAG_NOMIPMAPS), pixels.data(), pixels.size()); - auto* imageInfo = AddGeneratedImage(context, registration, OutdoorImageName(), image); + auto* imageInfo = AddGeneratedImage(context, registration, OUTDOOR_IMAGE_NAME, image); if (!imageInfo) return std::unexpected("could not register generated outdoor image"); From 3ff6a7b2c773393d20850aafa3470b90dc38575d Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 17 Jun 2026 12:55:00 +0100 Subject: [PATCH 34/35] chore: run clang-format --- src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h | 6 +- .../Game/IW3/Maps/D3DBspLoaderIW3.cpp | 447 ++++++++---------- .../Game/IW3/Maps/D3DBspDumperIW3.cpp | 21 +- 3 files changed, 216 insertions(+), 258 deletions(-) diff --git a/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h b/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h index ea6c4ec60..99391d7d5 100644 --- a/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h +++ b/src/ObjCommon/Game/IW3/Maps/D3DBspCommonIW3.h @@ -63,9 +63,9 @@ namespace IW3::d3dbsp 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_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, diff --git a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp index 6403e5c17..f23d44ee4 100644 --- a/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp +++ b/src/ObjLoading/Game/IW3/Maps/D3DBspLoaderIW3.cpp @@ -14,12 +14,12 @@ #include #include #include +#include #include #include #include #include #include -#include #include #include #include @@ -253,8 +253,7 @@ namespace // 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) + return PackedUnitVec{static_cast(packComponent(value[0])) | (static_cast(packComponent(value[1])) << 8u) | (static_cast(packComponent(value[2])) << 16u) | (63u << 24u)}; } @@ -361,7 +360,9 @@ namespace if (!value) return; - PixelLiteralConst entry{dest, {value[0], value[1], value[2], value[3]}}; + PixelLiteralConst entry{ + dest, {value[0], value[1], value[2], value[3]} + }; auto insertPosition = literalConsts.begin(); while (insertPosition != literalConsts.end() && insertPosition->dest <= dest) ++insertPosition; @@ -429,7 +430,8 @@ namespace return result; } - [[nodiscard]] int ComparePixelConsts(const Material& left, const MaterialTechnique& leftTechnique, const Material& right, const MaterialTechnique& rightTechnique) + [[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 @@ -540,7 +542,8 @@ namespace const auto* leftTechniqueSet = TechniqueSetForMaterial(left); const auto* rightTechniqueSet = TechniqueSetForMaterial(right); - if (const auto techniqueSetComparison = CompareCString(leftTechniqueSet ? leftTechniqueSet->name : nullptr, rightTechniqueSet ? rightTechniqueSet->name : nullptr)) + if (const auto techniqueSetComparison = + CompareCString(leftTechniqueSet ? leftTechniqueSet->name : nullptr, rightTechniqueSet ? rightTechniqueSet->name : nullptr)) return techniqueSetComparison; return CompareCString(left->info.name, right->info.name); @@ -565,10 +568,12 @@ namespace materials.emplace_back(material); } - std::sort(materials.begin(), materials.end(), [](const Material* left, const Material* right) - { - return CompareWorldMaterialsForSort(left, right) < 0; - }); + 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++) { @@ -583,15 +588,13 @@ namespace [[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) + 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) + [[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))); @@ -889,8 +892,14 @@ namespace 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) + [[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; @@ -906,19 +915,18 @@ namespace 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) + [[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()); @@ -1094,7 +1102,8 @@ namespace 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 : "")); + 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) @@ -1102,8 +1111,7 @@ namespace 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) + 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 : "")); @@ -1502,8 +1510,8 @@ namespace return result; } - [[nodiscard]] std::vector*> LoadStaticModelDependencies( - const std::vector& staticModelBlocks, AssetCreationContext& context) + [[nodiscard]] std::vector*> LoadStaticModelDependencies(const std::vector& staticModelBlocks, + AssetCreationContext& context) { std::vector*> result; result.reserve(staticModelBlocks.size()); @@ -1590,11 +1598,10 @@ namespace std::copy(productsOfInertia->begin(), productsOfInertia->end(), dynEnt.mass.productsOfInertia); } - void PopulateStaticModels( - clipMap_t& clipMap, - const std::vector& staticModelBlocks, - const std::vector*>& staticModelDependencies, - MemoryManager& memory) + void PopulateStaticModels(clipMap_t& clipMap, + const std::vector& staticModelBlocks, + const std::vector*>& staticModelDependencies, + MemoryManager& memory) { std::vector> validStaticModels; validStaticModels.reserve(staticModelBlocks.size()); @@ -1642,12 +1649,11 @@ namespace } } - void PopulateDynModelEntities( - clipMap_t& clipMap, - const std::vector& dynModelBlocks, - const std::vector& dynModelDependencies, - AssetCreationContext& context, - MemoryManager& memory) + void PopulateDynModelEntities(clipMap_t& clipMap, + const std::vector& dynModelBlocks, + const std::vector& dynModelDependencies, + AssetCreationContext& context, + MemoryManager& memory) { struct DynModelBuildEntry { @@ -1669,7 +1675,8 @@ namespace if (type == DYNENT_TYPE_INVALID) continue; - validDynModels.emplace_back(dynModelBlocks[i], dynModelDependencies[i].model->Asset(), ResolveDynModelPhysPreset(dynModelDependencies[i], context), i); + validDynModels.emplace_back( + dynModelBlocks[i], dynModelDependencies[i].model->Asset(), ResolveDynModelPhysPreset(dynModelDependencies[i], context), i); } if (validDynModels.empty()) @@ -1825,14 +1832,13 @@ namespace 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) + [[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; @@ -1866,13 +1872,12 @@ namespace 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]) + [[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"); @@ -2004,12 +2009,8 @@ namespace return nodeIndex; } - [[nodiscard]] BspLoadResult PartitionLeafBrushes( - const clipMap_t& clipMap, - std::vector& nodes, - LeafBrush* leafBrushes, - const int leafBrushCount, - cLeaf_t& leaf) + [[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); @@ -2080,8 +2081,7 @@ namespace // 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); + clipMap.materials[i].contentFlags = static_cast(static_cast(ReadI32(record, 68uz)) & IW3::d3dbsp::RUNTIME_MATERIAL_CONTENT_MASK); } return {}; @@ -2620,10 +2620,8 @@ namespace 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) + [[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); } @@ -2726,11 +2724,10 @@ namespace return {}; } - [[nodiscard]] BspLoadResult ApplyLightDefAttenuationImages( - std::vector& secondary, - const LightmapAtlasGroup& group, - const std::vector& lightDefs, - ISearchPath& searchPath) + [[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 @@ -2748,10 +2745,8 @@ namespace return {}; } - [[nodiscard]] std::expected, std::string> LoadPrimaryLightDefDependencies( - const IW3::d3dbsp::File& bsp, - AssetCreationContext& context, - AssetRegistration& registration) + [[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); @@ -2798,10 +2793,9 @@ namespace return left + right; } - [[nodiscard]] BspLoadResult BuildLightmapCouplingMatrix( - const IW3::d3dbsp::File& bsp, - const unsigned rawPageCount, - std::array& coupling) + [[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) @@ -2831,8 +2825,7 @@ namespace 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))); + vertexCountByLightmap[lightmapIndex] = SaturatingAdd(vertexCountByLightmap[lightmapIndex], static_cast(ReadU16(record, 16uz))); } for (auto left = 0u; left < rawPageCount; left++) @@ -2989,8 +2982,7 @@ namespace { for (auto rawPage = 0u; rawPage < layout.rawPageCount; rawPage++) { - aggregateWeights[rawPage] = - SaturatingAdd(aggregateWeights[rawPage], coupling[selectedRawPage * MAX_LIGHTMAP_PAGE_COUNT + rawPage]); + aggregateWeights[rawPage] = SaturatingAdd(aggregateWeights[rawPage], coupling[selectedRawPage * MAX_LIGHTMAP_PAGE_COUNT + rawPage]); } auto bestNext = SKY_LIGHTMAP_INDEX; @@ -3310,11 +3302,7 @@ namespace } void ApplySurfaceLightmapRemap( - GfxWorld& world, - const GfxSurface& surface, - const LightmapAtlasGroup& group, - const unsigned packedSlot, - std::vector& vertexLightmapRemaps) + 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; @@ -3361,7 +3349,8 @@ namespace if (triCount == 0uz) return {}; - if (!world.indices || !world.vd.vertices || surface.tris.baseIndex < 0 || static_cast(surface.tris.baseIndex) > static_cast(world.indexCount) + 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"); @@ -3462,11 +3451,10 @@ namespace uint16_t indexCount = 0u; }; - [[nodiscard]] bool RawSurfaceMaterialsMatch( - const RawWorldSurfaceIndexInfo& left, - const RawWorldSurfaceIndexInfo& right, - const size_t firstSurfaceIndex, - const std::vector& rawMaterialNames) + [[nodiscard]] bool RawSurfaceMaterialsMatch(const RawWorldSurfaceIndexInfo& left, + const RawWorldSurfaceIndexInfo& right, + const size_t firstSurfaceIndex, + const std::vector& rawMaterialNames) { if (left.materialIndex == right.materialIndex) return true; @@ -3482,22 +3470,19 @@ namespace return rawMaterialNames[left.materialIndex] == rawMaterialNames[firstSurfaceIndex]; } - [[nodiscard]] bool RawSurfaceIndexGroupsMatch( - const RawWorldSurfaceIndexInfo& left, - const RawWorldSurfaceIndexInfo& right, - const size_t firstSurfaceIndex, - const std::vector& rawMaterialNames) + [[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 + 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) + [[nodiscard]] BspLoadResult RewriteWorldIndicesLikeLinker(GfxWorld& world, + const std::vector& rawSurfaces, + const std::vector& rawMaterialNames, + MemoryManager& memory) { if (rawSurfaces.empty()) return {}; @@ -3541,7 +3526,8 @@ namespace 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)); + 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; @@ -3559,12 +3545,11 @@ namespace return {}; } - [[nodiscard]] BspLoadResult PopulateWorldSurfaces( - GfxWorld& world, - const IW3::d3dbsp::File& bsp, - const LightmapAtlasLayout& lightmapLayout, - const std::vector*>& materialDependencies, - MemoryManager& memory) + [[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) @@ -3743,8 +3728,7 @@ namespace return std::format("*lightmap{}_{}", lightmapIndex, suffix); } - void CopyPrimaryLightmapRawPageToAtlas( - std::vector& out, const std::byte* page, const LightmapAtlasGroup& group, const unsigned packedSlot) + 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; @@ -3760,8 +3744,7 @@ namespace } } - void CopySecondaryLightmapRawPageToAtlas( - std::vector& out, const std::byte* page, const LightmapAtlasGroup& group, const unsigned packedSlot) + 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; @@ -3821,11 +3804,10 @@ namespace } } - [[nodiscard]] BspLoadValue, std::vector>> BuildLightmapAtlasImages( - const IW3::d3dbsp::Lump& lightmaps, - const LightmapAtlasGroup& group, - const std::vector& lightDefs, - ISearchPath& searchPath) + [[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; @@ -3850,15 +3832,14 @@ namespace 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) + [[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()) @@ -3945,9 +3926,8 @@ namespace 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])); + 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)); } } @@ -3984,11 +3964,7 @@ namespace } [[nodiscard]] BspLoadResult PopulateWorldReflectionProbes( - GfxWorld& world, - const IW3::d3dbsp::File& bsp, - AssetCreationContext& context, - AssetRegistration& registration, - MemoryManager& memory) + 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()) @@ -3999,7 +3975,8 @@ namespace return CreateDefaultReflectionProbe(world, context, registration, memory); } - if (reflectionProbes->data.size() % REFLECTION_PROBE_RECORD_SIZE != 0uz || !FitsUnsigned(reflectionProbes->data.size() / REFLECTION_PROBE_RECORD_SIZE + 1uz)) + 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; @@ -4132,7 +4109,8 @@ namespace void PopulateWorldStaticModelReflectionProbes(GfxWorld& world) { - if (world.reflectionProbeCount == 0u || !world.reflectionProbes || world.dpvs.smodelCount == 0u || !world.dpvs.smodelInsts || !world.dpvs.smodelDrawInsts) + 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++) @@ -4213,12 +4191,8 @@ namespace int treeCount = 0; }; - [[nodiscard]] BspLoadValue FinishWorldAabbTree_r( - const GfxWorld& world, - GfxAabbTree* trees, - const size_t treeIndex, - size_t totalTreesUsed, - const size_t treeCount) + [[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); @@ -4302,8 +4276,7 @@ namespace [[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())) + 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); @@ -4406,8 +4379,10 @@ namespace 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])); + 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 @@ -4777,8 +4752,8 @@ namespace world.dpvs.emissiveSurfsEnd = surfIndex; } - [[nodiscard]] BspLoadResult - AppendNoDecalAabbTreeSurfaces(const GfxWorld& world, GfxAabbTree& tree, std::vector& sortedSurfIndex, const unsigned sourceSurfaceCount, unsigned& writeIndex) + [[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"); @@ -5003,7 +4978,8 @@ namespace 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)); + return std::unexpected( + std::format("GfxWorld primary light count {} exceeds raw primary-light record count {}", world.primaryLightCount, rawPrimaryLightCount)); if (rawPrimaryLightCount == 0uz) return {}; @@ -5020,7 +4996,8 @@ namespace } world.sunLight = AllocZeroed(memory); - ParseGfxLightRecord(primaryLights->data.data() + static_cast(world.sunPrimaryLightIndex) * IW3::d3dbsp::RAW_PRIMARY_LIGHT_SIZE, *world.sunLight); + 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 {}; @@ -5112,13 +5089,12 @@ namespace 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]) + [[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]{}; @@ -5173,10 +5149,10 @@ namespace { 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]); + 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]); } } @@ -5233,8 +5209,8 @@ namespace return true; } - [[nodiscard]] unsigned PrimaryLightForModelVertex( - const GfxWorld& world, const ComWorld& comWorld, const std::vector& checkLight, const float (&point)[3]) + [[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++) { @@ -5386,13 +5362,12 @@ namespace 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) + [[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()); @@ -5529,25 +5504,27 @@ namespace // 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); - }); + 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++) { @@ -5634,8 +5611,10 @@ namespace return {}; } - [[nodiscard]] BspLoadResult AppendStaticModelOnlyChild( - StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& tree, const GfxStaticModelInst& smodelInst, MemoryManager& memory) + [[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"); @@ -5661,11 +5640,7 @@ namespace } [[nodiscard]] BspLoadResult AddStaticModelToAabbTree_r( - const GfxWorld& world, - StaticModelIndexLists& staticModelIndexesByTree, - GfxAabbTree& tree, - const uint16_t staticModelIndex, - MemoryManager& memory) + const GfxWorld& world, StaticModelIndexLists& staticModelIndexesByTree, GfxAabbTree& tree, const uint16_t staticModelIndex, MemoryManager& memory) { AddStaticModelToTreeList(staticModelIndexesByTree, tree, staticModelIndex); @@ -5707,11 +5682,7 @@ namespace } [[nodiscard]] BspLoadResult AddStaticModelToCell( - const GfxWorld& world, - StaticModelIndexLists& staticModelIndexesByTree, - const uint16_t staticModelIndex, - const int cellIndex, - MemoryManager& memory) + 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"); @@ -5727,14 +5698,13 @@ namespace 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) + [[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) { @@ -5818,8 +5788,8 @@ namespace return {}; } - [[nodiscard]] unsigned SortGfxAabbTreeChildren( - const GfxWorld& world, const float (&mins)[3], const float (&maxs)[3], uint16_t* staticModels, const unsigned staticModelCount) + [[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++) @@ -5836,8 +5806,8 @@ namespace return childCount < 2u ? 0u : childCount; } - [[nodiscard]] BspLoadResult AddSortedStaticModelChild( - GfxAabbTree& tree, uint16_t*& smodelIndexes, unsigned& remainingModelCount, const unsigned childModelCount) + [[nodiscard]] BspLoadResult + AddSortedStaticModelChild(GfxAabbTree& tree, uint16_t*& smodelIndexes, unsigned& remainingModelCount, const unsigned childModelCount) { if (childModelCount == 0u) return {}; @@ -6046,8 +6016,8 @@ namespace if (world.dpvsPlanes.nodes) { - auto result = - FilterStaticModelIntoCells_r(world, staticModelIndexesByTree, packedIndex, world.dpvsPlanes.nodes, smodelInst.mins, smodelInst.maxs, memory); + auto result = FilterStaticModelIntoCells_r( + world, staticModelIndexesByTree, packedIndex, world.dpvsPlanes.nodes, smodelInst.mins, smodelInst.maxs, memory); if (!result) return std::unexpected(std::move(result.error())); } @@ -6264,11 +6234,7 @@ namespace && 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) + [[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) @@ -6672,8 +6638,8 @@ namespace ISearchPath& m_search_path; }; - [[nodiscard]] BspLoadResult PopulateWorldSkySurfaces( - GfxWorld& world, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory) + [[nodiscard]] BspLoadResult + PopulateWorldSkySurfaces(GfxWorld& world, AssetCreationContext& context, AssetRegistration& registration, MemoryManager& memory) { if (!world.dpvs.surfaces || world.surfaceCount <= 0) return {}; @@ -6740,11 +6706,7 @@ namespace } [[nodiscard]] BspLoadResult PopulateWorldOutdoorData( - GfxWorld& world, - const IW3::d3dbsp::File& bsp, - AssetCreationContext& context, - AssetRegistration& registration, - MemoryManager& memory) + 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); @@ -6987,7 +6949,8 @@ namespace 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) + 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(); diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp index 337812edc..05cd1fe02 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -275,11 +275,7 @@ namespace } [[nodiscard]] size_t FindLeafBrushRange( - const LeafBrush* leafBrushes, - const size_t leafBrushCount, - const LeafBrush* nodeBrushes, - const size_t nodeBrushCount, - const size_t searchStart) + 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; @@ -527,10 +523,10 @@ namespace 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)); + clipMap.numLeafBrushes, + recoveredBrushes.data(), + recoveredBrushes.size(), + static_cast(runningFirstLeafBrush)); if (brushesIndex < clipMap.numLeafBrushes) { @@ -539,7 +535,6 @@ namespace } } } - } // The raw leaf record carries the cluster/cell assignment even for @@ -1625,7 +1620,8 @@ namespace 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)) + 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: @@ -1714,8 +1710,7 @@ namespace { std::vector out; const uint32_t version = 8u; - const auto nodeCount = - static_cast(std::min(gameWorld.path.nodeCount, static_cast(std::numeric_limits::max()))); + const auto nodeCount = static_cast(std::min(gameWorld.path.nodeCount, static_cast(std::numeric_limits::max()))); Append(out, version); Append(out, nodeCount); From 91cc746040a2f9ffc45c54d66af2930c53068ee7 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Wed, 24 Jun 2026 11:50:58 +0100 Subject: [PATCH 35/35] fix: dump IW3 dyn_model entities from BSPs Reconstruct dyn_model entity blocks from clipMap --- .../Game/IW3/Maps/D3DBspDumperIW3.cpp | 144 +++++++++++++++++- 1 file changed, 140 insertions(+), 4 deletions(-) diff --git a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp index 05cd1fe02..64be23132 100644 --- a/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp +++ b/src/ObjWriting/Game/IW3/Maps/D3DBspDumperIW3.cpp @@ -248,6 +248,14 @@ namespace 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) @@ -1541,17 +1549,86 @@ namespace } } - [[nodiscard]] std::array StaticModelAngles(const cStaticModel_s& staticModel) + [[nodiscard]] std::array AxisToAngles(const float (&axis)[3][3]) { - float axis[3][3]{}; - StaticModelAxis(staticModel, axis); - 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; @@ -1673,6 +1750,64 @@ namespace } } + 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); @@ -1684,6 +1819,7 @@ namespace entities.assign(mapEnts.entityString, entityCharCount); AppendStaticModelEntities(entities, clipMap, world); + AppendDynModelEntities(entities, clipMap); if (!entities.empty() && entities.back() != '\n') entities += '\n';