Crafter.Asset/AGENTS.template.md

19 KiB

AGENTS.md

Guidance for coding agents and contributors working on Crafter.Asset itself. There is no README: AGENTS.consumer.md describes the library as its users see it, is written for the agents of the projects that depend on it, and crafter-build copies it into their AGENTS.md when they build. When this file and the code disagree, the code wins; the interfaces in interfaces/ are the reference.

Project overview

Crafter.Asset is the asset library of the Crafter engine: a C++ modules library (import Crafter.Asset;) that decodes source assets (images through stb_image, Wavefront OBJ, binary glTF), compresses vertex and pixel streams with GDeflate into files the GPU decompresses through VK_EXT_memory_decompression, and reads those back, with a CPU fallback. It depends on Crafter.Math for its vector and matrix types and is built with crafter-build. Its consumers are Crafter.Graphics, the renderer, which uploads the compressed streams, and crafter-build itself, which links the library to compress a project's cfg.assets at build time. Both pull this repository's master unpinned.

Path What lives there
interfaces/Crafter.Asset.cppm The umbrella module: export imports the four partitions.
interfaces/Crafter.Asset-<Partition>.cppm The partitions. :Texture and :Mesh are templates and inline functions only and have no implementation unit.
implementations/Crafter.Asset-Compression.cpp, -GLB.cpp Implementation units (module Crafter.Asset;) of the two partitions with out-of-line code. Compression #includes the vendored GDeflate wrappers; GLB is the only unit that sees nlohmann/json.
implementations/main.cpp crafter-asset, a CLI that converts a .png or .obj into the raw .tex/.mesh formats, with a --test-compression round trip. Compiled only with --exe.
project.cpp The build description: Crafter.Math as a dependency, libdeflate's C sources, the partitions, the --exe switch and the test.
tests/GLB/main.cpp The one test. Builds GLBs in memory and loads them.
lib/ Vendored: gdeflate/ (Microsoft's GDeflate reference, Apache-2.0, carrying NVIDIA's libdeflate 1.8 fork, MIT), json.hpp (nlohmann/json 3.12.0, MIT), stb_image.h 2.30 and stb_image_resize2.h 2.18 (public domain). Not edited, with the one exception under "Design rules".
bin/, build/ Generated by crafter-build, one Crafter.Asset-<target>-<march>-<mtune>-<hash>/ per variant. Gitignored.

Partitions and what they own:

  • :Compression is the GDeflate layer. CompressStreams turns a list of byte spans into one CompressedBlob: the streams concatenated, plus a RegionMeta table whose layout is VkDecompressMemoryRegionEXT without the device addresses. Every stream is its own GDeflate tile stream, so the GPU decompresses regions independently. DecompressCPU is the fallback, WriteBlob/ReadBlob serialise a blob behind a format's own header, and Fatal prints and aborts.
  • :Texture is TextureAsset<T>: decoding anything stb_image reads (LoadPNG, LoadPNGFromMemory, SizeFromMemory) to 8-bit or floating-point RGBA, opacity classification, Resize, the raw Save/Load and the compressed SaveCompressed/LoadCompressedTexture (.ctex). The stb implementations are compiled here, in the interface's global module fragment.
  • :Mesh is MeshAsset<T>: OBJ parsing (LoadOBJ, the per-material LoadOBJSplit, LoadMTL), the vertex types, tangent generation, raw Save/Load and compressed SaveCompressed/LoadCompressedMesh (.cmesh). MeshAsset<void> is positions and indices only.
  • :GLB is GlbAsset::Load, a glTF 2.0 binary loader: nodes, meshes with morph targets, skins, materials, textures, images, cameras and KHR_lights_punctual, with the JSON and BIN chunks and the extras kept verbatim for what it doesn't interpret.

Dev environment

The build tool is crafter-build, as the Flatpak net.catcrafts.CrafterBuild or a native build of the sibling checkout; crafter-build --version prints the compiler it resolved. The Flatpak compiles with its bundled LLVM and a native build with the distro's clang, which can be a major version apart. Both work from this directory.

Loading project.cpp clones Crafter.Math from forgejo.catcrafts.net into ~/.cache/crafter.build/external/ and, since it is unpinned, runs git pull on it on every invocation, so a build needs the network. --local takes the sibling checkout ../Crafter.Math instead. The flag stays in the args handed to Crafter.Math's project.cpp, and a consumer built with --local hands it to this one, which is how a --local build gets one copy of each module in the program instead of two.

crafter-build                              # bin/Crafter.Asset-<variant>/libCrafter.Asset.a and the PCMs
crafter-build --local                      # the same, against ../Crafter.Math
crafter-build test                         # build, then run the GLB test
crafter-build test --list                  # enumerate without running
crafter-build test --debug                 # debug build of the library and the test
crafter-build --exe                        # bin/Crafter.Asset-<variant>/crafter-asset; declares no test
crafter-build --target=wasm32-wasip1       # the wasm library: no x86 cpu_features, no tests
crafter-build test --target=x86_64-w64-mingw32 --runner=cmd:wine   # Windows cross-build, test under wine
crafter-build clean                        # delete bin/ and build/

Project flags (--local, --exe, --debug) can go before or after test. Flags ApplyStandardArgs doesn't interpret are part of the variant hash, so each combination has its own bin/ and build/ directory, and the directories of earlier variants and earlier compilers stay until clean; glob for a directory rather than predicting its hash.

There are no lint rules in project.cpp, so crafter-build lint and format have nothing to do here; style is kept by hand (see "Code style"). This repository has no CI of its own. crafter-build's CI builds it as a dependency on every crafter-build PR, which is where a break on master is noticed first. Before you call a change done: crafter-build test passes on the host, and crafter-build --target=wasm32-wasip1 still compiles when you touched Compression or Texture, which have wasm-only branches.

Dependency notes: a crafter-build newer than 2.2.0 keeps a section of this file holding the AGENTS.consumer.md of every dependency. Crafter.Math ships none at the time of writing, so there is no section; when it starts to, the next build appends one at the end of this file and says so. Commit it. Don't add the markers by hand: a section without notes is removed again on the next build.

Testing instructions

Tests are declared in project.cpp, and only for a native library build:

if (!isWasm && cfg.type == ConfigurationType::LibraryStatic) {
    cfg.AddTest("GLB").Dependencies({ &cfg });
}

The source is tests/<Name>/main.cpp, an ordinary int main() that links libCrafter.Asset.a and inherits the target, march and debug setting. Exit 0 passes, nonzero fails, 77 skips. Each test runs in its own process with a 60 s default timeout (test --timeout=<seconds>); the output of a failed or timed-out test is in build/test-logs/<name>.log.

Conventions, from tests/GLB/main.cpp:

  • Open with a comment that states what is covered and which failures it guards against.
  • Check(ok, what) prints PASS or FAIL with the description and counts failures; main returns EXIT_FAILURE when any failed. CheckError(loaded, text, what) checks that a load failed with text in its message, CheckLoads the opposite, printing the error. Don't stop at the first failure.
  • Fixtures are built in memory: BuildGlb(json, bin) lays out the container, LoadJson loads it, and Replace(json, {{from, to}}) derives a broken file from a good one and fails the test when from isn't in the JSON, so a case can't silently stop testing what it meant to. Only the path-based cases touch the disk, under fs::temp_directory_path() through TempFile, named per run so two runs don't collide.
  • A timing check compares against a smaller instance of the same work (the 100,000-node chain against one a quarter as deep), never against the clock, which flaked in debug builds.
  • CRAFTER_ASSET_TEST_GLB=<path to a skinned .glb> adds a check on a real file (every joint's world matrix times its inverse-bind matrix is the identity at rest); without it that case prints SKIP and the test still passes.

What isn't covered: Compression, Texture and Mesh have no test under tests/. crafter-asset --test-compression round-trips GDeflate at the tile boundaries (1, 65535, 65536, 65537 bytes and 16 MiB) but isn't part of crafter-build test, and the consumers' tests (Crafter.Graphics's MeshDecompressStagingRelease and its Decompression example) are the only round-trip coverage of the file formats. A change to those partitions adds a tests/<Name>/main.cpp and its AddTest line, in the GLB test's shape; a bug fix ships with a test that fails before it.

Code style

Nothing enforces it here, so match the surrounding code:

  • The first two lines of every first-party source are //SPDX-License-Identifier: LGPL-3.0-only and //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®, no space after the slashes, then a blank line. Vendored files keep their own headers.
  • import std; everywhere; standard headers are never #included. C headers and the vendored headers go in the global module fragment, after module;.
  • Four-space indentation, no tabs. PascalCase for types and functions, camelCase for variables and members, k-prefixed constants in anonymous namespaces (kMagic, kDecodeFloor); the file-format constants sit in TextureAssetFormat and MeshAssetFormat.
  • Fixed-width types. int only where a C API (stb) takes it, converted at the boundary. A size read from a file is compared in a width it can't wrap in before it is narrowed; ViewBytes and ReadAccessor are the pattern.
  • std::expected<T, std::string> for anything that can fail on user input. The JSON helpers (Integer, Index, Number, Array) return a fallback rather than throw, because a -fno-exceptions build aborts on a throw.
  • std::println and std::format in new code. main.cpp's iostream output is older and not worth churning.
  • Comments explain why: the failure mode, the constraint, the rejected alternative. The GLB unit and the recent commit bodies are the voice. Keep them, and write them for new non-obvious code.
  • K&R braces; if (x) return ...; on one line when it fits.

Design rules

These were decided deliberately. Don't undo them without the maintainer's say-so.

  1. Two error regimes, on purpose. User content, anything a program loads at runtime that it didn't build itself (a GLB, an image through LoadPNGFromMemory), comes back as the error of a std::expected, and the program decides. The pipeline's own output (.ctex and .cmesh through LoadCompressed*, DecompressCPU) goes through Compression::Fatal, which prints and aborts: a bad magic, version or size there means the build's data doesn't match the binary, and nothing sensible can be done at that point. Fatal rather than throw keeps the module buildable under -fno-exceptions, which the wasm targets are. LoadPNG is the one exception: it throws std::runtime_error so crafter-build can report which file failed, and is Fatal where there are no exceptions. A new loader goes on the side its input belongs to; never Fatal on user content.
  2. The compressed formats are a contract with Crafter.Graphics. .ctex is the magic CGDT, version 1, a header and one region, the pixels. .cmesh is CGDM, version 1, a header and three regions in the order vertices, indices, vertex data; always three, even from MeshAsset<void>, where the data region is empty. RegionMeta is three std::uint64_t, the VkDecompressMemoryRegionEXT fields minus the two addresses, and each stream is a GDeflate 1.0 tile stream at the maximum level, which the GPU decodes as is. A layout change bumps version in the *AssetFormat namespace, changes the writer and the reader together, and lands with the Crafter.Graphics change that reads it. The raw Save/Load formats (.tex, .mesh) have no magic and no version; don't grow them without adding one.
  3. Non-template code lives in an implementation unit. :Texture and :Mesh are templates and inline functions and behave like headers; the Compression and GLB entry points are out of line in implementations/ so they land in libCrafter.Asset.a, which is why the default ConfigurationType is LibraryStatic. A new non-template function goes in an implementation unit.
  4. Third-party code stays out of the interfaces. nlohmann/json is included only in the GLB implementation unit, and the interface hands extras on as JSON text, so the parser is nobody else's compile-time cost. stb is the one exception, included with its implementation in the :Texture interface's global module fragment, because LoadPNG is a template. Don't add another.
  5. Every size from a file is checked before it is used. The GLB loader compares offsets, counts and strides in 64 bits, as the file has them, before narrowing, validates every index (CheckReferences), finds cycles in the node graph in linear time, writes extras without recursion, and charges every allocation of vertices, indices and pixels to a decode budget (decodeLimit, by default 1 GiB plus 64 bytes per byte of file), so a small file can't allocate without bound. Images are refused from their header past 65535 a side or 2^28 pixels, which is where stb's 32-bit arithmetic wraps. Anything new that reads bytes keeps that discipline, and gets a broken-file case in the test.
  6. lib/ is vendored. It isn't restyled or refactored. A fix to it, such as the mingw _rotr macro collision in libdeflate (021ced6), is a minimal patch to the vendored copy whose commit message says so, so that the next update of the vendored version knows to reapply it.
  7. Targets branch at runtime in project.cpp and on __wasm__ in the sources. wasm skips libdeflate's x86 cpu_features (inline assembly), has no _Float16 and no exceptions, and runs no tests. There is no host-platform split: the host is Linux and mingw is a cross-compile target.
  8. The libdeflate include path is absolute, fs::absolute("lib/gdeflate/libdeflate"): as a dependency this project is compiled from the consumer's directory, and a relative -I falls through to a system libdeflate of a different ABI. Keep it absolute, and do the same for any include path added.

Commit and PR guidelines

  • Branch from master; PRs go to master on forgejo.catcrafts.net/Catcrafts/Crafter.Asset. Agent branches are named claude/<topic>. Pushes go over SSH: the push URL is forgejo@forgejo.catcrafts.net:Catcrafts/Crafter.Asset.git, the fetch URL HTTPS.
  • Subject: type(scope): imperative summary with feat, fix, refactor, docs or test, the partition as the scope (glb, texture, compression, ...), as the recent commits do; the older one-word subjects aren't the model. The body says what changed, grouped, and why. git log -2 shows the shape.
  • A change to an interface lands on every consumer's next build, since Crafter.Graphics and crafter-build pull master unpinned, and crafter-build links this library into itself. Build a consumer against the change before merging: Crafter.Graphics with --local from its checkout, or crafter-build's self-host build. What crafter-build calls: TextureAsset<Vector<std::uint8_t, 4, 4>>::LoadPNG<std::uint8_t>, Resize, SaveCompressed; MeshAsset<VertexNormalTangentUVPacked>::LoadOBJ, LoadOBJSplit, SaveCompressed; LoadMTL.
  • Update AGENTS.consumer.md in the same change when the API, a file format, a limit or an error behaviour changes. It is what the consumers' agents read, and it reaches their AGENTS.md on their next build.
  • Never commit bin/ or build/ (gitignored), and don't edit lib/ outside design rule 6.

Security considerations

  • The loaders parse untrusted bytes, and the GLB loader and LoadPNGFromMemory are the ones that meet user content. Their defences are design rule 5; the "broken files" section of the GLB test is their regression suite. A new field read from a file gets a broken-file case.
  • stb_image is the image attack surface. It is pinned at 2.30 in lib/; the size limits in TextureAsset exist because stb's own arithmetic overflows past them, and an encoded image is bounded to INT_MAX bytes because that is what stb takes.
  • Compression::Fatal aborts the process. Right for build output that doesn't match the binary, wrong for anything a user can hand the program (design rule 1).
  • project.cpp is compiled and run by crafter-build, and clones and builds Crafter.Math from its default branch on every invocation. Pin a commit in the GitProject spec if that ever matters.
  • The test writes only under fs::temp_directory_path().

Gotchas

  • Adding a partition is three edits: the interface file, the ifaces array in project.cpp, and the umbrella Crafter.Asset.cppm; an implementation unit goes into both impls arrays, the --exe branch and the library branch.
  • STB_IMAGE_IMPLEMENTATION and STB_IMAGE_RESIZE_IMPLEMENTATION are defined once, in Crafter.Asset-Texture.cppm. Defining them anywhere else in this tree, or in a consumer, duplicates the symbols at link time. CrafterAssetClearStbiFailure, beside them, is a plain global function because stb's failure reason is a static only that fragment can reset; it isn't API.
  • The GDeflate C++ wrappers (GDeflateCompress.cpp, GDeflateDecompress.cpp) are #included into the Compression implementation unit because cfg.cFiles compiles only .c files. Don't list them in project.cpp.
  • LoadOBJ deduplicates vertices with a linear scan and is quadratic, and takes only triangulated faces with v/vt/vn on every vertex. LoadOBJSplit hashes, fan-triangulates and is what crafter-build's BuildOBJBundle uses for Sponza-sized inputs. Fix performance there, not by speeding up LoadOBJ.
  • MeshAsset<void>::Load reads a two-count header and can't read a MeshAsset<T>::Save file, which has three. The compressed formats share one layout, which is why the data region is always present.
  • TextureAsset::Resize is constrained to 4-byte pixels (RGBA8); the _Float16 and float textures have no resize.
  • The GLB loader decodes every image at load, eagerly, and refuses images with a uri (external file or data URI): a GLB is expected to carry its images in the BIN chunk. Only PNG and JPEG are decoded, by their signatures; another format stays an empty image, and a texture pointing at one is an error.
  • There is no wasm test because wasm runs no tests here; a wasm-only branch is checked only by building --target=wasm32-wasip1.