Link Boundaries and Binary Size

CNA snapshot c6dec91f  ·  new since the 0.1.0 release  ·  source: docs/link-size-architecture.md

📝

In one sentence: CNA supports glTF, Draco, avatars, the system Guide, media, video, microphones, XNB, CNB, every platform and every renderer, but a CNA program contains only the subsystems it calls. Nothing was switched off to achieve this, and no build option is needed for it. This page describes CNA at snapshot c6dec91f; the design is CNA’s docs/link-size-architecture.md, and it arrived after the 0.1.0 release.

What a program contains

Each CNA module is a static archive, and a static link copies an archive member into an executable only when something already in the executable references a symbol it defines. A feature a program does not use is therefore not linked, provided nothing that is used names it indirectly. CNA keeps that true by cutting every structural reference that used to drag unrelated subsystems in, and it checks the result with link-footprint gates in its test suite.

QuestionAnswer at this snapshot
Does the default build still support everything?Yes. No feature is disabled, stubbed or moved behind a flag by this design.
Does a 2D game pay for glTF, Draco, avatars or MediaPlayer?No, unless it calls them. LinkFootprint_2DGame fails if it links any subsystem family it does not use.
Does a game that uses them get them?Yes. LinkFootprint_FullFeatures checks a representative set (glTF, the Model reader, Draco where built, avatars and the catalog, the system Guide, media, capture, streaming audio, cube and volume textures); the full test suite exercises the rest.
Do I need to switch features off to get a small game?No. The build options below exist for build time and deployment, not for the correctness of what is linked.

The boundaries

Before this work, single references pulled whole subsystems into every program: one Guide call reached the Gamer Services client, libcurl and the embedded avatar catalog; every ContentManager named every content reader; Game’s constructor registered every .xnb reader; FrameworkDispatcher::Update called straight into streaming audio, the microphone and the media player; SpriteBatch::Begin used dynamic_cast on the five stock effects. Each such reference is now cut where the dependency is not real. The main boundaries:

SubsystemLinked only into a program that
Built-in avatar catalog (catalog v3, about 13 MB, its own library)uses the avatar API or the system Guide
System Guide panels (home, friends, party, messages, achievements, leaderboards, players, settings, avatar views)initializes GamerServicesDispatcher (for example through GamerServicesComponent), calls a social Guide.Show* method or handles the Guide button
Guide frame, dialogs and licence (IsTrialMode, ShowMarketplace, message boxes)uses the Guide at all; they reach the system Guide only through hooks it installs
Gamer Services service clientmakes a service request or signs in
Content readers, one translation unit per asset familyloads that family with Load<T>
Model, glTF, the cgltf importer and Dracoloads a Model, SkinnedModelEXT or AnimationClipEXT
Every built-in .xnb reader at onceloads a type CNA does not know, an untyped external reference, or asks the reader registry for a reader object by name
.cnb encoders and CnbWriterruns a content tool; loading never encodes
TextureCube, RenderTargetCube, Texture3Dcreates, loads or binds one (renderers ask Texture::AsTextureCubeEXT()/AsTexture3DEXT() instead of casting)
Streaming audio, audio capture and media updatesuses that subsystem; it installs its own FrameworkDispatcher slot on first use
Camera and system traycalls IPlatform::GetCamera() or GetTray() (as CNA::Devices::Camera and SystemTray do); asking whether either is supported links neither
The Headless and Terminal platformschooses them by name; games construct the build’s platform directly
Networking (ENet)links CNA::Net

Renderers are a build-time choice as before: the generated registry lists only the renderers a build compiles, one by default (see Runtime Renderer Selection).

What changed for content loading

A Game still declares all 61 built-in XNB readers registered (60 on toolchains without a native 128-bit integer), every ContentManager declares every built-in .cnb loader, and ContentTypeReaderManager, CnbLoaderRegistry and GetXnbReaderUsageSummary() answer queries, removals and re-registrations as they did when everything was registered eagerly. What changed is when a reader is installed: the first Load<T> of a type installs that type’s family, with or without a Game, including the value readers its files may name (a Song’s duration, a Video’s frame rate, a Model’s Tag); asking a registry for a reader or loader object by name installs everything. A reader you removed stays removed until the built-ins are registered again (a new Game, a new ContentManager, or RegisterBuiltinLoaders() for .cnb loaders). See XNB Loading.

Build options

These options change what is built or how it is optimized. None of them is needed to keep an unused feature out of a program.

OptionDefaultEffect
CNA_ENABLE_GAMER_SERVICESthe value of CNA_ENABLE_NETBuilds GamerServices (Guide, profiles, avatars) independently of networking, so a Guide-only game can build without it. CNA_ENABLE_NET=ON requires it.
CNA_ENABLE_NETONBuilds CNA_Net and ENet.
CNA_OPTIMIZE_FOR_SIZEOFFGCC and Clang: compiles CNA with -ffunction-sections -fdata-sections and gives every program that links CNA --gc-sections (-dead_strip on Apple). MSVC (/Gy, /OPT:REF) and wasm-ld already collect unreferenced code in Release.
CNA_ENABLE_IPOOFFLink-time optimization for CNA’s Release targets.
CNA_SHARED_LIBRARYON for a native (not cross-compiled) ELF build with GCC or Clang and CMake 3.30 or newer; otherwise OFFLinks the runtime into libcna.so with every subsystem in it, the development trees’ choice; turning it on where CMake is older than 3.30 is a configure error. A size-oriented deployment links CNA statically (OFF); a program that links module targets such as CNA::Runtime is static either way.
CNA_CXX_RUNTIME_LINKAGEAUTOHow programs that link CNA link the C++ runtime (see Linkers and the C++ runtime).
CNA_ENABLE_DRACO, CNA_ENABLE_VIDEOON / AUTOWhether the decoder is built at all; a program that loads no models or video does not link it either way.

SDL3_image is no longer built and its submodule is gone: nothing in CNA used it, and images are decoded by CNA’s own loader as before.

A size-oriented Release consumer configures CNA with:

cmake -S . -B build -DCMAKE_BUILD_TYPE=MinSizeRel \
      -DCNA_SHARED_LIBRARY=OFF -DCNA_OPTIMIZE_FOR_SIZE=ON   # optionally -DCNA_ENABLE_IPO=ON

and links only the module targets it needs:

add_subdirectory(path/to/cna CNA)
add_executable(MyGame main.cpp)
target_link_libraries(MyGame PRIVATE CNA::Runtime)         # Game, graphics, input, audio, content
target_link_libraries(MyGame PRIVATE CNA::GamerServices)   # only if it uses the Guide

CNA’s own complete example of a 2D consumer is tests/link_footprint/footprint_2d_game.cpp.

One measured game

CNA measured the effect on one game, Mobile Eggbert (a 2D game using textures, sounds, Guide.IsTrialMode and Guide.ShowMarketplace), on Linux x86-64 with OPENGLES3, GCC 14 and mold, before and after this work, with the same game source:

ExecutableBeforeAfter
Debug, unstripped208 MB62.4 MB
Debug, fully stripped44.6 MB6.2 MB
Release, static CNA, stripped—4.7 MB, or 3.0 MB with CNA_OPTIMIZE_FOR_SIZE=ON

About 30 MB of the “before” size was the embedded avatar catalog, which this game never drew. These are CNA’s recorded figures for one game, one toolchain and one linker; nothing was re-measured for this page, and they are not a size guarantee for other programs. The same record notes what a 2D game still contains and why: sharp-runtime’s Decimal (about 30 KB, through the virtual BinaryReader::ReadDecimal), and, only in a build that opts into compiled XNA effects, MojoShader and the renderer’s compiled-effect support.

Checking a binary

  • Sizes: size -A game, objcopy --strip-debug, strip. Debug information is most of a Debug executable and is not shipped; compare stripped sizes, and .rodata for embedded data.
  • Why is X linked? Ask the linker that produced the binary, with the same command line: mold -Wl,--print-dependencies, lld -Wl,--why-extract=why.txt, or GNU ld -Wl,-Map=game.map followed by tools/link_audit/why_linked.py game.map --why X; tools/link_audit/link_closure.py lists every reference into an archive member.
  • Regression gates: LinkFootprint_2DGame, LinkFootprint_GamerServicesGame, LinkFootprint_FullFeatures, LinkFootprint_CapabilityQuery, LinkFootprint_CameraOnly and LinkFootprint_TrayOnly (cmake/Tests/LinkFootprint.cmake, scripts/check_link_footprint.py), and the ModuleLinkClosure_* probes, which pin the archives each module’s consumer links. They read the probes with the toolchain’s nm and report a skip when it is unavailable. They are registered tests; this page did not run them.

Linkers and the C++ runtime

GNU ld, lld and mold must extract the same archive members, and they can differ when a shared library needs a standard-library symbol that a not-yet-extracted CNA object also defines (with C++20 and later, every CNA object carries weak copies of the std::string members it uses). CNA measured mold linking the service client, the system Guide and the avatar catalog into a multi-renderer 2D game that way. So a program that links CNA’s targets with a shared C++ runtime on ELF now names the runtime ahead of CNA’s archives (CNA::BuildConfig adds -Wl,--push-state,--as-needed -lstdc++ -Wl,--pop-state, or -lc++ for libc++), and the three linkers then extract the same members. A program that names CNA’s archives on a hand-written link line instead of linking its targets does not get this.

CNA_CXX_RUNTIME_LINKAGE decides it per program and configuration: SHARED gives every program the option and STATIC none; AUTO (the default) reads each program’s flags and the targets it links, keeps a static runtime wherever -static, -static-pie, -static-libstdc++, -nostdlib, -nostdlib++ or -nodefaultlibs is asked for (-static-libgcc alone does not count), and errs towards static where it cannot see the answer. Declare STATIC for a static runtime that no CMake setting shows, such as a compiler wrapper or a hand-named -Wl,-Bstatic -lstdc++. With a static runtime CNA adds nothing, and naming the static runtime first (target_link_options(MyGame PRIVATE "SHELL:-Wl,--push-state,-Bstatic -lstdc++ -Wl,--pop-state")) gives each linker the small closure again.

What it means for existing C++ code

Rebuild C++ consumers against this snapshot. The boundary work added virtual functions and changed class layouts, so C++ programs and libraries compiled against an earlier CNA are not binary-compatible with it. Source that only calls these APIs compiles unchanged. The visible changes:

  • IPlatform::Supports(PlatformCapability) answers one capability; IPlatform::GetCamera() and GetTray() are no longer virtual and create the service on their first call. A custom IPlatform implementation overrides the protected GetCameraService() and GetTrayService() instead. Game::GetPlatformCapabilitiesEXT() reads the capability set on its first call, so an SDL3 game that never asks about cameras no longer starts SDL’s camera subsystem.
  • Effect::IsStockEffectEXT() reports XNA’s five stock effects without RTTI, and Texture::AsTextureCubeEXT()/AsTexture3DEXT() answer what a dynamic_cast would; both are CNAEXT extensions.
  • GraphicsDevice::GetShaderDialectEXT() returns the public CNA::ShaderDialectEXT from CNA/ShaderDialectEXT.hpp; the enumerators and values are unchanged.

The C API’s headers did not change for this work; see the Experimental C API for its own versioning.