From b417b46da466eb99d37523e9c2ec5b4aed2ca743 Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Wed, 14 Oct 2026 01:05:43 -0700 Subject: [PATCH 1/5] Make cfread_string_len read no more than fits in the buffer The char* version only checked the stored length with an Assertion, so in release builds a string longer than the buffer was read past its end, and a negative length wrote before it. It now reads at most buffer_size - 1 characters, skips the rest so the next read stays aligned, always terminates the buffer, and logs when characters are dropped. The SCP_string version clamps a negative or past-the-end length to what the file still holds, so a corrupt length can't cause a huge allocation. Co-Authored-By: Claude Opus 5.5 --- code/cfile/cfile.cpp | 30 +++++++--- code/cfile/cfile.h | 8 ++- test/src/cfile/cfile.cpp | 117 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 11 deletions(-) diff --git a/code/cfile/cfile.cpp b/code/cfile/cfile.cpp index 12f6df421a9..be283490ff2 100644 --- a/code/cfile/cfile.cpp +++ b/code/cfile/cfile.cpp @@ -34,6 +34,7 @@ #include "cfilesystem.h" +#include #include char Cfile_root_dir[CFILE_ROOT_DIRECTORY_LEN] = ""; @@ -1107,26 +1108,37 @@ void cfread_string(char *buf, int n, CFILE *file) void cfread_string_len(char *buf, int buffer_size, CFILE *file) { - int len; - len = cfread_int(file); - Assertion( (len <= buffer_size), "String cannot fit in buffer, len: %i, buffer: %i", len, buffer_size); - if (len) { - cfread(buf, len, 1, file); - Assertion(len != buffer_size || buf[len - 1] == 0, "Unterminated string does not fit in buffer"); + Assertion(buffer_size > 0, "cfread_string_len needs room for at least the terminator, buffer: %i", buffer_size); + + int len = cfread_int(file); + int to_read = std::clamp(len, 0, buffer_size - 1); + + if (to_read > 0 && cfread(buf, to_read, 1, file) != 1) { + to_read = 0; } + buf[to_read] = 0; - buf[MIN(len, buffer_size - 1)] = 0; + if (len > to_read) { + if (len > buffer_size) { + mprintf(("cfread_string_len: a string of %d characters was cut to fit a buffer of %d in %s\n", len, buffer_size, cf_get_filename(file))); + } + // skip what didn't fit so the next read starts after this string + cfseek(file, len - to_read, CF_SEEK_CUR); + } } SCP_string cfread_string_len(CFILE *file) { int len = cfread_int(file); + // a corrupt length must not make us allocate more than the file can still hold + len = std::clamp(len, 0, std::max(cfilelength(file) - cftell(file), 0)); SCP_string str; str.resize(len); - if (len) - cfread(&str[0], len, 1, file); + if (len > 0 && cfread(str.data(), len, 1, file) != 1) { + str.clear(); + } return str; } diff --git a/code/cfile/cfile.h b/code/cfile/cfile.h index 7c720ac2371..eddebcec64c 100644 --- a/code/cfile/cfile.h +++ b/code/cfile/cfile.h @@ -320,8 +320,9 @@ void cfread_string(char *buf,int n, CFILE *file); /** * @brief Read a fixed length string that is not null-terminated, with the length stored in file. * - * If it is not null-terminated, the length of the string *must* be less than the size of the buffer (to make room for one) - * + * Reads at most buffer_size - 1 characters and skips the rest of the string in the file, so the next read + * starts after it. A negative length reads nothing. + * * @param buf Pre-allocated array to store string * @param buffer_size Size of pre-allocated array * @param file File to read from @@ -332,6 +333,9 @@ void cfread_string_len(char *buf,int buffer_size, CFILE *file); /** * @brief Read a string from the file where the length is stored in the file + * + * A negative length reads nothing, and a length past the end of the file reads only what is left. + * * @param file The file to read the string from * @return The string that was read */ diff --git a/test/src/cfile/cfile.cpp b/test/src/cfile/cfile.cpp index f9842a55081..86574838f70 100644 --- a/test/src/cfile/cfile.cpp +++ b/test/src/cfile/cfile.cpp @@ -1,4 +1,5 @@ +#include #include #include #include @@ -212,3 +213,119 @@ TEST_F(CFileTest, subfolder_list) ASSERT_EQ(2, cf_get_file_list(table_files, CF_TYPE_TABLES, "*\\*.tbl", CF_SORT_NAME)); ASSERT_TRUE(table_files.back().substr(0, 6) == "folder"); } + +namespace { +// Builds the bytes cfwrite_string_len writes (a little-endian length, then the characters) as an in-memory file. +// The file reads from this object's buffer, so the object has to outlive the CFILE. +class StringLenFile { + public: + StringLenFile& add_int(int value) + { + for (int i = 0; i < 4; ++i) { + _bytes.push_back(static_cast((value >> (8 * i)) & 0xFF)); + } + return *this; + } + + StringLenFile& add_chars(const char* chars, size_t count) + { + _bytes.insert(_bytes.end(), chars, chars + count); + return *this; + } + + CFILE* open() const + { + CFileLocation res(true); + res.name_ext = "string_len.bin"; + res.full_name = "string_len.bin"; + res.data_ptr = _bytes.data(); + res.size = _bytes.size(); + return cfopen_special(res, "rb"); + } + + private: + SCP_vector _bytes; +}; +} // namespace + +TEST_F(CFileTest, string_len_fits) +{ + StringLenFile file; + file.add_int(3).add_chars("abc", 3).add_int(42); + auto fp = file.open(); + ASSERT_NE(fp, nullptr); + + char buf[8]; + cfread_string_len(buf, sizeof(buf), fp); + EXPECT_STREQ(buf, "abc"); + EXPECT_EQ(cfread_int(fp), 42); + + cfclose(fp); +} + +TEST_F(CFileTest, string_len_exact_fit_with_stored_terminator) +{ + StringLenFile file; + file.add_int(4).add_chars("abc\0", 4).add_int(42); + auto fp = file.open(); + ASSERT_NE(fp, nullptr); + + char buf[4]; + cfread_string_len(buf, sizeof(buf), fp); + EXPECT_STREQ(buf, "abc"); + EXPECT_EQ(cfread_int(fp), 42); + + cfclose(fp); +} + +TEST_F(CFileTest, string_len_longer_than_buffer) +{ + StringLenFile file; + file.add_int(10).add_chars("0123456789", 10).add_int(42); + auto fp = file.open(); + ASSERT_NE(fp, nullptr); + + char buf[8]; + memset(buf, 'X', sizeof(buf)); + cfread_string_len(buf, 4, fp); + EXPECT_STREQ(buf, "012"); + for (int i = 4; i < 8; ++i) { + EXPECT_EQ(buf[i], 'X') << "wrote past the buffer at " << i; + } + EXPECT_EQ(cfread_int(fp), 42); + + cfclose(fp); +} + +TEST_F(CFileTest, string_len_zero_and_negative) +{ + StringLenFile file; + file.add_int(0).add_int(-5).add_int(42); + auto fp = file.open(); + ASSERT_NE(fp, nullptr); + + char buf[8] = "old"; + cfread_string_len(buf, sizeof(buf), fp); + EXPECT_STREQ(buf, ""); + + strcpy_s(buf, "old"); + cfread_string_len(buf, sizeof(buf), fp); + EXPECT_STREQ(buf, ""); + EXPECT_EQ(cfread_int(fp), 42); + + cfclose(fp); +} + +TEST_F(CFileTest, string_len_scp_string) +{ + StringLenFile file; + file.add_int(3).add_chars("abc", 3).add_int(-5).add_int(1000000).add_chars("de", 2); + auto fp = file.open(); + ASSERT_NE(fp, nullptr); + + EXPECT_EQ(cfread_string_len(fp), "abc"); + EXPECT_EQ(cfread_string_len(fp), ""); + EXPECT_EQ(cfread_string_len(fp), "de"); + + cfclose(fp); +} From fd5b5e4915f2207e9825faedac289bb74d675e69 Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Wed, 14 Oct 2026 18:13:32 -0700 Subject: [PATCH 2/5] Make glow map intensity configurable per lighting profile and per texture The shader multiplied every glow map by two fixed constants: 1.5 always, and 3.0 more with post-processing on. They are now per-draw shader values with the same defaults, so nothing changes unless a mod sets them. - Lighting profiles take $Glow Map Intensity: (default 1.5) and $Glow Map HDR Multiplier:, which defaults to twice the intensity, so the old 1.5 x 3.0 is unchanged and raising the intensity raises both. - A texture can override them in a plain-text file named after it and placed next to it, for example shockwave3d.mat beside the shockwave3d texture: $Glow Map Intensity: 2.5 $Glow Map HDR Multiplier: 6 The .mat is read once when the texture loads and kept with the bitmap, so it follows texture replacement, covers every frame of an EFF, ANI or APNG, and applies wherever the texture is drawn. Decals use their diffuse texture's .mat, or their glow texture's when they have no diffuse. - Files must be plain ASCII; ';' starts a comment. Unknown entries, bad numbers and non-text files are ignored with a warning. Values are capped at 100 so glow stays within the 16-bit float emissive buffer; lighting profile values get the same checks. - .mat is added to the file types indexed in data/maps and data/effects, so builds without this change never see the files. A .mat follows the usual mod priority, like any other file. The old shader constants stay in lighting.sdr for mods that ship their own shaders referencing them. Co-Authored-By: Claude Opus 5.5 --- code/bmpman/bm_internal.h | 4 + code/bmpman/bmpman.cpp | 19 ++ code/bmpman/bmpman.h | 9 + code/cfile/cfile.cpp | 4 +- code/def_files/data/effects/decal-f.sdr | 6 +- code/def_files/data/effects/decal-v.sdr | 2 + code/def_files/data/effects/lighting.sdr | 1 + code/def_files/data/effects/main-f.sdr | 6 +- code/def_files/data/effects/main-g.sdr | 2 + code/def_files/data/effects/main-v.sdr | 2 + code/graphics/decal_draw_list.cpp | 6 + code/graphics/material.cpp | 12 + code/graphics/material.h | 7 + code/graphics/texture_material.cpp | 194 +++++++++++++ code/graphics/texture_material.h | 35 +++ code/graphics/uniforms.cpp | 3 + code/graphics/util/uniform_structs.h | 13 +- code/lighting/lighting_profiles.cpp | 51 ++++ code/lighting/lighting_profiles.h | 9 + code/model/modelrender.cpp | 9 + code/source_groups.cmake | 2 + test/src/graphics/test_texture_material.cpp | 255 ++++++++++++++++++ test/src/lighting/test_glow_settings.cpp | 122 +++++++++ test/src/source_groups.cmake | 5 + .../texture_material/data/effects/mat_fx.mat | 1 + .../texture_material/data/maps/mat_anim.eff | 3 + .../texture_material/data/maps/mat_anim.mat | 1 + .../data/maps/mat_anim_0000.png | Bin 0 -> 75 bytes .../data/maps/mat_anim_0001.png | Bin 0 -> 75 bytes .../texture_material/data/maps/mat_apng.mat | 1 + .../texture_material/data/maps/mat_apng.png | Bin 0 -> 205 bytes .../data/maps/mat_bad_number.mat | 1 + .../texture_material/data/maps/mat_bom.mat | 1 + .../texture_material/data/maps/mat_crlf.mat | 2 + .../texture_material/data/maps/mat_empty.mat | 0 .../data/maps/mat_encrypted.mat | Bin 0 -> 1549 bytes .../texture_material/data/maps/mat_glow.mat | 2 + .../texture_material/data/maps/mat_huge.mat | 1 + .../data/maps/mat_inline_comment.mat | 1 + .../data/maps/mat_intensity_only.mat | 2 + .../data/maps/mat_many_unknown.mat | 5 + .../texture_material/data/maps/mat_nan.mat | 1 + .../data/maps/mat_negative.mat | 1 + .../data/maps/mat_no_value.mat | 2 + .../texture_material/data/maps/mat_plain.png | Bin 0 -> 75 bytes .../data/maps/mat_sharp_s.mat | 2 + .../data/maps/mat_textured.mat | 1 + .../data/maps/mat_textured.png | Bin 0 -> 75 bytes .../data/maps/mat_trailing.mat | 1 + .../data/maps/mat_unknown_key.mat | 2 + .../texture_material/data/maps/mat_utf16.mat | Bin 0 -> 48 bytes .../data/tables/bad-ltp.tbm | 11 + .../lighting/data/tables/glow-ltp.tbm | 12 + .../data/tables/neg-ltp.tbm | 9 + 54 files changed, 833 insertions(+), 8 deletions(-) create mode 100644 code/graphics/texture_material.cpp create mode 100644 code/graphics/texture_material.h create mode 100644 test/src/graphics/test_texture_material.cpp create mode 100644 test/src/lighting/test_glow_settings.cpp create mode 100644 test/test_data/graphics/texture_material/data/effects/mat_fx.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_anim.eff create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_anim.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_anim_0000.png create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_anim_0001.png create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_apng.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_apng.png create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_bad_number.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_bom.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_crlf.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_empty.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_encrypted.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_glow.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_huge.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_inline_comment.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_intensity_only.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_many_unknown.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_nan.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_negative.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_no_value.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_plain.png create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_sharp_s.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_textured.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_textured.png create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_trailing.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_unknown_key.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_utf16.mat create mode 100644 test/test_data/lighting/bad_glow_values_are_rejected/data/tables/bad-ltp.tbm create mode 100644 test/test_data/lighting/data/tables/glow-ltp.tbm create mode 100644 test/test_data/lighting/negative_glow_values_clamp_to_zero/data/tables/neg-ltp.tbm diff --git a/code/bmpman/bm_internal.h b/code/bmpman/bm_internal.h index b5a0aa82605..7a511b8165c 100644 --- a/code/bmpman/bm_internal.h +++ b/code/bmpman/bm_internal.h @@ -11,6 +11,8 @@ #include "bmpman/bmpman.h" +struct texture_material; + #include /** @@ -63,6 +65,8 @@ struct bitmap_entry { int dir_type; //!< which directory this was loaded from (to skip other locations with same name) + const texture_material* material; //!< settings from the texture's .mat file, or nullptr + // compressed bitmap stuff (.dds) - RT please take a look at this and tell me if we really need it size_t mem_taken; //!< How much memory does this bitmap use? - UnknownPlayer int num_mipmaps; //!< number of mipmap levels, we need to read all of them diff --git a/code/bmpman/bmpman.cpp b/code/bmpman/bmpman.cpp index 9b9788adb67..2f6c1a1d4bf 100644 --- a/code/bmpman/bmpman.cpp +++ b/code/bmpman/bmpman.cpp @@ -22,6 +22,7 @@ #include "globalincs/systemvars.h" #include "graphics/2d.h" #include "graphics/matrix.h" +#include "graphics/texture_material.h" #include "io/key.h" #include "io/timer.h" #include "jpgutils/jpgutils.h" @@ -1112,6 +1113,13 @@ int bm_is_valid(int handle) { return entry->handle == handle; } +const texture_material* bm_get_texture_material(int handle) { + if (!bm_is_valid(handle)) + return nullptr; + + return bm_get_entry(handle)->material; +} + // Load an image and validate it while retrieving information for later use // Input: type = current BM_TYPE_* @@ -1272,12 +1280,17 @@ int bm_load(const char *real_filename, int dir_type) { return -1; } + const texture_material* material = nullptr; + // Lets find out what type it is { // see if it's already loaded (checks for any type with filename) if (bm_load_sub_fast(filename, &handle, dir_type)) return handle; + // before any file is opened or slot filled, since a bad .mat can throw in unit tests + material = texture_material_find(filename, dir_type); + // if we are still here then we need to fall back to a file-based search int rval = bm_load_sub_slow(filename, BM_NUM_TYPES, bm_ext_list, &img_cfp, dir_type); @@ -1337,6 +1350,7 @@ int bm_load(const char *real_filename, int dir_type) { entry->mem_taken = (size_t)bm_size; entry->dir_type = dir_type; entry->handle = handle; + entry->material = material; entry->load_count++; @@ -1600,6 +1614,8 @@ int bm_load_animation(const char *real_filename, int *nframes, int *fps, int *ke // used later if EFF type strcpy_s(clean_name, filename); + const texture_material* material = nullptr; + // Lets find out what type it is { int handle = -1; @@ -1623,6 +1639,8 @@ int bm_load_animation(const char *real_filename, int *nframes, int *fps, int *ke return handle; } + material = texture_material_find(clean_name, dir_type); + // if we are still here then we need to fall back to a file-based search int rval = bm_load_sub_slow(filename, BM_ANI_NUM_TYPES, bm_ani_ext_list, &img_cfp, dir_type); @@ -1832,6 +1850,7 @@ int bm_load_animation(const char *real_filename, int *nframes, int *fps, int *ke entry->num_mipmaps = mm_lvl; entry->mem_taken = (size_t)img_size; entry->dir_type = dir_type; + entry->material = material; entry->load_count++; diff --git a/code/bmpman/bmpman.h b/code/bmpman/bmpman.h index d52a01f5125..46ef78f1601 100644 --- a/code/bmpman/bmpman.h +++ b/code/bmpman/bmpman.h @@ -389,6 +389,15 @@ void bm_unlock(int handle); */ int bm_is_valid(int handle); +struct texture_material; + +/** + * @brief Gets the settings from the bitmap's .mat file + * + * @returns The settings, or nullptr if the handle is invalid or the bitmap has no .mat file + */ +const texture_material* bm_get_texture_material(int handle); + /** * @brief Gets info on the bitmap indexed by handle. * diff --git a/code/cfile/cfile.cpp b/code/cfile/cfile.cpp index be283490ff2..e010fff4353 100644 --- a/code/cfile/cfile.cpp +++ b/code/cfile/cfile.cpp @@ -53,7 +53,7 @@ cf_pathtype Pathtypes[CF_MAX_PATH_TYPES] = { // Root must be index 1!! { CF_TYPE_ROOT, "", ".mve .ogg", CF_TYPE_ROOT }, { CF_TYPE_DATA, "data", ".cfg .txt", CF_TYPE_ROOT }, - { CF_TYPE_MAPS, "data" DIR_SEPARATOR_STR "maps", ".pcx .ani .eff .tga .jpg .png .dds .ktx", CF_TYPE_DATA }, + { CF_TYPE_MAPS, "data" DIR_SEPARATOR_STR "maps", ".pcx .ani .eff .tga .jpg .png .dds .ktx .mat", CF_TYPE_DATA }, { CF_TYPE_TEXT, "data" DIR_SEPARATOR_STR "text", ".txt .net", CF_TYPE_DATA }, { CF_TYPE_MODELS, "data" DIR_SEPARATOR_STR "models", ".pof", CF_TYPE_DATA }, { CF_TYPE_TABLES, "data" DIR_SEPARATOR_STR "tables", ".tbl .tbm .lua", CF_TYPE_DATA }, @@ -69,7 +69,7 @@ cf_pathtype Pathtypes[CF_MAX_PATH_TYPES] = { { CF_TYPE_MOVIES, "data" DIR_SEPARATOR_STR "movies", ".mve .msb .ogg .mp4 .srt .webm .png",CF_TYPE_DATA }, { CF_TYPE_INTERFACE, "data" DIR_SEPARATOR_STR "interface", ".pcx .ani .dds .tga .eff .png .jpg .rml .rcss .ktx", CF_TYPE_DATA }, { CF_TYPE_FONT, "data" DIR_SEPARATOR_STR "fonts", ".vf .ttf .otf", CF_TYPE_DATA }, - { CF_TYPE_EFFECTS, "data" DIR_SEPARATOR_STR "effects", ".ani .eff .pcx .neb .tga .jpg .png .dds .ktx .sdr .vert .frag .glsl", CF_TYPE_DATA }, + { CF_TYPE_EFFECTS, "data" DIR_SEPARATOR_STR "effects", ".ani .eff .pcx .neb .tga .jpg .png .dds .ktx .sdr .vert .frag .glsl .mat", CF_TYPE_DATA }, { CF_TYPE_HUD, "data" DIR_SEPARATOR_STR "hud", ".pcx .ani .eff .tga .jpg .png .dds .ktx", CF_TYPE_DATA }, { CF_TYPE_PLAYERS, "data" DIR_SEPARATOR_STR "players", ".hcf", /* DON'T add pilot files here!! */ CF_TYPE_DATA }, { CF_TYPE_PLAYER_IMAGES, "data" DIR_SEPARATOR_STR "players" DIR_SEPARATOR_STR "images", ".pcx .png .dds .ktx", CF_TYPE_PLAYERS }, diff --git a/code/def_files/data/effects/decal-f.sdr b/code/def_files/data/effects/decal-f.sdr index 34d65a67ba8..85e68110b8c 100644 --- a/code/def_files/data/effects/decal-f.sdr +++ b/code/def_files/data/effects/decal-f.sdr @@ -73,6 +73,8 @@ uniform decalInfoData { int diffuse_blend_mode; int glow_blend_mode; + float glow_intensity; + float glow_hdr_multiplier; }; vec3 computeViewPosition(vec2 textureCoord) { @@ -180,9 +182,9 @@ void main() { // We have a valid glow map vec4 color = texture(glowMap, vec3(tex_coord, float(glow_index))); - color.rgb = srgb_to_linear(color.rgb) * GLOW_MAP_SRGB_MULTIPLIER; + color.rgb = srgb_to_linear(color.rgb) * glow_hdr_multiplier; - color.rgb *= GLOW_MAP_INTENSITY; + color.rgb *= glow_intensity; if (glow_blend_mode == 0) { // Normal alpha blending diff --git a/code/def_files/data/effects/decal-v.sdr b/code/def_files/data/effects/decal-v.sdr index 42d99146327..d5af5031da9 100644 --- a/code/def_files/data/effects/decal-v.sdr +++ b/code/def_files/data/effects/decal-v.sdr @@ -51,6 +51,8 @@ uniform decalInfoData { int diffuse_blend_mode; int glow_blend_mode; + float glow_intensity; + float glow_hdr_multiplier; }; void main() { diff --git a/code/def_files/data/effects/lighting.sdr b/code/def_files/data/effects/lighting.sdr index 799617b52eb..57944a72a86 100644 --- a/code/def_files/data/effects/lighting.sdr +++ b/code/def_files/data/effects/lighting.sdr @@ -6,6 +6,7 @@ const int LT_CONE = 3; // A cone light, like a flood light const int LT_AMBIENT = 4; // Directionless ambient light const float SPEC_FACTOR_NO_SPEC_MAP = 0.1; +// no longer used here; kept for mods that ship their own shaders referencing them const float GLOW_MAP_INTENSITY = 1.5; const float GLOW_MAP_SRGB_MULTIPLIER = 3.0; const float PI = 3.14159f; diff --git a/code/def_files/data/effects/main-f.sdr b/code/def_files/data/effects/main-f.sdr index 746fe0973b0..06502862099 100644 --- a/code/def_files/data/effects/main-f.sdr +++ b/code/def_files/data/effects/main-f.sdr @@ -89,6 +89,8 @@ uniform modelData { int sMiscmapIndex; float alphaMult; int flags; + float glowIntensity; + float glowHdrMultiplier; }; #ifdef VULKAN @@ -444,9 +446,9 @@ void main() #prereplace ENDIF_FLAG //MODEL_SDR_FLAG_TEAMCOLOR #prereplace ENDIF_FLAG //MODEL_SDR_FLAG_MISC #prereplace IF_FLAG MODEL_SDR_FLAG_HDR - glowColor = srgb_to_linear(glowColor) * GLOW_MAP_SRGB_MULTIPLIER; + glowColor = srgb_to_linear(glowColor) * glowHdrMultiplier; #prereplace ENDIF_FLAG //MODEL_SDR_FLAG_HDR - emissiveColor.rgb += glowColor * GLOW_MAP_INTENSITY; + emissiveColor.rgb += glowColor * glowIntensity; #prereplace ENDIF_FLAG //MODEL_SDR_FLAG_GLOW #prereplace IF_FLAG MODEL_SDR_FLAG_ALPHA_MULT diff --git a/code/def_files/data/effects/main-g.sdr b/code/def_files/data/effects/main-g.sdr index e348c386dfa..9e20de1e1e1 100644 --- a/code/def_files/data/effects/main-g.sdr +++ b/code/def_files/data/effects/main-g.sdr @@ -83,6 +83,8 @@ layout (std140) uniform modelData { float alphaMult; int flags; + float glowIntensity; + float glowHdrMultiplier; }; in VertexOutput { diff --git a/code/def_files/data/effects/main-v.sdr b/code/def_files/data/effects/main-v.sdr index a0adda205a5..458566be598 100644 --- a/code/def_files/data/effects/main-v.sdr +++ b/code/def_files/data/effects/main-v.sdr @@ -103,6 +103,8 @@ uniform modelData { float alphaMult; int flags; + float glowIntensity; + float glowHdrMultiplier; }; #ifdef VULKAN diff --git a/code/graphics/decal_draw_list.cpp b/code/graphics/decal_draw_list.cpp index b0f81848da6..3b546833549 100644 --- a/code/graphics/decal_draw_list.cpp +++ b/code/graphics/decal_draw_list.cpp @@ -5,6 +5,7 @@ #include "render/3d.h" #include "tracing/tracing.h" #include "light.h" +#include "graphics/texture_material.h" namespace { @@ -96,6 +97,11 @@ void decal_draw_list::prepare_global_data() { bm_get_base_frame(batch_info.normal)); info->diffuse_blend_mode = draw_info.first.material.get_blend_mode(0) == ALPHA_BLEND_ADDITIVE ? 1 : 0; info->glow_blend_mode = draw_info.first.material.get_blend_mode(2) == ALPHA_BLEND_ADDITIVE ? 1 : 0; + // glow-only decals have no diffuse texture to name the .mat after + const auto mat = bm_get_texture_material(batch_info.diffuse >= 0 ? batch_info.diffuse : batch_info.glow); + const auto glow = mat != nullptr ? mat->glow : texture_glow_settings(); + info->glow_intensity = texture_glow_intensity(glow); + info->glow_hdr_multiplier = texture_glow_hdr_multiplier(glow); } } diff --git a/code/graphics/material.cpp b/code/graphics/material.cpp index 5a57d0359e6..22c0fefea70 100644 --- a/code/graphics/material.cpp +++ b/code/graphics/material.cpp @@ -737,6 +737,18 @@ void model_material::reset_alpha_mult() { Alpha_mult = 1.0f; } +void model_material::set_glow(const texture_glow_settings& glow) { + Glow = glow; +} + +float model_material::get_glow_intensity() const { + return texture_glow_intensity(Glow); +} + +float model_material::get_glow_hdr_multiplier() const { + return texture_glow_hdr_multiplier(Glow); +} + uint model_material::get_shader_flags() const { uint Shader_flags = 0; diff --git a/code/graphics/material.h b/code/graphics/material.h index 64ad751a62f..1230339385f 100644 --- a/code/graphics/material.h +++ b/code/graphics/material.h @@ -2,6 +2,7 @@ #define _MATERIAL_H #include "graphics/grinternal.h" +#include "graphics/texture_material.h" #include "model/model.h" #include @@ -203,6 +204,8 @@ class model_material : public material bool Use_alpha_mult = false; float Alpha_mult = 1.0f; + texture_glow_settings Glow; + public: model_material(); @@ -263,6 +266,10 @@ class model_material : public material void set_alpha_mult(float alpha); void reset_alpha_mult(); + void set_glow(const texture_glow_settings& glow); + float get_glow_intensity() const; + float get_glow_hdr_multiplier() const; + }; class particle_material : public material diff --git a/code/graphics/texture_material.cpp b/code/graphics/texture_material.cpp new file mode 100644 index 00000000000..48efb1b27fe --- /dev/null +++ b/code/graphics/texture_material.cpp @@ -0,0 +1,194 @@ +#include "graphics/texture_material.h" + +#include "cfile/cfile.h" +#include "lighting/lighting_profiles.h" +#include "parse/parselo.h" + +#include + +namespace { + +// Misses are stored as nullopt so each name is read once; node-based, so bitmap entries can keep pointers into it +SCP_unordered_map, SCP_string_lcase_hash, SCP_string_lcase_equal_to> + Texture_materials; + +constexpr int MAX_MAT_FILE_LEN = 1024 * 1024; + +void mat_warning(const SCP_string& text) +{ + Warning(LOCATION, "%s", text.c_str()); + // Warning() only reaches the log in debug builds +#ifdef NDEBUG + mprintf(("TEXTURE MATERIAL: %s\n", text.c_str())); +#endif +} + +bool at_line_end() +{ + ignore_gray_space(); + return *Mp == '\0' || *Mp == EOLN || *Mp == CARRIAGE_RETURN || *Mp == ';'; +} + +std::optional parse_glow_value(const char* filename, const char* key, SCP_vector& warnings) +{ + ignore_gray_space(); + const char* start = Mp; + while (*Mp != '\0' && !is_white_space(*Mp) && *Mp != ';') + Mp++; + const SCP_string text(start, Mp - start); + + char* end = nullptr; + const float value = strtof(text.c_str(), &end); + if (text.empty() || *end != '\0' || !std::isfinite(value)) { + warnings.push_back(SCP_string(filename) + ": " + key + " needs a number, not '" + text + "'. Ignoring it."); + return std::nullopt; + } + if (!at_line_end()) + warnings.push_back(SCP_string(filename) + ": extra text after " + key + " " + text + ". Ignoring the extra text."); + + if (value < 0.0f) { + warnings.push_back(SCP_string(filename) + ": " + key + " is negative; using 0."); + return 0.0f; + } + if (value > lighting_profiles::MAX_GLOW_MAP_VALUE) { + warnings.push_back(SCP_string(filename) + ": " + key + " is above " + + std::to_string(static_cast(lighting_profiles::MAX_GLOW_MAP_VALUE)) + "; using that."); + return lighting_profiles::MAX_GLOW_MAP_VALUE; + } + return value; +} + +void parse_texture_material(const char* filename, char* text, texture_material& mat, SCP_vector& warnings) +{ + reset_parse(text); + + int unknown = 0; + // each pass consumes at least one character, so the file length bounds the loop + for (int pass = 0; !check_for_eof(); pass++) { + if (pass > MAX_MAT_FILE_LEN) { + warnings.push_back(SCP_string(filename) + ": stopped reading after " + std::to_string(pass) + " entries."); + break; + } + + if (*Mp == ';') { + advance_to_eoln(nullptr); + continue; + } + + if (optional_string("$Glow Map Intensity:")) { + mat.glow.intensity = parse_glow_value(filename, "$Glow Map Intensity:", warnings); + } else if (optional_string("$Glow Map HDR Multiplier:")) { + mat.glow.hdr_multiplier = parse_glow_value(filename, "$Glow Map HDR Multiplier:", warnings); + } else if (unknown++ == 0) { + warnings.push_back(SCP_string(filename) + ": unrecognized entry [" + next_tokens() + "]. Ignoring it."); + } + advance_to_eoln(nullptr); + } + + if (unknown > 1) + warnings.push_back(SCP_string(filename) + ": " + std::to_string(unknown - 1) + " more unrecognized entries ignored."); +} + +// Plain ASCII only: the shared table reader would decrypt or re-encode the text and could outgrow any fixed buffer +bool is_plain_text(const SCP_vector& bytes, size_t start, size_t len) +{ + for (size_t i = start; i < len; i++) { + const auto c = static_cast(bytes[i]); + if (c != '\t' && c != '\n' && c != '\r' && (c < 0x20 || c > 0x7e)) + return false; + } + return true; +} + +bool read_texture_material(const char* filename, int dir_type, texture_material& mat) +{ + CFILE* cfp = cfopen(filename, "rb", dir_type); + if (cfp == nullptr) + return false; + + const int file_len = cfilelength(cfp); + if (file_len <= 0 || file_len > MAX_MAT_FILE_LEN) { + cfclose(cfp); + mat_warning(SCP_string(filename) + " is empty or larger than " + std::to_string(MAX_MAT_FILE_LEN) + + " bytes. Ignoring it."); + return false; + } + + SCP_vector text(static_cast(file_len) + 1, '\0'); + const int read = cfread(text.data(), file_len, 1, cfp); + cfclose(cfp); + if (read != 1) { + mat_warning(SCP_string("Could not read ") + filename + ". Ignoring it."); + return false; + } + + const size_t start = (file_len >= 3 && memcmp(text.data(), "\xEF\xBB\xBF", 3) == 0) ? 3 : 0; + if (!is_plain_text(text, start, static_cast(file_len))) { + mat_warning(SCP_string(filename) + " is not a plain-text .mat file. Ignoring it."); + return false; + } + + // a texture can load while a table is being parsed, so the parse position and counters are saved around this one; + // warnings wait until it is restored, since a debug build can exit from the warning dialog + SCP_vector warnings; + pause_parse(); + try { + parse_texture_material(filename, text.data() + start, mat, warnings); + } catch (...) { + unpause_parse(); + throw; + } + unpause_parse(); + + for (const auto& warning : warnings) + mat_warning(warning); + + return true; +} + +} // namespace + +const texture_material* texture_material_find(const char* texture_filename, int dir_type) +{ + if (texture_filename == nullptr || *texture_filename == '\0') + return nullptr; + + SCP_string name = texture_filename; + const auto dot = name.rfind('.'); + if (dot != SCP_string::npos) + name.erase(dot); + name += ".mat"; + + const auto key = name + '|' + std::to_string(dir_type); + const auto found = Texture_materials.find(key); + if (found != Texture_materials.end()) + return found->second ? &*found->second : nullptr; + + auto& entry = Texture_materials[key]; + if (!cf_exists_full(name.c_str(), dir_type)) + return nullptr; + + texture_material mat; + if (!read_texture_material(name.c_str(), dir_type, mat)) + return nullptr; + + mprintf(("TEXTURE MATERIAL: Using '%s'\n", name.c_str())); + entry = mat; + return &*entry; +} + +void texture_material_reset() +{ + Texture_materials.clear(); +} + +float texture_glow_intensity(const texture_glow_settings& glow) +{ + return glow.intensity.value_or(lighting_profiles::current_glow_map_intensity()); +} + +float texture_glow_hdr_multiplier(const texture_glow_settings& glow) +{ + return glow.hdr_multiplier.value_or( + lighting_profiles::current_glow_map_hdr_multiplier(texture_glow_intensity(glow))); +} diff --git a/code/graphics/texture_material.h b/code/graphics/texture_material.h new file mode 100644 index 00000000000..28d7437c409 --- /dev/null +++ b/code/graphics/texture_material.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +struct texture_glow_settings { + std::optional intensity; + std::optional hdr_multiplier; +}; + +struct texture_material { + texture_glow_settings glow; +}; + +/** + * @brief Finds and parses the .mat file that belongs to a texture + * + * Each name is read once per session, so edits to a .mat file take effect after a restart. + * + * @param texture_filename The texture's filename; any extension is ignored + * @param dir_type The CF_TYPE_ the texture was requested from + * + * @returns The parsed settings, valid until texture_material_reset(), or nullptr if the texture has no usable .mat file + */ +const texture_material* texture_material_find(const char* texture_filename, int dir_type); + +/** + * @brief Forgets every .mat file read so far + * + * Only for when no bitmap is loaded, such as between unit tests, since loaded bitmaps point into this storage. + */ +void texture_material_reset(); + +// A texture's glow values, falling back to the current lighting profile for any it doesn't set +float texture_glow_intensity(const texture_glow_settings& glow); +float texture_glow_hdr_multiplier(const texture_glow_settings& glow); diff --git a/code/graphics/uniforms.cpp b/code/graphics/uniforms.cpp index 15ddb93a7dc..5f20ca0de5f 100644 --- a/code/graphics/uniforms.cpp +++ b/code/graphics/uniforms.cpp @@ -222,6 +222,9 @@ void convert_model_material(model_uniform_data* data_out, if (material.is_alpha_mult_active()) { data_out->alphaMult = material.get_alpha_mult(); } + + data_out->glowIntensity = material.get_glow_intensity(); + data_out->glowHdrMultiplier = material.get_glow_hdr_multiplier(); } void convert_shadow_material(shadow_uniform_data* data_out, diff --git a/code/graphics/util/uniform_structs.h b/code/graphics/util/uniform_structs.h index ab622172506..e3edcef5bc6 100644 --- a/code/graphics/util/uniform_structs.h +++ b/code/graphics/util/uniform_structs.h @@ -123,8 +123,13 @@ struct model_uniform_data { int sMiscmapIndex; float alphaMult; int flags; - float pad; + float glowIntensity; + + float glowHdrMultiplier; + float pad[3]; }; +static_assert(offsetof(model_uniform_data, glowHdrMultiplier) == 1056 && sizeof(model_uniform_data) == 1072, + "model_uniform_data must match the GLSL modelData layout"); const size_t model_uniform_data_size = sizeof(model_uniform_data); const float mud_align = model_uniform_data_size / 16.0f; @@ -228,8 +233,12 @@ struct decal_info { int diffuse_blend_mode; int glow_blend_mode; - float pad[3]; + float glow_intensity; + float glow_hdr_multiplier; + float pad; }; +static_assert(offsetof(decal_info, glow_hdr_multiplier) == 24 && sizeof(decal_info) == 32, + "decal_info must match the GLSL decalInfoData layout"); struct matrix_uniforms { matrix4 modelViewMatrix; diff --git a/code/lighting/lighting_profiles.cpp b/code/lighting/lighting_profiles.cpp index d0f4c542ccd..aa00ecd2874 100644 --- a/code/lighting/lighting_profiles.cpp +++ b/code/lighting/lighting_profiles.cpp @@ -15,6 +15,8 @@ #include "parse/parsehi.h" #include "parse/parselo.h" +#include + namespace lighting_profiles { typedef int profile_index; //************************************************* @@ -97,6 +99,40 @@ float current_exposure() return _current.exposure; } +namespace { +std::optional checked_glow_value(float value, const char* key, const SCP_string& profile_name) +{ + if (!std::isfinite(value)) { + Warning(LOCATION, "%s in lighting profile %s needs a number. Ignoring it.", key, profile_name.c_str()); + return std::nullopt; + } + if (value < 0.0f) { + Warning(LOCATION, "%s in lighting profile %s is negative; using 0.", key, profile_name.c_str()); + return 0.0f; + } + if (value > MAX_GLOW_MAP_VALUE) { + Warning(LOCATION, + "%s in lighting profile %s is above %d; using that.", + key, + profile_name.c_str(), + static_cast(MAX_GLOW_MAP_VALUE)); + return MAX_GLOW_MAP_VALUE; + } + return value; +} +} // namespace + +float current_glow_map_intensity() +{ + return _current.glow_map_intensity; +} + +float current_glow_map_hdr_multiplier(float intensity) +{ + // unless set, the HDR multiplier follows the intensity, keeping the old 1.5 x 3.0 by default + return _current.glow_map_hdr_multiplier.value_or(2.0f * intensity); +} + piecewise_power_curve_intermediates current_piecewise_intermediates() { return calc_intermediates(_current.ppc_values); @@ -423,6 +459,18 @@ void profile::parse(const char* filename, const SCP_string& profile_name, const profile_name, &cockpit_light_intensity_modifier); + float glow_value; + if (parse_optional_float_into("$Glow Map Intensity:", &glow_value)) { + parsed = true; + if (auto checked = checked_glow_value(glow_value, "$Glow Map Intensity:", profile_name)) + glow_map_intensity = *checked; + } + if (parse_optional_float_into("$Glow Map HDR Multiplier:", &glow_value)) { + parsed = true; + if (auto checked = checked_glow_value(glow_value, "$Glow Map HDR Multiplier:", profile_name)) + glow_map_hdr_multiplier = *checked; + } + if (!parsed) { stuff_string(buffer, F_RAW); Warning(LOCATION, "Unhandled line in lighting profile\n\t%s", buffer.c_str()); @@ -444,6 +492,9 @@ void profile::reset() exposure = 4.0f; + glow_map_intensity = 1.5f; + glow_map_hdr_multiplier.reset(); + missile_light_brightness.reset(); missile_light_radius.reset(); missile_light_radius.base = 20.0f; diff --git a/code/lighting/lighting_profiles.h b/code/lighting/lighting_profiles.h index 92819615bdf..29094c03ab8 100644 --- a/code/lighting/lighting_profiles.h +++ b/code/lighting/lighting_profiles.h @@ -4,6 +4,8 @@ #include "globalincs/adjustment.h" #include "globalincs/vmallocator.h" +#include + namespace lighting_profiles { // This file handles the profiles.tbl and -ltp.tbm files. The purpose of these files is to provide control over the HDR @@ -78,6 +80,8 @@ class profile { adjustment overall_brightness; adjustment cockpit_light_radius_modifier; adjustment cockpit_light_intensity_modifier; + float glow_map_intensity; + std::optional glow_map_hdr_multiplier; void reset(); profile& operator=(const profile& rhs); @@ -97,6 +101,11 @@ const piecewise_power_curve_values& current_piecewise_values(); piecewise_power_curve_intermediates current_piecewise_intermediates(); piecewise_power_curve_intermediates calc_intermediates(piecewise_power_curve_values input); float current_exposure(); +// keeps glow within the 16-bit float emissive buffer: 100 x a default HDR multiplier of 200 is 20,000, under 65,504 +constexpr float MAX_GLOW_MAP_VALUE = 100.0f; + +float current_glow_map_intensity(); +float current_glow_map_hdr_multiplier(float intensity); void lab_set_exposure(float exIn); void lab_set_tonemapper(TonemapperAlgorithm tnin); void lab_set_ppc(const piecewise_power_curve_values &ppcin); diff --git a/code/model/modelrender.cpp b/code/model/modelrender.cpp index a9a772cf652..d08a6b13353 100644 --- a/code/model/modelrender.cpp +++ b/code/model/modelrender.cpp @@ -1065,8 +1065,11 @@ void model_render_buffers(model_draw_list* scene, model_material *rendering_mate texture_maps[TM_SPEC_GLOSS_TYPE] = -1; texture_maps[TM_AMBIENT_TYPE] = -1; + int material_base = -1; + if (forced_texture != -2) { texture_maps[TM_BASE_TYPE] = forced_texture; + material_base = forced_texture; alpha = forced_alpha; if (interp->get_warp_bitmap() >= 0) { @@ -1094,6 +1097,9 @@ void model_render_buffers(model_draw_list* scene, model_material *rendering_mate continue; } + // the .mat still applies to the glow map when the lab hides the diffuse map + material_base = texture_maps[TM_BASE_TYPE]; + // doing glow maps? if ( !(model_flags & MR_NO_GLOWMAPS) ) { auto tglow = &tmap->textures[TM_GLOW_TYPE]; @@ -1216,6 +1222,9 @@ void model_render_buffers(model_draw_list* scene, model_material *rendering_mate model_render_determine_color(&clr, alpha, blend_mode, no_texturing ? true : false, rendering_material->is_desaturated()); rendering_material->set_color(clr); + const auto mat = bm_get_texture_material(material_base); + rendering_material->set_glow(mat != nullptr ? mat->glow : texture_glow_settings()); + if ( (tmap_flags & TMAP_FLAG_TEXTURED) && (buffer->flags & VB_FLAG_UV1) ) { rendering_material->set_texture_map(TM_BASE_TYPE, texture_maps[TM_BASE_TYPE]); diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 04734bca905..1f893b546ef 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -483,6 +483,8 @@ add_file_folder("Graphics" graphics/render_queue.h graphics/shadows.cpp graphics/shadows.h + graphics/texture_material.cpp + graphics/texture_material.h graphics/tmapper.h graphics/uniforms.cpp graphics/uniforms.h diff --git a/test/src/graphics/test_texture_material.cpp b/test/src/graphics/test_texture_material.cpp new file mode 100644 index 00000000000..6e7fc373f16 --- /dev/null +++ b/test/src/graphics/test_texture_material.cpp @@ -0,0 +1,255 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "util/FSTestFixture.h" + +namespace { + +// Debug test builds turn Warning() into an exception, Release builds only count it +void expect_warning(const char* name, int dir_type, const char* text) +{ +#ifndef NDEBUG + try { + texture_material_find(name, dir_type); + FAIL() << "expected a warning containing '" << text << "'"; + } catch (const os::dialogs::WarningException& e) { + EXPECT_NE(SCP_string(e.what()).find(text), SCP_string::npos) << e.what(); + } +#else + int warnings = Global_warning_count; + texture_material_find(name, dir_type); + EXPECT_EQ(Global_warning_count, warnings + 1) << "expected a warning containing '" << text << "'"; +#endif +} + +} // namespace + +class TextureMaterialTest : public test::FSTestFixture { +public: + TextureMaterialTest() : test::FSTestFixture(INIT_CFILE) { + pushModDir("graphics"); + pushModDir("texture_material"); + } + +protected: + void TearDown() override { + texture_material_reset(); + test::FSTestFixture::TearDown(); + } +}; + +class TextureMaterialBitmapTest : public test::FSTestFixture { +public: + TextureMaterialBitmapTest() : test::FSTestFixture(INIT_CFILE | INIT_GRAPHICS) { + pushModDir("graphics"); + pushModDir("texture_material"); + } + +protected: + // the fixture runs as a standalone server, where bmpman swaps every name for a placeholder + void SetUp() override { + test::FSTestFixture::SetUp(); + _was_standalone = Is_standalone; + Is_standalone = 0; + } + + void TearDown() override { + Is_standalone = _was_standalone; + test::FSTestFixture::TearDown(); + texture_material_reset(); + } + +private: + int _was_standalone = 0; +}; + +TEST_F(TextureMaterialTest, reads_both_glow_values) { + auto mat = texture_material_find("mat_glow", CF_TYPE_MAPS); + ASSERT_NE(mat, nullptr); + EXPECT_FLOAT_EQ(*mat->glow.intensity, 2.5f); + EXPECT_FLOAT_EQ(*mat->glow.hdr_multiplier, 6.0f); +} + +TEST_F(TextureMaterialTest, unset_values_stay_unset_and_comments_are_skipped) { + auto mat = texture_material_find("mat_intensity_only", CF_TYPE_MAPS); + ASSERT_NE(mat, nullptr); + EXPECT_FLOAT_EQ(*mat->glow.intensity, 0.75f); + EXPECT_FALSE(mat->glow.hdr_multiplier.has_value()); + + auto inline_comment = texture_material_find("mat_inline_comment", CF_TYPE_MAPS); + ASSERT_NE(inline_comment, nullptr); + EXPECT_FLOAT_EQ(*inline_comment->glow.intensity, 1.5f); +} + +TEST_F(TextureMaterialTest, crlf_and_utf8_bom_are_read) { + auto crlf = texture_material_find("mat_crlf", CF_TYPE_MAPS); + ASSERT_NE(crlf, nullptr); + EXPECT_FLOAT_EQ(*crlf->glow.intensity, 0.5f); + EXPECT_FLOAT_EQ(*crlf->glow.hdr_multiplier, 2.25f); + + auto bom = texture_material_find("mat_bom", CF_TYPE_MAPS); + ASSERT_NE(bom, nullptr); + EXPECT_FLOAT_EQ(*bom->glow.intensity, 1.75f); +} + +TEST_F(TextureMaterialTest, texture_without_a_mat_file) { + EXPECT_EQ(texture_material_find("mat_plain", CF_TYPE_MAPS), nullptr); + EXPECT_EQ(texture_material_find("", CF_TYPE_MAPS), nullptr); + EXPECT_EQ(texture_material_find(nullptr, CF_TYPE_MAPS), nullptr); +} + +TEST_F(TextureMaterialTest, extension_and_case_are_ignored) { + auto upper = texture_material_find("MAT_GLOW.png", CF_TYPE_MAPS); + ASSERT_NE(upper, nullptr); + EXPECT_EQ(texture_material_find("mat_glow", CF_TYPE_MAPS), upper); + EXPECT_EQ(texture_material_find("mat_glow.dds", CF_TYPE_MAPS), upper); +} + +TEST_F(TextureMaterialTest, directory_type_is_part_of_the_lookup) { + auto any = texture_material_find("mat_fx", CF_TYPE_ANY); + ASSERT_NE(any, nullptr); + EXPECT_FLOAT_EQ(*any->glow.intensity, 6.0f); + EXPECT_EQ(texture_material_find("mat_fx", CF_TYPE_MAPS), nullptr); + EXPECT_NE(texture_material_find("mat_fx", CF_TYPE_EFFECTS), nullptr); +} + +TEST_F(TextureMaterialTest, non_text_files_are_rejected) { + expect_warning("mat_sharp_s", CF_TYPE_MAPS, "not a plain-text"); + expect_warning("mat_utf16", CF_TYPE_MAPS, "not a plain-text"); + expect_warning("mat_encrypted", CF_TYPE_MAPS, "not a plain-text"); + expect_warning("mat_empty", CF_TYPE_MAPS, "is empty"); +#ifdef NDEBUG + EXPECT_EQ(texture_material_find("mat_sharp_s", CF_TYPE_MAPS), nullptr); + EXPECT_EQ(texture_material_find("mat_empty", CF_TYPE_MAPS), nullptr); +#endif +} + +TEST_F(TextureMaterialTest, a_rejected_file_warns_once) { + expect_warning("mat_sharp_s", CF_TYPE_MAPS, "not a plain-text"); +#ifdef NDEBUG + int warnings = Global_warning_count; + EXPECT_EQ(texture_material_find("mat_sharp_s", CF_TYPE_MAPS), nullptr); + EXPECT_EQ(Global_warning_count, warnings); +#endif +} + +TEST_F(TextureMaterialTest, bad_values_warn_and_are_ignored) { + expect_warning("mat_bad_number", CF_TYPE_MAPS, "needs a number"); + expect_warning("mat_nan", CF_TYPE_MAPS, "needs a number"); + expect_warning("mat_no_value", CF_TYPE_MAPS, "needs a number"); +#ifdef NDEBUG + EXPECT_FALSE(texture_material_find("mat_bad_number", CF_TYPE_MAPS)->glow.intensity.has_value()); + EXPECT_FALSE(texture_material_find("mat_nan", CF_TYPE_MAPS)->glow.intensity.has_value()); + auto no_value = texture_material_find("mat_no_value", CF_TYPE_MAPS); + EXPECT_FALSE(no_value->glow.intensity.has_value()); + EXPECT_FLOAT_EQ(*no_value->glow.hdr_multiplier, 4.0f); +#endif +} + +TEST_F(TextureMaterialTest, out_of_range_values_are_clamped) { + expect_warning("mat_negative", CF_TYPE_MAPS, "negative"); + expect_warning("mat_huge", CF_TYPE_MAPS, "above"); +#ifdef NDEBUG + EXPECT_FLOAT_EQ(*texture_material_find("mat_negative", CF_TYPE_MAPS)->glow.intensity, 0.0f); + EXPECT_FLOAT_EQ(*texture_material_find("mat_huge", CF_TYPE_MAPS)->glow.intensity, + lighting_profiles::MAX_GLOW_MAP_VALUE); +#endif +} + +TEST_F(TextureMaterialTest, extra_text_and_unknown_entries_warn_and_keep_the_rest) { + expect_warning("mat_trailing", CF_TYPE_MAPS, "extra text"); + expect_warning("mat_unknown_key", CF_TYPE_MAPS, "unrecognized"); +#ifdef NDEBUG + EXPECT_FLOAT_EQ(*texture_material_find("mat_trailing", CF_TYPE_MAPS)->glow.intensity, 2.0f); + EXPECT_FLOAT_EQ(*texture_material_find("mat_unknown_key", CF_TYPE_MAPS)->glow.intensity, 1.25f); +#endif +} + +TEST_F(TextureMaterialTest, the_largest_values_fit_the_emissive_buffer) { + // the emissive render target is 16-bit float, whose largest finite value is 65504 + texture_glow_settings glow; + glow.intensity = lighting_profiles::MAX_GLOW_MAP_VALUE; + EXPECT_LE(texture_glow_intensity(glow) * texture_glow_hdr_multiplier(glow), 65504.0f); +} + +TEST_F(TextureMaterialTest, many_unknown_entries_warn_once_and_are_counted) { +#ifndef NDEBUG + EXPECT_THROW(texture_material_find("mat_many_unknown", CF_TYPE_MAPS), os::dialogs::WarningException); +#else + // one warning for the first unknown entry, one summary for the other three + int warnings = Global_warning_count; + auto mat = texture_material_find("mat_many_unknown", CF_TYPE_MAPS); + EXPECT_EQ(Global_warning_count, warnings + 2); + ASSERT_NE(mat, nullptr); + EXPECT_FLOAT_EQ(*mat->glow.intensity, 1.25f); +#endif +} + +TEST_F(TextureMaterialTest, an_outer_parse_resumes_after_a_mat_file) { +#ifndef NDEBUG + EXPECT_THROW(texture_material_find("mat_bad_number", CF_TYPE_MAPS), os::dialogs::WarningException); + // a parse left paused makes this read fail + EXPECT_NO_THROW(read_file_text("mat_glow.mat", CF_TYPE_MAPS)); +#endif + char outer[] = "$Outer: 1\n"; + reset_parse(outer); + pause_parse(); + const texture_material* mat = nullptr; + try { + mat = texture_material_find("mat_intensity_only", CF_TYPE_MAPS); + } catch (...) { + unpause_parse(); + throw; + } + unpause_parse(); + EXPECT_NE(mat, nullptr); + EXPECT_EQ(Mp, outer); +} + +TEST_F(TextureMaterialBitmapTest, loaded_texture_carries_its_material) { + int textured = bm_load("mat_textured", CF_TYPE_MAPS); + ASSERT_GE(textured, 0); + auto mat = bm_get_texture_material(textured); + ASSERT_NE(mat, nullptr); + EXPECT_FLOAT_EQ(*mat->glow.intensity, 4.0f); + + int plain = bm_load("mat_plain", CF_TYPE_MAPS); + ASSERT_GE(plain, 0); + EXPECT_EQ(bm_get_texture_material(plain), nullptr); + + EXPECT_EQ(bm_get_texture_material(-1), nullptr); +} + +TEST_F(TextureMaterialBitmapTest, eff_frames_share_the_material) { + int nframes = 0; + int first = bm_load_animation("mat_anim", &nframes, nullptr, nullptr, nullptr, false, CF_TYPE_MAPS); + ASSERT_GE(first, 0); + ASSERT_EQ(nframes, 2); + + for (int i = 0; i < nframes; i++) { + auto mat = bm_get_texture_material(first + i); + ASSERT_NE(mat, nullptr); + EXPECT_FLOAT_EQ(*mat->glow.hdr_multiplier, 8.0f); + } +} + +TEST_F(TextureMaterialBitmapTest, apng_frames_share_the_material) { + int nframes = 0; + int first = bm_load_animation("mat_apng", &nframes, nullptr, nullptr, nullptr, false, CF_TYPE_MAPS); + ASSERT_GE(first, 0); + ASSERT_EQ(nframes, 2); + + for (int i = 0; i < nframes; i++) { + auto mat = bm_get_texture_material(first + i); + ASSERT_NE(mat, nullptr); + EXPECT_FLOAT_EQ(*mat->glow.intensity, 9.0f); + } +} diff --git a/test/src/lighting/test_glow_settings.cpp b/test/src/lighting/test_glow_settings.cpp new file mode 100644 index 00000000000..34de27efb28 --- /dev/null +++ b/test/src/lighting/test_glow_settings.cpp @@ -0,0 +1,122 @@ +#include +#include +#include + +#include "util/FSTestFixture.h" + +static float profile_hdr_multiplier() +{ + return lighting_profiles::current_glow_map_hdr_multiplier(lighting_profiles::current_glow_map_intensity()); +} + +class GlowSettingsTest : public test::FSTestFixture { +public: + GlowSettingsTest() : test::FSTestFixture(INIT_CFILE) { + pushModDir("lighting"); + } + +protected: + void SetUp() override { + test::FSTestFixture::SetUp(); + lighting_profiles::load_profiles(); + } + + void TearDown() override { + lighting_profiles::switch_to(lighting_profiles::default_name()); + test::FSTestFixture::TearDown(); + } +}; + +TEST_F(GlowSettingsTest, defaults_match_the_old_shader_constants) { + lighting_profiles::switch_to("Glow Default"); + ASSERT_EQ(lighting_profiles::current()->name, "Glow Default"); + EXPECT_FLOAT_EQ(lighting_profiles::current_glow_map_intensity(), 1.5f); + EXPECT_FLOAT_EQ(profile_hdr_multiplier(), 3.0f); +} + +TEST_F(GlowSettingsTest, hdr_multiplier_follows_intensity_unless_set) { + lighting_profiles::switch_to("Glow Intensity"); + EXPECT_FLOAT_EQ(lighting_profiles::current_glow_map_intensity(), 2.0f); + EXPECT_FLOAT_EQ(profile_hdr_multiplier(), 4.0f); + + lighting_profiles::switch_to("Glow Both"); + EXPECT_FLOAT_EQ(lighting_profiles::current_glow_map_intensity(), 2.0f); + EXPECT_FLOAT_EQ(profile_hdr_multiplier(), 5.0f); + + lighting_profiles::switch_to("Glow Intensity"); + EXPECT_FLOAT_EQ(profile_hdr_multiplier(), 4.0f); +} + +TEST_F(GlowSettingsTest, texture_values_override_the_profile) { + lighting_profiles::switch_to("Glow Both"); + model_material material; + EXPECT_FLOAT_EQ(material.get_glow_intensity(), 2.0f); + EXPECT_FLOAT_EQ(material.get_glow_hdr_multiplier(), 5.0f); + + material.set_glow({3.0f, std::nullopt}); + EXPECT_FLOAT_EQ(material.get_glow_intensity(), 3.0f); + EXPECT_FLOAT_EQ(material.get_glow_hdr_multiplier(), 5.0f); + + material.set_glow({std::nullopt, 7.0f}); + EXPECT_FLOAT_EQ(material.get_glow_intensity(), 2.0f); + EXPECT_FLOAT_EQ(material.get_glow_hdr_multiplier(), 7.0f); + + material.set_glow({}); + EXPECT_FLOAT_EQ(material.get_glow_intensity(), 2.0f); + EXPECT_FLOAT_EQ(material.get_glow_hdr_multiplier(), 5.0f); + + lighting_profiles::switch_to("Glow Intensity"); + material.set_glow({3.0f, std::nullopt}); + EXPECT_FLOAT_EQ(material.get_glow_hdr_multiplier(), 6.0f); +} + +class GlowNegativeProfileTest : public test::FSTestFixture { +public: + GlowNegativeProfileTest() : test::FSTestFixture(INIT_CFILE) { + pushModDir("lighting"); + } + +protected: + void TearDown() override { +#ifdef NDEBUG + lighting_profiles::switch_to(lighting_profiles::default_name()); +#endif + test::FSTestFixture::TearDown(); + } +}; + +TEST_F(GlowNegativeProfileTest, negative_glow_values_clamp_to_zero) { +#ifndef NDEBUG + GTEST_SKIP() << "in debug test builds Warning() throws mid-parse"; +#else + int warnings = Global_warning_count; + lighting_profiles::load_profiles(); + EXPECT_EQ(Global_warning_count, warnings + 2); + + lighting_profiles::switch_to("Glow Negative"); + EXPECT_FLOAT_EQ(lighting_profiles::current_glow_map_intensity(), 0.0f); + EXPECT_FLOAT_EQ(profile_hdr_multiplier(), 0.0f); + + lighting_profiles::switch_to("Glow Negative HDR"); + EXPECT_FLOAT_EQ(lighting_profiles::current_glow_map_intensity(), 1.5f); + EXPECT_FLOAT_EQ(profile_hdr_multiplier(), 0.0f); +#endif +} + +TEST_F(GlowNegativeProfileTest, bad_glow_values_are_rejected) { +#ifndef NDEBUG + GTEST_SKIP() << "in debug test builds Warning() throws mid-parse"; +#else + int warnings = Global_warning_count; + lighting_profiles::load_profiles(); + EXPECT_EQ(Global_warning_count, warnings + 4); + + lighting_profiles::switch_to("Glow NaN"); + EXPECT_FLOAT_EQ(lighting_profiles::current_glow_map_intensity(), 1.5f); + EXPECT_FLOAT_EQ(profile_hdr_multiplier(), 3.0f); + + lighting_profiles::switch_to("Glow Huge"); + EXPECT_FLOAT_EQ(lighting_profiles::current_glow_map_intensity(), lighting_profiles::MAX_GLOW_MAP_VALUE); + EXPECT_FLOAT_EQ(profile_hdr_multiplier(), lighting_profiles::MAX_GLOW_MAP_VALUE); +#endif +} diff --git a/test/src/source_groups.cmake b/test/src/source_groups.cmake index 1c268dda86e..3bee637e5dc 100644 --- a/test/src/source_groups.cmake +++ b/test/src/source_groups.cmake @@ -25,6 +25,7 @@ add_file_folder("Globalincs" add_file_folder("Graphics" graphics/test_font.cpp + graphics/test_texture_material.cpp ) if (FSO_BUILD_WITH_VULKAN) @@ -33,6 +34,10 @@ if (FSO_BUILD_WITH_VULKAN) ) endif() +add_file_folder("Lighting" + lighting/test_glow_settings.cpp +) + add_file_folder("Math" math/test_vecmat.cpp ) diff --git a/test/test_data/graphics/texture_material/data/effects/mat_fx.mat b/test/test_data/graphics/texture_material/data/effects/mat_fx.mat new file mode 100644 index 00000000000..50c3a42ab45 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/effects/mat_fx.mat @@ -0,0 +1 @@ +$Glow Map Intensity: 6 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_anim.eff b/test/test_data/graphics/texture_material/data/maps/mat_anim.eff new file mode 100644 index 00000000000..bfa7706c289 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_anim.eff @@ -0,0 +1,3 @@ +$Type: png +$Frames: 2 +$FPS: 10 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_anim.mat b/test/test_data/graphics/texture_material/data/maps/mat_anim.mat new file mode 100644 index 00000000000..1310b9a38fb --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_anim.mat @@ -0,0 +1 @@ +$Glow Map HDR Multiplier: 8 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_anim_0000.png b/test/test_data/graphics/texture_material/data/maps/mat_anim_0000.png new file mode 100644 index 0000000000000000000000000000000000000000..0dbabee15ef40c1fe1f847c077904340696eecac GIT binary patch literal 75 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1|(O0oL2{=ggjjwLn`JZU$|iLQC`w`f*u1y XgoR?E@O~*}pe%!@tDnm{r-UW|JslBh literal 0 HcmV?d00001 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_anim_0001.png b/test/test_data/graphics/texture_material/data/maps/mat_anim_0001.png new file mode 100644 index 0000000000000000000000000000000000000000..3a61ea5b43527e0672f86c6c7738ddf8b9cc5464 GIT binary patch literal 75 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1|(O0oL2{=ggjjwLn`JZTNFAywl_*sh-F|n X>aJbb{q(IlP?o{d)z4*}Q$iB}L}(JF literal 0 HcmV?d00001 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_apng.mat b/test/test_data/graphics/texture_material/data/maps/mat_apng.mat new file mode 100644 index 00000000000..dfccf8d1146 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_apng.mat @@ -0,0 +1 @@ +$Glow Map Intensity: 9 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_apng.png b/test/test_data/graphics/texture_material/data/maps/mat_apng.png new file mode 100644 index 0000000000000000000000000000000000000000..4773cbcba49a3643d804c9b4276cf619e3365e82 GIT binary patch literal 205 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1|(O0oL2{=I1-aXe1J3)5Ho!4om>Et)slNwL6s_j9{mNuNCM>RoQTJq*H!_S#R3RxoJrcJS zP{6A5`#`-4KO9i9NDqhTu~|hZ!0Ni8a?IVjosukh?xt1N%`rGukEsX;v?Z~u z;7%7d)Fkm3b7s)LKMS3IY)ZrCaVibr z6U5WLsU39c+(@Lcl@}e>q6)7KNZ6BD7@Gn&%cA<)5dvtFvv(Gs9zXQC?52?`O43}E z#h*5lPwwk7Y}=9x9|AJtOx;OD2q5tB@{wDi32xB;zZ2qGM#r3+5jzu2Xt8DBkF$@_ zwiJF3hL-N1Pi{A4o!Np%3|kW*j>*v0RCuoqo?*e*W~aK#!>Fr>EsM@tFlBA(7vbf2 z{8EXg>J0X^Jzop+?}lzb5I5vjM#yPX0BTFku3yDF6& zQ>*T12v)eF$s-FT0MTuF8$L6G*i8rWcW#h}_bdpnrWb@B4MXp+mv@D3c@PJ;-dt+( z9uF(2*I*Q&1*7J2InIs&n5k7bU`&T-0{z_`eCcRKj>nSR>s>s!Hu|geTm{*5ajfz| z2%U(im`&n$5BH;TONedQlO%NX*b(1_h6yiQE2Lj}Bp3WhX%HgzqoXWDy#7CWlv`@6wMiw`^Wpe+ISLM(!#pK6EbV_$&J=)Aa_3$ zy8YzpXhf4as#W#$N{f~s7kqzI@mCU#|L&p)QFN9X&)Tt|7JdE2Q@yT?1O&mW0j2^$)0%G~ zpgRcinZxZE31OVNd>@DhB<-g4y@)(R*;Zl#nizGK=knhY6lsV(JU+QZ$aPjoDTMt5 literal 0 HcmV?d00001 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_glow.mat b/test/test_data/graphics/texture_material/data/maps/mat_glow.mat new file mode 100644 index 00000000000..2c0649d9348 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_glow.mat @@ -0,0 +1,2 @@ +$Glow Map Intensity: 2.5 +$Glow Map HDR Multiplier: 6 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_huge.mat b/test/test_data/graphics/texture_material/data/maps/mat_huge.mat new file mode 100644 index 00000000000..30166d80895 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_huge.mat @@ -0,0 +1 @@ +$Glow Map Intensity: 3e38 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_inline_comment.mat b/test/test_data/graphics/texture_material/data/maps/mat_inline_comment.mat new file mode 100644 index 00000000000..2dc339d5c95 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_inline_comment.mat @@ -0,0 +1 @@ +$Glow Map Intensity: 1.5 ; brighter than the hull diff --git a/test/test_data/graphics/texture_material/data/maps/mat_intensity_only.mat b/test/test_data/graphics/texture_material/data/maps/mat_intensity_only.mat new file mode 100644 index 00000000000..d3939ae4572 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_intensity_only.mat @@ -0,0 +1,2 @@ +; only the intensity is set +$Glow Map Intensity: 0.75 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_many_unknown.mat b/test/test_data/graphics/texture_material/data/maps/mat_many_unknown.mat new file mode 100644 index 00000000000..3e16aa26436 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_many_unknown.mat @@ -0,0 +1,5 @@ +$Shine: 1 +$Gloss: 2 +$Glow Map Intensity: 1.25 +$Spec: 3 +$Rim: 4 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_nan.mat b/test/test_data/graphics/texture_material/data/maps/mat_nan.mat new file mode 100644 index 00000000000..ffe5a3236f2 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_nan.mat @@ -0,0 +1 @@ +$Glow Map Intensity: nan diff --git a/test/test_data/graphics/texture_material/data/maps/mat_negative.mat b/test/test_data/graphics/texture_material/data/maps/mat_negative.mat new file mode 100644 index 00000000000..9d8014f3ec4 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_negative.mat @@ -0,0 +1 @@ +$Glow Map Intensity: -2 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_no_value.mat b/test/test_data/graphics/texture_material/data/maps/mat_no_value.mat new file mode 100644 index 00000000000..25b4b3063d9 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_no_value.mat @@ -0,0 +1,2 @@ +$Glow Map Intensity: +$Glow Map HDR Multiplier: 4 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_plain.png b/test/test_data/graphics/texture_material/data/maps/mat_plain.png new file mode 100644 index 0000000000000000000000000000000000000000..03bb96559cd600fe2b1019212e3d6121a71c04c5 GIT binary patch literal 75 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1|(O0oL2{=ggjjwLn`JZU$9vCQC`w`f*u1y Xl7(WSvf5WWpe%!@tDnm{r-UW|JzNp8 literal 0 HcmV?d00001 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_sharp_s.mat b/test/test_data/graphics/texture_material/data/maps/mat_sharp_s.mat new file mode 100644 index 00000000000..c816be3e21f --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_sharp_s.mat @@ -0,0 +1,2 @@ +$Glow Map Intensity: 2 +; ßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßß diff --git a/test/test_data/graphics/texture_material/data/maps/mat_textured.mat b/test/test_data/graphics/texture_material/data/maps/mat_textured.mat new file mode 100644 index 00000000000..535854bcb26 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_textured.mat @@ -0,0 +1 @@ +$Glow Map Intensity: 4 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_textured.png b/test/test_data/graphics/texture_material/data/maps/mat_textured.png new file mode 100644 index 0000000000000000000000000000000000000000..d7bbffc5d1d6b4f2ed9fc4dee7da00af6b43edfe GIT binary patch literal 75 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1|(O0oL2{=ggjjwLn`JZTP$4oQC`w`f*u1y Xj)h{OcieRupe%!@tDnm{r-UW|I&=}O literal 0 HcmV?d00001 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_trailing.mat b/test/test_data/graphics/texture_material/data/maps/mat_trailing.mat new file mode 100644 index 00000000000..aa6e70a066e --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_trailing.mat @@ -0,0 +1 @@ +$Glow Map Intensity: 2.0 abc diff --git a/test/test_data/graphics/texture_material/data/maps/mat_unknown_key.mat b/test/test_data/graphics/texture_material/data/maps/mat_unknown_key.mat new file mode 100644 index 00000000000..e0f2c19588b --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_unknown_key.mat @@ -0,0 +1,2 @@ +$Glow Map Intensity: 1.25 +$Specular Boost: 3 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_utf16.mat b/test/test_data/graphics/texture_material/data/maps/mat_utf16.mat new file mode 100644 index 0000000000000000000000000000000000000000..aad4b9205eeac8222655ea8305576956344cdf00 GIT binary patch literal 48 ycmezWPldsqA%`KKp`1a1!IvSCp#VsGGUPFoFr)&>Vunm0tCGPAC~C;S#Q*>oV+nNt literal 0 HcmV?d00001 diff --git a/test/test_data/lighting/bad_glow_values_are_rejected/data/tables/bad-ltp.tbm b/test/test_data/lighting/bad_glow_values_are_rejected/data/tables/bad-ltp.tbm new file mode 100644 index 00000000000..84a90312daf --- /dev/null +++ b/test/test_data/lighting/bad_glow_values_are_rejected/data/tables/bad-ltp.tbm @@ -0,0 +1,11 @@ +#PROFILES + +$Profile: Glow NaN +$Glow Map Intensity: nan +$Glow Map HDR Multiplier: nan + +$Profile: Glow Huge +$Glow Map Intensity: 500 +$Glow Map HDR Multiplier: 500 + +#END PROFILES diff --git a/test/test_data/lighting/data/tables/glow-ltp.tbm b/test/test_data/lighting/data/tables/glow-ltp.tbm new file mode 100644 index 00000000000..550a9030376 --- /dev/null +++ b/test/test_data/lighting/data/tables/glow-ltp.tbm @@ -0,0 +1,12 @@ +#PROFILES + +$Profile: Glow Default + +$Profile: Glow Intensity +$Glow Map Intensity: 2.0 + +$Profile: Glow Both +$Glow Map Intensity: 2.0 +$Glow Map HDR Multiplier: 5.0 + +#END PROFILES diff --git a/test/test_data/lighting/negative_glow_values_clamp_to_zero/data/tables/neg-ltp.tbm b/test/test_data/lighting/negative_glow_values_clamp_to_zero/data/tables/neg-ltp.tbm new file mode 100644 index 00000000000..1ca10d139fc --- /dev/null +++ b/test/test_data/lighting/negative_glow_values_clamp_to_zero/data/tables/neg-ltp.tbm @@ -0,0 +1,9 @@ +#PROFILES + +$Profile: Glow Negative +$Glow Map Intensity: -1.0 + +$Profile: Glow Negative HDR +$Glow Map HDR Multiplier: -2.0 + +#END PROFILES From 7163aebc08c0874845a4d9dfaa9738bb3933c6d5 Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Thu, 15 Oct 2026 03:10:22 -0700 Subject: [PATCH 3/5] Accept UTF-8 in .mat files FSO treats text files as UTF-8 in Unicode mode, so a .mat file with a non-ASCII comment was being ignored. Valid UTF-8 is now read; other encodings, and files with control bytes such as UTF-16 without a byte order mark, still get a warning and are ignored. Release builds now also check that each rejected file returns no material. Co-Authored-By: Claude Opus 5.5 --- code/graphics/texture_material.cpp | 16 ++++++---- test/src/graphics/test_texture_material.cpp | 29 ++++++++++++++---- .../texture_material/data/maps/mat_ctrl.mat | 2 ++ .../texture_material/data/maps/mat_del.mat | 2 ++ .../texture_material/data/maps/mat_tab.mat | 2 ++ .../data/maps/mat_truncated.mat | 2 ++ .../data/maps/mat_utf16_nobom.mat | Bin 0 -> 46 bytes .../texture_material/data/maps/mat_utf8.mat | 2 ++ 8 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_ctrl.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_del.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_tab.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_truncated.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_utf16_nobom.mat create mode 100644 test/test_data/graphics/texture_material/data/maps/mat_utf8.mat diff --git a/code/graphics/texture_material.cpp b/code/graphics/texture_material.cpp index 48efb1b27fe..ded85a50979 100644 --- a/code/graphics/texture_material.cpp +++ b/code/graphics/texture_material.cpp @@ -6,6 +6,8 @@ #include +#include + namespace { // Misses are stored as nullopt so each name is read once; node-based, so bitmap entries can keep pointers into it @@ -89,17 +91,19 @@ void parse_texture_material(const char* filename, char* text, texture_material& warnings.push_back(SCP_string(filename) + ": " + std::to_string(unknown - 1) + " more unrecognized entries ignored."); } -// Plain ASCII only: the shared table reader would decrypt or re-encode the text and could outgrow any fixed buffer -bool is_plain_text(const SCP_vector& bytes, size_t start, size_t len) +// A NUL (as in UTF-16 without a byte order mark) is valid UTF-8 but would end the parse without a warning +bool is_utf8_text(const SCP_vector& bytes, size_t start, size_t len) { for (size_t i = start; i < len; i++) { const auto c = static_cast(bytes[i]); - if (c != '\t' && c != '\n' && c != '\r' && (c < 0x20 || c > 0x7e)) + if (c != '\t' && c != '\n' && c != '\r' && (c < 0x20 || c == 0x7f)) return false; } - return true; + return utf8::find_invalid(bytes.begin() + start, bytes.begin() + len) == bytes.begin() + len; } +// Read as-is rather than through the shared table reader, which would decrypt or re-encode the text and could +// outgrow any fixed buffer bool read_texture_material(const char* filename, int dir_type, texture_material& mat) { CFILE* cfp = cfopen(filename, "rb", dir_type); @@ -123,8 +127,8 @@ bool read_texture_material(const char* filename, int dir_type, texture_material& } const size_t start = (file_len >= 3 && memcmp(text.data(), "\xEF\xBB\xBF", 3) == 0) ? 3 : 0; - if (!is_plain_text(text, start, static_cast(file_len))) { - mat_warning(SCP_string(filename) + " is not a plain-text .mat file. Ignoring it."); + if (!is_utf8_text(text, start, static_cast(file_len))) { + mat_warning(SCP_string(filename) + " is not UTF-8 text. Ignoring it; save it as UTF-8 to use it."); return false; } diff --git a/test/src/graphics/test_texture_material.cpp b/test/src/graphics/test_texture_material.cpp index 6e7fc373f16..cea8d6ab582 100644 --- a/test/src/graphics/test_texture_material.cpp +++ b/test/src/graphics/test_texture_material.cpp @@ -100,6 +100,18 @@ TEST_F(TextureMaterialTest, crlf_and_utf8_bom_are_read) { EXPECT_FLOAT_EQ(*bom->glow.intensity, 1.75f); } +TEST_F(TextureMaterialTest, utf8_comments_and_tabs_are_read) { + int warnings = Global_warning_count; + auto mat = texture_material_find("mat_utf8", CF_TYPE_MAPS); + ASSERT_NE(mat, nullptr); + EXPECT_FLOAT_EQ(*mat->glow.intensity, 3.5f); + + auto tab = texture_material_find("mat_tab", CF_TYPE_MAPS); + ASSERT_NE(tab, nullptr); + EXPECT_FLOAT_EQ(*tab->glow.intensity, 1.5f); + EXPECT_EQ(Global_warning_count, warnings); +} + TEST_F(TextureMaterialTest, texture_without_a_mat_file) { EXPECT_EQ(texture_material_find("mat_plain", CF_TYPE_MAPS), nullptr); EXPECT_EQ(texture_material_find("", CF_TYPE_MAPS), nullptr); @@ -122,18 +134,23 @@ TEST_F(TextureMaterialTest, directory_type_is_part_of_the_lookup) { } TEST_F(TextureMaterialTest, non_text_files_are_rejected) { - expect_warning("mat_sharp_s", CF_TYPE_MAPS, "not a plain-text"); - expect_warning("mat_utf16", CF_TYPE_MAPS, "not a plain-text"); - expect_warning("mat_encrypted", CF_TYPE_MAPS, "not a plain-text"); + expect_warning("mat_sharp_s", CF_TYPE_MAPS, "not UTF-8 text"); + expect_warning("mat_utf16", CF_TYPE_MAPS, "not UTF-8 text"); + expect_warning("mat_utf16_nobom", CF_TYPE_MAPS, "not UTF-8 text"); + expect_warning("mat_encrypted", CF_TYPE_MAPS, "not UTF-8 text"); + expect_warning("mat_ctrl", CF_TYPE_MAPS, "not UTF-8 text"); + expect_warning("mat_del", CF_TYPE_MAPS, "not UTF-8 text"); + expect_warning("mat_truncated", CF_TYPE_MAPS, "not UTF-8 text"); expect_warning("mat_empty", CF_TYPE_MAPS, "is empty"); #ifdef NDEBUG - EXPECT_EQ(texture_material_find("mat_sharp_s", CF_TYPE_MAPS), nullptr); - EXPECT_EQ(texture_material_find("mat_empty", CF_TYPE_MAPS), nullptr); + for (const char* name : {"mat_sharp_s", "mat_utf16", "mat_utf16_nobom", "mat_encrypted", "mat_ctrl", "mat_del", + "mat_truncated", "mat_empty"}) + EXPECT_EQ(texture_material_find(name, CF_TYPE_MAPS), nullptr) << name; #endif } TEST_F(TextureMaterialTest, a_rejected_file_warns_once) { - expect_warning("mat_sharp_s", CF_TYPE_MAPS, "not a plain-text"); + expect_warning("mat_sharp_s", CF_TYPE_MAPS, "not UTF-8 text"); #ifdef NDEBUG int warnings = Global_warning_count; EXPECT_EQ(texture_material_find("mat_sharp_s", CF_TYPE_MAPS), nullptr); diff --git a/test/test_data/graphics/texture_material/data/maps/mat_ctrl.mat b/test/test_data/graphics/texture_material/data/maps/mat_ctrl.mat new file mode 100644 index 00000000000..6cb177498d7 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_ctrl.mat @@ -0,0 +1,2 @@ +$Glow Map Intensity: 2 +; ab diff --git a/test/test_data/graphics/texture_material/data/maps/mat_del.mat b/test/test_data/graphics/texture_material/data/maps/mat_del.mat new file mode 100644 index 00000000000..bee4ab086b2 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_del.mat @@ -0,0 +1,2 @@ +$Glow Map Intensity: 2 +; ab diff --git a/test/test_data/graphics/texture_material/data/maps/mat_tab.mat b/test/test_data/graphics/texture_material/data/maps/mat_tab.mat new file mode 100644 index 00000000000..d8c2703ceaa --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_tab.mat @@ -0,0 +1,2 @@ +$Glow Map Intensity: 1.5 + ; note diff --git a/test/test_data/graphics/texture_material/data/maps/mat_truncated.mat b/test/test_data/graphics/texture_material/data/maps/mat_truncated.mat new file mode 100644 index 00000000000..280a8ac0018 --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_truncated.mat @@ -0,0 +1,2 @@ +$Glow Map Intensity: 2 +; à \ No newline at end of file diff --git a/test/test_data/graphics/texture_material/data/maps/mat_utf16_nobom.mat b/test/test_data/graphics/texture_material/data/maps/mat_utf16_nobom.mat new file mode 100644 index 0000000000000000000000000000000000000000..422dfaa694b6bbef22241daf1fea749ee7e17f98 GIT binary patch literal 46 wcmY#kaA(M2$Y&^LP+;(7NMtAg(w+=?3?&SyK(d%26UeG$umXx2F>o;e0J!7_c>n+a literal 0 HcmV?d00001 diff --git a/test/test_data/graphics/texture_material/data/maps/mat_utf8.mat b/test/test_data/graphics/texture_material/data/maps/mat_utf8.mat new file mode 100644 index 00000000000..9191ddcadff --- /dev/null +++ b/test/test_data/graphics/texture_material/data/maps/mat_utf8.mat @@ -0,0 +1,2 @@ +; Glühen für die Schiffe — Kommentar +$Glow Map Intensity: 3.5 ; été From 2c1bd37d5ae2f7c1fa14ec1fb5686827afb07dcc Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Thu, 15 Oct 2026 03:24:38 -0700 Subject: [PATCH 4/5] Mark the non-UTF-8 .mat test inputs as binary The Mac clang-tidy step reads the PR diff as UTF-8 and fails on their bytes. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitattributes b/.gitattributes index 837a0ac25a8..083dcf3ec3d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,7 @@ *.tbl text eol=lf *.tbm text eol=lf *.sdr text eol=lf + +# Test inputs that are deliberately not UTF-8, kept out of text diffs +test/test_data/graphics/texture_material/data/maps/mat_sharp_s.mat binary +test/test_data/graphics/texture_material/data/maps/mat_truncated.mat binary From 6c8fa18ef11a4d11fc46f7d409a2b11a8e8118d2 Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Thu, 15 Oct 2026 07:02:33 -0700 Subject: [PATCH 5/5] Drop the release-only log line in mat_warning mprintf is compiled out of release builds, so the block never ran. Co-Authored-By: Claude Mythos 5.1 --- code/graphics/texture_material.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/code/graphics/texture_material.cpp b/code/graphics/texture_material.cpp index ded85a50979..039ff5d097d 100644 --- a/code/graphics/texture_material.cpp +++ b/code/graphics/texture_material.cpp @@ -19,10 +19,6 @@ constexpr int MAX_MAT_FILE_LEN = 1024 * 1024; void mat_warning(const SCP_string& text) { Warning(LOCATION, "%s", text.c_str()); - // Warning() only reaches the log in debug builds -#ifdef NDEBUG - mprintf(("TEXTURE MATERIAL: %s\n", text.c_str())); -#endif } bool at_line_end()