From 8a320dbfd472752855317244c5390dd770e218ae Mon Sep 17 00:00:00 2001 From: themuffinator Date: Wed, 14 Oct 2026 10:36:51 +0100 Subject: [PATCH 1/2] Fix dmap vertex allocation on heavily split maps Allocate only shared vertices, preserve original epsilon matching and order, and avoid repeated triangle-list scans. Add native regression coverage and record the remaining hub2 runtime surface limit. Refs #181 --- .github/workflows/commit-validation.yml | 2 + .github/workflows/push-verification.yml | 2 + docs/dev/dmap-compilation.md | 77 +++++++ docs/dev/release-completion.md | 17 ++ src/tools/compilers/dmap/output.cpp | 132 ++++++++---- src/tools/compilers/dmap/tritjunction.cpp | 14 +- tools/tests/dmap_vertex_sharing.py | 56 +++++ tools/tests/native/DmapVertexSharingTest.cpp | 204 +++++++++++++++++++ tools/validation/openq4_validate.py | 1 + 9 files changed, 469 insertions(+), 36 deletions(-) create mode 100644 docs/dev/dmap-compilation.md create mode 100644 tools/tests/dmap_vertex_sharing.py create mode 100644 tools/tests/native/DmapVertexSharingTest.cpp diff --git a/.github/workflows/commit-validation.yml b/.github/workflows/commit-validation.yml index 3ff9a9bd8..379c43725 100644 --- a/.github/workflows/commit-validation.yml +++ b/.github/workflows/commit-validation.yml @@ -143,6 +143,7 @@ jobs: tools/tests/demo_playback.py \ tools/tests/docs_link_integrity.py \ tools/tests/dmap_render_geometry_lifecycle.py \ + tools/tests/dmap_vertex_sharing.py \ tools/tests/fatal_error_startup_safety.py \ tools/tests/android_build.py \ tools/tests/android_touch_input.py \ @@ -521,6 +522,7 @@ jobs: python tools/tests/demo_playback.py python tools/tests/docs_link_integrity.py python tools/tests/dmap_render_geometry_lifecycle.py + python tools/tests/dmap_vertex_sharing.py python tools/tests/fatal_error_startup_safety.py python tools/tests/android_build.py python tools/tests/android_touch_input.py diff --git a/.github/workflows/push-verification.yml b/.github/workflows/push-verification.yml index 564d89a12..a12c93007 100644 --- a/.github/workflows/push-verification.yml +++ b/.github/workflows/push-verification.yml @@ -143,6 +143,7 @@ jobs: tools/tests/demo_playback.py \ tools/tests/docs_link_integrity.py \ tools/tests/dmap_render_geometry_lifecycle.py \ + tools/tests/dmap_vertex_sharing.py \ tools/tests/fatal_error_startup_safety.py \ tools/tests/android_build.py \ tools/tests/android_touch_input.py \ @@ -521,6 +522,7 @@ jobs: python tools/tests/demo_playback.py python tools/tests/docs_link_integrity.py python tools/tests/dmap_render_geometry_lifecycle.py + python tools/tests/dmap_vertex_sharing.py python tools/tests/fatal_error_startup_safety.py python tools/tests/android_build.py python tools/tests/android_touch_input.py diff --git a/docs/dev/dmap-compilation.md b/docs/dev/dmap-compilation.md new file mode 100644 index 000000000..b97e3854b --- /dev/null +++ b/docs/dev/dmap-compilation.md @@ -0,0 +1,77 @@ +# dmap surface storage + +The `hub2.map` attachment in [issue #181](https://github.com/themuffinator/openQ4/issues/181) +exposed a crash in `ShareMapTriVerts` while writing the compiled `.proc` file. +The supplied dump records a request for 36,635,340 vertex slots, or +2,344,661,760 bytes with the 64-byte vertex layout. This exceeds the legacy +heap's signed 32-bit allocation limit. The index allocation succeeds, but the +vertex allocation returns null and the old writer immediately writes through it. + +Surface sharing now keeps references to the first occurrence of each unique +vertex and allocates the final vertex buffer only after sharing is complete. +Index storage still includes every input triangle. The implementation checks +index-count multiplication, rejected geometry allocations and non-finite +positions, freeing the surface before reporting a compiler error. + +A spatial hash replaces the linear scan through all preceding vertices. +Neighboring cells are searched across epsilon boundaries, and the earliest +matching vertex wins. This preserves the original position/UV tolerances, +normal matching, vertex order and index order, including texture seams and +hard edges. It does not discard overlapping triangles or change triangulation. + +T-junction repair and output surface assembly also retain the tail of each +accumulated triangle list. +Appending each new fragment no longer traverses every previously appended +fragment. This preserves list order and avoids quadratic list assembly on +heavily split surfaces. A sample of the original `hub2` compile was inside +`MergeTriLists`, called by `FixGlobalTjunctions`; the same rescan was measured +later in `WriteOutputSurfaces`. + +## Regression coverage + +`python tools/tests/dmap_vertex_sharing.py` compiles the production sharing +implementation against a counted, fallible geometry allocator. It compares +results with the original linear matching rule, including positive and negative +cell boundaries, seams, smoothed/hard normals, ambiguous matches and varied +geometry. A 14,000-triangle surface with three unique vertices reproduces the +allocation-limit condition at a small scale: the old reservation exceeds the +256 KiB test limit, while the shared vertices and all 42,000 indexes fit. +Rejected index/vertex allocations, integer overflow and invalid positions also +exercise the error and cleanup paths. + +Both GitHub validation workflows and the local validation runner include the +test. Use `--compiler cl` in the Windows developer environment, or +`--sanitize` with Clang/GCC on Linux. The existing +`dmap_render_geometry_lifecycle.py` and `aas_compiler_contract.py` checks cover +the surrounding allocator lifetime and compiler wiring. + +The reporter's map and crash dump are investigation inputs only and are not +redistributed in the repository. Local qualification evidence belongs under +`.tmp/issue-181/`. + +## Supplied-map qualification (14 October 2026) + +The Windows x64 optimized build completed `dmap game/hub2` against installed +stock assets and exited normally. Geometry took 503 seconds, collision 3 seconds, +and navigation 9 seconds. The run produced the `.proc`, `.cm` and all five AAS +files. The largest surface contains 36,635,340 indexes but only 21,559 shared +vertices: its final vertex allocation is 1,379,776 bytes rather than the old +2,344,661,760-byte reservation. Windows Clang/MSVC native checks and Linux +Clang/GCC address/undefined-behavior sanitizer checks pass. Running the original +sharing implementation in the same regression fixture reproduces the null write. + +Compilation success does **not** qualify this map for gameplay. Loading the +result reaches `R_ParseModel: bad surface counts`: its largest surface exceeds +the renderer's existing 16,777,216-index limit. The load also reports +`MAX_SIL_EDGES`. Those runtime limits and the source map's duplicate-plane, +node-without-volume and backwards-triangle diagnostics remain follow-up work; +the compiler fix does not relax renderer validation or rewrite map geometry. +Keep issue #181 open to track this distinction. + +A separate staged OpenGL single-player smoke loads stock `game/airdefense1`, +enters gameplay, writes an engine screenshot and exits normally after three +seconds. This confirms stock-map startup, not playability of the rebuilt `hub2`. + +The reporter's log additionally rejects the `hidef2kbc72` mod manifest's required +openQ4 version `1`; it expects a `major.minor.patch` value. This is separate from +the compiler allocation failure and was excluded from the stock-asset compile. diff --git a/docs/dev/release-completion.md b/docs/dev/release-completion.md index b9bff7240..cbfa57499 100644 --- a/docs/dev/release-completion.md +++ b/docs/dev/release-completion.md @@ -2,6 +2,23 @@ ## Post-0.14.000 corrections +- [x] Qualify the `hub2` map compiler crash fix from + [#181](https://github.com/themuffinator/openQ4/issues/181). Allocate shared + vertices by their actual count, retain matching/order behavior, reject failed + allocations safely, and avoid repeated triangle-list rescans during T-junction + repair and output assembly. Windows MSVC and Clang native regression tests and + Linux sanitizer checks pass. The supplied map completes geometry, collision + and navigation compilation, with a 503-second geometry pass and normal exit. + Gameplay remains blocked by its oversized surface exceeding the renderer's + existing index limit; keep #181 open for that follow-up. + See [compiler storage and regression coverage](dmap-compilation.md). + +Candidate release wording: Large, heavily split maps such as `hub2` compile +without crashing on an oversized temporary vertex allocation. Surface sharing +and T-junction list assembly also do less repeated work. Rebuild affected maps +with the updated engine. The supplied `hub2` map still exceeds a separate runtime +surface limit and is not yet qualified for gameplay. + - [x] Reduce repeated hidden HUD animation checks without changing animation phase, appearance or reveal behavior. Eight optimized native UI suites pass. Matched 720p gameplay comparisons in both condition orders add 2.60-4.32% diff --git a/src/tools/compilers/dmap/output.cpp b/src/tools/compilers/dmap/output.cpp index 4c02677f3..ea8ab3daa 100644 --- a/src/tools/compilers/dmap/output.cpp +++ b/src/tools/compilers/dmap/output.cpp @@ -31,6 +31,12 @@ If you have questions concerning this license or the applicable additional terms #include "dmap.h" +#include +#include +#include +#include +#include + //================================================================================= @@ -181,6 +187,22 @@ static bool MatchVert( const idDrawVert *a, const idDrawVert *b ) { return true; } +// Unit-sized spatial buckets keep the epsilon search local. Search both sides +// of a bucket boundary and retain the earliest matching vertex, just as the +// original linear search did. Float keys avoid narrowing large map coordinates +// to an integer; floor() still makes every key an integral position. +typedef std::array mapTriVertexCell_t; + +struct mapTriVertexCellHash_t { + size_t operator()( const mapTriVertexCell_t &cell ) const { + size_t hash = 0; + for ( int i = 0; i < 3; i++ ) { + hash ^= std::hash()( cell[i] ) + size_t( 0x9e3779b9u ) + ( hash << 6 ) + ( hash >> 2 ); + } + return hash; + } +}; + /* ==================== ShareMapTriVerts @@ -189,47 +211,85 @@ Converts independent triangles to shared vertex triangles ==================== */ srfTriangles_t *ShareMapTriVerts( const mapTri_t *tris ) { - const mapTri_t *step; - int count; - int i, j; - int numVerts; - int numIndexes; - srfTriangles_t *uTri; - - // unique the vertexes - count = CountTriList( tris ); - - uTri = R_AllocStaticTriSurf(); - R_AllocStaticTriSurfVerts( uTri, count * 3 ); - R_AllocStaticTriSurfIndexes( uTri, count * 3 ); - - numVerts = 0; - numIndexes = 0; - - for ( step = tris ; step ; step = step->next ) { - for ( i = 0 ; i < 3 ; i++ ) { - const idDrawVert *dv; + const int count = CountTriList( tris ); + if ( count < 0 || count > idMath::INT_MAX / 3 ) { + common->Error( "ShareMapTriVerts: too many triangles (%i)", count ); + return NULL; + } - dv = &step->v[i]; + srfTriangles_t *uTri = R_AllocStaticTriSurf(); + const int numIndexes = count * 3; + R_AllocStaticTriSurfIndexes( uTri, numIndexes ); + if ( numIndexes != 0 && uTri->indexes == NULL ) { + R_FreeStaticTriSurf( uTri ); + common->Error( "ShareMapTriVerts: could not allocate %i indexes", numIndexes ); + return NULL; + } + + // Keep references to the first occurrence of each vertex until sharing is + // complete. Reserving three full vertices per triangle can exceed the heap's + // single-allocation limit even when the shared surface is small (#181). + std::vector uniqueVerts; + std::unordered_map, mapTriVertexCellHash_t> cells; + int index = 0; + for ( const mapTri_t *step = tris; step; step = step->next ) { + for ( int i = 0; i < 3; i++ ) { + const idDrawVert *dv = &step->v[i]; + mapTriVertexCell_t cell; + float neighbors[3][2]; + int numNeighbors[3]; + for ( int axis = 0; axis < 3; axis++ ) { + if ( !std::isfinite( dv->xyz[axis] ) ) { + R_FreeStaticTriSurf( uTri ); + common->Error( "ShareMapTriVerts: non-finite vertex position" ); + return NULL; + } + cell[axis] = float( std::floor( double( dv->xyz[axis] ) ) ); + // Leave room for rounding in MatchVert's float subtraction. + neighbors[axis][0] = float( std::floor( double( dv->xyz[axis] ) - 2 * XYZ_EPSILON ) ); + neighbors[axis][1] = float( std::floor( double( dv->xyz[axis] ) + 2 * XYZ_EPSILON ) ); + numNeighbors[axis] = neighbors[axis][0] == neighbors[axis][1] ? 1 : 2; + } - // search for a match - for ( j = 0 ; j < numVerts ; j++ ) { - if ( MatchVert( &uTri->verts[j], dv ) ) { - break; + int match = int( uniqueVerts.size() ); + for ( int x = 0; x < numNeighbors[0]; x++ ) { + for ( int y = 0; y < numNeighbors[1]; y++ ) { + for ( int z = 0; z < numNeighbors[2]; z++ ) { + const mapTriVertexCell_t neighbor = { neighbors[0][x], neighbors[1][y], neighbors[2][z] }; + const auto found = cells.find( neighbor ); + if ( found == cells.end() ) { + continue; + } + for ( int candidate : found->second ) { + if ( candidate < match && MatchVert( uniqueVerts[candidate], dv ) ) { + match = candidate; + break; + } + } + } } } - if ( j == numVerts ) { - numVerts++; - uTri->verts[j].xyz = dv->xyz; - uTri->verts[j].normal = dv->normal; - uTri->verts[j].st[0] = dv->st[0]; - uTri->verts[j].st[1] = dv->st[1]; + if ( match == int( uniqueVerts.size() ) ) { + uniqueVerts.push_back( dv ); + cells[cell].push_back( match ); } - - uTri->indexes[numIndexes++] = j; + uTri->indexes[index++] = match; } } + const int numVerts = int( uniqueVerts.size() ); + R_AllocStaticTriSurfVerts( uTri, numVerts ); + if ( numVerts != 0 && uTri->verts == NULL ) { + R_FreeStaticTriSurf( uTri ); + common->Error( "ShareMapTriVerts: could not allocate %i shared vertices", numVerts ); + return NULL; + } + for ( int i = 0; i < numVerts; i++ ) { + uTri->verts[i].Clear(); + uTri->verts[i].xyz = uniqueVerts[i]->xyz; + uTri->verts[i].normal = uniqueVerts[i]->normal; + uTri->verts[i].st = uniqueVerts[i]->st; + } uTri->numVerts = numVerts; uTri->numIndexes = numIndexes; @@ -435,6 +495,7 @@ typedef struct interactionTris_s { // vertexes because they had different planes, texture coordinates, or lights. // Different mergeGroups will stay in separate surfaces. ambient = NULL; + mapTri_t **ambientTail = &ambient; // each light that illuminates any of the groups in the surface will // get its own list of indexes out of the original surface @@ -450,7 +511,10 @@ typedef struct interactionTris_s { // copy it out to the ambient list copy = CopyTriList( groupStep->triList ); - ambient = MergeTriLists( ambient, copy ); + *ambientTail = copy; + while ( *ambientTail ) { + ambientTail = &(*ambientTail)->next; + } groupStep->surfaceEmited = true; // duplicate it into an interaction for each groupLight diff --git a/src/tools/compilers/dmap/tritjunction.cpp b/src/tools/compilers/dmap/tritjunction.cpp index 0964696a1..866888c23 100644 --- a/src/tools/compilers/dmap/tritjunction.cpp +++ b/src/tools/compilers/dmap/tritjunction.cpp @@ -494,9 +494,15 @@ void FixAreaGroupsTjunctions( optimizeGroup_t *groupList ) { } newList = NULL; + mapTri_t **tail = &newList; for ( tri = group->triList ; tri ; tri = tri->next ) { fixed = FixTriangleAgainstHash( tri ); - newList = MergeTriLists( newList, fixed ); + // Large split surfaces must not rescan the accumulated list for + // every input triangle. Preserve the same output order with a tail. + *tail = fixed; + while ( *tail ) { + tail = &(*tail)->next; + } } FreeTriList( group->triList ); group->triList = newList; @@ -648,9 +654,13 @@ void FixGlobalTjunctions( uEntity_t *e ) { } mapTri_t *newList = NULL; + mapTri_t **tail = &newList; for ( mapTri_t *tri = group->triList ; tri ; tri = tri->next ) { mapTri_t *fixed = FixTriangleAgainstHash( tri ); - newList = MergeTriLists( newList, fixed ); + *tail = fixed; + while ( *tail ) { + tail = &(*tail)->next; + } } FreeTriList( group->triList ); group->triList = newList; diff --git a/tools/tests/dmap_vertex_sharing.py b/tools/tests/dmap_vertex_sharing.py new file mode 100644 index 000000000..b6956219d --- /dev/null +++ b/tools/tests/dmap_vertex_sharing.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Run production dmap vertex sharing against allocator limits and a linear oracle.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--compiler") + parser.add_argument("--sanitize", action="store_true") + args = parser.parse_args() + compiler = args.compiler or os.environ.get("CXX") or shutil.which("clang++") or shutil.which("g++") or shutil.which("cl") + if not compiler: + raise SystemExit("A C++ compiler is required") + production = (ROOT / "src/tools/compilers/dmap/output.cpp").read_text(encoding="utf-8") + start = production.index("#define\tXYZ_EPSILON") + end = production.index("\n}", production.index("srfTriangles_t\t*ShareMapTriVerts(", start)) + 2 + fixture = (ROOT / "tools/tests/native/DmapVertexSharingTest.cpp").read_text(encoding="utf-8") + unit = fixture.replace("// @PRODUCTION@", production[start:end]) + work_root = ROOT / ".tmp" / "dmap-vertex-sharing" + work_root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=work_root) as directory: + work = Path(directory) + source = work / "test.cpp" + source.write_text(unit, encoding="utf-8") + executable = work / ("test.exe" if os.name == "nt" else "test") + if Path(compiler).name.lower() in ("cl", "cl.exe"): + command = [compiler, "/nologo", "/std:c++17", "/EHsc", "/O2", "/W4", "/WX", str(source), "/Fe" + str(executable)] + if args.sanitize: + parser.error("--sanitize requires clang++ or g++") + else: + command = [compiler, "-std=c++17", "-O2", "-Wall", "-Wextra", "-Werror", str(source), "-o", str(executable)] + if args.sanitize: + command[1:1] = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] + subprocess.run(command, cwd=work, check=True, timeout=120) + environment = os.environ.copy() + if args.sanitize and os.name == "nt": + runtime = subprocess.run([compiler, "-print-runtime-dir"], cwd=work, + check=True, capture_output=True, text=True).stdout.strip() + if not Path(runtime).is_dir(): + runtime = str(Path(runtime).parent / "windows") + environment["PATH"] = runtime + os.pathsep + environment.get("PATH", "") + subprocess.run([str(executable)], cwd=work, env=environment, check=True, timeout=60) + + +if __name__ == "__main__": + main() diff --git a/tools/tests/native/DmapVertexSharingTest.cpp b/tools/tests/native/DmapVertexSharingTest.cpp new file mode 100644 index 000000000..00bd25aae --- /dev/null +++ b/tools/tests/native/DmapVertexSharingTest.cpp @@ -0,0 +1,204 @@ +// Compile the production dmap vertex-sharing implementation with a counted, +// fallible geometry allocator. No renderer or proprietary map assets required. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef INT_MAX +#undef INT_MAX +#endif + +struct idMath { + static constexpr int INT_MAX = std::numeric_limits::max(); + static float Fabs( float value ) { return std::fabs( value ); } +}; +using idVec3 = std::array; +static float DotProduct( const idVec3 &a, const idVec3 &b ) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} +struct idDrawVert { + idVec3 xyz{}, normal{}; + std::array st{}; + float unused[8]{}; + void Clear() { *this = {}; } +}; +static_assert( sizeof( idDrawVert ) == 64, "match the production vertex allocation size" ); +struct mapTri_t { + mapTri_t *next = nullptr; + idDrawVert v[3]; +}; +struct srfTriangles_t { + idDrawVert *verts = nullptr; + int *indexes = nullptr; + int numVerts = 0, numIndexes = 0; +}; +struct TestCommon { + [[noreturn]] void Error( const char *format, ... ) { + char message[256]; + va_list args; + va_start( args, format ); + std::vsnprintf( message, sizeof( message ), format, args ); + va_end( args ); + throw std::runtime_error( message ); + } +} testCommon; +static TestCommon *common = &testCommon; +static size_t vertexLimit = 16 * 1024 * 1024, indexLimit = 16 * 1024 * 1024; +static size_t vertexRequest = 0; +static int liveSurfaces = 0, liveBuffers = 0, forcedCount = -1; +static srfTriangles_t *R_AllocStaticTriSurf() { + liveSurfaces++; + return new srfTriangles_t; +} +static void R_AllocStaticTriSurfVerts( srfTriangles_t *tri, int count ) { + vertexRequest = size_t( count ) * sizeof( idDrawVert ); + if ( count > 0 && vertexRequest <= vertexLimit ) { + tri->verts = new idDrawVert[count]; + liveBuffers++; + } +} +static void R_AllocStaticTriSurfIndexes( srfTriangles_t *tri, int count ) { + if ( count > 0 && size_t( count ) * sizeof( int ) <= indexLimit ) { + tri->indexes = new int[count]; + liveBuffers++; + } +} +static void R_FreeStaticTriSurf( srfTriangles_t *tri ) { + liveBuffers -= ( tri->verts != nullptr ) + ( tri->indexes != nullptr ); + delete[] tri->verts; + delete[] tri->indexes; + delete tri; + liveSurfaces--; +} +static int CountTriList( const mapTri_t *tri ) { + if ( forcedCount >= 0 ) { + return forcedCount; + } + int count = 0; + for ( ; tri; tri = tri->next ) { count++; } + return count; +} + +// @PRODUCTION@ + +static void Require( bool condition, const char *message ) { + if ( !condition ) { throw std::runtime_error( message ); } +} +static idDrawVert Vertex( float x, float y = 0, float z = 0 ) { + idDrawVert vert; + vert.xyz = { x, y, z }; + vert.normal = { 0, 0, 1 }; + return vert; +} +static std::vector Triangles( std::vector vertices ) { + while ( vertices.size() % 3 ) { vertices.push_back( vertices.back() ); } + std::vector tris( vertices.size() / 3 ); + for ( size_t i = 0; i < tris.size(); i++ ) { + tris[i].next = i + 1 < tris.size() ? &tris[i + 1] : nullptr; + for ( int j = 0; j < 3; j++ ) { tris[i].v[j] = vertices[i * 3 + j]; } + } + return tris; +} +static void CompareWithLinearSearch( const std::vector &input ) { + auto tris = Triangles( input ); + std::vector expectedVerts; + std::vector expectedIndexes; + for ( const auto &tri : tris ) { + for ( const auto &vert : tri.v ) { + size_t match = 0; + while ( match < expectedVerts.size() && !MatchVert( &expectedVerts[match], &vert ) ) { match++; } + if ( match == expectedVerts.size() ) { expectedVerts.push_back( vert ); } + expectedIndexes.push_back( int( match ) ); + } + } + srfTriangles_t *result = ShareMapTriVerts( tris.empty() ? nullptr : tris.data() ); + Require( result->numVerts == int( expectedVerts.size() ), "shared vertex count differs from linear search" ); + Require( result->numIndexes == int( expectedIndexes.size() ), "index count changed" ); + for ( int i = 0; i < result->numVerts; i++ ) { + Require( result->verts[i].xyz == expectedVerts[i].xyz && + result->verts[i].st == expectedVerts[i].st && + result->verts[i].normal == expectedVerts[i].normal, "first matching vertex changed" ); + } + for ( int i = 0; i < result->numIndexes; i++ ) { + Require( result->indexes[i] == expectedIndexes[i], "index order or epsilon matching changed" ); + } + R_FreeStaticTriSurf( result ); +} +static void ExpectFailure( mapTri_t *tri, const char *message ) { + bool failed = false; + try { + srfTriangles_t *result = ShareMapTriVerts( tri ); + R_FreeStaticTriSurf( result ); + } catch ( const std::runtime_error &error ) { + failed = std::string( error.what() ).find( message ) != std::string::npos; + } + Require( failed, "missing useful compile error" ); + Require( liveSurfaces == 0 && liveBuffers == 0, "failed compile leaked geometry" ); +} +int main() { + try { + CompareWithLinearSearch( {} ); + // Epsilon matches across positive/negative bucket boundaries on every + // axis, ambiguous matches, UV seams, hard normals and smoothed normals. + std::vector vertices; + for ( int axis = 0; axis < 3; axis++ ) { + for ( float base : { -10.0f, -1.0f, 0.0f, 1.0f, 10.0f, 65536.0f } ) { + for ( float offset : { -0.009f, 0.009f, 0.0f, -0.01f, 0.01f, 0.02f } ) { + auto vertex = Vertex( 3, 4, 5 ); + vertex.xyz[axis] = base + offset; + vertices.push_back( vertex ); + } + } + } + for ( float uv : { 0.0f, 0.0009f, 0.0011f, 1.0f } ) { + for ( idVec3 normal : { idVec3{ 0, 0, 0 }, idVec3{ 0, 0, 1 }, idVec3{ 0, 1, 0 }, idVec3{ 0, 0, -1 } } ) { + auto vertex = Vertex( 1 ); vertex.st[0] = uv; vertex.normal = normal; vertices.push_back( vertex ); + } + } + // Deterministic varied geometry and nearby copies exercise hash collisions + // without making the test depend on unordered_map's iteration order. + for ( int i = 0; i < 2000; i++ ) { + auto vertex = Vertex( float( i % 41 ) - 20, float( i % 37 ) - 18, float( i % 31 ) - 15 ); + vertices.push_back( vertex ); vertex.xyz[i % 3] += 0.005f; vertices.push_back( vertex ); + } + CompareWithLinearSearch( vertices ); + + // A small allocator cap reproduces #181 without a multi-gigabyte fixture: + // the old three-vertices-per-triangle reservation is rejected, but the + // actual three shared vertices and all indexes fit comfortably. + std::vector repeated( 14000 ); + for ( size_t i = 0; i < repeated.size(); i++ ) { + repeated[i].next = i + 1 < repeated.size() ? &repeated[i + 1] : nullptr; + repeated[i].v[0] = Vertex( 0 ); repeated[i].v[1] = Vertex( 1 ); repeated[i].v[2] = Vertex( 0, 1 ); + } + vertexLimit = indexLimit = 256 * 1024; + srfTriangles_t *result = ShareMapTriVerts( repeated.data() ); + Require( result->numVerts == 3 && result->numIndexes == 42000, "large repeated surface was truncated" ); + Require( vertexRequest == 3 * sizeof( idDrawVert ), "vertex allocation uses unshared triangle count" ); + for ( int i = 0; i < result->numIndexes; i++ ) { Require( result->indexes[i] == i % 3, "repeated surface index changed" ); } + R_FreeStaticTriSurf( result ); + indexLimit = 1; + ExpectFailure( repeated.data(), "indexes" ); + indexLimit = 256 * 1024; vertexLimit = 1; + ExpectFailure( repeated.data(), "shared vertices" ); + vertexLimit = 256 * 1024; + forcedCount = idMath::INT_MAX / 3 + 1; + ExpectFailure( repeated.data(), "too many triangles" ); + forcedCount = -1; + repeated[0].v[0].xyz[0] = std::numeric_limits::infinity(); + ExpectFailure( repeated.data(), "non-finite" ); + Require( liveSurfaces == 0 && liveBuffers == 0, "geometry leaked" ); + std::puts( "dmap_vertex_sharing: passed (legacy equivalence, bounded allocation, failure cleanup)" ); + return 0; + } catch ( const std::exception &error ) { + std::fprintf( stderr, "FAIL: %s\n", error.what() ); + return 1; + } +} diff --git a/tools/validation/openq4_validate.py b/tools/validation/openq4_validate.py index a2771f813..c9c040b30 100644 --- a/tools/validation/openq4_validate.py +++ b/tools/validation/openq4_validate.py @@ -436,6 +436,7 @@ def run_python_tests(args: argparse.Namespace, root: Path, env: dict[str, str]) root / "tools" / "tests" / "demo_playback.py", root / "tools" / "tests" / "docs_link_integrity.py", root / "tools" / "tests" / "dmap_render_geometry_lifecycle.py", + root / "tools" / "tests" / "dmap_vertex_sharing.py", root / "tools" / "tests" / "fatal_error_startup_safety.py", root / "tools" / "tests" / "android_build.py", root / "tools" / "tests" / "android_touch_input.py", From a7e7a0a08ea285d1c574457b458b81eff57c93dd Mon Sep 17 00:00:00 2001 From: "Prof. Muffi Nator PhD" Date: Wed, 14 Oct 2026 12:51:00 +0100 Subject: [PATCH 2/2] Allow Windows PR validation to finish the full test suite The Windows x64 job reached its 60-minute limit during existing UI regression tests, before build and dedicated-server validation. Match the 150-minute Push Verification allowance while retaining every validation step. --- .github/workflows/commit-validation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/commit-validation.yml b/.github/workflows/commit-validation.yml index 379c43725..c838fa608 100644 --- a/.github/workflows/commit-validation.yml +++ b/.github/workflows/commit-validation.yml @@ -835,7 +835,7 @@ jobs: name: Windows x64 Commit Validation runs-on: windows-2025 needs: script-smoke - timeout-minutes: 60 + timeout-minutes: 150 env: OPENQ4_GAMELIBS_REPO: ${{ github.workspace }}