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:
:Compressionis the GDeflate layer.CompressStreamsturns a list of byte spans into oneCompressedBlob: the streams concatenated, plus aRegionMetatable whose layout isVkDecompressMemoryRegionEXTwithout the device addresses. Every stream is its own GDeflate tile stream, so the GPU decompresses regions independently.DecompressCPUis the fallback,WriteBlob/ReadBlobserialise a blob behind a format's own header, andFatalprints and aborts.:TextureisTextureAsset<T>: decoding anything stb_image reads (LoadPNG,LoadPNGFromMemory,SizeFromMemory) to 8-bit or floating-point RGBA, opacity classification,Resize, the rawSave/Loadand the compressedSaveCompressed/LoadCompressedTexture(.ctex). The stb implementations are compiled here, in the interface's global module fragment.:MeshisMeshAsset<T>: OBJ parsing (LoadOBJ, the per-materialLoadOBJSplit,LoadMTL), the vertex types, tangent generation, rawSave/Loadand compressedSaveCompressed/LoadCompressedMesh(.cmesh).MeshAsset<void>is positions and indices only.:GLBisGlbAsset::Load, a glTF 2.0 binary loader: nodes, meshes with morph targets, skins, materials, textures, images, cameras andKHR_lights_punctual, with the JSON and BIN chunks and theextraskept 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)printsPASSorFAILwith the description and counts failures;mainreturnsEXIT_FAILUREwhen any failed.CheckError(loaded, text, what)checks that a load failed withtextin its message,CheckLoadsthe opposite, printing the error. Don't stop at the first failure.- Fixtures are built in memory:
BuildGlb(json, bin)lays out the container,LoadJsonloads it, andReplace(json, {{from, to}})derives a broken file from a good one and fails the test whenfromisn't in the JSON, so a case can't silently stop testing what it meant to. Only the path-based cases touch the disk, underfs::temp_directory_path()throughTempFile, 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 printsSKIPand 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-onlyand//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, aftermodule;.- Four-space indentation, no tabs.
PascalCasefor types and functions,camelCasefor variables and members,k-prefixed constants in anonymous namespaces (kMagic,kDecodeFloor); the file-format constants sit inTextureAssetFormatandMeshAssetFormat. - Fixed-width types.
intonly 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;ViewBytesandReadAccessorare 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-exceptionsbuild aborts on a throw.std::printlnandstd::formatin 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.
- 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 astd::expected, and the program decides. The pipeline's own output (.ctexand.cmeshthroughLoadCompressed*,DecompressCPU) goes throughCompression::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.Fatalrather thanthrowkeeps the module buildable under-fno-exceptions, which the wasm targets are.LoadPNGis the one exception: it throwsstd::runtime_errorso crafter-build can report which file failed, and isFatalwhere there are no exceptions. A new loader goes on the side its input belongs to; neverFatalon user content. - The compressed formats are a contract with Crafter.Graphics.
.ctexis the magicCGDT, version 1, a header and one region, the pixels..cmeshisCGDM, version 1, a header and three regions in the order vertices, indices, vertex data; always three, even fromMeshAsset<void>, where the data region is empty.RegionMetais threestd::uint64_t, theVkDecompressMemoryRegionEXTfields 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 bumpsversionin the*AssetFormatnamespace, changes the writer and the reader together, and lands with the Crafter.Graphics change that reads it. The rawSave/Loadformats (.tex,.mesh) have no magic and no version; don't grow them without adding one. - Non-template code lives in an implementation unit.
:Textureand:Meshare templates and inline functions and behave like headers; the Compression and GLB entry points are out of line inimplementations/so they land inlibCrafter.Asset.a, which is why the defaultConfigurationTypeisLibraryStatic. A new non-template function goes in an implementation unit. - Third-party code stays out of the interfaces. nlohmann/json is included only in the GLB implementation unit, and the interface hands
extrason as JSON text, so the parser is nobody else's compile-time cost. stb is the one exception, included with its implementation in the:Textureinterface's global module fragment, becauseLoadPNGis a template. Don't add another. - 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, writesextraswithout 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. lib/is vendored. It isn't restyled or refactored. A fix to it, such as the mingw_rotrmacro 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.- Targets branch at runtime in
project.cppand on__wasm__in the sources. wasm skips libdeflate's x86cpu_features(inline assembly), has no_Float16and no exceptions, and runs no tests. There is no host-platform split: the host is Linux and mingw is a cross-compile target. - 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-Ifalls 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 tomasteronforgejo.catcrafts.net/Catcrafts/Crafter.Asset. Agent branches are namedclaude/<topic>. Pushes go over SSH: the push URL isforgejo@forgejo.catcrafts.net:Catcrafts/Crafter.Asset.git, the fetch URL HTTPS. - Subject:
type(scope): imperative summarywithfeat,fix,refactor,docsortest, 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 -2shows the shape. - A change to an interface lands on every consumer's next build, since Crafter.Graphics and crafter-build pull
masterunpinned, and crafter-build links this library into itself. Build a consumer against the change before merging: Crafter.Graphics with--localfrom 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/orbuild/(gitignored), and don't editlib/outside design rule 6.
Security considerations
- The loaders parse untrusted bytes, and the GLB loader and
LoadPNGFromMemoryare 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 inTextureAssetexist because stb's own arithmetic overflows past them, and an encoded image is bounded toINT_MAXbytes because that is what stb takes. Compression::Fatalaborts 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.cppis compiled and run by crafter-build, and clones and builds Crafter.Math from its default branch on every invocation. Pin acommitin theGitProjectspec if that ever matters.- The test writes only under
fs::temp_directory_path().
Gotchas
- Adding a partition is three edits: the interface file, the
ifacesarray inproject.cpp, and the umbrellaCrafter.Asset.cppm; an implementation unit goes into bothimplsarrays, the--exebranch and the library branch. STB_IMAGE_IMPLEMENTATIONandSTB_IMAGE_RESIZE_IMPLEMENTATIONare defined once, inCrafter.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 becausecfg.cFilescompiles only.cfiles. Don't list them inproject.cpp. LoadOBJdeduplicates vertices with a linear scan and is quadratic, and takes only triangulated faces withv/vt/vnon every vertex.LoadOBJSplithashes, fan-triangulates and is what crafter-build'sBuildOBJBundleuses for Sponza-sized inputs. Fix performance there, not by speeding upLoadOBJ.MeshAsset<void>::Loadreads a two-count header and can't read aMeshAsset<T>::Savefile, which has three. The compressed formats share one layout, which is why the data region is always present.TextureAsset::Resizeis constrained to 4-byte pixels (RGBA8); the_Float16andfloattextures 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.