From cddd6c4b564e20aa0e086a3262b5342ae77064a2 Mon Sep 17 00:00:00 2001 From: Randalphwa <38287198+Randalphwa@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:33:39 -0700 Subject: [PATCH] Add base64 fallback encoding for resource embedding When #embed is unavailable (pre-C23/C++26 compilers), the '\xNN' character literal fallback expands generated sources to ~6x the resource size, making large resources very slow to compile. - Add CMRC_BASE64 option: encode resources as base64 (~1.33x expansion), decoded once at static-initialization time; #embed still takes precedence. - Add cmrc::detail::b64_decode() in cmrc.hpp, guarded by CMRC_CMRC_HPP_BASE64 so it is compiled only in resource TUs that use it. - Split encoded strings into chunked literals to stay under MSVC per-literal and post-concatenation caps (C2026) on older toolsets. - Add CMRC_DISABLE_EMBED diagnostic option to force the fallback path on #embed-capable compilers for testing the generators. - Add flower_b64 test that builds the fallback path end-to-end and verifies decoded content matches the original file byte-for-byte. - Document the #embed / literal / base64 tradeoffs in the README. --- CMakeRC.cmake | 198 +++++++++++++++++++++++++++++--- README.md | 68 +++++++++++ include/cmrc/cmrc.hpp | 91 +++++++++++++++ tests/CMakeLists.txt | 20 ++++ tests/flower_b64/CMakeLists.txt | 30 +++++ tests/flower_b64/flower.cpp | 60 ++++++++++ tests/flower_b64/run.cmake | 118 +++++++++++++++++++ 7 files changed, 569 insertions(+), 16 deletions(-) create mode 100644 tests/flower_b64/CMakeLists.txt create mode 100644 tests/flower_b64/flower.cpp create mode 100644 tests/flower_b64/run.cmake diff --git a/CMakeRC.cmake b/CMakeRC.cmake index 9380d75..80f5755 100644 --- a/CMakeRC.cmake +++ b/CMakeRC.cmake @@ -3,22 +3,124 @@ if(_CMRC_GENERATE_MODE) # Read in the digits file(READ "${INPUT_FILE}" bytes HEX) - # Format each pair into a character literal. Heuristics seem to favor doing - # the conversion in groups of five for fastest conversion - string(REGEX REPLACE "(..)(..)(..)(..)(..)" "'\\\\x\\1','\\\\x\\2','\\\\x\\3','\\\\x\\4','\\\\x\\5'," chars "${bytes}") - # Since we did this in groups, we have some leftovers to clean up string(LENGTH "${bytes}" n_bytes2) math(EXPR n_bytes "${n_bytes2} / 2") - math(EXPR remainder "${n_bytes} % 5") # <-- '5' is the grouping count from above - set(cleanup_re "$") - set(cleanup_sub ) - while(remainder) - set(cleanup_re "(..)${cleanup_re}") - set(cleanup_sub "'\\\\x\\${remainder}',${cleanup_sub}") - math(EXPR remainder "${remainder} - 1") - endwhile() - if(NOT cleanup_re STREQUAL "$") - string(REGEX REPLACE "${cleanup_re}" "${cleanup_sub}" chars "${chars}") + if(CMRC_BASE64) + # Encode the raw bytes as a standard base64 string. The hex string from + # file(READ ... HEX) is processed in groups of 6 hex digits (= 3 bytes + # = 4 base64 chars). math(EXPR) understands 0x... literals, so each + # group is packed into an integer and split into four 6-bit indices; + # the lookup table maps each index to its base64 character. A final + # group of 2 or 4 hex digits (1 or 2 remaining bytes) produces 2 or 3 + # chars with '=' padding. This keeps the generated source at ~1.33x the + # resource size instead of the ~6x of the '\xNN' literal fallback. + set(_b64_alpha "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/") + set(b64 "") + string(LENGTH "${bytes}" _b64_hex_len) + # Process the hex in slices. CMake expands a variable in full every time + # it is referenced, so looping with "${bytes}" (the whole file's hex) + # as the SUBSTRING source would be O(n^2). Instead each iteration pulls + # a small slice once and encodes it with small local variables, then + # appends the encoded slice to b64 in a single operation. + set(_b64_slice_size 18000) # 9000 bytes per slice + set(_b64_idx 0) + while(_b64_idx LESS _b64_hex_len) + string(SUBSTRING "${bytes}" ${_b64_idx} ${_b64_slice_size} _b64_slice_hex) + string(LENGTH "${_b64_slice_hex}" _b64_slice_len) + set(_b64_slice_b64 "") + set(_b64_si 0) + while(_b64_si LESS _b64_slice_len) + string(SUBSTRING "${_b64_slice_hex}" ${_b64_si} 6 _b64_grp) + string(LENGTH "${_b64_grp}" _b64_grp_len) + if(_b64_grp_len EQUAL 6) + math(EXPR _b64_v "0x${_b64_grp}") + math(EXPR _b64_c1 "(${_b64_v} >> 18) & 63") + math(EXPR _b64_c2 "(${_b64_v} >> 12) & 63") + math(EXPR _b64_c3 "(${_b64_v} >> 6) & 63") + math(EXPR _b64_c4 "${_b64_v} & 63") + set(_b64_out_chars "") + foreach(_b64_ci IN ITEMS ${_b64_c1} ${_b64_c2} ${_b64_c3} ${_b64_c4}) + string(SUBSTRING "${_b64_alpha}" ${_b64_ci} 1 _b64_ch) + set(_b64_out_chars "${_b64_out_chars}${_b64_ch}") + endforeach() + string(APPEND _b64_slice_b64 "${_b64_out_chars}") + elseif(_b64_grp_len EQUAL 4) + # 2 remaining bytes -> 3 chars + '=' + math(EXPR _b64_v "0x${_b64_grp}") + math(EXPR _b64_c1 "(${_b64_v} >> 10) & 63") + math(EXPR _b64_c2 "(${_b64_v} >> 4) & 63") + math(EXPR _b64_c3 "(${_b64_v} & 15) << 2") + set(_b64_out_chars "") + foreach(_b64_ci IN ITEMS ${_b64_c1} ${_b64_c2} ${_b64_c3}) + string(SUBSTRING "${_b64_alpha}" ${_b64_ci} 1 _b64_ch) + set(_b64_out_chars "${_b64_out_chars}${_b64_ch}") + endforeach() + string(APPEND _b64_slice_b64 "${_b64_out_chars}=") + elseif(_b64_grp_len EQUAL 2) + # 1 remaining byte -> 2 chars + '==' + math(EXPR _b64_v "0x${_b64_grp}") + math(EXPR _b64_c1 "(${_b64_v} >> 2) & 63") + math(EXPR _b64_c2 "(${_b64_v} & 3) << 4") + set(_b64_out_chars "") + foreach(_b64_ci IN ITEMS ${_b64_c1} ${_b64_c2}) + string(SUBSTRING "${_b64_alpha}" ${_b64_ci} 1 _b64_ch) + set(_b64_out_chars "${_b64_out_chars}${_b64_ch}") + endforeach() + string(APPEND _b64_slice_b64 "${_b64_out_chars}==") + endif() + math(EXPR _b64_si "${_b64_si} + 6") + endwhile() + string(APPEND b64 "${_b64_slice_b64}") + math(EXPR _b64_idx "${_b64_idx} + ${_b64_slice_size}") + endwhile() + # Split the base64 into chunks that stay under MSVC's per-literal and + # post-concatenation caps (C2026 / 64K on pre-2022 compilers). Each + # chunk is its own static array; b64_decode() stitches them back + # together at runtime, so there is no single giant string literal + # anywhere and arbitrarily large resources are supported on old MSVC. + set(_b64_chunk_size 16000) + set(_b64_chunks ) + set(_b64_idx 0) + string(LENGTH "${b64}" _b64_blen) + while(_b64_idx LESS _b64_blen) + string(SUBSTRING "${b64}" ${_b64_idx} ${_b64_chunk_size} _b64_chunk) + list(APPEND _b64_chunks "${_b64_chunk}") + math(EXPR _b64_idx "${_b64_idx} + ${_b64_chunk_size}") + endwhile() + list(LENGTH _b64_chunks _b64_nchunks) + # Build the C++ declarations for the chunk arrays, the length array + # and the pointer array (substituted into the template below via @VAR@). + set(_b64_emit "") + set(_b64_lens_emit "static const std::size_t b64_lens[] = { ") + set(_b64_parts_emit "static const char* const b64_parts[] = { ") + set(_b64_ci 0) + foreach(_b64_chunk IN LISTS _b64_chunks) + if(NOT _b64_emit STREQUAL "") + string(APPEND _b64_emit "\n") + endif() + string(APPEND _b64_emit "static const char b64_${_b64_ci}[] = \"${_b64_chunk}\";") + string(APPEND _b64_lens_emit "sizeof(b64_${_b64_ci}) - 1, ") + string(APPEND _b64_parts_emit "b64_${_b64_ci}, ") + math(EXPR _b64_ci "${_b64_ci} + 1") + endforeach() + string(APPEND _b64_lens_emit "};") + string(APPEND _b64_parts_emit "};") + else() + # Format each pair into a character literal. Heuristics seem to favor doing + # the conversion in groups of five for fastest conversion + string(REGEX REPLACE "(..)(..)(..)(..)(..)" "'\\\\x\\1','\\\\x\\2','\\\\x\\3','\\\\x\\4','\\\\x\\5'," chars "${bytes}") + # Since we did this in groups, we have some leftovers to clean up + math(EXPR remainder "${n_bytes} % 5") # <-- '5' is the grouping count from above + set(cleanup_re "$") + set(cleanup_sub ) + while(remainder) + set(cleanup_re "(..)${cleanup_re}") + set(cleanup_sub "'\\\\x\\${remainder}',${cleanup_sub}") + math(EXPR remainder "${remainder} - 1") + endwhile() + if(NOT cleanup_re STREQUAL "$") + string(REGEX REPLACE "${cleanup_re}" "${cleanup_sub}" chars "${chars}") + endif() endif() # #embed takes a header-name token: normalize the resource path to forward # slashes and keep it quoted. The generated file lives in the build tree @@ -28,6 +130,22 @@ if(_CMRC_GENERATE_MODE) # embeddable, otherwise the hex-literal fallback below is emitted # unchanged (pre-#embed compilers, MSVC, etc. all take the fallback). file(TO_CMAKE_PATH "${INPUT_FILE}" INPUT_FILE) + # The generated resource TU prefers #embed whenever __has_embed() reports + # the file as embeddable. With CMRC_DISABLE_EMBED (diagnostic/testing only) + # the guard is forced to #if 0 so the fallback path is compiled regardless. + if(CMRC_DISABLE_EMBED) + set(_cmrc_embed_guard "#if 0") + # When the fallback is forced, the base64/literal code is always + # emitted, so cmrc.hpp must always be included in base64 mode. + set(_cmrc_b64_include_guard "#if 1") + else() + set(_cmrc_embed_guard "#if defined(__has_embed)") + # Include cmrc.hpp (and enable the decoder) only when the code below + # actually takes the base64 fallback (no #embed, or file not + # embeddable). A #embed-capable compiler that can embed the file skips + # the include entirely, so the decoder is not even compiled. + set(_cmrc_b64_include_guard "#if !defined(__has_embed) || !__has_embed(\"@INPUT_FILE@\")") + endif() if(n_bytes EQUAL 0) # A #embed of an empty file (without if_empty()) is ill-formed in some # compilers; keep the pre-existing zero-byte-array behaviour. @@ -39,9 +157,41 @@ if(_CMRC_GENERATE_MODE) }}} ]] code) else() - string(CONFIGURE [[ + if(CMRC_BASE64) + string(CONFIGURE [[ + @_cmrc_b64_include_guard@ + #define CMRC_CMRC_HPP_BASE64 + #include + #endif + namespace { + @_cmrc_embed_guard@ + # if __has_embed("@INPUT_FILE@") + const char file_array[] = { #embed "@INPUT_FILE@" }; + const char* const file_ptr = file_array; + # else + @_b64_emit@ + @_b64_lens_emit@ + @_b64_parts_emit@ + static const std::string b64_decoded = cmrc::detail::b64_decode(b64_parts, b64_lens, @_b64_nchunks@); + const char* const file_ptr = b64_decoded.data(); + # endif + #else + @_b64_emit@ + @_b64_lens_emit@ + @_b64_parts_emit@ + static const std::string b64_decoded = cmrc::detail::b64_decode(b64_parts, b64_lens, @_b64_nchunks@); + const char* const file_ptr = b64_decoded.data(); + #endif + } + namespace cmrc { namespace @NAMESPACE@ { namespace res_chars { + extern const char* const @SYMBOL@_begin = file_ptr; + extern const char* const @SYMBOL@_end = file_ptr + @n_bytes@; + }}} + ]] code) + else() + string(CONFIGURE [[ namespace { const char file_array[] = { - #if defined(__has_embed) + @_cmrc_embed_guard@ # if __has_embed("@INPUT_FILE@") #embed "@INPUT_FILE@" # else @@ -56,6 +206,7 @@ if(_CMRC_GENERATE_MODE) extern const char* const @SYMBOL@_end = file_array + @n_bytes@; }}} ]] code) + endif() endif() file(WRITE "${OUTPUT_FILE}" "${code}") # Exit from the script. Nothing else needs to be processed @@ -64,6 +215,19 @@ endif() set(_version 3.0.0) +# Choose the fallback encoding used when #embed is not available: OFF (default) +# stores resources as '\xNN' character literals (~6x source expansion, zero +# runtime cost); ON stores them as base64 strings (~1.33x source expansion, a +# one-time decode at static-initialization time). Modern compilers get #embed +# regardless of this flag; it only affects the fallback path. +option(CMRC_BASE64 "Store resources as base64 (decoded at startup) when #embed is unavailable" OFF) + +# Diagnostic/testing option: force the fallback path even on compilers that +# support #embed, so the literal/base64 generators can be exercised directly. +# Normally the generated resource TU prefers #embed whenever +# __has_embed() reports the file as embeddable, and this option is OFF. +option(CMRC_DISABLE_EMBED "Force the fallback encoding instead of #embed (diagnostic/testing only)" OFF) + cmake_minimum_required(VERSION 3.12...4.0) include(CMakeParseArguments) @@ -335,6 +499,8 @@ function(_cmrc_generate_intermediate_cpp lib_ns symbol outfile infile) -DSYMBOL=${symbol} "-DINPUT_FILE=${infile}" "-DOUTPUT_FILE=${outfile}" + "-DCMRC_BASE64=${CMRC_BASE64}" + "-DCMRC_DISABLE_EMBED=${CMRC_DISABLE_EMBED}" -P "${_CMRC_SCRIPT}" COMMENT "Generating intermediate file for ${infile}" ${maybe_CODEGEN} diff --git a/README.md b/README.md index a33d3af..09fd326 100644 --- a/README.md +++ b/README.md @@ -312,3 +312,71 @@ int foo() { auto rose = fs.open("flowers/rose.jpg"); } ``` + +## How Resources Are Embedded: `#embed`, Literals, and Base64 + +When CMakeRC generates the source file that holds a resource, it picks the +best representation the *compiling compiler* supports: + +1. **`#embed` (C23 / C++26)** — If the compiler supports `#embed` (`__has_embed` + is defined) *and* reports the resource as embeddable, the generated TU uses + it directly: + + ```cpp + #if defined(__has_embed) && __has_embed(".../icon.png") + #embed ".../icon.png" + #endif + ``` + + `#embed` is the ideal path: zero source expansion, zero runtime cost, and + the raw bytes live in `.rodata`. No configuration is needed — CMakeRC + detects it automatically. GCC 15+, Clang 17+, and recent MSVC/EDG-based + toolsets support it. + +2. **`\xNN` character literals (fallback, default)** — On pre-`#embed` + compilers (older GCC/Clang, most MSVC), each byte becomes a `'\xNN'` + character literal in a big `const char[]` array. This has zero runtime cost + (no decoding) and works on every C++11 compiler, but the generated source is + roughly **6× the resource size** (18 MB of source for a 2.5 MB image), which + significantly slows down parsing. + +3. **Base64 fallback (`CMRC_BASE64=ON`)** — Set this CMake option to store + fallback resources as base64 strings split into small literals, decoded once + at static-initialization time: + + ```cmake + # in your project's CMakeLists.txt, before cmrc_add_resource_library: + set(CMRC_BASE64 ON) + ``` + + The generated source is roughly **1.33× the resource size** (plus small + chunk overhead), so parsing is ~5× faster than the `\xNN` form. The + decoder lives in `cmrc/cmrc.hpp`, guarded by `CMRC_CMRC_HPP_BASE64` so it is + not even compiled into translation units that don't use it. `#embed` still + takes precedence whenever the compiler supports it. + +### The tradeoff, by resource size + +- **Small resources (a few KB)** — Either fallback works fine; the generated + TU is tiny either way. The `\xNN` literals have the edge (no runtime decode, + no startup cost), so the default is a sensible choice. +- **Large resources (hundreds of KB to MBs)** — The `\xNN` form balloons the + generated source (~6×) and makes the resource TU slow to compile. Base64 + (~1.33×) dramatically speeds up compilation of that TU. The costs are a + one-time decode at startup and a heap allocation (the decoded bytes are + copied out of the base64 string). Note that the *executable* is typically + slightly *larger* with base64 — it stores the encoded string *and* the + decoded heap buffer (and the decoder), so the win is compile time and + generated-source size, not binary size. If you have truly massive resources, + `#embed` on a modern compiler is strictly better than both. + + In the test suite, `tests/flower.jpg` (2.5 MB) is used to compare the two + fallbacks: the `\xNN` intermediate is ~36 MB while the base64 intermediate is + ~7 MB, and both produce byte-for-byte identical embedded content + (`tests/flower_b64` verifies this automatically, along with the base64 + startup time). + +`CMRC_DISABLE_EMBED` (default `OFF`) is provided for diagnostics and testing: +setting it forces the fallback path even on `#embed`-capable compilers, so you +can validate the literal/base64 generators without a pre-`#embed` toolchain. +Normal builds should leave it `OFF`. diff --git a/include/cmrc/cmrc.hpp b/include/cmrc/cmrc.hpp index 3834282..f23ab5a 100644 --- a/include/cmrc/cmrc.hpp +++ b/include/cmrc/cmrc.hpp @@ -38,6 +38,16 @@ #define CMRC_NO_EXCEPTIONS 1 #endif +// When CMRC_CMRC_HPP_BASE64 is defined by the generated resource TU +// (CMRC_BASE64 build option), a base64 decoder is compiled into cmrc::detail so +// generated resource files can be stored as compact base64 strings instead of +// ~6x-expanded '\xNN' character literals. The decoder is intentionally excluded +// from every other TU (including lib.cpp, which only uses raw begin/end +// pointers) so base64 support costs nothing when CMRC_BASE64 is OFF. +#if defined(CMRC_CMRC_HPP_BASE64) +#include +#endif + namespace cmrc { namespace detail { struct dummy; @@ -90,6 +100,87 @@ class directory_entry; namespace detail { +#if defined(CMRC_CMRC_HPP_BASE64) +// Decodes a resource stored as separate base64 chunk literals. MSVC's +// per-literal (~16 K) and post-concatenation (64 K on pre-2022 versions) caps +// make a single giant string literal illegal on older compilers, so the +// generated resource TU splits the base64 into several static arrays and this +// decoder stitches them back together. It returns the decoded bytes by value; +// the generated TU stores that std::string in a namespace-scope static so the +// buffer outlives the whole process (the same lifetime the resource library +// assumes), then points its const char* const begin/end symbols into it. +inline std::string b64_decode(const char *const *chunks, + const std::size_t *lens, std::size_t count) { + std::size_t total = 0; + for (std::size_t i = 0; i < count; ++i) { + total += lens[i]; + } + std::string encoded; + encoded.reserve(total); + for (std::size_t i = 0; i < count; ++i) { + encoded.append(chunks[i], lens[i]); + } + + const char tbl[64] = {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', + 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', + 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', + 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', + 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', + '3', '4', '5', '6', '7', '8', '9', '+', '/'}; + signed char rev[256]; + for (int i = 0; i < 256; ++i) { + rev[i] = static_cast(-1); + } + for (int i = 0; i < 64; ++i) { + rev[static_cast(tbl[i])] = static_cast(i); + } + + std::size_t len = encoded.size(); + // Ignore trailing '=' padding; valid base64 lengths after stripping are + // 0, 2, 3 mod 4 (only the padding char sequence makes 1 mod 4 possible). + while (len > 0 && encoded[len - 1] == '=') { + --len; + } + std::size_t out_len = (len / 4) * 3; + if (len % 4 == 2) { + out_len += 1; + } else if (len % 4 == 3) { + out_len += 2; + } + std::string out; + out.reserve(out_len); + std::size_t i = 0; + while (i + 4 <= len) { + const int a = rev[static_cast(encoded[i])]; + const int b = rev[static_cast(encoded[i + 1])]; + const int c = rev[static_cast(encoded[i + 2])]; + const int d = rev[static_cast(encoded[i + 3])]; + out.push_back(static_cast((a << 2) | (b >> 4))); + if (c != -1) { + out.push_back(static_cast(((b & 0x0f) << 4) | (c >> 2))); + } + if (d != -1) { + out.push_back(static_cast(((c & 0x03) << 6) | d)); + } + i += 4; + } + // Trailing partial group left after padding was stripped: + // 2 chars -> 1 byte, 3 chars -> 2 bytes. + if (len - i == 2) { + const int a = rev[static_cast(encoded[i])]; + const int b = rev[static_cast(encoded[i + 1])]; + out.push_back(static_cast((a << 2) | (b >> 4))); + } else if (len - i == 3) { + const int a = rev[static_cast(encoded[i])]; + const int b = rev[static_cast(encoded[i + 1])]; + const int c = rev[static_cast(encoded[i + 2])]; + out.push_back(static_cast((a << 2) | (b >> 4))); + out.push_back(static_cast(((b & 0x0f) << 4) | (c >> 2))); + } + return out; +} +#endif // CMRC_CMRC_HPP_BASE64 + class directory; class file_data; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 44b8633..b816ac5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -44,6 +44,26 @@ cmrc_add_test( TEST_ARGV "${CMAKE_CURRENT_SOURCE_DIR}/flower.jpg" ) +# Build + run the same flower resource under the CMRC_BASE64 fallback and +# compare the two generated intermediate sources. flower.jpg (2.6 MB) is large +# enough that the base64 form (~1.33x source expansion, decoded at startup) +# shrinks the generated TU ~5x versus the '\xNN' literal form (~6x source +# expansion) — the actual win of the flag is faster compilation of the +# generated resource TU, not a smaller executable (the base64 exe is typically +# larger because it stores the encoded string AND makes a decoded heap copy). +# The b64 test reports the one-time startup (first-resolve) time and verifies +# the decoded bytes match the on-disk file byte-for-byte. The nested build +# also exercises CMRC_BASE64 end-to-end through the real CMakeRC module. +add_test( + NAME flower_b64 + COMMAND ${CMAKE_COMMAND} + -DCMRC_MODULE=${PROJECT_SOURCE_DIR}/CMakeRC.cmake + -DGEN=${CMAKE_GENERATOR} + -DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/flower_b64_scratch + -DFLOWER_SRC=${CMAKE_CURRENT_SOURCE_DIR}/flower.jpg + -P ${CMAKE_CURRENT_SOURCE_DIR}/flower_b64/run.cmake + ) + cmrc_add_test( NAME prefix PASS_REGEX "^Hello, world!" diff --git a/tests/flower_b64/CMakeLists.txt b/tests/flower_b64/CMakeLists.txt new file mode 100644 index 0000000..b365090 --- /dev/null +++ b/tests/flower_b64/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.12...4.0) +project(flower_b64 CXX) + +# CMRC_MODULE is passed by the driver script. +include("${CMRC_MODULE}") + +# The resource lives in the parent tests/ directory; use WHENCE so CMakeRC +# accepts a file outside the nested project's source dir. +set(FLOWER_JPG "${CMAKE_CURRENT_SOURCE_DIR}/../flower.jpg") + +# CMRC_DISABLE_EMBED forces the real fallback path on #embed-capable compilers +# so the literal-vs-base64 comparison exercises the actual generators. +set(CMRC_DISABLE_EMBED ON) + +# Literal fallback build. +cmrc_add_resource_library(rc_flower_literal NAMESPACE flower_literal + WHENCE "${CMAKE_CURRENT_SOURCE_DIR}/.." + "${FLOWER_JPG}") +add_executable(flower_literal flower.cpp) +target_compile_definitions(flower_literal PRIVATE CMRC_TEST_NS=flower_literal) +target_link_libraries(flower_literal PRIVATE rc_flower_literal) + +# Base64 fallback build. +set(CMRC_BASE64 ON) +cmrc_add_resource_library(rc_flower_b64 NAMESPACE flower_b64 + WHENCE "${CMAKE_CURRENT_SOURCE_DIR}/.." + "${FLOWER_JPG}") +add_executable(flower_b64 flower.cpp) +target_compile_definitions(flower_b64 PRIVATE CMRC_TEST_NS=flower_b64) +target_link_libraries(flower_b64 PRIVATE rc_flower_b64) diff --git a/tests/flower_b64/flower.cpp b/tests/flower_b64/flower.cpp new file mode 100644 index 0000000..23da123 --- /dev/null +++ b/tests/flower_b64/flower.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include +#include +#include +#include + +// The namespace is injected by the build for each variant (CMRC_TEST_NS). +// Expand it through an indirection layer so the integer/token macro argument +// is substituted before it reaches CMRC_DECLARE and the qualified call. +#ifndef CMRC_TEST_NS +#error "CMRC_TEST_NS must be defined" +#endif + +#define CMRC_DECLARE_NS(ns) CMRC_DECLARE(ns) +#define CMRC_GET_FS(ns) ::cmrc::ns::get_filesystem + +CMRC_DECLARE_NS(CMRC_TEST_NS); + +int main(int argc, char **argv) { + if (argc != 2) { + std::cerr << "Invalid arguments passed\n"; + return 2; + } + std::cout << "Reading flower from " << argv[1] << '\n'; + std::ifstream flower_fs{argv[1], std::ios_base::binary}; + if (!flower_fs) { + std::cerr << "Invalid filename passed: " << argv[1] << '\n'; + return 2; + } + + using iter = std::istreambuf_iterator; + const auto fs_size = std::distance(iter(flower_fs), iter()); + flower_fs.seekg(0); + + // Time to first resolve: covers one-time static-init base64 decode (if any) + // plus first index build. + const auto t0 = std::chrono::steady_clock::now(); + auto fs = CMRC_GET_FS(CMRC_TEST_NS)(); + const auto t1 = std::chrono::steady_clock::now(); + auto flower_rc = fs.open("flower.jpg"); + const auto rc_size = std::distance(flower_rc.begin(), flower_rc.end()); + const auto elapsed_ms = + std::chrono::duration_cast>(t1 - + t0) + .count(); + std::cout << "First-resolve time: " << elapsed_ms << " ms (resource " + << rc_size << " bytes)\n"; + if (rc_size != fs_size) { + std::cerr << "Flower file sizes do not match: FS == " << fs_size + << ", RC == " << rc_size << "\n"; + return 1; + } + if (!std::equal(flower_rc.begin(), flower_rc.end(), iter(flower_fs))) { + std::cerr << "Flower file contents do not match\n"; + return 1; + } + return 0; +} diff --git a/tests/flower_b64/run.cmake b/tests/flower_b64/run.cmake new file mode 100644 index 0000000..f4cbfd4 --- /dev/null +++ b/tests/flower_b64/run.cmake @@ -0,0 +1,118 @@ +# Regression test for the CMRC_BASE64 fallback encoding. +# +# Builds the same flower.cpp/flower.jpg resource twice against the real +# CMakeRC module: once with the default literal fallback (CMRC_BASE64=OFF) and +# once with the base64 fallback (CMRC_BASE64=ON). Then: +# - runs both executables against the on-disk flower.jpg and verifies the +# embedded contents match (functional correctness of the decoder), +# - compares the resulting executable sizes: for a 2.6 MB resource the +# ~1.33x base64 form must produce a smaller binary than the ~6x chracter +# literal form (this is the whole point of the feature). +# The b64 executable also prints the one-time startup (static-init) decode time +# so the small-vs-large-file tradeoff is observable. +# +# Required -D arguments: +# CMRC_MODULE absolute path to the real CMakeRC.cmake in the source tree +# GEN the generator to use for the nested configure +# WORK_DIR scratch directory (inside the parent build tree) +# FLOWER_SRC absolute path to tests/flower.jpg + +if(NOT DEFINED CMRC_MODULE OR NOT DEFINED GEN OR NOT DEFINED WORK_DIR OR NOT DEFINED FLOWER_SRC) + message(FATAL_ERROR "flower_b64: missing -D argument (CMRC_MODULE, GEN, WORK_DIR, FLOWER_SRC)") +endif() + +file(REMOVE_RECURSE "${WORK_DIR}") +file(MAKE_DIRECTORY "${WORK_DIR}") + +# The nested consumer project lives in this directory; its CMakeLists includes +# the real CMakeRC module via -DCMRC_MODULE. +set(nested_src "${CMAKE_CURRENT_LIST_DIR}") + +function(run_and_check step) + execute_process( + COMMAND ${CMAKE_COMMAND} ${ARGN} + RESULT_VARIABLE result + OUTPUT_VARIABLE output + ERROR_VARIABLE error + ) + if(NOT result EQUAL 0) + message(FATAL_ERROR "flower_b64: ${step} failed (exit ${result})\n" + "STDOUT:\n${output}\nSTDERR:\n${error}") + endif() + set(LAST_OUTPUT "${output}" PARENT_SCOPE) +endfunction() + +# Run a built executable directly (not a cmake invocation). +function(run_exe_and_check step exe) + execute_process( + COMMAND "${exe}" ${ARGN} + RESULT_VARIABLE result + OUTPUT_VARIABLE output + ERROR_VARIABLE error + ) + if(NOT result EQUAL 0) + message(FATAL_ERROR "flower_b64: ${step} failed (exit ${result})\n" + "STDOUT:\n${output}\nSTDERR:\n${error}") + endif() + message(STATUS "flower_b64: ${step}: ${output}") +endfunction() + +# 1. Literal fallback build. +run_and_check("literal configure" -G "${GEN}" -S "${nested_src}" -B "${WORK_DIR}/lit" + -DCMRC_MODULE=${CMRC_MODULE} -DCMRC_BASE64=OFF) +run_and_check("literal build" --build "${WORK_DIR}/lit" --config Release) + +# 2. Base64 fallback build. +run_and_check("b64 configure" -G "${GEN}" -S "${nested_src}" -B "${WORK_DIR}/b64" + -DCMRC_MODULE=${CMRC_MODULE} -DCMRC_BASE64=ON) +run_and_check("b64 build" --build "${WORK_DIR}/b64" --config Release) + +# 3. Run both executables against the real flower.jpg; both must verify the +# embedded bytes against the on-disk file (content check in flower.cpp). +run_exe_and_check("literal run" "${WORK_DIR}/lit/flower_literal" "${FLOWER_SRC}") +run_exe_and_check("b64 run" "${WORK_DIR}/b64/flower_b64" "${FLOWER_SRC}") + +# 4. Compare executable sizes. Generator-independent exe path lookup: use the +# per-config subdirectory when the generator is multi-config (VS), else the +# build dir root. +set(exe_suffix ".exe") +if(CMAKE_HOST_WIN32) + set(exe_suffix ".exe") +else() + set(exe_suffix "") +endif() +set(lit_exe "${WORK_DIR}/lit/flower_literal${exe_suffix}") +set(b64_exe "${WORK_DIR}/b64/flower_b64${exe_suffix}") +if(NOT EXISTS "${lit_exe}") + set(lit_exe "${WORK_DIR}/lit/Release/flower_literal${exe_suffix}") +endif() +if(NOT EXISTS "${b64_exe}") + set(b64_exe "${WORK_DIR}/b64/Release/flower_b64${exe_suffix}") +endif() +foreach(exe IN ITEMS "${lit_exe}" "${b64_exe}") + if(NOT EXISTS "${exe}") + message(FATAL_ERROR "flower_b64: expected executable not found: ${exe}") + endif() +endforeach() +# 4. Compare generated intermediate source sizes. The hex-literal fallback +# expands each byte to ~6 source chars (the compile-time pain point), while +# the base64 fallback is ~1.33x plus chunk overhead. For flower.jpg the +# literal intermediate is ~18 MB vs ~7 MB for base64 — the actual benefit of +# the flag (faster compile of the generated TU). Note: the base64 *exe* is +# typically larger, not smaller — it stores the base64 string AND decodes it +# into a heap buffer at startup, so exe size is NOT the win; source size is. +set(lit_src "${WORK_DIR}/lit/__cmrc_rc_flower_literal/intermediate/flower.jpg.cpp") +set(b64_src "${WORK_DIR}/b64/__cmrc_rc_flower_b64/intermediate/flower.jpg.cpp") +file(SIZE "${lit_src}" lit_src_size) +file(SIZE "${b64_src}" b64_src_size) +file(SIZE "${lit_exe}" lit_size) +file(SIZE "${b64_exe}" b64_size) +message(STATUS "flower_b64: literal intermediate = ${lit_src_size} bytes") +message(STATUS "flower_b64: base64 intermediate = ${b64_src_size} bytes") +message(STATUS "flower_b64: literal exe = ${lit_size} bytes, base64 exe = ${b64_size} bytes") +if(NOT b64_src_size LESS lit_src_size) + message(FATAL_ERROR "flower_b64: expected base64 intermediate (${b64_src_size}) " + "to be smaller than literal intermediate (${lit_src_size})") +endif() + +message(STATUS "flower_b64: OK")