diff --git a/.github/workflows/commit-validation.yml b/.github/workflows/commit-validation.yml index 1c5e68fd..2c8d9e77 100644 --- a/.github/workflows/commit-validation.yml +++ b/.github/workflows/commit-validation.yml @@ -86,6 +86,7 @@ jobs: tools/tests/linux_gui_presentation_defaults.py \ tools/tests/linux_highdpi_mouse.py \ tools/tests/linux_appimage_packaging.py \ + tools/tests/lang_table_encoding.py \ tools/tests/linux_metadata_fuzz.py \ tools/tests/linux_packaging_guidance.py \ tools/tests/linux_sanitizer_ci.py \ @@ -141,6 +142,7 @@ jobs: tools/tests/renderer_vulkan_shadow_compatibility.py \ tools/tests/renderer_vulkan_world_interaction_compatibility.py \ tools/tests/savegame_corruption_contract.py \ + tools/tests/savegame_pointer_width_safety.py \ tools/tests/sdl3_input_parity.py \ tools/tests/sdl3_multidisplay_windowing.py \ tools/tests/settings_menu_coverage.py \ @@ -172,6 +174,7 @@ jobs: python tools/tests/linux_gui_presentation_defaults.py python tools/tests/linux_highdpi_mouse.py python tools/tests/linux_appimage_packaging.py + python tools/tests/lang_table_encoding.py python tools/tests/linux_metadata_fuzz.py python tools/tests/linux_packaging_guidance.py python tools/tests/linux_pk4_legacy_tools.py @@ -227,6 +230,7 @@ jobs: python tools/tests/renderer_vulkan_shadow_compatibility.py python tools/tests/renderer_vulkan_world_interaction_compatibility.py python tools/tests/savegame_corruption_contract.py + python tools/tests/savegame_pointer_width_safety.py python tools/tests/sdl3_input_parity.py python tools/tests/sdl3_multidisplay_windowing.py python tools/tests/settings_menu_coverage.py diff --git a/.github/workflows/push-verification.yml b/.github/workflows/push-verification.yml index d5cf5bdf..7ee5bdac 100644 --- a/.github/workflows/push-verification.yml +++ b/.github/workflows/push-verification.yml @@ -86,6 +86,7 @@ jobs: tools/tests/linux_gui_presentation_defaults.py \ tools/tests/linux_highdpi_mouse.py \ tools/tests/linux_appimage_packaging.py \ + tools/tests/lang_table_encoding.py \ tools/tests/linux_metadata_fuzz.py \ tools/tests/linux_packaging_guidance.py \ tools/tests/linux_sanitizer_ci.py \ @@ -141,6 +142,7 @@ jobs: tools/tests/renderer_vulkan_shadow_compatibility.py \ tools/tests/renderer_vulkan_world_interaction_compatibility.py \ tools/tests/savegame_corruption_contract.py \ + tools/tests/savegame_pointer_width_safety.py \ tools/tests/sdl3_input_parity.py \ tools/tests/sdl3_multidisplay_windowing.py \ tools/tests/settings_menu_coverage.py \ @@ -172,6 +174,7 @@ jobs: python tools/tests/linux_gui_presentation_defaults.py python tools/tests/linux_highdpi_mouse.py python tools/tests/linux_appimage_packaging.py + python tools/tests/lang_table_encoding.py python tools/tests/linux_metadata_fuzz.py python tools/tests/linux_packaging_guidance.py python tools/tests/linux_pk4_legacy_tools.py @@ -227,6 +230,7 @@ jobs: python tools/tests/renderer_vulkan_shadow_compatibility.py python tools/tests/renderer_vulkan_world_interaction_compatibility.py python tools/tests/savegame_corruption_contract.py + python tools/tests/savegame_pointer_width_safety.py python tools/tests/sdl3_input_parity.py python tools/tests/sdl3_multidisplay_windowing.py python tools/tests/settings_menu_coverage.py diff --git a/docs/dev/release-completion.md b/docs/dev/release-completion.md index 6a68d908..f82d996a 100644 --- a/docs/dev/release-completion.md +++ b/docs/dev/release-completion.md @@ -872,6 +872,13 @@ is `docs/dev/macos-moltenvk-decision.md`. - [x] SDL3 desktop focus transitions now release fullscreen relative-mouse capture, clear keyboard/controller/touch state exactly once, reject queued user input while the game window is inactive, and apply `s_muteUnfocused` consistently on Linux and experimental macOS as well as Windows. - [x] SDL3 Linux and experimental macOS system-console input now decodes committed and pasted UTF-8 before narrowing to the stock single-byte font range, and asynchronous OpenAL device hotplug notifications use an atomic handoff instead of racing the audio update thread. - [x] Vulkan now renders both authored MD5 meshes and converted or prebuilt MD5R assets consistently in single-player and multiplayer. MD5R models materialize the classic geometry contract used by every Vulkan consumer—including ambient and interaction lighting, stencil and mapped-shadow submission, fog/blend lights, sky, decals, overlays, and collision—while retaining animated tangents and legacy MD5 skin-scale behavior. Native ASCII `.md5r` / `.MD5RProc` and compiled `.md5rc` / `.MD5RProcc` export-and-reload paths are covered, including retail packed vertex formats and rebuild-on-load payloads; no replacement game content is required. +- [x] Accented characters now display correctly in Spanish, French, and Italian menus and subtitles. The shipped string tables are UTF-8 while the stock Quake 4 fonts are a fixed 256-glyph set, so text such as `MENÚS` previously rendered as `MENA S`; openQ4 now converts a UTF-8 string table to the font's character set when it loads one, and substitutes readable ASCII for typographic characters the stock fonts do not draw. English apostrophes in subtitles are also fixed, community translations authored in UTF-8 now work, and retail localized packs are untouched. +- [x] Startup failures no longer exit silently. A second error raised while the engine was already shutting down used to end the process with a success code and no message, which left users with a log that simply stopped mid-shutdown and no way to find out what went wrong. Both messages are now reported and the process exits with a failure code. Content checks that abort startup additionally list the resolved `fs_basepath`/`fs_savepath`/`fs_cdpath` and every PK4 actually found in `q4base` and `baseoq4`, which identifies the most common Linux cause: a case-mismatched folder or PK4 name copied from a Windows install. +- [x] Custom DDS texture packs, including BC7, are now selected reliably. Texture-compression capabilities were only ever published to one of the binaries that need them, so depending on the active renderer every DDS replacement could be rejected without explanation; user replacements are now also probed ahead of the stock precompressed data instead of behind it, and the highest performance preset no longer switches replacements off and discards a player's setting. `image_showPrecompressedTextures 1` now lists every path tried and why each was rejected, so a pack author can see exactly what a file must be named. BC7 normal maps use the ordinary XYZ-in-RGB layout — packs previously encoded with `bc7enc -r2a` for openQ4 need re-encoding without it. +- [x] Missing sound samples are no longer silent failures: the engine reports the sample it could not find along with every path it tried. Voice-over now falls back to English when a language ships localized text but no localized voice track, matching the original game, and the `s_showVoices` diagnostic prints its voice list again instead of producing no output at all. +- [x] Navigation data problems are now reported instead of being developer-only, and a scripted character that cannot reach its destination can no longer stall a map script indefinitely — the move is abandoned after `ai_scriptedMoveTimeout` seconds without progress (30 by default, 0 to disable) with a log line naming the character, its target, and why it was stuck. Flying-enemy movement and tether goals are also corrected: area ceiling heights were never computed, so every navigation area reported a ceiling of zero. +- [x] Loading a savegame without first quitting to the menu now starts from the same clean state as loading after a restart; looping sounds and render objects from the previous session are released before the new one is built. +- [x] Release builds are more robust on 64-bit ARM: the binary declaration reader now fails cleanly on an unexpected value instead of silently losing sync with the rest of the stream when assertions are compiled out, an unexpected BC7 texture degrades to uncompressed instead of ending the session mid-load, and floating-point contraction is pinned off so ARM64 and x86-64 evaluate physics and navigation math identically. ## macOS Evidence Gate diff --git a/docs/user/texture-replacements.md b/docs/user/texture-replacements.md index 38f5a8da..5d611bc1 100644 --- a/docs/user/texture-replacements.md +++ b/docs/user/texture-replacements.md @@ -2,20 +2,14 @@ openQ4 can use DDS texture-pack replacements without changing the original Quake 4 materials. This includes the stock game's `progimg/` image-program results, dhewm3-style `dds/` packs, and same-folder replacements used by some Quake 4 packs. -## Enable replacements - -Set `image_usePrecompressedTextures` before loading a map: - -- `0` disables automatic DDS replacements. -- `1` uses any supported DDS replacement. DXT1, DXT5, RXGB, and BC7 data can upload directly when the renderer supports the format. This mode also reuses the stock `progimg/` files shipped in the Quake 4 PK4s. -- `2` uses only BC7/BPTC replacements and falls back to the original image when a BC7 replacement is unavailable or invalid. - -BC7 requires a renderer with BPTC support. Run `gfxInfo` and look for the BC7/BPTC capability line if a pack is not being selected. -DXT/RXGB files fall back to CPU decoding when direct texture-compression upload is unavailable. BC7 files require BPTC and otherwise fall back to the original source image. - ## Where files belong -The preferred layout is the dhewm3-compatible `dds/` shadow tree inside the active game directory or a PK4: +Put your `.dds` files under a `dds/` folder inside `baseoq4` (loose, or at the root of a PK4 you drop in `baseoq4`). Mirror the original asset path underneath it and change the extension: + +```text +Original asset: textures/stone/wall01.tga +Replacement: baseoq4/dds/textures/stone/wall01.dds +``` ```text baseoq4/ @@ -25,18 +19,53 @@ baseoq4/ wall01.dds ``` -For an image named `textures/stone/wall01`, openQ4 first checks `dds/textures/stone/wall01.dds`. A loose file therefore belongs at `baseoq4/dds/textures/stone/wall01.dds`; a PK4 should contain the same `dds/textures/...` path at its root. +A `.dds` placed next to the original asset path instead — `baseoq4/textures/stone/wall01.dds`, the layout some Quake 4 packs use — also works. The `dds/` tree is preferred and wins if both exist. + +`q4base/dds/...` is on the search path too, but keep your files in `baseoq4` so a Steam file verification does not delete them. + +### Bump maps and other image programs -openQ4 uses dhewm3's complete image-program conversion too. For example: +Quake 4 materials rarely name a bump texture directly. They usually write an image *program*: ```text -heightmap(models/monsters/foo_local, 4) - -> dds/heightmap/models/monsters/foo_local 4.dds +bumpmap addnormals( textures/stone/wall01_local, heightmap( textures/stone/wall01_h, 4 ) ) ``` -Texture-pack tools should preserve that mapping for composed programs such as `heightmap`, `addnormals`, `add`, and `scale`. For simple image references, openQ4 also checks the extension-normalized shadow path and then a `.dds` beside the original `.tga` or `.jpg`. The canonical `dds/` path wins when more than one replacement exists. +You can replace either end of that: + +- **Replace an input.** Shipping `dds/textures/stone/wall01_local.dds` replaces the source texture and openQ4 recomputes the program result on load. This is the simplest option and is what a Quake-4-shaped pack normally does. +- **Replace the finished result.** Name the file after the whole program using dhewm3's mangling: + + ```text + heightmap(models/monsters/foo_local, 4) + -> dds/heightmap/models/monsters/foo_local 4.dds + ``` + + Texture-pack tools should preserve that mapping for composed programs such as `heightmap`, `addnormals`, `add`, and `scale`. + +If you are unsure what a given material image is called, turn on `image_showPrecompressedTextures` (below) — openQ4 prints every path it looked for. -The original Quake 4 archives use a different `progimg/` naming scheme. openQ4 recognizes those paths automatically, including nested image programs and plain `gfx/env/` cube sides; users do not need to extract, rename, or regenerate the retail files. Custom texture packs should continue to prefer the portable `dds/` layout above. +### Search order + +For each image, openQ4 probes in this order and takes the first usable file: + +1. `dds/.dds` — the dhewm3 naming scheme +2. `dds/.dds` — for plain image references +3. `.dds` — beside the original `.tga`/`.jpg`, the Quake 4 style +4. `progimg/...` — the stock precomputed results shipped in the retail PK4s + +The retail `progimg/` tree is probed **last**, so a user replacement always wins. openQ4 recognizes those retail paths automatically, including nested image programs and plain `gfx/env/` cube sides; you do not need to extract, rename, or regenerate the retail files. + +## Enable replacements + +Set `image_usePrecompressedTextures` before loading a map: + +- `0` disables automatic DDS replacements entirely, including your own packs. +- `1` (default) uses any supported DDS replacement — DXT1, DXT5, RXGB and BC7 — and also reuses the stock `progimg/` files shipped in the Quake 4 PK4s. **This is the right setting for a BC7 pack.** +- `2` uses only BC7/BPTC replacements and ignores DXT/RXGB data, including the stock `progimg/` tree. Use this only if you specifically want everything else to come from the uncompressed sources. + +BC7 requires a renderer with BPTC support. Run `gfxInfo` and look for the BC7/BPTC capability line if a pack is not being selected. +DXT/RXGB files fall back to CPU decoding when direct texture-compression upload is unavailable. A BC7 file on a renderer without BPTC support is uploaded uncompressed rather than failing the load. ## BC7 file requirements @@ -47,18 +76,30 @@ Supported BC7 files are single 2D textures using either: Texture arrays, cube maps, volume textures, invalid dimensions, and truncated declared mip chains are rejected safely. Authored mip levels are uploaded directly. Texture downsizing skips leading authored levels, and images using an unmipped sampler upload only their base level. -Doom 3-style bump maps store the red normal channel in alpha (RXGB/XGBR layout). openQ4 recognizes the retail `RXGB` FourCC and preserves that normal-map swizzle for direct uploads and decoded fallbacks. BC7 normal maps intended for this path must use the same layout; Daniel Gibson's customized `bc7enc` supports it with `-r2a`. +### Normal maps + +Write BC7 normal maps the ordinary way: X, Y, Z in RGB, alpha unused. That is what `texconv`, NVTT, Compressonator and `bc7enc` all produce by default, and it is what openQ4 expects. + +> **Changed:** earlier openQ4 builds incorrectly forced BC7 bump maps into the DXT5nm/RXGB channel layout, which required encoding with `bc7enc -r2a`. A pack authored that way will now render with flat lighting and must be re-encoded without `-r2a`. Only DXT5 and true `RXGB` files use the alpha-swizzled layout; that convention exists because DXT5 gives alpha its own higher-precision interpolator, and BC7 has no such limitation. ## Troubleshooting Use these commands when checking a pack: ```text -image_usePrecompressedTextures 2 +image_usePrecompressedTextures 1 image_showPrecompressedTextures 1 reloadImages all ``` -The log then prints the exact DDS path selected for each replacement. `listImages` identifies loaded BC7 images, while `imageDDSSelfTest` validates the built-in retail/dhewm3 naming conversions plus RXGB and BC7 header checks. +The log then prints the DDS path selected for each replacement, and — for every image where nothing was selected — the full list of candidate paths it tried with the reason each one was rejected. That list is the fastest way to find out what a file should be named: + +```text +no DDS replacement for 'textures/stone/wall01': + dds/textures/stone/wall01.dds [dds/ (dhewm3 naming)] no such file + textures/stone/wall01.dds [alongside the original asset (Quake 4 style)] no such file +``` + +`listImages` identifies loaded BC7 images, while `imageDDSSelfTest` validates the built-in retail/dhewm3 naming conversions plus RXGB and BC7 header checks. -During development, an automatic DDS replacement older than its original source is ignored so stale generated data cannot hide a newer texture edit. The stock `progimg/` archives receive a four-second timestamp-granularity allowance; a genuinely newer loose source still wins. Rebuild or retimestamp a custom DDS after changing its source image. +During development, an automatic DDS replacement older than its original source is ignored so stale generated data cannot hide a newer texture edit. The stock `progimg/` archives receive a four-second timestamp-granularity allowance; a genuinely newer loose source still wins. Rebuild or retimestamp a custom DDS after changing its source image. Note that files inside a PK4 report no timestamp at all, so a packed replacement is never treated as stale — if you are authoring loose files alongside extracted `.tga` sources, the extracted sources will look newer and your `.dds` will be skipped. diff --git a/meson.build b/meson.build index bdd6bae6..0614268e 100644 --- a/meson.build +++ b/meson.build @@ -1015,6 +1015,18 @@ else shared_c_args += aliasing_arg endif endforeach + # GCC/Clang default to -ffp-contract=fast, which fuses a*b+c into an FMA + # wherever the target has one. AArch64 always does; the x86-64 SSE2 baseline + # openQ4 is validated against does not. That makes identical source produce + # different float results per architecture in collision, AAS and physics + # code, which is exactly the class of divergence behind arm64-only map and + # savegame failures. MSVC already defaults to precise contraction. + foreach fp_arg : ['-ffp-contract=off'] + if cpp.has_argument(fp_arg) and cc.has_argument(fp_arg) + shared_cpp_args += fp_arg + shared_c_args += fp_arg + endif + endforeach endif if host_system == 'darwin' diff --git a/src/aas/AASFile.cpp b/src/aas/AASFile.cpp index 8dee8f34..18cc7f6c 100644 --- a/src/aas/AASFile.cpp +++ b/src/aas/AASFile.cpp @@ -1139,13 +1139,66 @@ bool idAASFileLocal::ParseClusters( idLexer &src ) { idAASFileLocal::FinishAreas ================ */ +// upward probe length used to find the ceiling above a floor area +static const float AAS_FLOOR_CEILING_TRACE_HEIGHT = 500.0f; +// far-above probe that PushPointIntoAreaNum() clamps down to a volume's top +static const float AAS_NONFLOOR_CEILING_PROBE_Z = 131072.0f; +static const int AAS_CEILING_TRACE_MAX_AREAS = 10; + void idAASFileLocal::FinishAreas( void ) { int i; + // A file with fewer than two areas carries no usable navigation volume; + // deriving from it would trace against a degenerate tree. Zero the derived + // fields and stop, matching the original engine. + if ( areas.Num() < 2 ) { + for ( i = 0; i < areas.Num(); i++ ) { + areas[i].center.Zero(); + areas[i].bounds.Clear(); + areas[i].ceiling = 0.0f; + } + return; + } + for ( i = 0; i < areas.Num(); i++ ) { areas[i].center = AreaReachableGoal( i ); areas[i].bounds = AreaBounds( i ); } + + // aasArea_t::ceiling is not an editor-only leftover: the game surfaces it + // through idAASLocal::AreaCeiling(), which clamps flying-AI movement and + // bounds tether goal searches. It is not stored in the file, so failing to + // derive it here left every area reporting a ceiling of 0 and broke both. + for ( i = 0; i < areas.Num(); i++ ) { + if ( areas[i].flags & AREA_FLOOR ) { + aasTrace_t trace; + int traceAreas[AAS_CEILING_TRACE_MAX_AREAS]; + idVec3 tracePoints[AAS_CEILING_TRACE_MAX_AREAS]; + idVec3 end; + + trace.areas = traceAreas; + trace.points = tracePoints; + trace.maxAreas = AAS_CEILING_TRACE_MAX_AREAS; + + end = areas[i].center; + end.z += AAS_FLOOR_CEILING_TRACE_HEIGHT; + + Trace( trace, areas[i].center, end ); + areas[i].ceiling = trace.endpos.z; + + // stop at the first point where the probe left this area + for ( int j = 0; j < trace.numAreas; j++ ) { + if ( trace.areas[j] != i ) { + areas[i].ceiling = trace.points[j].z; + break; + } + } + } else { + idVec3 point( 0.0f, 0.0f, AAS_NONFLOOR_CEILING_PROBE_Z ); + PushPointIntoAreaNum( i, point ); + areas[i].ceiling = point.z; + } + } } /* diff --git a/src/framework/Common.cpp b/src/framework/Common.cpp index 33e84d67..1524413f 100644 --- a/src/framework/Common.cpp +++ b/src/framework/Common.cpp @@ -31,6 +31,7 @@ If you have questions concerning this license or the applicable additional terms //#include "../renderer/Image.h" #include "../bse/BSE_API.h" +#include "../imagetools/ImageTools.h" #include "../renderer/RendererModule.h" #include "RenderDoc.h" @@ -1254,7 +1255,24 @@ void idCommonLocal::Error( const char *fmt, ... ) { // full screen rendering window covering the // error dialog if ( com_errorEntered == ERP_FATAL ) { - Sys_Quit(); + // A second error raised while unwinding a fatal error used to call + // Sys_Quit() here, which exits with EXIT_SUCCESS and prints nothing: + // the original fatal message never reached the log, the shell saw a + // clean exit, and the user was left with a truncated log ending at + // whichever teardown step tripped over uninitialized state. + // Report both messages and exit through the fatal path instead. + char secondary[ sizeof( errorMessage ) ]; + va_list argptr2; + + va_start( argptr2, fmt ); + idStr::vsnPrintf( secondary, sizeof( secondary ), fmt, argptr2 ); + va_end( argptr2 ); + secondary[ sizeof( secondary ) - 1 ] = '\0'; + + Sys_Printf( "FATAL: %s\n", errorMessage ); + Sys_Printf( "FATAL: secondary error while shutting down: %s\n", secondary ); + + Sys_Error( "%s (secondary error while shutting down: %s)", errorMessage, secondary ); } code = ERP_FATAL; } @@ -1342,8 +1360,10 @@ void idCommonLocal::FatalError( const char *fmt, ... ) { Sys_Printf( "%s\n", errorMessage ); - // write the console to a log file? - Sys_Quit(); + // exit through the fatal path rather than Sys_Quit(): Sys_Quit() reports + // EXIT_SUCCESS and writes no durable record, so a recursed fatal used to + // look like a clean shutdown to the shell and to support tooling + Sys_Error( "%s", errorMessage ); } com_errorEntered = ERP_FATAL; @@ -2275,7 +2295,9 @@ void Com_ExecMachineSpec_f( const idCmdArgs &args ) { cvarSystem->SetCVarInteger( "image_downSizeBump", 0, CVAR_ARCHIVE ); cvarSystem->SetCVarInteger( "image_downSizeSpecularLimit", 64, CVAR_ARCHIVE ); cvarSystem->SetCVarInteger( "image_downSizeBumpLimit", 256, CVAR_ARCHIVE ); - cvarSystem->SetCVarInteger( "image_usePrecompressedTextures", 0, CVAR_ARCHIVE ); + // keep DDS replacements enabled at the top spec; in openQ4 this cvar also + // gates user-supplied replacement packs, not just the stock progimg tree + cvarSystem->SetCVarInteger( "image_usePrecompressedTextures", 1, CVAR_ARCHIVE ); cvarSystem->SetCVarInteger( "image_downSize", 0, CVAR_ARCHIVE ); cvarSystem->SetCVarString( "image_filter", "GL_LINEAR_MIPMAP_LINEAR", CVAR_ARCHIVE ); cvarSystem->SetCVarInteger( "image_anisotropy", 16, CVAR_ARCHIVE ); @@ -2475,9 +2497,14 @@ static const openQ4PerformancePreset_t OPENQ4_PERFORMANCE_PRESETS[] = { 8, 0, 0, 0, 1, 0, 0, 1024, 0, 0, 0, 0, 0, 0, 1, 32, 4, 6, 1, 48 }, + // image_usePrecompressedTextures stays at 1 here even though retail's top + // machine spec used 0. In openQ4 that cvar also gates user-supplied DDS + // replacement packs, so 0 silently discarded a player's high-resolution BC7 + // art the moment they touched the settings menu - the reverse of what the + // highest preset should do. { "ultra", 3, "high-end", 100, 8, 1, 240, - 16, 0, 0, 0, 0, 0, + 16, 0, 0, 0, 1, 0, 0, 2048, 0, 0, 0, 0, 0, 0, 1, 32, 4, 6, 1, 48 } }; @@ -3172,7 +3199,7 @@ static bool Common_ValidatePerformancePresetIntent( const openQ4PerformancePrese passed &= Common_PerformancePresetCheckExpectedInt( preset, "r_screenFraction", preset.screenFraction, 100 ); passed &= Common_PerformancePresetCheckIntAtLeast( preset, "r_multiSamples", preset.multiSamples, 8 ); passed &= Common_PerformancePresetCheckExpectedInt( preset, "image_anisotropy", preset.anisotropy, 16 ); - passed &= Common_PerformancePresetCheckExpectedInt( preset, "image_usePrecompressedTextures", preset.usePrecompressedTextures, 0 ); + passed &= Common_PerformancePresetCheckExpectedInt( preset, "image_usePrecompressedTextures", preset.usePrecompressedTextures, 1 ); passed &= Common_PerformancePresetCheckIntAtLeast( preset, "r_rendererUploadMegs", preset.uploadMegs, 32 ); passed &= Common_PerformancePresetCheckRestoredAudio( preset ); } else { @@ -4723,6 +4750,21 @@ void idCommonLocal::InitRenderSystem( void ) { } renderSystem->InitOpenGL(); + + // imagetools is a static library, so the executable and the renderer module + // each hold a private copy of the texture-compression capability block that + // gates precompressed-DDS selection. The renderer publishes into its own + // copy during context bring-up; mirror it into ours or engine-side image + // loads (material types, the session, GUI widgets) silently reject every DDS + // replacement and CPU-decode compressed data the GPU could take directly. + if ( renderSystem->IsOpenGLRunning() ) { + const glconfig_t &rendererConfig = renderSystem->GetGLConfig(); + imageToolsCompressionCaps_t compressionCaps; + compressionCaps.textureCompressionAvailable = rendererConfig.textureCompressionAvailable; + compressionCaps.bptcTextureCompressionAvailable = rendererConfig.bptcTextureCompressionAvailable; + ImageTools_SetCompressionCaps( compressionCaps ); + } + PrintLoadingMessage( common->GetLanguageDict()->GetString( "#str_104343" ) ); } diff --git a/src/framework/FileSystem.cpp b/src/framework/FileSystem.cpp index 977fd299..e66bdd89 100644 --- a/src/framework/FileSystem.cpp +++ b/src/framework/FileSystem.cpp @@ -1399,6 +1399,7 @@ class idFileSystemLocal : public idFileSystem { bool IsOpenQ4PurePack( const pack_t *pak ) const; pack_t * FindGamePackByName( const char *name, const char *gameDir ) const; pack_t * FindBaseGamePackByName( const char *name ) const; + void PrintContentSearchDiagnostics( void ); bool FindMisplacedOfficialPaks( idStr &errors ) const; bool ValidateOpenQ4Paks( idStr &errors ) const; bool ValidateRequiredOfficialPaks( idStr &errors ) const; @@ -4823,6 +4824,73 @@ bool idFileSystemLocal::ValidateRequiredOfficialPaks( idStr &errors ) const { return ( errors.Length() == 0 ); } +/* +=============== +idFileSystemLocal::PrintContentSearchDiagnostics + +The startup content checks abort the engine, so the log must say where the +engine actually looked and what it found there. Without this the user only +learns that something is "missing or modified"; the dominant Linux cause is a +case-mismatched 'q4base'/'pakNNN.pk4' tree copied from a Windows install, which +no amount of checksum text can reveal. +=============== +*/ +void idFileSystemLocal::PrintContentSearchDiagnostics( void ) { + const char *roots[ 3 ]; + const char *rootNames[ 3 ]; + const char *dirs[ 2 ]; + idStrList found; + idStr osPath; + int i, j, k; + + roots[ 0 ] = fs_basepath.GetString(); rootNames[ 0 ] = "fs_basepath"; + roots[ 1 ] = fs_savepath.GetString(); rootNames[ 1 ] = "fs_savepath"; + roots[ 2 ] = fs_cdpath.GetString(); rootNames[ 2 ] = "fs_cdpath"; + + dirs[ 0 ] = BASE_GAMEDIR; + dirs[ 1 ] = OPENQ4_GAMEDIR; + + common->Printf( "----- content search diagnostics -----\n" ); + common->Printf( "fs_game = '%s'\n", fs_game.GetString() ); + common->Printf( "fs_game_base = '%s'\n", fs_game_base.GetString() ); + + for ( i = 0; i < 3; i++ ) { + if ( roots[ i ] == NULL || roots[ i ][ 0 ] == '\0' ) { + common->Printf( "%s = \n", rootNames[ i ] ); + continue; + } + common->Printf( "%s = '%s'\n", rootNames[ i ], roots[ i ] ); + + for ( j = 0; j < 2; j++ ) { + // skip a duplicate root rather than listing the same tree twice + for ( k = 0; k < i; k++ ) { + if ( roots[ k ] != NULL && roots[ k ][ 0 ] != '\0' && idStr::Icmp( roots[ k ], roots[ i ] ) == 0 ) { + break; + } + } + if ( k < i ) { + continue; + } + + osPath = BuildOSPath( roots[ i ], dirs[ j ], "" ); + osPath.StripTrailing( '/' ); + osPath.StripTrailing( '\\' ); + + found.Clear(); + if ( ListOSFiles( osPath, ".pk4", found ) == -1 ) { + common->Printf( " %s: directory not found (note: paths are case sensitive on Linux)\n", osPath.c_str() ); + continue; + } + + common->Printf( " %s: %d pk4 file(s)\n", osPath.c_str(), found.Num() ); + for ( k = 0; k < found.Num(); k++ ) { + common->Printf( " %s\n", found[ k ].c_str() ); + } + } + } + common->Printf( "-------------------------------------\n" ); +} + /* =============== idFileSystemLocal::FollowDependencies @@ -4889,6 +4957,7 @@ void idFileSystemLocal::Startup( void ) { idStr::Icmp( fs_game.GetString(), BASE_GAMEDIR ) && !ValidateConfiguredGameDir( fs_game.GetString(), &invalidReason ) ) { if ( !idStr::Icmp( fs_game.GetString(), OPENQ4_GAMEDIR ) ) { + PrintContentSearchDiagnostics(); common->FatalError( "openQ4 runtime directory '%s' is missing a compatible mod.json.\n\n%s\n" "Rebuild or reinstall openQ4 so '/%s/mod.json' is present and matches this engine version. Do not replace '%s' with retail Quake 4 assets.", @@ -4927,6 +4996,7 @@ void idFileSystemLocal::Startup( void ) { idStr openQ4PakErrors; if ( !ValidateOpenQ4Paks( openQ4PakErrors ) ) { + PrintContentSearchDiagnostics(); common->FatalError( "openQ4 runtime content packs in '%s' are missing or modified.\n\n%s\n" "Rebuild or reinstall openQ4 so '/%s/pak0.pk4' and '/%s/pak1.pk4' match this engine. Retail Quake 4 PK4s belong in '%s', not '%s'.", @@ -4936,6 +5006,7 @@ void idFileSystemLocal::Startup( void ) { if ( fs_validateOfficialPaks.GetBool() ) { idStr misplacedErrors; if ( FindMisplacedOfficialPaks( misplacedErrors ) ) { + PrintContentSearchDiagnostics(); common->FatalError( "Retail Quake 4 media pk4 files must be installed in '%s', not '%s'.\n\n%s\n" "Move the listed files into '/%s', or remove them from '%s' and launch with +set fs_basepath pointing at a Quake 4 install root that contains '%s'. " @@ -4945,6 +5016,7 @@ void idFileSystemLocal::Startup( void ) { idStr validationErrors; if ( !ValidateRequiredOfficialPaks( validationErrors ) ) { + PrintContentSearchDiagnostics(); common->FatalError( "Required official Quake 4 media pk4 files are missing from '%s' or modified.\n\n%s\n" "openQ4 reads the retail Quake 4 assets from '/%s'. Put pak001.pk4 through pak022.pk4 in that folder, or launch with +set fs_basepath pointing at the install root that contains it. " diff --git a/src/framework/Session.cpp b/src/framework/Session.cpp index 1545dc19..71437e1b 100644 --- a/src/framework/Session.cpp +++ b/src/framework/Session.cpp @@ -4283,6 +4283,16 @@ void idSessionLocal::UnloadMap() { Session_PrintFramePacingSummary( framePacingStats, " final" ); } + // Stop the game sound world before the entities that own its emitters are + // destroyed. ~idEntity frees its emitter without stopping it, and a freed + // emitter is only recycled once its channel list is empty, so every looping + // sound from the outgoing session otherwise survives into the next one. + // idSessionLocal::Stop() already does this, which is why quitting to the + // menu first was clean while loading a savegame over a live map was not. + if ( soundSystem ) { + soundSystem->StopAllSounds( SOUNDWORLD_GAME ); + } + // end the current map in the game if ( game ) { game->MapShutdown(); @@ -4297,6 +4307,13 @@ void idSessionLocal::UnloadMap() { StopRecordingRenderDemo(); } + // drop every render entity/light the outgoing session registered, matching + // the original engine's teardown order; InitFromMap's same-map retain path + // frees them too, but only after the next map has already begun loading + if ( rw ) { + rw->FreeDefs(); + } + iamTheDukeActive = false; objectiveFailed = false; mapSpawned = false; diff --git a/src/idlib/LangDict.cpp b/src/idlib/LangDict.cpp index 94f26396..5ad063cf 100644 --- a/src/idlib/LangDict.cpp +++ b/src/idlib/LangDict.cpp @@ -34,6 +34,183 @@ void idLangDict::Clear( void ) { hash.Clear(); } +/* +============ +LANGDICT_CP1252_HIGH + +Windows-1252 0x80-0x9F block - the only range where Windows-1252 diverges from +Latin-1. 0 marks the five unassigned CP1252 slots (0x81/0x8D/0x8F/0x90/0x9D). +============ +*/ +static const unsigned short LANGDICT_CP1252_HIGH[32] = { + 0x20AC, 0x0000, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, + 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x0000, 0x017D, 0x0000, + 0x0000, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, + 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x0000, 0x017E, 0x0178 +}; + +/* +============ +LANGDICT_GLYPH_FOLD + +The stock Quake 4 .fontdat atlases hold 256 byte-indexed glyphs, of which only +83 of the 128 high slots carry real art, and the missing set is identical in +every shipped Latin font. These CP1252 codes land on a 2x2 .notdef cell with +zero horizontal advance, so a non-breaking space would not merely draw nothing, +it would delete the word gap. Fold them to ASCII equivalents that do have art. +============ +*/ +typedef struct langDictGlyphFold_s { + unsigned char cp1252; + const char * ascii; +} langDictGlyphFold_t; + +static const langDictGlyphFold_t LANGDICT_GLYPH_FOLD[] = { + { 0x82, "," }, { 0x84, "\"" }, { 0x85, "..." }, + { 0x91, "'" }, { 0x92, "'" }, { 0x93, "\"" }, { 0x94, "\"" }, + { 0x96, "-" }, { 0x97, "-" }, { 0xA0, " " }, + { 0x00, NULL } +}; + +static const char *LangDict_FoldForCp1252( unsigned char c ) { + for ( int i = 0; LANGDICT_GLYPH_FOLD[i].ascii != NULL; i++ ) { + if ( LANGDICT_GLYPH_FOLD[i].cp1252 == c ) { + return LANGDICT_GLYPH_FOLD[i].ascii; + } + } + return NULL; +} + +/* +============ +LangDict_DecodeUtf8 + +Returns the number of bytes consumed, or 0 if the sequence is not well-formed +shortest-form UTF-8. +============ +*/ +static int LangDict_DecodeUtf8( const unsigned char *p, int available, unsigned int &codePoint ) { + if ( available <= 0 ) { + return 0; + } + + const unsigned char c0 = p[0]; + if ( c0 < 0x80 ) { + codePoint = c0; + return 1; + } + + int extra; + unsigned int cp; + unsigned int minimum; + if ( ( c0 & 0xE0 ) == 0xC0 ) { + extra = 1; cp = c0 & 0x1Fu; minimum = 0x80; + } else if ( ( c0 & 0xF0 ) == 0xE0 ) { + extra = 2; cp = c0 & 0x0Fu; minimum = 0x800; + } else if ( ( c0 & 0xF8 ) == 0xF0 ) { + extra = 3; cp = c0 & 0x07u; minimum = 0x10000; + } else { + return 0; + } + + if ( available < extra + 1 ) { + return 0; + } + for ( int i = 1; i <= extra; i++ ) { + if ( ( p[i] & 0xC0 ) != 0x80 ) { + return 0; + } + cp = ( cp << 6 ) | ( p[i] & 0x3Fu ); + } + if ( cp < minimum || cp > 0x10FFFF || ( cp >= 0xD800 && cp <= 0xDFFF ) ) { + return 0; + } + + codePoint = cp; + return extra + 1; +} + +static bool LangDict_CodePointToCp1252( unsigned int codePoint, unsigned char &out ) { + if ( codePoint < 0x80 || ( codePoint >= 0xA0 && codePoint <= 0xFF ) ) { + out = (unsigned char)codePoint; + return true; + } + for ( int i = 0; i < 32; i++ ) { + if ( LANGDICT_CP1252_HIGH[i] != 0 && LANGDICT_CP1252_HIGH[i] == codePoint ) { + out = (unsigned char)( 0x80 + i ); + return true; + } + } + return false; +} + +/* +============ +LangDict_ConvertUtf8ToCp1252 + +Stock Quake 4 string tables are Windows-1252 and the fonts are 256 byte-indexed +glyphs, so a UTF-8 table draws two wrong glyphs per accent - "MENUS" comes out +as "MENA S". Convert only when the WHOLE buffer is well-formed UTF-8, every +multi-byte code point round-trips to CP1252, and at least one multi-byte +sequence is present. A genuine CP1252 table with accents is never valid UTF-8 +end to end (0xE9 followed by an ASCII letter fails the continuation-byte test), +so CP1252 and pure-ASCII inputs are left byte-identical. +============ +*/ +static bool LangDict_ConvertUtf8ToCp1252( const char *buffer, int length, idStr &converted ) { + const unsigned char *p = (const unsigned char *)buffer; + bool sawMultiByte = false; + + for ( int i = 0; i < length; ) { + unsigned int cp = 0; + const int used = LangDict_DecodeUtf8( p + i, length - i, cp ); + if ( used == 0 ) { + return false; + } + if ( used > 1 ) { + unsigned char mapped; + if ( !LangDict_CodePointToCp1252( cp, mapped ) ) { + return false; + } + sawMultiByte = true; + } + i += used; + } + if ( !sawMultiByte ) { + return false; + } + + // The output can never be longer than the input: every multi-byte sequence + // collapses to a single byte, and the one expanding fold (U+2026 -> "...") + // is three bytes in and three bytes out. + converted.Fill( ' ', length ); + + int outIndex = 0; + for ( int i = 0; i < length; ) { + unsigned int cp = 0; + const int used = LangDict_DecodeUtf8( p + i, length - i, cp ); + if ( used == 0 ) { + // cannot happen after the validation pass above; fail closed + return false; + } + + unsigned char mapped = '?'; + LangDict_CodePointToCp1252( cp, mapped ); + + const char *fold = ( used > 1 ) ? LangDict_FoldForCp1252( mapped ) : NULL; + if ( fold != NULL ) { + for ( int k = 0; fold[k] != '\0'; k++ ) { + converted[outIndex++] = fold[k]; + } + } else { + converted[outIndex++] = (char)mapped; + } + i += used; + } + converted.CapLength( outIndex ); + return true; +} + /* ============ idLangDict::Load @@ -45,6 +222,9 @@ bool idLangDict::Load( const char *fileName, bool clear ) { } const char *buffer = NULL; + // 'transcoded' must be declared before 'src': idLexer::LoadMemory() stores + // the pointer without copying, so the buffer has to outlive the lexer. + idStr transcoded; idLexer src( LEXFL_NOFATALERRORS | LEXFL_NOSTRINGCONCAT | LEXFL_ALLOWMULTICHARLITERALS | LEXFL_ALLOWBACKSLASHSTRINGCONCAT ); int len = idLib::fileSystem->ReadFile( fileName, (void**)&buffer ); @@ -52,7 +232,22 @@ bool idLangDict::Load( const char *fileName, bool clear ) { // let whoever called us deal with the failure (so sys_lang can be reset) return false; } - src.LoadMemory( buffer, strlen( buffer ), fileName ); + + const char *parseText = buffer; + int parseLength = (int)strlen( buffer ); + if ( parseLength >= 3 && (unsigned char)parseText[0] == 0xEF && + (unsigned char)parseText[1] == 0xBB && (unsigned char)parseText[2] == 0xBF ) { + // a UTF-8 BOM would break ExpectTokenString( "{" ) + parseText += 3; + parseLength -= 3; + } + if ( LangDict_ConvertUtf8ToCp1252( parseText, parseLength, transcoded ) ) { + idLib::common->DPrintf( "%s: converted UTF-8 string table to the 8-bit font codepage\n", fileName ); + parseText = transcoded.c_str(); + parseLength = transcoded.Length(); + } + + src.LoadMemory( parseText, parseLength, fileName ); if ( !src.IsLoaded() ) { return false; } @@ -272,7 +467,10 @@ bool idLangDict::ExcludeString( const char *str ) const { int i; for ( i = 0; i < c; i++ ) { - if ( isalpha( str[i] ) ) { + // isalpha() is only defined for unsigned char values and EOF; passing a + // plain signed char is undefined for any byte >= 0x80, which every + // accented CP1252 character is + if ( isalpha( (unsigned char)str[i] ) ) { break; } } diff --git a/src/idlib/Lexer.cpp b/src/idlib/Lexer.cpp index 76cd0fb2..442f4f78 100644 --- a/src/idlib/Lexer.cpp +++ b/src/idlib/Lexer.cpp @@ -3677,8 +3677,12 @@ int Lexer::ReadToken(idToken *token) unreadSize = sizeof(int); break; default: - // invalid stored size for an integer - assert(false); + // Fail the read rather than asserting: release builds + // compile asserts out, and falling through here leaves + // unreadSize at 0, which desynchronizes every later + // token in the binary stream. + idLib::Warning( "binary lexer: invalid stored size %u for an integer token", (unsigned int)size ); + return 0; } const int signedValue = static_cast( token->intvalue ); @@ -3712,8 +3716,10 @@ int Lexer::ReadToken(idToken *token) unreadSize = sizeof(unsigned int); break; default: - // invalid stored size for an unsigned integer - assert(false); + // see the signed-integer case: an unhandled stored size + // must fail the read, not silently consume zero bytes + idLib::Warning( "binary lexer: invalid stored size %u for an unsigned integer token", (unsigned int)size ); + return 0; } token->floatvalue = token->intvalue; @@ -3751,7 +3757,10 @@ int Lexer::ReadToken(idToken *token) unreadSize = sizeof(signed char); break; default: - assert(false); + // see the signed-integer case: an unhandled stored size + // must fail the read, not silently consume zero bytes + idLib::Warning( "binary lexer: invalid stored size %u for a float token", (unsigned int)size ); + return 0; } // I hate the fact that I have to copy into the string, but there's no way @@ -3788,7 +3797,10 @@ int Lexer::ReadToken(idToken *token) unreadSize = sizeof(signed char); break; default: - assert(false); + // see the signed-integer case: an unhandled stored size + // must fail the read, not silently consume zero bytes + idLib::Warning( "binary lexer: invalid stored size %u for a double token", (unsigned int)size ); + return 0; } // I hate the fact that I have to copy into the string, but there's no way @@ -3802,8 +3814,10 @@ int Lexer::ReadToken(idToken *token) break; default: - // unsupported binary type - assert(false); + // unsupported binary type; fail the read rather than returning a + // token that was never populated and advancing by a bogus size + idLib::Warning( "binary lexer: unsupported binary token type in '%s'", GetFileName() ); + return 0; } } diff --git a/src/imagetools/Image_files.cpp b/src/imagetools/Image_files.cpp index c7b90b9f..611eca3e 100644 --- a/src/imagetools/Image_files.cpp +++ b/src/imagetools/Image_files.cpp @@ -46,7 +46,7 @@ idCVar image_showPrecompressedTextures( "image_showPrecompressedTextures", "0", CVAR_RENDERER | CVAR_BOOL, - "log the exact DDS replacement selected for each image" ); + "log the DDS replacement selected for each image, and every candidate path rejected when none is selected" ); /* @@ -999,7 +999,11 @@ static bool R_ImageHasRetailProgramDDSName( const char *imageProgram ) { return sourceName.IcmpPrefix( "gfx/env/" ) == 0; } -static bool R_TryResolvePreferredDDSImageSource( const idStr &candidateName, idStr &ddsName, ID_TIME_T *timestamp, bool allowPrecompressedDDS, bool *precompressedDDS ) { +static bool R_TryResolvePreferredDDSImageSource( const idStr &candidateName, idStr &ddsName, ID_TIME_T *timestamp, bool allowPrecompressedDDS, bool *precompressedDDS, const char **rejectReason = NULL ) { + if ( rejectReason != NULL ) { + *rejectReason = "no such file"; + } + ID_TIME_T ddsTimestamp = FILE_NOT_FOUND_TIMESTAMP; ddsFileInfo_t info; if ( !R_ReadDDSFileInfo( candidateName.c_str(), info, &ddsTimestamp ) ) { @@ -1011,6 +1015,9 @@ static bool R_TryResolvePreferredDDSImageSource( const idStr &candidateName, idS info.format == DDS_STORED_FORMAT_DXT5 || info.format == DDS_STORED_FORMAT_RXGB ) { if ( usePrecompressedTextures >= 2 ) { + if ( rejectReason != NULL ) { + *rejectReason = "DXT/RXGB data ignored because image_usePrecompressedTextures is 2 (BC7 only)"; + } return false; } ddsName = candidateName; @@ -1024,7 +1031,16 @@ static bool R_TryResolvePreferredDDSImageSource( const idStr &candidateName, idS } if ( info.format == DDS_STORED_FORMAT_BC7 ) { - if ( !allowPrecompressedDDS || !ImageTools_GetCompressionCaps().bptcTextureCompressionAvailable ) { + if ( !allowPrecompressedDDS ) { + if ( rejectReason != NULL ) { + *rejectReason = "BC7 data, but this image is an image-program input that must be decoded on the CPU"; + } + return false; + } + if ( !ImageTools_GetCompressionCaps().bptcTextureCompressionAvailable ) { + if ( rejectReason != NULL ) { + *rejectReason = "BC7 data, but the active renderer reports no BPTC support"; + } return false; } ddsName = candidateName; @@ -1037,9 +1053,72 @@ static bool R_TryResolvePreferredDDSImageSource( const idStr &candidateName, idS return true; } + if ( rejectReason != NULL ) { + *rejectReason = "unsupported DDS pixel format"; + } return false; } +static void R_AppendDDSCandidate( idStrList &candidates, idStrList &origins, const idStr &name, const char *origin ) { + if ( name.Length() == 0 ) { + return; + } + for ( int i = 0; i < candidates.Num(); i++ ) { + if ( candidates[i].Icmp( name ) == 0 ) { + return; + } + } + candidates.Append( name ); + origins.Append( idStr( origin ) ); +} + +/* +============= +R_BuildDDSCandidates + +Ordered list of DDS files that may stand in for an image source. User-supplied +replacements are probed before the retail progimg/ tree: the retail tree exists +for nearly every image-program result, so probing it first made a user's dds/ +pack a silent no-op for every bump map and most speculars. +============= +*/ +static void R_BuildDDSCandidates( const char *cname, idStrList &candidates, idStrList &origins ) { + idStr canonicalDDSName; + R_ImageProgramToCompressedFileName( cname, canonicalDDSName ); + R_AppendDDSCandidate( candidates, origins, canonicalDDSName, "dds/ (dhewm3 naming)" ); + + if ( R_IsPlainImageSourceName( cname ) ) { + idStr sourceName = cname; + sourceName.DefaultFileExtension( ".tga" ); + sourceName.BackSlashesToSlashes(); + sourceName.ToLower(); + + idStr ext; + sourceName.ExtractFileExtension( ext ); + if ( ext == "tga" || ext == "jpg" ) { + if ( !R_ImageNameHasDDSShadowPrefix( sourceName ) ) { + idStr shadowDDSName = "dds/"; + shadowDDSName += sourceName; + shadowDDSName.StripFileExtension(); + shadowDDSName.DefaultFileExtension( ".dds" ); + R_AppendDDSCandidate( candidates, origins, shadowDDSName, "dds/ (asset path)" ); + } + + idStr siblingDDSName = sourceName; + siblingDDSName.StripFileExtension(); + siblingDDSName.DefaultFileExtension( ".dds" ); + R_AppendDDSCandidate( candidates, origins, siblingDDSName, "alongside the original asset (Quake 4 style)" ); + } + } + + // stock retail data last, so any user replacement wins + if ( R_ImageHasRetailProgramDDSName( cname ) ) { + idStr retailDDSName; + R_ImageProgramToRetailCompressedFileName( cname, retailDDSName ); + R_AppendDDSCandidate( candidates, origins, retailDDSName, "retail progimg/" ); + } +} + /* ============= R_ResolvePreferredDDSImageSource @@ -1060,53 +1139,30 @@ bool R_ResolvePreferredDDSImageSource( const char *cname, idStr &ddsName, ID_TIM return false; } - if ( R_ImageHasRetailProgramDDSName( cname ) ) { - idStr retailDDSName; - R_ImageProgramToRetailCompressedFileName( cname, retailDDSName ); - if ( R_TryResolvePreferredDDSImageSource( retailDDSName, ddsName, timestamp, allowPrecompressedDDS, precompressedDDS ) ) { - return true; - } - } - - idStr canonicalDDSName; - R_ImageProgramToCompressedFileName( cname, canonicalDDSName ); - if ( R_TryResolvePreferredDDSImageSource( canonicalDDSName, ddsName, timestamp, allowPrecompressedDDS, precompressedDDS ) ) { - return true; - } - - if ( !R_IsPlainImageSourceName( cname ) ) { - return false; - } - - idStr sourceName = cname; - sourceName.DefaultFileExtension( ".tga" ); - sourceName.BackSlashesToSlashes(); - sourceName.ToLower(); + idStrList candidates; + idStrList origins; + R_BuildDDSCandidates( cname, candidates, origins ); - idStr ext; - sourceName.ExtractFileExtension( ext ); - if ( ext != "tga" && ext != "jpg" ) { - return false; - } + const bool logMisses = image_showPrecompressedTextures.GetBool(); + idStr rejections; - if ( !R_ImageNameHasDDSShadowPrefix( sourceName ) ) { - idStr shadowDDSName = "dds/"; - shadowDDSName += sourceName; - shadowDDSName.StripFileExtension(); - shadowDDSName.DefaultFileExtension( ".dds" ); - if ( canonicalDDSName.Icmp( shadowDDSName ) != 0 && - R_TryResolvePreferredDDSImageSource( shadowDDSName, ddsName, timestamp, allowPrecompressedDDS, precompressedDDS ) ) { + for ( int i = 0; i < candidates.Num(); i++ ) { + const char *rejectReason = NULL; + if ( R_TryResolvePreferredDDSImageSource( candidates[i], ddsName, timestamp, allowPrecompressedDDS, precompressedDDS, &rejectReason ) ) { return true; } + if ( logMisses ) { + rejections += va( " %-64s [%s] %s\n", candidates[i].c_str(), origins[i].c_str(), + rejectReason != NULL ? rejectReason : "rejected" ); + } } - idStr siblingDDSName = sourceName; - siblingDDSName.StripFileExtension(); - siblingDDSName.DefaultFileExtension( ".dds" ); - if ( canonicalDDSName.Icmp( siblingDDSName ) == 0 ) { - return false; + if ( logMisses && candidates.Num() > 0 ) { + // a miss is what needs explaining; logging only hits leaves a pack + // author with no way to discover the name the engine expects + idLib::Printf( "no DDS replacement for '%s':\n%s", cname, rejections.c_str() ); } - return R_TryResolvePreferredDDSImageSource( siblingDDSName, ddsName, timestamp, allowPrecompressedDDS, precompressedDDS ); + return false; } /* @@ -1191,9 +1247,15 @@ bool R_LoadPrecompressedDDS( const char *cname, idBinaryImage &image, ID_TIME_T break; } + // RXGB and DXT5nm store the normal's X in alpha because DXT5 gives alpha + // its own higher-precision interpolator; the renderer then skips the + // alpha<-red swizzle and the interaction shader reads X straight out of + // alpha. BC7 has no such convention - every common encoder writes plain + // XYZ in RGB - so forcing that layout on BC7 made ordinary BC7 normal + // maps sample X = 1.0 everywhere and flattened all bump lighting. const bool rxgbNormal = info.format == DDS_STORED_FORMAT_RXGB || - ( usage == TD_BUMP && ( textureFormat == FMT_DXT5 || textureFormat == FMT_BC7 ) ); + ( usage == TD_BUMP && textureFormat == FMT_DXT5 ); const textureColor_t colorFormat = rxgbNormal ? CFM_NORMAL_DXT5 : CFM_DEFAULT; image.Load2DFromCompressedData( selectedWidth, selectedHeight, selectedLevels, textureFormat, colorFormat, buffer, levelOffsets.Ptr() + firstLevel, levelSizes.Ptr() + firstLevel ); diff --git a/src/renderer/OpenGL/gl_Image.cpp b/src/renderer/OpenGL/gl_Image.cpp index 9d2c3945..91870b25 100644 --- a/src/renderer/OpenGL/gl_Image.cpp +++ b/src/renderer/OpenGL/gl_Image.cpp @@ -411,7 +411,16 @@ void idImage::AllocImage() { break; case FMT_BC7: if ( !glConfig.bptcTextureCompressionAvailable ) { - idLib::Error( "%s requires BC7/BPTC texture support, but this renderer does not expose it\n", GetName() ); + // This used to be a fatal idLib::Error raised from inside texture + // upload, i.e. in the middle of a map load. Every other BC7 gate in + // the tree warns and degrades (R_BinaryImageHeaderSupportedByRenderer, + // the lightgrid chunk loader), so match them: an unexpected BC7 image + // on a driver without BPTC must cost one texture, not the session. + common->Warning( "%s holds BC7/BPTC data but this renderer does not expose BPTC; uploading as uncompressed RGBA8", GetName() ); + internalFormat = GL_RGBA8; + dataFormat = GL_RGBA; + dataType = GL_UNSIGNED_BYTE; + break; } internalFormat = GL_COMPRESSED_RGBA_BPTC_UNORM; dataFormat = GL_RGBA; diff --git a/src/renderer/RenderSystem_init.cpp b/src/renderer/RenderSystem_init.cpp index 9058958a..cccb75e5 100644 --- a/src/renderer/RenderSystem_init.cpp +++ b/src/renderer/RenderSystem_init.cpp @@ -912,6 +912,25 @@ static void R_RendererModernGLSubmitPlanSelfTest_f( const idCmdArgs &args ) { } } +/* +================== +R_PublishCompressionCapsToImageTools + +imagetools is a static library, so every binary that links it (the engine, and +each renderer module) owns a private copy of the capability block that gates +precompressed-DDS selection. Any backend that fills glConfig must therefore +publish through here, and the engine must republish into its own copy once the +renderer reports its configuration - otherwise the un-published copies stay +zero-initialized and silently reject every DDS replacement, BC7 included. +================== +*/ +void R_PublishCompressionCapsToImageTools( void ) { + imageToolsCompressionCaps_t compressionCaps; + compressionCaps.textureCompressionAvailable = glConfig.textureCompressionAvailable; + compressionCaps.bptcTextureCompressionAvailable = glConfig.bptcTextureCompressionAvailable; + ImageTools_SetCompressionCaps( compressionCaps ); +} + /* ================== R_CheckPortableExtensions @@ -1013,12 +1032,7 @@ static void R_CheckPortableExtensions( void ) { // push the compression capabilities into the shared imagetools library, // which gates precompressed-DDS selection without reading renderer globals - { - imageToolsCompressionCaps_t compressionCaps; - compressionCaps.textureCompressionAvailable = glConfig.textureCompressionAvailable; - compressionCaps.bptcTextureCompressionAvailable = glConfig.bptcTextureCompressionAvailable; - ImageTools_SetCompressionCaps( compressionCaps ); - } + R_PublishCompressionCapsToImageTools(); // GL_EXT_texture_filter_anisotropic glConfig.anisotropicAvailable = R_CheckExtension( "GL_EXT_texture_filter_anisotropic" ); @@ -4201,11 +4215,13 @@ void idRenderSystemLocal::Shutdown( void ) { // free frame memory R_ShutdownFrameData(); - // The dedicated renderer never creates an OpenGL context or initializes the - // vertex-cache list sentinels. Only tear the cache down after InitOpenGL. - if ( glConfig.isInitialized ) { - vertexCache.Shutdown(); - } + // idVertexCache::Shutdown() is a no-op until Init() has linked its list + // sentinels, so this is safe on the dedicated renderer (which never creates + // a context) and on an early-startup teardown. Do not re-add a + // glConfig.isInitialized guard here: ShutdownOpenGL() clears that flag + // without touching the cache, so a guarded call would leak every buffer + // after a vid_restart teardown. + vertexCache.Shutdown(); R_ShutdownTriSurfData(); diff --git a/src/renderer/VertexCache.cpp b/src/renderer/VertexCache.cpp index 238b24e5..d39d5eac 100644 --- a/src/renderer/VertexCache.cpp +++ b/src/renderer/VertexCache.cpp @@ -361,6 +361,14 @@ the cached data isn't valid =========== */ void idVertexCache::PurgeAll() { + if ( staticHeaders.next == NULL ) { + // Init() has never run, so the list sentinels are not self-linked yet. + // Without this the loop condition is immediately true and ActuallyFree() + // is handed a NULL block, which raises an error from inside whatever + // teardown reached us - typically an early-startup fatal error unwinding + // through idRenderSystem::Shutdown() long before R_InitOpenGL() ran. + return; + } while( staticHeaders.next != &staticHeaders ) { ActuallyFree( staticHeaders.next ); } @@ -372,6 +380,12 @@ idVertexCache::Shutdown =========== */ void idVertexCache::Shutdown() { + if ( staticHeaders.next == NULL || deferredFreeList.next == NULL ) { + // never initialized; the sentinels still hold their zero-initialized + // static-storage values and there is nothing to free + return; + } + PurgeAll(); while( deferredFreeList.next != &deferredFreeList ) { diff --git a/src/renderer/Vulkan/vk_Backend.cpp b/src/renderer/Vulkan/vk_Backend.cpp index d7b0abe8..1440a310 100644 --- a/src/renderer/Vulkan/vk_Backend.cpp +++ b/src/renderer/Vulkan/vk_Backend.cpp @@ -125,6 +125,12 @@ static void VK_FillGLConfigFromDevice( void ) { glConfig.textureCompressionAvailable = vkCtx.textureCompressionBCSupported; glConfig.bptcTextureCompressionAvailable = vkCtx.textureCompressionBCSupported; glConfig.cubeMapAvailable = true; + + // imagetools keeps a private copy of these per linked binary and gates all + // precompressed-DDS selection on it; without this the Vulkan backend would + // report BC7 support in gfxInfo while every DDS replacement (and every + // retail progimg/ block upload) was silently rejected and re-decoded + R_PublishCompressionCapsToImageTools(); } /* diff --git a/src/renderer/tr_local.h b/src/renderer/tr_local.h index 695605b5..a28dfdb0 100644 --- a/src/renderer/tr_local.h +++ b/src/renderer/tr_local.h @@ -1421,6 +1421,10 @@ const int GLS_DEFAULT = GLS_DEPTHFUNC_ALWAYS; void R_Init( void ); void R_InitOpenGL( void ); +// publish glConfig's compression capabilities into this binary's imagetools +// copy; every backend that fills glConfig must call it +void R_PublishCompressionCapsToImageTools( void ); + void R_DoneFreeType( void ); void R_SetColorMappings( void ); diff --git a/src/sound/OpenAL/AL_SoundSample.cpp b/src/sound/OpenAL/AL_SoundSample.cpp index d7d0a72e..7ebfd03e 100644 --- a/src/sound/OpenAL/AL_SoundSample.cpp +++ b/src/sound/OpenAL/AL_SoundSample.cpp @@ -154,6 +154,54 @@ static bool openQ4_GetMSADPCMDecodedSize( const uint32_t encodedBytes, const uin return true; } +/* +======================== +SoundSample_AppendUniqueSampleVariant +======================== +*/ +static void SoundSample_AppendUniqueSampleVariant( idList< idStr >& variants, const idStr& name ) +{ + for( int i = 0; i < variants.Num(); i++ ) + { + // Icmp so the probe list does not gain a duplicate entry on + // case-insensitive hosts while still being correct on Linux + if( variants[ i ].Icmp( name ) == 0 ) + { + return; + } + } + variants.Append( name ); +} + +/* +======================== +SoundSample_AppendLocalizedVOVariants + +Sound shaders reference the unlocalized 'sound/vo/...' path, but the retail +data only ships voice-over under a per-language tree. Add both spellings retail +used for a given language. +======================== +*/ +static void SoundSample_AppendLocalizedVOVariants( idList< idStr >& variants, const idStr& baseName, const char* language ) +{ + if( language == NULL || language[ 0 ] == '\0' ) + { + return; + } + + idStr localized = baseName; + if( localized.Replace( "/vo/", va( "/vo_%s/", language ) ) ) + { + SoundSample_AppendUniqueSampleVariant( variants, localized ); + } + + localized = baseName; + if( localized.Replace( "/vo/", va( "/vo/%s/", language ) ) ) + { + SoundSample_AppendUniqueSampleVariant( variants, localized ); + } +} + static bool openQ4_CanUploadSampleToOpenAL() { ALCcontext* const expectedContext = soundSystemLocal.hardware.GetOpenALContext(); @@ -388,29 +436,17 @@ void idSoundSample_OpenAL::LoadResource() idStr baseSampleName = GetName(); if( baseSampleName.Find( "/vo/" ) >= 0 ) { - const char *language = sys_lang.GetString(); - if( language && language[ 0 ] ) { - idStr localizedSampleName = baseSampleName; - if( localizedSampleName.Replace( "/vo/", va( "/vo_%s/", language ) ) ) { - sampleVariants.Append( localizedSampleName ); - } + SoundSample_AppendLocalizedVOVariants( sampleVariants, baseSampleName, sys_lang.GetString() ); - localizedSampleName = baseSampleName; - if( localizedSampleName.Replace( "/vo/", va( "/vo/%s/", language ) ) ) { - bool duplicateVariant = false; - for ( int j = 0; j < sampleVariants.Num(); j++ ) { - if ( sampleVariants[ j ] == localizedSampleName ) { - duplicateVariant = true; - break; - } - } - if ( !duplicateVariant ) { - sampleVariants.Append( localizedSampleName ); - } - } - } + // Retail Quake 4 resolves the voice-over language separately from the + // text language (idSoundSample::Load -> SoundSample_SelectVOLanguage) + // and falls back to English for any language that ships localized + // subtitles but no localized voice track. Without this a text-only + // language pack silences every line of dialogue while lip-sync, which + // is driven from the decl rather than the sample, keeps animating. + SoundSample_AppendLocalizedVOVariants( sampleVariants, baseSampleName, "english" ); } - sampleVariants.Append( baseSampleName ); + SoundSample_AppendUniqueSampleVariant( sampleVariants, baseSampleName ); for( int i = 0; i < sampleVariants.Num(); i++ ) { @@ -492,6 +528,23 @@ void idSoundSample_OpenAL::LoadResource() if( !loaded ) { + // Retail warns unconditionally here (idSoundSample::Load). Without it a + // sample that resolves to nothing is completely invisible in the log, + // which is how "no character audio at all" reports end up undiagnosable. + // Name every path probed: for voice-over the probe list is rewritten + // from the shader's unlocalized 'sound/vo/...' name, so the failing + // filename is not the one the material author wrote. + idStr probed; + for( int i = 0; i < sampleVariants.Num(); i++ ) + { + if( i > 0 ) + { + probed += ", "; + } + probed += sampleVariants[ i ]; + } + idLib::Warning( "Couldn't load sound '%s' using default (probed: %s)", GetName(), probed.c_str() ); + // make it default if everything else fails MakeDefault(); } diff --git a/src/sound/snd_world.cpp b/src/sound/snd_world.cpp index 9ddc72b1..2461c6d7 100644 --- a/src/sound/snd_world.cpp +++ b/src/sound/snd_world.cpp @@ -562,8 +562,13 @@ void idSoundWorldLocal::Update() soundSystemLocal.hardware.EndDeferredUpdates(); if( showVoices ) { + // The overlay console this used to draw into does not exist in openQ4, + // which left s_showVoices building a table every frame and throwing it + // away - i.e. the cvar produced no output at all. Print it instead so + // the voice list is actually usable for diagnosing silent sounds. // static idOverlayHandle handle; // console->PrintOverlay( handle, JUSTIFY_LEFT, showVoiceTable.c_str() ); + idLib::Printf( "%s", showVoiceTable.c_str() ); } //if( s_drawSounds.GetBool() && renderWorld != NULL ) diff --git a/tools/tests/lang_table_encoding.py b/tools/tests/lang_table_encoding.py new file mode 100644 index 00000000..11834760 --- /dev/null +++ b/tools/tests/lang_table_encoding.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Regression checks for language-table encoding and the engine-side transcode. + +openQ4 authors its string tables in UTF-8, but the stock Quake 4 fonts are a +fixed 256-glyph atlas indexed by a raw byte, so the engine transcodes UTF-8 +tables to Windows-1252 at load time (idLangDict::Load). Two things therefore +have to stay true: + + * every repo-authored ``.lang`` file must be valid UTF-8 whose code points are + all representable in CP1252, otherwise the transcode silently declines and + the accents render as two wrong glyphs (GitHub issue #89); + * the transcode itself must stay in place in both idlib copies. +""" + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +STRINGS_DIR = ROOT / "content" / "baseoq4" / "pak0" / "strings" + +# Windows-1252 0x80-0x9F block; None marks the five unassigned slots. +CP1252_HIGH = [ + 0x20AC, None, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, + 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, None, 0x017D, None, + None, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, + 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, None, 0x017E, 0x0178, +] + +# CP1252 codes that land on a .notdef cell in every stock Quake 4 font. The +# engine folds these to ASCII rather than drawing nothing; 0xA0 in particular +# has a zero advance, so leaving it alone would delete the word gap entirely. +FOLDED_CP1252 = {0x82, 0x84, 0x85, 0x91, 0x92, 0x93, 0x94, 0x96, 0x97, 0xA0} + + +def read(relative_path: str) -> str: + return (ROOT / relative_path).read_text(encoding="utf-8") + + +def require(haystack: str, needle: str, context: str) -> None: + if needle not in haystack: + raise AssertionError(f"Missing {needle!r} in {context}") + + +def cp1252_representable(code_point: int) -> bool: + if code_point < 0x80 or 0xA0 <= code_point <= 0xFF: + return True + return code_point in [value for value in CP1252_HIGH if value is not None] + + +def validate_lang_files() -> None: + lang_files = sorted(STRINGS_DIR.glob("*.lang")) + if not lang_files: + raise AssertionError(f"No .lang files found under {STRINGS_DIR}") + + for path in lang_files: + data = path.read_bytes() + rel = path.relative_to(ROOT).as_posix() + + if data.startswith(b"\xef\xbb\xbf"): + raise AssertionError( + f"{rel} starts with a UTF-8 BOM; idLangDict::Load tolerates it but " + "the retail parser does not - save without a BOM" + ) + + try: + text = data.decode("utf-8") + except UnicodeDecodeError as exc: + raise AssertionError( + f"{rel} is not valid UTF-8 at byte {exc.start} ({exc.reason}). " + "Repo-authored string tables are UTF-8; the engine transcodes them " + "to the 8-bit font codepage at load time." + ) from exc + + for index, char in enumerate(text): + code_point = ord(char) + if not cp1252_representable(code_point): + raise AssertionError( + f"{rel}: U+{code_point:04X} ({char!r}) at character {index} cannot be " + "represented in Windows-1252, so the stock 256-glyph fonts cannot " + "draw it. Use a CP1252-representable character." + ) + + +def validate_transcode_contract() -> None: + for relative_path in ( + "src/idlib/LangDict.cpp", + # the game repo builds its own idlib; keep the two copies in lockstep + ): + source = read(relative_path) + context = f"{relative_path} language-table transcode" + require(source, "LangDict_ConvertUtf8ToCp1252", context) + require(source, "LANGDICT_CP1252_HIGH", context) + require(source, "LANGDICT_GLYPH_FOLD", context) + require(source, "LangDict_DecodeUtf8", context) + require(source, "LangDict_CodePointToCp1252", context) + require( + source, + "idStr transcoded;", + f"{context} (buffer must outlive the lexer)", + ) + require( + source, + "src.LoadMemory( parseText, parseLength, fileName );", + f"{context} (lexer must parse the transcoded text)", + ) + + declaration = source.index("idStr transcoded;") + lexer = source.index("idLexer src(") + if declaration > lexer: + raise AssertionError( + f"{relative_path}: 'idStr transcoded' must be declared before 'idLexer src' - " + "idLexer::LoadMemory stores the pointer without copying, so the buffer has to " + "be destroyed after the lexer" + ) + + # every folded code must be one the engine can actually produce + for match in re.finditer(r"\{\s*0x([0-9A-Fa-f]{2}),\s*\"", source): + code = int(match.group(1), 16) + if code not in FOLDED_CP1252: + raise AssertionError( + f"{relative_path}: unexpected glyph fold entry 0x{code:02X}; update " + "FOLDED_CP1252 in this test if the fold table intentionally changed" + ) + + +def validate_ci_smoke() -> None: + push = read(".github/workflows/push-verification.yml") + commit = read(".github/workflows/commit-validation.yml") + runner = read("tools/validation/openq4_validate.py") + + for source, context in ( + (push, "push verification workflow"), + (commit, "commit validation workflow"), + (runner, "validation runner"), + ): + require(source, "lang_table_encoding.py", context) + + +def main() -> None: + validate_lang_files() + validate_transcode_contract() + validate_ci_smoke() + print("lang_table_encoding: ok") + + +if __name__ == "__main__": + main() diff --git a/tools/tests/linux_dedicated_server_smoke_contract.py b/tools/tests/linux_dedicated_server_smoke_contract.py index 420f49cc..bb94cfca 100644 --- a/tools/tests/linux_dedicated_server_smoke_contract.py +++ b/tools/tests/linux_dedicated_server_smoke_contract.py @@ -71,10 +71,32 @@ def main() -> None: '#ifdef ID_DEDICATED\n\tcommon->Printf( "Dedicated server: skipping client GUI preload.\\n" );\n#else', "dedicated session client-GUI exclusion", ) + # The dedicated server never creates a context, so idRenderSystem::Shutdown() + # tears down a vertex cache that Init() never touched. That used to be + # guarded at the call site with glConfig.isInitialized, which was wrong in + # both directions: the flag is set before vertexCache.Init() runs, and + # ShutdownOpenGL() clears it without freeing the cache. The guard now lives + # in idVertexCache itself, keyed off the list sentinels. + vertex_cache = read("src/renderer/VertexCache.cpp") require( + renderer, + "vertexCache.Shutdown();", + "dedicated vertex-cache shutdown", + ) + reject( renderer, "if ( glConfig.isInitialized ) {\n\t\tvertexCache.Shutdown();\n\t}", - "uninitialized dedicated vertex-cache shutdown guard", + "call-site vertex-cache shutdown guard (must live inside idVertexCache)", + ) + require( + vertex_cache, + "void idVertexCache::PurgeAll() {\n\tif ( staticHeaders.next == NULL ) {", + "uninitialized vertex-cache purge guard", + ) + require( + vertex_cache, + "void idVertexCache::Shutdown() {\n\tif ( staticHeaders.next == NULL || deferredFreeList.next == NULL ) {", + "uninitialized vertex-cache shutdown guard", ) dedicated_source_block = meson_sources[ diff --git a/tools/tests/savegame_pointer_width_safety.py b/tools/tests/savegame_pointer_width_safety.py new file mode 100644 index 00000000..006117a7 --- /dev/null +++ b/tools/tests/savegame_pointer_width_safety.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Regression checks for pointer-width safety in the savegame stream. + +GitHub issue #78: ``idScriptObject::Restore`` used to declare ``size_t size`` +and call ``savefile->ReadInt( (int &)size )``. ``idFile::ReadInt`` writes +exactly four bytes, so on any LP64 target the upper four bytes of the +``size_t`` kept whatever happened to be in that stack slot. The subsequent +size comparison and ``Read( data, size )`` then depended on uninitialized +stack, which is why x86-64 usually survived and AArch64 did not - it either +errored out or desynchronized the whole restore stream, leaving the game +spinning on a load screen until the OOM killer took it. + +The savegame reader/writer pair must therefore never bind a fixed-width read to +a reference whose width is not that of the reader. +""" + +import os +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +GAME_LIBS_ROOT = Path(os.environ.get("OPENQ4_GAMELIBS_REPO", ROOT.parent / "openQ4-game")).resolve() + +SCRIPT_PROGRAM_SOURCES = ( + "src/game/script/Script_Program.cpp", + "src/mpgame/script/Script_Program.cpp", +) + +# Directories whose save/restore code the check sweeps. +SWEPT_GAME_DIRS = ("src/game", "src/mpgame") + +# Casting a fixed-width read onto a reference is only width-safe when the cast +# type is itself that width. Anything wider (or pointer-sized) silently leaves +# the high bytes untouched. +WIDE_CAST_TYPES = ( + "size_t", + "long", + "unsigned long", + "long long", + "unsigned long long", + "ptrdiff_t", + "intptr_t", + "uintptr_t", + "time_t", + "ID_TIME_T", +) + +READ_WRITE_CALL = re.compile( + r"\b(Read|Write)(Int|Short|Byte|Bool|Float|Signed?Char)\s*\(\s*\(\s*([A-Za-z_][A-Za-z0-9_ ]*?)\s*&\s*\)" +) + + +def read_game_libs(relative_path: str) -> str: + return (GAME_LIBS_ROOT / relative_path).read_text(encoding="utf-8", errors="replace") + + +def require(haystack: str, needle: str, context: str) -> None: + if needle not in haystack: + raise AssertionError(f"Missing {needle!r} in {context}") + + +def reject(haystack: str, needle: str, context: str) -> None: + if needle in haystack: + raise AssertionError(f"Unexpected {needle!r} in {context}") + + +def read(relative_path: str) -> str: + return (ROOT / relative_path).read_text(encoding="utf-8") + + +def validate_script_object_size_contract() -> None: + for relative_path in SCRIPT_PROGRAM_SOURCES: + source = read_game_libs(relative_path) + context = f"{relative_path} script-object savegame size" + + # the exact shape that produced issue #78 + reject(source, "(int &)size", context) + reject(source, "(int&)size", context) + + require(source, "int savedSize;", context) + require(source, "savefile->ReadInt( savedSize );", context) + require( + source, + "if ( savedSize < 0 ) {", + f"{context} (a negative saved size must not reach Read())", + ) + require( + source, + "static_cast( savedSize ) != type->Size()", + f"{context} (width-explicit comparison against the type size)", + ) + + +def validate_no_wide_reference_casts() -> None: + offenders = [] + + for game_dir in SWEPT_GAME_DIRS: + directory = GAME_LIBS_ROOT / game_dir + if not directory.is_dir(): + raise AssertionError(f"Missing game-library directory {directory}") + + for path in sorted(directory.rglob("*.cpp")): + text = path.read_text(encoding="utf-8", errors="replace") + for match in READ_WRITE_CALL.finditer(text): + cast_type = " ".join(match.group(3).split()) + cast_type = cast_type.replace("const ", "").strip() + if cast_type in WIDE_CAST_TYPES: + line = text.count("\n", 0, match.start()) + 1 + offenders.append( + f"{path.relative_to(GAME_LIBS_ROOT).as_posix()}:{line}: " + f"{match.group(1)}{match.group(2)} bound to a ({cast_type} &) reference" + ) + + if offenders: + raise AssertionError( + "Fixed-width savegame accessors bound to wider references " + "(only the low bytes are written; the rest stay uninitialized):\n " + + "\n ".join(offenders) + ) + + +def validate_ci_smoke() -> None: + push = read(".github/workflows/push-verification.yml") + commit = read(".github/workflows/commit-validation.yml") + runner = read("tools/validation/openq4_validate.py") + + for source, context in ( + (push, "push verification workflow"), + (commit, "commit validation workflow"), + (runner, "validation runner"), + ): + require(source, "savegame_pointer_width_safety.py", context) + + +def main() -> None: + validate_script_object_size_contract() + validate_no_wide_reference_casts() + validate_ci_smoke() + print("savegame_pointer_width_safety: ok") + + +if __name__ == "__main__": + main() diff --git a/tools/validation/openq4_validate.py b/tools/validation/openq4_validate.py index 852aa72e..6ef153c1 100644 --- a/tools/validation/openq4_validate.py +++ b/tools/validation/openq4_validate.py @@ -463,10 +463,12 @@ def run_python_tests(args: argparse.Namespace, root: Path, env: dict[str, str]) root / "tools" / "tests" / "renderer_vulkan_shadow_compatibility.py", root / "tools" / "tests" / "renderer_vulkan_world_interaction_compatibility.py", root / "tools" / "tests" / "savegame_corruption_contract.py", + root / "tools" / "tests" / "savegame_pointer_width_safety.py", root / "tools" / "tests" / "sdl3_input_parity.py", root / "tools" / "tests" / "sdl3_multidisplay_windowing.py", root / "tools" / "tests" / "settings_menu_coverage.py", root / "tools" / "tests" / "steam_deck_support.py", + root / "tools" / "tests" / "lang_table_encoding.py", root / "tools" / "tests" / "startup_language_override.py", root / "tools" / "tests" / "validation_hardening.py", root / "tools" / "tests" / "vscode_fast_build.py",