diff --git a/CMakeLists.txt b/CMakeLists.txt index 38f9da8b00..c025b419e1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2469,6 +2469,13 @@ set_tests_properties(test_demo_game_pk3 PROPERTIES LABELS "scripts;validation;examples" REQUIRED_FILES "${CMAKE_SOURCE_DIR}/tests/scripts/test_demo_game_pk3.sh") +add_test(NAME test_native_module_regressions + COMMAND ${CMAKE_SOURCE_DIR}/tests/scripts/test_native_module_regressions.sh + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) +set_tests_properties(test_native_module_regressions PROPERTIES + LABELS "unit;scripts;validation" + REQUIRED_FILES "${CMAKE_SOURCE_DIR}/tests/scripts/test_native_module_regressions.sh") + add_test(NAME test_compile_engine_lto COMMAND ${CMAKE_SOURCE_DIR}/tests/scripts/test_compile_engine_lto.sh ${CMAKE_SOURCE_DIR}/scripts/compile_engine.sh WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) diff --git a/tests/README.md b/tests/README.md index f155d19aa4..e5ddab8ed4 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_native_module_regressions`, `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`. diff --git a/tests/scripts/test_native_module_regressions.sh b/tests/scripts/test_native_module_regressions.sh new file mode 100755 index 0000000000..f9b94b656d --- /dev/null +++ b/tests/scripts/test_native_module_regressions.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# Regression checks for pk3-backed native module loading and script fallback paths. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +command -v python3 >/dev/null 2>&1 || fail "python3 not in PATH" + +python3 - "$PROJECT_ROOT" <<'PY' +import re +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +files_c = root / "src/qcommon/files.c" +lua_debug_c = root / "src/qcommon/lua_debug.c" + + +def fail(message: str) -> None: + print(f"FAIL: {message}", file=sys.stderr) + sys.exit(1) + + +def strip_comments(text: str) -> str: + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.sub(r"//.*", "", text) + + +def find_function_body(text: str, name: str) -> str: + text_no_comments = strip_comments(text) + match = re.search(r"(?m)^[A-Za-z_][A-Za-z0-9_\s\*]*\b" + re.escape(name) + r"\s*\([^;]*\)\s*\{", text_no_comments) + if not match: + fail(f"{name}: function definition not found") + + start = text_no_comments.find("{", match.start()) + if start < 0: + fail(f"{name}: opening brace not found") + + depth = 0 + i = start + state = "code" + while i < len(text_no_comments): + ch = text_no_comments[i] + if state == "code": + if ch == '"': + state = "string" + elif ch == "'": + state = "char" + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return text_no_comments[start : i + 1] + elif state == "string": + if ch == "\\": + i += 2 + continue + if ch == '"': + state = "code" + elif state == "char": + if ch == "\\": + i += 2 + continue + if ch == "'": + state = "code" + i += 1 + + fail(f"{name}: closing brace not found") + + +def assert_contains(haystack: str, needle: str, context: str) -> None: + if needle not in haystack: + fail(f"{context}: expected {needle!r}") + + +def assert_regex(haystack: str, pattern: str, context: str) -> None: + if not re.search(pattern, haystack, flags=re.S): + fail(f"{context}: expected pattern {pattern!r}") + + +def assert_order(haystack: str, first: str, second: str, context: str) -> None: + first_pos = haystack.find(first) + second_pos = haystack.find(second) + if first_pos < 0 or second_pos < 0 or first_pos >= second_pos: + fail(f"{context}: expected {first!r} before {second!r}") + + +def assert_count(haystack: str, needle: str, expected: int, context: str) -> None: + actual = haystack.count(needle) + if actual != expected: + fail(f"{context}: expected {expected} occurrences of {needle!r}, found {actual}") + + +for path in (files_c, lua_debug_c): + if not path.is_file(): + fail(f"missing source file: {path}") + +files_text = files_c.read_text() +files_uncommented = strip_comments(files_text) + +assert_contains( + files_uncommented, + '#define FS_NATIVE_LIB_CACHE_PREFIX "vm/native_cache/"', + "native cache prefix", +) + +startup_body = find_function_body(files_text, "FS_Startup") +assert_regex( + startup_body, + r'com_nativeLibraryExtractPk3\s*=\s*Cvar_Get\s*\(\s*"com_nativeLibraryExtractPk3"\s*,\s*"1"\s*,\s*CVAR_ARCHIVE\s*\)', + "com_nativeLibraryExtractPk3 default", +) +assert_contains( + startup_body, + "Cvar_SetDescription( com_nativeLibraryExtractPk3,", + "com_nativeLibraryExtractPk3 description", +) +assert_contains( + startup_body, + 'Com_Printf( "com_nativeLibraryExtractPk3: extracting embedded native libs from pk3 is enabled.\\n" );', + "com_nativeLibraryExtractPk3 startup log", +) + +pk3_body = find_function_body(files_text, "FS_TryLoadLibraryFromPk3Cache") +assert_contains( + pk3_body, + "if ( !com_nativeLibraryExtractPk3 || !com_nativeLibraryExtractPk3->integer )", + "pk3 extraction cvar gate", +) +assert_contains(pk3_body, "if ( !name || !name[0] )", "pk3 extraction empty-name guard") +assert_contains(pk3_body, "slash = strrchr( name, '/' );", "pk3 cache basename slash detection") +assert_contains(pk3_body, "base = slash + 1;", "pk3 cache basename isolation") +assert_contains(pk3_body, 'strstr( base, ".dll" )', "Windows native extension filter") +assert_contains(pk3_body, 'Q_stricmp( base + strlen( base ) - 3, ".so" )', "POSIX native extension filter") +assert_contains( + pk3_body, + 'Com_sprintf( cacheQpath, sizeof( cacheQpath ), "%s%s", FS_NATIVE_LIB_CACHE_PREFIX, base );', + "basename-only native cache qpath", +) +assert_contains(pk3_body, "len = FS_ReadFile( name, &fileBuf );", "direct pk3 library read") +assert_contains(pk3_body, "if ( slash )", "explicit path must not try implicit pk3 fallbacks") +assert_contains(pk3_body, 'Com_sprintf( alt, sizeof( alt ), "vm/%s", name );', "vm/ pk3 fallback path") +assert_contains(pk3_body, 'Com_sprintf( alt, sizeof( alt ), "modules/%s", name );', "modules/ pk3 fallback path") +assert_order(pk3_body, "len = FS_ReadFile( name, &fileBuf );", 'Com_sprintf( alt, sizeof( alt ), "vm/%s", name );', "direct read before vm/ fallback") +assert_order(pk3_body, 'Com_sprintf( alt, sizeof( alt ), "vm/%s", name );', 'Com_sprintf( alt, sizeof( alt ), "modules/%s", name );', "vm/ fallback before modules/ fallback") +assert_count(pk3_body, "FS_ReadFile(", 3, "pk3 read attempts") + +assert_contains(pk3_body, "crcPak = crc32_buffer( (const byte *)fileBuf, (unsigned int)len );", "pk3 CRC calculation") +assert_contains( + pk3_body, + "Q_strncpyz( osCachePath, FS_BuildOSPath( fs_homepath->string, fs_gamedir, cacheQpath ), sizeof( osCachePath ) );", + "native cache writes under fs_homepath/fs_gamedir", +) +assert_contains(pk3_body, 'fp = Sys_FOpen( osCachePath, "rb" );', "native cache reuse open") +assert_contains(pk3_body, "readLen > 0 && readLen == len", "native cache length gate") +assert_contains(pk3_body, "crcDisk = crc32_buffer( (const byte *)diskBuf, (unsigned int)readLen );", "native cache CRC calculation") +assert_contains(pk3_body, "if ( crcDisk == crcPak )", "native cache CRC reuse gate") +assert_order(pk3_body, "if ( crcDisk == crcPak )", "h = FS_TryLoadLibraryPath( osCachePath );", "dlopen only after cache CRC validation") +assert_contains(pk3_body, "FS_CreatePath( osCachePath )", "native cache create path") +assert_contains(pk3_body, 'FILE *out = Sys_FOpen( osCachePath, "wb" );', "direct native cache write open") +assert_contains(pk3_body, "fwrite( fileBuf, 1, (size_t)len, out )", "direct native cache write") +assert_contains(pk3_body, "FS_LoadLibrary: using pk3 native cache", "cache reuse diagnostic") +assert_contains(pk3_body, "FS_LoadLibrary: failed to write pk3 native cache", "cache write failure diagnostic") +assert_contains(pk3_body, "FS_LoadLibrary: extracted pk3 native lib to", "cache extraction diagnostic") + +load_body = find_function_body(files_text, "FS_LoadLibrary") +assert_contains(load_body, "libHandle = FS_TryLoadLibraryFromPk3Cache( name );", "FS_LoadLibrary pk3 cache probe") +assert_order(load_body, "libHandle = FS_TryLoadLibraryFromPk3Cache( name );", "while ( !libHandle && sp )", "pk3 cache before loose filesystem search") + +lua_text = lua_debug_c.read_text() +lua_body = find_function_body(lua_text, "LuaDebug_LoadScript") +assert_contains(lua_body, "if ( luaL_loadfile( s_luaState, scriptPath ) != LUA_OK )", "Lua file load primary path") +assert_contains(lua_body, "lua_pop( s_luaState, 1 );", "Lua clears failed loadfile error before pk3 fallback") +assert_contains(lua_body, "blen = FS_ReadFile( scriptPath, &buf );", "Lua pk3 script read fallback") +assert_contains(lua_body, "if ( luaL_loadbuffer( s_luaState, (const char *)buf, (size_t)blen, scriptPath ) != LUA_OK )", "Lua pk3 script loadbuffer fallback") +assert_contains(lua_body, "FS_FreeFile( buf );", "Lua frees pk3 fallback buffer") +assert_order(lua_body, "luaL_loadfile", "FS_ReadFile", "Lua loadfile failure before filesystem fallback") +assert_order(lua_body, "FS_ReadFile", "luaL_loadbuffer", "Lua filesystem fallback before loadbuffer") + +print("PASS: test_native_module_regressions") +PY