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:
:Commonpicks the SIMD register type per ISA and holds what the SIMD vectors share:VectorBase<Len, Packing, T>withVectorType,Max,MaxElement,AlignmentElement,BatchSizeandOptimalPacking, 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.:VectorF32isVectorF32<Len, Packing>: five complete copies of the struct in one#ifchain, one per backend. x86 (__x86_64), WebAssembly (__wasm_simd128__), RISC-V (__riscv_vector), AArch64 (__aarch64__ && __ARM_NEON), and the scalar#elseoverstd::array.:VectorF16is the same surface for_Float16with three backends: AVX512-FP16, NEON with ARMv8.2-FP16 arithmetic, and a generic_Float16copy for other non-x86, non-NEON targets. Where none applies,:Basicdeclaresusing VectorF16 = VectorF32.:Basicholds that alias andToRadian.:Vectoris the plain scalarVector<T, Len, Alignment>, independent of the rest, with the quaternion functions as statics ofVector<T, 4>.:MatrixRowMajorbuilds on:VectorF32and:Vector: each row is aVectorF32<4, 1>.:Intersectionbuilds on:VectorF32and: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,PATHis/app/bin:/usr/binand the host's/usr/binis at/run/host/usr/bin. A runner probe forqemu-aarch64orwinefails 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-staticandwinerun,qemu-aarch64does not (libcapstone.so.5missing).flatpak-spawn --hostis not permitted. - It has its own
/tmp. Scratch files for the compiler go underbuild/(gitignored), not under/tmp. - Its caches are under
~/.var/app/net.catcrafts.CrafterBuild/cache/crafter.build/<target>-<march>-<clang version>-<hash>/, onestd.pcmper 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-onlyand//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.cppmand 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.PascalCasefor types and functions,camelCasefor variables and parameters; template parameters areLen,Packing,Alignment.VectorF32andVectorF16takestd::uint8_tparameters,VectorandMatrixRowMajortakestd::uint32_t; keep it, thestatic_cast<std::uint8_t>in:Intersectionand:MatrixRowMajorbridge the two.- Shape-specific members are
requiresclauses 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:Vectorand the NEONvfmsq_f32note. Commit bodies follow the same rule.
Design rules
Decided deliberately; change them only with the maintainer's say-so.
- 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. - The ISA is chosen at compile time from predefined macros, one backend per
#ifbranch, 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 ofVectorF32(and the three ofVectorF16), with the scalar fallback as the reference semantics: every target without an intrinsics backend compiles through it, andMatrixRowMajorneeds its batchedDot. - Fixed-size storage, one register per object.
Len * Packing <= MaxElementis astatic_assert; RVV pins the register width to the guaranteed VLEN at compile time rather than going vector-length-agnostic. - Batched operations lock their arity to
BatchSize.Dottakes2 * BatchSizearguments;Length,LengthSqandNormalizetakeBatchSize. The public variadic wrappers forward to the per-shape*Packbodies. There is no scalar-returningDoton the SIMD vectors: callers pad with repeated pairs and read one lane, asMatrixRowMajordoes. LoadandStoremove the whole register (AlignmentElementlanes) through unaligned loads and stores; the padding lanes carry no meaning.VectorF16aliasesVectorF32where there is no native half path. The alias lives in:Basic; the same condition is spelled out in:Common,:VectorF16and:Basicbecause macros do not cross module boundaries. Change all three together.- 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, andtests/Matrixchecks that they agree. The 4x3Rotationonce copied the 4x4's rows and rotated the other way. - Quaternions are
Vector<T, 4>in (x, y, z, w) order, unit, applied asRotatedoes (q v q*).QuanternionMultiply(a, b)is b then a, andQuanternionFromEulertakes half angles. New quaternion code keeps these. - 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 againstmasteron the project's Forgejo atforgejo.catcrafts.net/Catcrafts/Crafter.Math. Push over SSH (forgejo@forgejo.catcrafts.net:Catcrafts/Crafter.Math.git). - Subject: a short imperative summary, with a
feat:orfix: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 -3shows the voice. Add aCo-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
masteris live in their next build. Keep the exported surface compatible, or update them in lockstep, and build Crafter.Graphics against your branch with--localwhen you change a signature. - Keep the README's module table and example true to the exported surface, and update
AGENTS.consumer.mdwhen a convention or a signature consumers lean on changes. Consumers pick the new notes up on their next build, crafter-build's ownAGENTS.mdincluded, whose tree then shows as dirty until they commit the section. - Never commit
build/orbin/.
Gotchas
- AArch64 does not compile the Matrix test with clang 23 (crafter-build's 23.1.2):
arm_neon.h's inline wrappersvmulq_f32andvfmaq_f32are called from both:Common(the sin/cos helpers) and:VectorF32, and compilingtests/Matrix/main.cppfails withdefinition with same mangled name '_ZL9vmulq_f32...' as another definition, pointing intoarm_neon.hthrough:Common. The other three tests compile and pass on both AArch64 tiers. Bisected on 2026-10-31: a unit fails whenVectorF32<3, 1>arithmetic (operator*,MulitplyAdd) is emitted before aVectorF32<3, 1>Sin,CosorSinCos(Common's helpers), whichMatrixRowMajor::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; withvmulq_f32andvfmaq_f32taken out of the helpers the error moved tovdupq_n_f32.:Commonalready dodged the same error forvfmsq_f32by writingvfmaq_f32with 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:Commonthat calls noarm_neon.hwrapper 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#includesarm_neon.hitself before the import compiles, which is a consumer's workaround in a pinch. - The x86
#elseofMaxis 32 bytes, so an x86 build without AVX-512 instantiates__m256forLen * Packingof 5 to 8 whether or not AVX is enabled, and the SSE sin/cos helpers use FMA.x86-64-v3is the real floor; plainx86-64andx86-64-v2are untested and would need__AVX__guards to work. - Several x86
VectorF32members lackconst: the scalaroperator+ - * /,Sin,Cos,SinCosandShuffle. The other backends have it, so code that compiles on NEON can fail on x86 through aconstobject. Fix them when you touch them. Vectorarithmetic between different lengths covers the shorter length and leaves the remaining lanes of the result uninitialised;==compares the shorter length; the memberNormalize()has no zero check while the staticNormalize(a)has;Vector<T, 1>has no value constructor.GetAlingment()has no return for more than 64 bytes; thestatic_assertonLen * Packingcatches it first, so keep that assert.- A
--runner=cmd:<tool>is probed withwhich <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 testcounts tier variants: "12 passed" is four tests.