Crafter.Math/AGENTS.template.md

20 KiB

AGENTS.md

Guidance for coding agents and contributors working on Crafter.Math itself. The README is the user-facing manual, and AGENTS.consumer.md is what agents working in a consumer of this library read: crafter-build copies it into the dependency-notes section of the consumer's AGENTS.md on every build. When any of the three disagrees with the code, the code wins.

Project overview

Crafter.Math is the Crafter engine's math library, shipped as one C++ module, Crafter.Math, with seven partitions. It has no implementation units and no dependencies: project.cpp declares a static library whose only sources are the interfaces, so every function is a template or an inline body that a consumer instantiates with its own target and -march. The library is the bottom of the Crafter stack: Crafter.Asset, Crafter.Graphics and crafter-build's own project.cpp all load it from its default branch.

Path What lives there
interfaces/Crafter.Math.cppm The umbrella: export imports every partition.
interfaces/Crafter.Math-<Partition>.cppm One partition per file, see below.
project.cpp The build description: the library plus the test matrix (AddMarchVariants).
tests/<Name>/main.cpp One test program per directory: Vector, Matrix, Quaternion, Intersection.
AGENTS.consumer.md Notes for consumers' agents; crafter-build injects them into consumers' AGENTS.md. This file never gets such a section itself, since the library has no dependencies.
build/, bin/ Generated and gitignored. build/test-logs/ keeps the output of failed tests.

Partitions, in import order:

  • :Common picks the SIMD register type per ISA and holds what the SIMD vectors share: VectorBase<Len, Packing, T> with VectorType, Max, MaxElement, AlignmentElement, BatchSize and OptimalPacking, the shuffle/blend/negate mask builders, and the sin/cos polynomial helpers for SSE, AVX, AVX-512 and NEON. The intrinsics headers (immintrin.h, wasm_simd128.h, arm_neon.h, riscv_vector.h) are included in the global module fragment of every unit that needs them.
  • :VectorF32 is VectorF32<Len, Packing>: five complete copies of the struct in one #if chain, one per backend. x86 (__x86_64), WebAssembly (__wasm_simd128__), RISC-V (__riscv_vector), AArch64 (__aarch64__ && __ARM_NEON), and the scalar #else over std::array.
  • :VectorF16 is the same surface for _Float16 with three backends: AVX512-FP16, NEON with ARMv8.2-FP16 arithmetic, and a generic _Float16 copy for other non-x86, non-NEON targets. Where none applies, :Basic declares using VectorF16 = VectorF32.
  • :Basic holds that alias and ToRadian.
  • :Vector is the plain scalar Vector<T, Len, Alignment>, independent of the rest, with the quaternion functions as statics of Vector<T, 4>.
  • :MatrixRowMajor builds on :VectorF32 and :Vector: each row is a VectorF32<4, 1>.
  • :Intersection builds on :VectorF32 and :MatrixRowMajor: batched ray, sphere and OBB tests.

Numbers that follow from :Common, which the tests and consumers depend on (checked against the built PCMs on 2026-10-31):

Backend Register (Max) MaxElement (floats) VectorF32<3,1>::OptimalPacking VectorF32<3, Opt>: size, BatchSize
x86 AVX-512 (x86-64-v4, sapphirerapids) 64 B 16 5 64 B, 3 (15 lanes)
x86 AVX2 (x86-64-v3), and the scalar fallback 32 B 8 2 32 B, 4 (8 lanes)
AArch64 NEON, WebAssembly SIMD128 16 B 4 1 16 B, 4
RISC-V RVV 16, 32 or 64 B from the march's Zvl* 4, 8 or 16

VectorF32<Len, 1> is one 16-byte register on every backend, with AlignmentElement 4 and BatchSize 4. Vector<float, 3> is 12 bytes aligned 4; Vector<float, 3, 3> is 12 bytes aligned 1 (packed); Vector<float, 3, 4> is 16 bytes. MatrixRowMajor<float, 4, 3, 1> is 48 bytes and the 4x4 is 64, both aligned 16. With AVX512-FP16, VectorF16<3, 1> is a real struct with AlignmentElement 8 and MaxElement 32; on the other x86 tiers it is the alias.

Dev environment

crafter-build is the only tool, from its Flatpak (alias crafter-build='flatpak run net.catcrafts.CrafterBuild'; a locally built one runs with --branch=local). The library has no dependencies, so nothing is fetched. The host is Linux x86_64; cross targets need their sysroot or toolchain on the host.

Things about the Flatpak sandbox that cost time before:

  • It sees the host filesystem but not the host's PATH. Inside, PATH is /app/bin:/usr/bin and the host's /usr/bin is at /run/host/usr/bin. A runner probe for qemu-aarch64 or wine fails there, which crafter-build reports as a failed test ("runner unavailable"), not a skip, and it builds nothing for that test.
  • A host binary from /run/host/usr/bin/ runs inside the sandbox only when it needs no host shared libraries: qemu-aarch64-static and wine run, qemu-aarch64 does not (libcapstone.so.5 missing). flatpak-spawn --host is not permitted.
  • It has its own /tmp. Scratch files for the compiler go under build/ (gitignored), not under /tmp.
  • Its caches are under ~/.var/app/net.catcrafts.CrafterBuild/cache/crafter.build/<target>-<march>-<clang version>-<hash>/, one std.pcm per target and march. A native crafter-build uses ~/.cache/crafter.build/ instead.

Each test variant has its own directory, build/<Test>-<march>-<target>-<march>-<mtune>-<hash>/, with the PCMs of all eight interfaces compiled for that tier, their objects and a std.pcm. A scratch program against a tier compiles with the Flatpak's clang against that directory; touch tests/<Name>/main.cpp && crafter-build -v test '<Name>-<march>' prints the exact command to copy. The shape, for x86-64-v4:

flatpak run --command=clang++ net.catcrafts.CrafterBuild -stdlib=libc++ --target=x86_64-unknown-linux-gnu \
  -march=x86-64-v4 -mtune=generic -std=c++26 \
  -fprebuilt-module-path=$HOME/.var/app/net.catcrafts.CrafterBuild/cache/crafter.build/x86_64-unknown-linux-gnu-x86-64-v4-<clang>-<hash> \
  -fprebuilt-module-path=build/Quaternion-x86-64-v4-x86_64-unknown-linux-gnu-x86-64-v4-generic-<hash> \
  -O1 build/probe.cpp -c -o build/probe.o
# link: the same flags, build/<variant>/Crafter.Math*.o build/probe.o -fuse-ld=lld -L/app/lib -Wl,-rpath,/app/lib
# run:  flatpak run --command=$PWD/build/probe net.catcrafts.CrafterBuild

Build, test and style commands

crafter-build                        # the static library; little to see, it has no implementation units
crafter-build test                   # every tier for the host target: 12 tests on x86_64
crafter-build test 'Matrix*'         # name glob; names are <Test>-<march>
crafter-build test --list
crafter-build test --target=x86_64-w64-mingw32 --runner=cmd:/run/host/usr/bin/wine -- --target=x86_64-w64-mingw32
crafter-build test --target=aarch64-linux-gnu --runner=cmd:<wrapper> -- --target=aarch64-linux-gnu --sysroot=/opt/aarch64-rootfs
crafter-build clean

--target= is given twice for a cross run: before -- it filters the tests, after -- it reaches project.cpp, which gates the tiers on cfg.target. project.cpp registers no lint rules, so crafter-build lint and format exit 1 with "no rules"; style is checked by eye (see Code style). There is no CI for this repository. Before a change is done, crafter-build test passes on the host, and on every cross target you can reach when you touched :Common, :VectorF32 or :VectorF16.

The <wrapper> for AArch64 is a script on the host, because an explicit --runner=cmd: gets none of the environment the default qemu runner sets, and that default runner (qemu-aarch64 on PATH) cannot be found inside the sandbox:

#!/bin/sh
exec env QEMU_LD_PREFIX=/opt/aarch64-rootfs LD_LIBRARY_PATH=/opt/aarch64-rootfs/lib:/opt/aarch64-rootfs/usr/lib /run/host/usr/bin/qemu-aarch64-static "$@"

Without it the binaries die with Could not open '/lib/ld-linux-aarch64.so.1', reported as 💥 signal 127. The sysroot is an Arch Linux ARM rootfs with libc++ 22.1.3 and its std.cppm, which the Flatpak's clang 23 accepts. qemu's default CPU has FP16, so both AArch64 tiers run.

Testing instructions

Tests are declared in project.cpp, not discovered: the loop over { "Vector", "Intersection", "Matrix", "Quaternion" } calls cfg.AddMarchVariants(name, mathInterfaces, tiers) with the tier array of the target's architecture, and each call makes one executable per tier, named <Name>-<march>, that recompiles all eight interfaces with that tier's -march and -mtune. That is the point of the matrix: a PCM compiled once with the library's own march would exercise one backend. The tiers are three for x86 (sapphirerapids with AVX512-FP16, x86-64-v4, x86-64-v3), two for AArch64 (armv8.2-a+fp16, armv8-a) and three for RISC-V (rv64gcv_zvl512b, rv64gcv_zvl256b, rv64gcv). A new test is a new tests/<Name>/main.cpp plus its name in that loop; a new tier is a line in the array.

A test is an int main(): exit 0 passes, nonzero fails, 77 skips. The four follow one shape: a table of std::string* TestX() functions that return nullptr on success or a heap-allocated message, and a main that runs them in order, prints [name] message to stderr and returns 1 at the first failure. Tolerances are 1e-4 in Matrix and Quaternion, 1e-3 in Intersection and 0.01 in Vector, whose TestAllCombinations sweeps every (Len, Packing) up to MaxElement for both VectorF16 and VectorF32. That sweep only exercises shapes whose Len * Packing is a power of two; Len == 3 shapes are covered by the Matrix and Intersection tests and by the Quaternion test's RotatePivot cases, which run VectorF32 and VectorF16 at Packing 1 and at the widest packing a 3-vector and a quaternion share (8 halves on AVX512-FP16, where the VectorF16 cross product used to break at the 128-bit lane boundary). Open a new test file with a comment on what it guards, as tests/Quaternion/main.cpp does, and when a fix changes behaviour, add the case that failed before it.

What passes where, as of 2026-10-31 with crafter-build's clang 23.1.2:

Target How it was run Result
x86_64 Linux, three tiers crafter-build test on a Xeon w7-3465X (has AVX512-FP16) 12 passed
x86_64 Windows (x86_64-w64-mingw32), three tiers wine from /run/host/usr/bin 12 passed
AArch64, two tiers qemu through the wrapper above Vector, Intersection and Quaternion pass (6); the Matrix unit does not compile, see Gotchas
RISC-V never built here: no riscv64 sysroot (qemu-riscv64-static is installed) unknown
WebAssembly no tests declared; the library builds for wasm32-wasip1 as a dependency of Crafter.Graphics n/a

The sapphirerapids tier only runs on a CPU with AVX512-FP16; elsewhere it dies with an illegal instruction (💥), which is not a logic failure. Failed, crashed and timed-out tests write build/test-logs/<Test>-<march>.log; a later passing run does not delete them, so check the timestamp before trusting one.

Real hardware is driven the same way, through --runner=cmd:<script> where the script ships and runs the binary (Crafter.Build's README describes TestRunner). A musl device needs the sysroot's glibc loader and libraries copied next to the binary and run through ld-linux-aarch64.so.1 --library-path.

Code style

  • The first two lines of every source are exactly //SPDX-License-Identifier: LGPL-3.0-only and //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®, no space after //, then a blank line. Crafter.Build spells its header with a space; this repository does not.
  • Four-space indentation in new code. Crafter.Math-Vector.cppm and parts of the two SIMD vector files still use tabs; match the block you are in and do not reindent files, it buries the real diff. LF line endings are enforced by .gitattributes. Several files have no final newline; leave that alone too.
  • import std; everywhere. The only #includes are the intrinsics headers in the global module fragment and <cmath> in the tests.
  • PascalCase for types and functions, camelCase for variables and parameters; template parameters are Len, Packing, Alignment. VectorF32 and VectorF16 take std::uint8_t parameters, Vector and MatrixRowMajor take std::uint32_t; keep it, the static_cast<std::uint8_t> in :Intersection and :MatrixRowMajor bridge the two.
  • Shape-specific members are requires clauses on one class (requires(Len == 3), requires(CollumSize == 4 && RowSize == 3 && Repeats == 1 && std::same_as<T, float>)), not partial specialisations. Follow that.
  • Misspelled identifiers are API: the Quanternion* functions, MulitplyAdd, MulitplySub, CollumSize, Aligment, GetAlingment. Crafter.Graphics and Crafter.Asset build against them from this repository's default branch, so a rename is a breaking change: do it only on purpose, with the consumers updated in the same move. Do not add new misspellings.
  • Comments say why: the constraint, the failure mode, the rejected alternative. See the VLEN block in :Common, the quaternion block in :Vector and the NEON vfmsq_f32 note. Commit bodies follow the same rule.

Design rules

Decided deliberately; change them only with the maintainer's say-so.

  1. Interfaces only. No implementation units: the SIMD width is decided where the code is instantiated, by the consumer's -march, and crafter-build recompiles the PCMs per variant. A function that needs an out-of-line body does not belong here.
  2. The ISA is chosen at compile time from predefined macros, one backend per #if branch, each a complete struct. No runtime dispatch and no mixing of backends in one struct. An operation added to one backend is added to all five of VectorF32 (and the three of VectorF16), with the scalar fallback as the reference semantics: every target without an intrinsics backend compiles through it, and MatrixRowMajor needs its batched Dot.
  3. Fixed-size storage, one register per object. Len * Packing <= MaxElement is a static_assert; RVV pins the register width to the guaranteed VLEN at compile time rather than going vector-length-agnostic.
  4. Batched operations lock their arity to BatchSize. Dot takes 2 * BatchSize arguments; Length, LengthSq and Normalize take BatchSize. The public variadic wrappers forward to the per-shape *Pack bodies. There is no scalar-returning Dot on the SIMD vectors: callers pad with repeated pairs and read one lane, as MatrixRowMajor does.
  5. Load and Store move the whole register (AlignmentElement lanes) through unaligned loads and stores; the padding lanes carry no meaning.
  6. VectorF16 aliases VectorF32 where there is no native half path. The alias lives in :Basic; the same condition is spelled out in :Common, :VectorF16 and :Basic because macros do not cross module boundaries. Change all three together.
  7. Both matrix shapes mean the same transform. The 4x4 transforms row vectors (v * M, translation in row 3), the 4x3 column vectors (M * v, translation in column 3, VkTransformMatrixKHR), so the 4x4's upper 3x3 is the 4x3's transposed. Every factory and inverse exists for both and agrees, and tests/Matrix checks that they agree. The 4x3 Rotation once copied the 4x4's rows and rotated the other way.
  8. Quaternions are Vector<T, 4> in (x, y, z, w) order, unit, applied as Rotate does (q v q*). QuanternionMultiply(a, b) is b then a, and QuanternionFromEuler takes half angles. New quaternion code keeps these.
  9. No dependencies. Crafter.Math is loaded by every consumer's build and by crafter-build's own; anything it depended on would be fetched and built by all of them.

Commit and PR guidelines

  • Branch off master (claude/<topic> has been the pattern for agent work) and open the PR against master on the project's Forgejo at forgejo.catcrafts.net/Catcrafts/Crafter.Math. Push over SSH (forgejo@forgejo.catcrafts.net:Catcrafts/Crafter.Math.git).
  • Subject: a short imperative summary, with a feat: or fix: type prefix as the recent commits have. Body: what was wrong, why, and why the fix has this shape, wrapped at about 75 columns; git log -3 shows the voice. Add a Co-Authored-By: trailer when an agent wrote it.
  • Every consumer tracks this repository's default branch unpinned (Crafter.Build, Crafter.Asset, Crafter.Graphics), so a push to master is live in their next build. Keep the exported surface compatible, or update them in lockstep, and build Crafter.Graphics against your branch with --local when you change a signature.
  • Keep the README's module table and example true to the exported surface, and update AGENTS.consumer.md when a convention or a signature consumers lean on changes. Consumers pick the new notes up on their next build, crafter-build's own AGENTS.md included, whose tree then shows as dirty until they commit the section.
  • Never commit build/ or bin/.

Gotchas

  • AArch64 does not compile the Matrix test with clang 23 (crafter-build's 23.1.2): arm_neon.h's inline wrappers vmulq_f32 and vfmaq_f32 are called from both :Common (the sin/cos helpers) and :VectorF32, and compiling tests/Matrix/main.cpp fails with definition with same mangled name '_ZL9vmulq_f32...' as another definition, pointing into arm_neon.h through :Common. The other three tests compile and pass on both AArch64 tiers. Bisected on 2026-10-31: a unit fails when VectorF32<3, 1> arithmetic (operator*, MulitplyAdd) is emitted before a VectorF32<3, 1> Sin, Cos or SinCos (Common's helpers), which MatrixRowMajor::Rotation(pitch, yaw, roll) does after the test's earlier cases. The reverse order compiles, and so do the same pairs on <1,1>, <2,1>, <4,1>, <1,4> and <2,2>: clang merges the two copies in some deserialization orders and not others. Any wrapper both units call can collide; with vmulq_f32 and vfmaq_f32 taken out of the helpers the error moved to vdupq_n_f32. :Common already dodged the same error for vfmsq_f32 by writing vfmaq_f32 with a negated constant; that no longer suffices, and the Matrix test compiled for AArch64 with clang 22.1.8 when the NEON backend landed (2026-10-06). The fix is a helper block in :Common that calls no arm_neon.h wrapper at all (vector-extension operators and clang builtins), or routing every shared operation through one unit, and it needs that test green. An importing unit that #includes arm_neon.h itself before the import compiles, which is a consumer's workaround in a pinch.
  • The x86 #else of Max is 32 bytes, so an x86 build without AVX-512 instantiates __m256 for Len * Packing of 5 to 8 whether or not AVX is enabled, and the SSE sin/cos helpers use FMA. x86-64-v3 is the real floor; plain x86-64 and x86-64-v2 are untested and would need __AVX__ guards to work.
  • Several x86 VectorF32 members lack const: the scalar operator+ - * /, Sin, Cos, SinCos and Shuffle. The other backends have it, so code that compiles on NEON can fail on x86 through a const object. Fix them when you touch them.
  • Vector arithmetic between different lengths covers the shorter length and leaves the remaining lanes of the result uninitialised; == compares the shorter length; the member Normalize() has no zero check while the static Normalize(a) has; Vector<T, 1> has no value constructor.
  • GetAlingment() has no return for more than 64 bytes; the static_assert on Len * Packing catches it first, so keep that assert.
  • A --runner=cmd:<tool> is probed with which <tool> inside the sandbox. A missing runner fails the tests rather than skipping them, and their build is skipped too, so "runner unavailable" says nothing about whether the code compiles.
  • crafter-build test counts tier variants: "12 passed" is four tests.