From e15e0073b47f54c5faddb7bebc73e944408365b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 13 Jun 2026 10:06:16 +0000 Subject: [PATCH] tests: guard renderer frame wrapping Co-authored-by: Tim Fox --- CMakeLists.txt | 9 + tests/README.md | 2 +- .../test_renderer_frame_wrap_guards.py | 296 ++++++++++++++++++ 3 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 tests/scripts/test_renderer_frame_wrap_guards.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 38f9da8b00..63db412ebd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2516,6 +2516,15 @@ add_test(NAME test_vulkan_regression_source_guards set_tests_properties(test_vulkan_regression_source_guards PROPERTIES LABELS "unit;scripts;validation;renderer") +if(Python3_Interpreter_FOUND) + add_test(NAME test_renderer_frame_wrap_guards + COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_renderer_frame_wrap_guards.py ${CMAKE_SOURCE_DIR} + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + set_tests_properties(test_renderer_frame_wrap_guards PROPERTIES + LABELS "unit;scripts;validation;renderer" + REQUIRED_FILES "${CMAKE_SOURCE_DIR}/tests/scripts/test_renderer_frame_wrap_guards.py") +endif() + add_test(NAME test_gltf_opengl_regressions COMMAND ${CMAKE_SOURCE_DIR}/tests/scripts/test_gltf_opengl_regressions.sh WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) diff --git a/tests/README.md b/tests/README.md index f155d19aa4..0caa19ee4c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,4 +2,4 @@ - **Unit** (`BUILD_UNIT_TESTS=ON`): `unit_macros`, `unit_qmath`, `unit_surfaceflags`, `unit_qhelpers`, `unit_crc`, `unit_pathutil`, `unit_msg`, `unit_info`, `unit_cm_bounds`, `unit_parse`, `unit_endian` (CRC, COM path, `Info_*`, `COM_Parse*`, and endian tests use the same minimal `stub_qcommon_min.c` + `q_shared.c` + `q_math.c` link as `unit_qhelpers`; `unit_msg` links `msg.c` + `huffman_static.c` with `stub_qcommon_min.c`, `stub_msg_cvar.c`, and `-DDEDICATED`; `unit_cm_bounds` links `cm_bounds.c` + `q_math.c` only) — run `ctest -R unit_` or `./unit_*` from the build directory. - **Script regression tests**: `test_botlib_bounded_strings` (botlib string invariants). Run with `ctest -R test_botlib_bounded_strings` from the build directory. -- **Validation**: `smoke_test`, `renderer_regression_check`, `check_artifacts`, `test_run_vulkan_script`, `test_compile_engine_lto`, `test_demo_game_pk3`, `test_vk_vegetation_dispatch_order`, `test_vulkan_mesh_shader_opt_in`, `test_vulkan_runtime_regressions`, `test_botlib_chat_message_bounds`, `test_vulkan_renderer_guards`, `test_vulkan_regression_source_guards`, `test_gltf_opengl_regressions`, `test_botlib_bounded_strings` — see `scripts/`, `tests/scripts/`, and `docs/RENDERER_CONFIDENCE.md`. +- **Validation**: `smoke_test`, `renderer_regression_check`, `check_artifacts`, `test_run_vulkan_script`, `test_compile_engine_lto`, `test_demo_game_pk3`, `test_vk_vegetation_dispatch_order`, `test_vulkan_mesh_shader_opt_in`, `test_vulkan_runtime_regressions`, `test_botlib_chat_message_bounds`, `test_vulkan_renderer_guards`, `test_vulkan_regression_source_guards`, `test_renderer_frame_wrap_guards`, `test_gltf_opengl_regressions`, `test_botlib_bounded_strings` — see `scripts/`, `tests/scripts/`, and `docs/RENDERER_CONFIDENCE.md`. diff --git a/tests/scripts/test_renderer_frame_wrap_guards.py b/tests/scripts/test_renderer_frame_wrap_guards.py new file mode 100644 index 0000000000..0e78525787 --- /dev/null +++ b/tests/scripts/test_renderer_frame_wrap_guards.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Regression guards for renderer model frame wrapping. + +These source-level checks protect the crash fix that made MD3/MDR/IQM +front-end frame wrapping validate the model frame count before modulo and +before frame-indexed culling/drawing work. The renderer copies are duplicated +between src/renderers and src/platform/renderers, so keep both trees covered. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +def fail(message: str) -> None: + print(f"FAIL: {message}", file=sys.stderr) + raise SystemExit(1) + + +def strip_comments_and_strings(source: str) -> str: + """Return source with comments/strings replaced by spaces for brace scans.""" + + out: list[str] = [] + i = 0 + state = "code" + while i < len(source): + ch = source[i] + nxt = source[i + 1] if i + 1 < len(source) else "" + + if state == "code": + if ch == "/" and nxt == "/": + out.extend(" ") + i += 2 + state = "line_comment" + continue + if ch == "/" and nxt == "*": + out.extend(" ") + i += 2 + state = "block_comment" + continue + if ch == '"': + out.append(" ") + i += 1 + state = "string" + continue + if ch == "'": + out.append(" ") + i += 1 + state = "char" + continue + out.append(ch) + i += 1 + continue + + if state == "line_comment": + out.append("\n" if ch == "\n" else " ") + i += 1 + if ch == "\n": + state = "code" + continue + + if state == "block_comment": + if ch == "*" and nxt == "/": + out.extend(" ") + i += 2 + state = "code" + else: + out.append("\n" if ch == "\n" else " ") + i += 1 + continue + + if state == "string": + if ch == "\\" and nxt: + out.extend(" ") + i += 2 + else: + out.append("\n" if ch == "\n" else " ") + i += 1 + if ch == '"': + state = "code" + continue + + if state == "char": + if ch == "\\" and nxt: + out.extend(" ") + i += 2 + else: + out.append("\n" if ch == "\n" else " ") + i += 1 + if ch == "'": + state = "code" + continue + + return "".join(out) + + +def read_source(path: Path) -> str: + if not path.is_file(): + fail(f"missing file: {path}") + return path.read_text(encoding="utf-8") + + +def function_body(source: str, name: str, path: Path) -> str: + scrubbed = strip_comments_and_strings(source) + signature = re.compile( + rf"(?m)^[ \t]*(?:static[ \t]+)?[A-Za-z_][A-Za-z0-9_ \t\*]*[ \t]+" + rf"{re.escape(name)}[ \t]*\([^;{{]*\)[ \t]*\{{" + ) + match = signature.search(scrubbed) + if not match: + fail(f"{path}: could not find function {name}") + + open_brace = scrubbed.find("{", match.start(), match.end()) + depth = 0 + for idx in range(open_brace, len(scrubbed)): + if scrubbed[idx] == "{": + depth += 1 + elif scrubbed[idx] == "}": + depth -= 1 + if depth == 0: + return source[open_brace : idx + 1] + + fail(f"{path}: function {name} body is unterminated") + + +def compact(source: str) -> str: + return re.sub(r"\s+", " ", strip_comments_and_strings(source)).strip() + + +def require_regex(body: str, pattern: str, context: str) -> None: + if not re.search(pattern, compact(body)): + fail(f"{context}: expected pattern {pattern!r}") + + +def require_order(body: str, needles: list[str], context: str) -> None: + text = compact(body) + last = -1 + for needle in needles: + pos = text.find(needle) + if pos < 0: + fail(f"{context}: missing {needle!r}") + if pos <= last: + fail(f"{context}: {needle!r} appears out of order") + last = pos + + +def check_md3(path: Path) -> None: + body = function_body(read_source(path), "R_AddMD3Surfaces", path) + context = f"{path}: R_AddMD3Surfaces" + + require_regex( + body, + r"if \( !tr\.currentModel->md3\[0\] \|\| tr\.currentModel->md3\[0\]->numFrames < 1 \) \{ return; \}", + context, + ) + require_regex( + body, + r"if \( ent->e\.renderfx & RF_WRAP_FRAMES \) \{ const int nf = tr\.currentModel->md3\[0\]->numFrames; " + r"ent->e\.frame %= nf; ent->e\.oldframe %= nf; \}", + context, + ) + require_regex( + body, + r"ent->e\.frame = 0; ent->e\.oldframe = 0;", + context, + ) + require_order( + body, + [ + "numFrames < 1", + "RF_WRAP_FRAMES", + "ent->e.frame %= nf;", + "ent->e.oldframe %= nf;", + "ent->e.frame >= tr.currentModel->md3[0]->numFrames", + "ent->e.frame = 0;", + "ent->e.oldframe = 0;", + "lod = R_ComputeLOD( ent );", + "cull = R_CullModel( header, ent, bounds );", + ], + context, + ) + + +def check_mdr(path: Path) -> None: + body = function_body(read_source(path), "R_MDRAddAnimSurfaces", path) + context = f"{path}: R_MDRAddAnimSurfaces" + + require_regex( + body, + r"if \( !header \|\| header->numFrames < 1 \) \{ return; \}", + context, + ) + require_regex( + body, + r"if \( ent->e\.renderfx & RF_WRAP_FRAMES \) \{ const int nf = \(int\)header->numFrames; " + r"ent->e\.frame %= nf; ent->e\.oldframe %= nf; \}", + context, + ) + require_regex( + body, + r"ent->e\.frame = 0; ent->e\.oldframe = 0;", + context, + ) + require_order( + body, + [ + "header->numFrames < 1", + "RF_WRAP_FRAMES", + "ent->e.frame %= nf;", + "ent->e.oldframe %= nf;", + "ent->e.frame >= header->numFrames", + "ent->e.frame = 0;", + "ent->e.oldframe = 0;", + "cull = R_MDRCullModel (header, ent);", + ], + context, + ) + + +def check_iqm_frontend(path: Path) -> None: + body = function_body(read_source(path), "R_AddIQMSurfaces", path) + context = f"{path}: R_AddIQMSurfaces" + + require_regex( + body, + r"if \( ent->e\.renderfx & RF_WRAP_FRAMES && data->num_frames > 0 \) \{ " + r"ent->e\.frame %= data->num_frames; ent->e\.oldframe %= data->num_frames; \}", + context, + ) + require_regex( + body, + r"ent->e\.frame = 0; ent->e\.oldframe = 0;", + context, + ) + require_order( + body, + [ + "RF_WRAP_FRAMES && data->num_frames > 0", + "ent->e.frame %= data->num_frames;", + "ent->e.oldframe %= data->num_frames;", + "ent->e.frame >= data->num_frames", + "ent->e.frame = 0;", + "ent->e.oldframe = 0;", + "cull = R_CullIQM ( data, ent );", + ], + context, + ) + + +def check_iqm_backend(path: Path) -> None: + body = function_body(read_source(path), "RB_IQMSurfaceAnim", path) + context = f"{path}: RB_IQMSurfaceAnim" + + require_regex( + body, + r"int frame = data->num_frames \? backEnd\.currentEntity->e\.frame % data->num_frames : 0;", + context, + ) + require_regex( + body, + r"int oldframe = data->num_frames \? backEnd\.currentEntity->e\.oldframe % data->num_frames : 0;", + context, + ) + require_order( + body, + [ + "data->num_frames ? backEnd.currentEntity->e.frame % data->num_frames : 0;", + "data->num_frames ? backEnd.currentEntity->e.oldframe % data->num_frames : 0;", + "ComputePoseMats( data, frame, oldframe", + ], + context, + ) + + +def main() -> None: + root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parents[2] + + renderer_roots = [ + root / "src/renderers", + root / "src/platform/renderers", + ] + for renderer_root in renderer_roots: + for renderer in ("opengl", "vulkan"): + check_md3(renderer_root / renderer / "tr_mesh.c") + check_mdr(renderer_root / renderer / "tr_animation.c") + iqm_path = renderer_root / renderer / "tr_model_iqm.c" + check_iqm_frontend(iqm_path) + check_iqm_backend(iqm_path) + + print("PASS: test_renderer_frame_wrap_guards") + + +if __name__ == "__main__": + main()