AGENTS.md

This commit is contained in:
Jorijn van der Graaf 2026-10-31 06:52:22 +01:00
commit b701059c8c
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
7 changed files with 331 additions and 62 deletions

45
AGENTS.consumer.md Normal file
View file

@ -0,0 +1,45 @@
## Crafter.Math
The Crafter engine's math library: a C++ module with no implementation units and no dependencies, so everything is a template or an inline body compiled into your translation units with your build's `--target` and `--march`. Source and issues: https://forgejo.catcrafts.net/Catcrafts/Crafter.Math. Namespace `Crafter`. The README there documents the whole surface; these notes are what an agent working in a consumer needs.
### Which type
**`Vector<T, Len, Alignment = 0>`** is plain data, for vertex and uniform structs, events, anything stored or passed around. `.x .y .z .w` (and `.r .g .b .a`) alias `v[]`; constructors exist for `Len` 2, 3 and 4 (`Vector<float, 2>{x, y}`); `+ - * /` with vectors and scalars, `+=` and friends, `==`, unary `-`; statics `Dot(a, b)`, `Cross(a, b)` (`Len == 3`), `Normalize(a)`; members `Length()`, `LengthSq()`, `Normalize()`, `Invert()`. `std::format("{}", v)` prints `{x,y,z}`.
- `Alignment` is the number of stored elements. `0` means `Len`, naturally aligned: `Vector<float, 3>` is 12 bytes aligned 4. Any `N >= Len` stores `T v[N]` and marks the struct `__attribute__((packed))`, alignment 1: `Vector<float, 3, 3>` is 12 bytes aligned 1 (the vertex type Crafter.Graphics uses), `Vector<float, 3, 4>` is 16 bytes, a `vec4` slot. Conversion between alignments is implicit.
- Arithmetic between two lengths covers the shorter one and leaves the other lanes of the result uninitialised, and `==` compares the shorter. The member `Normalize()` divides without a zero check; the static `Normalize(a)` checks.
**`VectorF32<Len, Packing>`** is one SIMD register holding `Packing` vectors of `Len` floats, for math-heavy loops. Construct from a `const float*` or broadcast one `float`; `Load(ptr)`, `Store(ptr)`, and `Store<float>()` returning a `std::array`. Operators as above; statics `Cross` (`Len == 3`), `MulitplyAdd(a, b, c)` (`a*b + c`), `MulitplySub(a, b, c)` (`a*b - c`), `Rotate(v, q)` and `RotatePivot(v, q, pivot)` by a `VectorF32<4, Packing>` quaternion, `QuanternionFromEuler(halfAngles)`, `Blend<{{bools}}>(a, b)`; members `Sin()`, `Cos()`, `SinCos()`, `Shuffle<{{i, j, k}}>()`, `Negate<{{bools}}>()`, `ExtractLo<N>()`. Inside a template write `v.template Store<float>()`.
- `Load`, `Store` and the pointer constructor move the whole register, `AlignmentElement` floats (4, 8 or 16), not `Len * Packing`. Give them a buffer that big (`alignas(16) float buf[4] = { x, y, z, 0 };` is the idiom in the tests), zero the padding lanes on the way in and ignore them on the way out. The pointer need not be aligned.
- `Dot`, `Length`, `LengthSq` and `Normalize` are batched with the arity fixed at compile time: `Normalize`, `Length` and `LengthSq` take exactly `BatchSize` vectors, `Dot` takes `2 * BatchSize` (a0, b0, a1, b1, ...). They return `VectorF32<1, Packing * BatchSize>` with one lane per input vector (`Normalize` returns `std::array<VectorF32<Len, Packing>, BatchSize>`). There is no two-argument `Dot`: for one product pass the same pair `BatchSize` times and read lane 0, as `MatrixRowMajor::operator*` does, or use `Vector<float, 3>::Dot`.
- `BatchSize` is `AlignmentElement / Packing`, 4 for every `VectorF32<Len, 1>`. `OptimalPacking` is how many `Len`-vectors fill one register (table below). Shapes convert implicitly, copying the lanes they share.
- The x86 backend lacks `const` on the scalar operators, `Sin`, `Cos`, `SinCos` and `Shuffle`: call them on non-const objects.
**`VectorF16<Len, Packing>`** is the same surface for `_Float16` where the target has native half arithmetic: x86 with AVX512-FP16, AArch64 with ARMv8.2-FP16. Everywhere else, including every x86 tier below `sapphirerapids` and all of RISC-V, it is `using VectorF16 = VectorF32`, converting on `Load` and `Store` from `_Float16*`. Never overload a function on `VectorF16` and `VectorF32` both, and never assume its lanes are halves.
**`MatrixRowMajor<float, 4, RowSize, 1>`** is four-column and row-major, each row a `VectorF32<4, 1>` in the public `rows[]`; constructors take the 12 or 16 floats row by row. Only `T = float`, `CollumSize = 4`, `Repeats = 1` exist.
- `<float, 4, 3, 1>` (48 bytes) is the column-vector shape: `M * VectorF32<3, 1>` applies it with an implicit `w = 1`, `TransformNormal(v)` with `w = 0`; translation sits in column 3. `Store(float*)` writes the 12 floats row-major, which is `VkTransformMatrixKHR`: `m.Store(reinterpret_cast<float*>(instance.transform.matrix))`.
- `<float, 4, 4, 1>` (64 bytes) is the row-vector shape for the GPU side (`v * M`, translation in row 3). There is no matrix-times-vector operator for it in C++. `operator*` between two 4x4s, `Transpose()`, `LookTo(eye, direction, up)` and `LookAt(eye, focus, up)` are 4x4 only.
- Both shapes have `Identity()`, `Scaling(x, y, z)`, `Translation(x, y, z)` and `Rotation(pitch, yaw, roll)` in radians (each also from a `VectorF32<3, 1>`), `Rotation(Vector<float, 4> quaternion)`, `TRS(t, q, s)` (scale, then rotate, then translate: a glTF node), `InverseAffine()` (any rotation, scale and shear plus a translation, never a projection) and `Decompose()` returning `(t, q, s)`, with a mirroring expressed as a negative X scale. Each means the same transform in both shapes. `std::format` prints both.
**Quaternions** are `Vector<T, 4>` in `(x, y, z, w)` order, unit, `w` the scalar part, applied by `Vector<T, 3>::Rotate(v, q)` and `RotatePivot(v, q, pivot)`; identity is `(0, 0, 0, 1)`. The functions are statics of `Vector<T, 4>` and are spelled `Quanternion`: `QuanternionMultiply(a, b)` is the Hamilton product, b first then a (`Rotate(v, a*b) == Rotate(Rotate(v, b), a)`); `QuanternionConjugate(q)` is the inverse rotation; `QuanternionFromAxisAngle(unitAxis, radians)` is right-handed (+90° about Z takes X to Y); `QuanternionFromUnitVectors(from, to)` is the shortest arc, defined for opposite directions too; `QuanternionFromBasis(right, up, forward)`; `QuanternionFromEuler(rollHalf, pitchHalf, yawHalf)` takes **half** angles; `QuanternionNlerp(a, b, t)` and `QuanternionSlerp(a, b, t)` take the shorter arc and return unit quaternions.
**Intersection tests** (`IntersectionTestRayTriangle`, `IntersectionTestRaySphere`, `IntersectionTestRayOrientedBox`, `IntersectionTestSphereOrientedBox`, `IntersectionTestOrientedBoxOrientedBox`, with `PackedOBBs<Packing>` and `GetOBBCorners`) are batched over `Packing * BatchSize` primitives per call; `Packing` defaults to `VectorF32<3, 1>::OptimalPacking` (the ray-box test to the smaller of the 3- and 4-vector packings). A vertex argument is `std::array<VectorF32<3, Packing>, BatchSize>`: element `b`, lanes `s*3 .. s*3+2`, holds primitive `b * Packing + s`. The result is a `VectorF32<1, Total>` with lane `i` for primitive `i`: the ray parameter `t`, or `std::numeric_limits<float>::max()` for a miss (the two box-overlap tests return `0` for a hit). The triangle test culls back faces; the sphere test returns the near root, so a ray starting inside misses. `tests/Intersection/main.cpp` in the Crafter.Math repository has `PackVec3`, `PackVec4` and `PackScalars` helpers worth copying.
### Targets and SIMD
| Backend | Register | `MaxElement` (floats) | `VectorF32<3,1>::OptimalPacking` | `BatchSize` of `VectorF32<3, Opt>` |
|---|---|---|---|---|
| x86 AVX-512 (`x86-64-v4`, `sapphirerapids`) | 64 B | 16 | 5 | 3 (15 lanes) |
| x86 AVX2 (`x86-64-v3`), and the scalar fallback | 32 B | 8 | 2 | 4 (8 lanes) |
| AArch64 NEON, WebAssembly SIMD128 | 16 B | 4 | 1 | 4 |
| RISC-V RVV | 16, 32 or 64 B from the `Zvl*` in the march | 4, 8 or 16 | | |
- The backend and register width are fixed when the module is compiled, from predefined macros, so from the `--march` your build uses. crafter-build's default is `native`: fine for a developer build, wrong for a binary that ships. `--march=x86-64-v3` is the lowest x86 tier the library is tested on (its SSE paths use FMA); the `sapphirerapids` tier runs only on CPUs with AVX512-FP16.
- Tested as of 2026-10-31: all three x86 tiers on Linux and on Windows (`x86_64-w64-mingw32` under wine); AArch64 `armv8-a` and `armv8.2-a+fp16` under qemu, except Crafter.Math's own Matrix test, which does not compile for AArch64 with clang 23 (`arm_neon.h` wrapper functions defined twice across module units; it bites a unit that emits `VectorF32<3, 1>` arithmetic before a `VectorF32<3, 1>` `Sin`, `Cos` or `SinCos`, as that test does through `MatrixRowMajor::Rotation(pitch, yaw, roll)`). If an AArch64 build of yours fails with `definition with same mangled name` in `arm_neon.h`, it is that issue. RISC-V has never been built. WebAssembly builds (crafter-build adds `-msimd128`; flags you add to your own units must match) and has no tests.
### Spellings
- `Quanternion*`, `MulitplyAdd`, `MulitplySub`, `CollumSize`, `Aligment` are the real identifiers. Don't correct them in a consumer; a rename is an upstream change that updates every consumer at once.

145
AGENTS.md Normal file
View file

@ -0,0 +1,145 @@
# AGENTS.md
Guidance for coding agents and contributors working on Crafter.Math itself. The [README](README.md) is the user-facing manual, and [AGENTS.consumer.md](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 import`s 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`:
```bash
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
```bash
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:
```sh
#!/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 `#include`s 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 `#include`s `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.

View file

@ -6,7 +6,7 @@ import std;
import :VectorF32;
namespace Crafter {
template<typename T>
export template<typename T>
constexpr T ToRadian(T degrees) {
return degrees * (std::numbers::pi / 180);
}

View file

@ -186,10 +186,17 @@ namespace Crafter {
template <std::array<std::uint8_t, Len> ShuffleValues>
static consteval bool CheckEpi32Shuffle() {
if constexpr (PerLane == 8) {
for(std::uint8_t i = 1; i < Len; i+=2) {
if(ShuffleValues[i-1] != ShuffleValues[i] - 1) {
return false;
}
// 16-bit lanes move as 32-bit words: every output word has to
// take both halves of one source word, in order. The check
// used to accept any two consecutive sources, (1, 2) included,
// so Shuffle<{{1,2,0}}> on a VectorF16 took the epi32 path and
// came out wrong. An odd Len puts the second slot half a word
// off, so only Packing 1 can qualify then, and its last lane
// has to be a word's low half.
if (Len % 2 != 0 && Packing > 1) return false;
for (std::uint8_t i = 0; i < Len; i += 2) {
if (ShuffleValues[i] % 2 != 0) return false;
if (i + 1 < Len && ShuffleValues[i + 1] != ShuffleValues[i] + 1) return false;
}
}
for(std::uint8_t i = 0; i < Len; i++) {
@ -300,9 +307,13 @@ namespace Crafter {
template <std::array<std::uint8_t, Len> ShuffleValues>
static consteval std::uint8_t GetShuffleMaskEpi32() {
// One 2-bit field per 32-bit word, four words to the immediate; a
// word holds perWord elements, so a 16-bit lane index is halved
// (the old shift of 4 * i for halves ran off the 8-bit immediate).
constexpr std::uint8_t perWord = 4 / sizeof(T);
std::uint8_t mask = 0;
for(std::uint8_t i = 0; i < std::min(Len, std::uint8_t(8)); i+=4/sizeof(T)) {
mask = mask | (ShuffleValues[i] & 0b11) << (8 / sizeof(T) * i);
for (std::uint8_t i = 0; i < std::min<std::uint8_t>(Len, 4 * perWord); i += perWord) {
mask = mask | (((ShuffleValues[i] / perWord) & 0b11) << (2 * (i / perWord)));
}
return mask;
}

View file

@ -443,50 +443,21 @@ namespace Crafter {
}
}
// Through Shuffle, as VectorF32::Cross is: it checks whether every lane
// move stays inside its 128-bit lane and takes a full permute when one
// does not. This used to call _mm*_shuffle_epi8 directly, which
// shuffles bytes per 128-bit lane only, so in a 256- or 512-bit
// register a 3-vector whose halves straddle a lane boundary (the third
// of every eight for Packing 8, say) came out wrong.
constexpr static VectorF16<Len, Packing> Cross(VectorF16<Len, Packing> a, VectorF16<Len, Packing> b) requires(Len == 3) {
if constexpr(std::is_same_v<typename VectorBase<Len, Packing, _Float16>::VectorType, __m128h>) {
constexpr std::array<std::uint8_t, VectorBase<Len, Packing, _Float16>::AlignmentElement*2> shuffleMask1 = VectorBase<Len, Packing, _Float16>::template GetShuffleMaskEpi8<{{1,2,0}}>();
__m128i shuffleVec1 = _mm_loadu_epi8(shuffleMask1.data());
__m128h row1 = _mm_castsi128_ph(_mm_shuffle_epi8(_mm_castph_si128(a.v), shuffleVec1));
__m128h row4 = _mm_castsi128_ph(_mm_shuffle_epi8(_mm_castph_si128(b.v), shuffleVec1));
VectorF16<Len, Packing> row1 = a.template Shuffle<{{1,2,0}}>();
VectorF16<Len, Packing> row4 = b.template Shuffle<{{1,2,0}}>();
constexpr std::array<std::uint8_t, VectorBase<Len, Packing, _Float16>::AlignmentElement*2> shuffleMask3 = VectorBase<Len, Packing, _Float16>::template GetShuffleMaskEpi8<{{2,0,1}}>();
__m128i shuffleVec3 = _mm_loadu_epi8(shuffleMask3.data());
__m128h row3 = _mm_castsi128_ph(_mm_shuffle_epi8(_mm_castph_si128(a.v), shuffleVec3));
__m128h row2 = _mm_castsi128_ph(_mm_shuffle_epi8(_mm_castph_si128(b.v), shuffleVec3));
VectorF16<Len, Packing> row3 = a.template Shuffle<{{2,0,1}}>();
VectorF16<Len, Packing> row2 = b.template Shuffle<{{2,0,1}}>();
__m128h result = _mm_mul_ph(row3, row4);
return _mm_fmsub_ph(row1,row2,result);
} else if constexpr (std::is_same_v<typename VectorBase<Len, Packing, _Float16>::VectorType, __m256h>) {
constexpr std::array<std::uint8_t, VectorBase<Len, Packing, _Float16>::AlignmentElement*2> shuffleMask1 = VectorBase<Len, Packing, _Float16>::template GetShuffleMaskEpi8<{{1,2,0}}>();
__m512i shuffleVec1 = _mm512_castsi256_si512(_mm256_loadu_epi8(shuffleMask1.data()));
__m256h row1 = _mm256_castsi256_ph(_mm512_castsi512_si256(_mm512_shuffle_epi8(_mm512_castsi256_si512(_mm256_castph_si256(a.v)), shuffleVec1)));
__m256h row4 = _mm256_castsi256_ph(_mm512_castsi512_si256(_mm512_shuffle_epi8(_mm512_castsi256_si512(_mm256_castph_si256(b.v)), shuffleVec1)));
constexpr std::array<std::uint8_t, VectorBase<Len, Packing, _Float16>::AlignmentElement*2> shuffleMask3 = VectorBase<Len, Packing, _Float16>::template GetShuffleMaskEpi8<{{2,0,1}}>();
__m512i shuffleVec3 = _mm512_castsi256_si512(_mm256_loadu_epi8(shuffleMask3.data()));
__m256h row3 = _mm256_castsi256_ph(_mm512_castsi512_si256(_mm512_shuffle_epi8(_mm512_castsi256_si512(_mm256_castph_si256(a.v)), shuffleVec3)));
__m256h row2 = _mm256_castsi256_ph(_mm512_castsi512_si256(_mm512_shuffle_epi8(_mm512_castsi256_si512(_mm256_castph_si256(b.v)), shuffleVec3)));
__m256h result = _mm256_mul_ph(row3, row4);
return _mm256_fmsub_ph(row1,row2,result);
} else {
constexpr std::array<std::uint8_t, VectorBase<Len, Packing, _Float16>::AlignmentElement*2> shuffleMask1 = VectorBase<Len, Packing, _Float16>::template GetShuffleMaskEpi8<{{1,2,0}}>();
__m512i shuffleVec1 = _mm512_loadu_epi8(shuffleMask1.data());
__m512h row1 = _mm512_castsi512_ph(_mm512_shuffle_epi8(_mm512_castph_si512(a.v), shuffleVec1));
__m512h row4 = _mm512_castsi512_ph(_mm512_shuffle_epi8(_mm512_castph_si512(b.v), shuffleVec1));
constexpr std::array<std::uint8_t, VectorBase<Len, Packing, _Float16>::AlignmentElement*2> shuffleMask3 = VectorBase<Len, Packing, _Float16>::template GetShuffleMaskEpi8<{{2,0,1}}>();
__m512i shuffleVec3 = _mm512_loadu_epi8(shuffleMask3.data());
__m512h row3 = _mm512_castsi512_ph(_mm512_shuffle_epi8(_mm512_castph_si512(a.v), shuffleVec3));
__m512h row2 = _mm512_castsi512_ph(_mm512_shuffle_epi8(_mm512_castph_si512(b.v), shuffleVec3));
__m512h result = _mm512_mul_ph(row3, row4);
return _mm512_fmsub_ph(row1,row2,result);
}
VectorF16<Len, Packing> result = row3 * row4;
return VectorF16<Len, Packing>::MulitplySub(row1, row2, result);
}
constexpr static _Float16 Dot(VectorF16<Len, Packing> a, VectorF16<Len, Packing> b) requires(Packing == 1) {
@ -1007,12 +978,10 @@ namespace Crafter {
return v + t * q.template Shuffle<{{3,3,3,3}}>() + Cross(qv, t);
}
constexpr static VectorF16<4, 2> RotatePivot(VectorF16<3, Packing> v, VectorF16<4, Packing> q, VectorF16<3, Packing> pivot) requires(Len == 3) {
VectorF16<Len, Packing> translated = v - pivot;
VectorF16<3, Packing> qv(q.v);
VectorF16<Len, Packing> t = Cross(qv, translated) * _Float16(2);
VectorF16<Len, Packing> rotated = translated + t * q.template Shuffle<{{3,3,3,3}}>() + Cross(qv, t);
return rotated + pivot;
// Through Rotate, like the other backends; see VectorF32's note.
constexpr static VectorF16<3, Packing> RotatePivot(VectorF16<3, Packing> v, VectorF16<4, Packing> q, VectorF16<3, Packing> pivot) requires(Len == 3) {
VectorF16<3, Packing> translated = v - pivot;
return Rotate(translated, q) + pivot;
}
constexpr static VectorF16<4, Packing> QuanternionFromEuler(VectorF16<3, Packing> EulerHalf) requires(Len == 4) {

View file

@ -1366,12 +1366,13 @@ namespace Crafter {
return v + t * q.template Shuffle<{{3,3,3,3}}>() + Cross(qv, t);
}
constexpr static VectorF32<4, 2> RotatePivot(VectorF32<3, Packing> v, VectorF32<4, Packing> q, VectorF32<3, Packing> pivot) requires(Len == 3) {
VectorF32<Len, Packing> translated = v - pivot;
VectorF32<3, Packing> qv(q.v);
VectorF32<Len, Packing> t = Cross(qv, translated) * float(2);
VectorF32<Len, Packing> rotated = translated + t * q.template Shuffle<{{3,3,3,3}}>() + Cross(qv, t);
return rotated + pivot;
// Through Rotate, like the other backends: it repacks the quaternion's
// xyz lanes into the 3-vector layout. This used to repeat Rotate's
// arithmetic with the register taken as is (qv(q.v)), which only lines
// up for Packing 1, and to return a VectorF32<4, 2> whatever the shape.
constexpr static VectorF32<3, Packing> RotatePivot(VectorF32<3, Packing> v, VectorF32<4, Packing> q, VectorF32<3, Packing> pivot) requires(Len == 3) {
VectorF32<3, Packing> translated = v - pivot;
return Rotate(translated, q) + pivot;
}
constexpr static VectorF32<4, Packing> QuanternionFromEuler(VectorF32<3, Packing> EulerHalf) requires(Len == 4) {

View file

@ -5,6 +5,10 @@
// applies them: the static Normalize on a 4-vector, the Hamilton product and
// its composition order, conjugate, axis-angle, shortest-arc between two
// directions (including the opposite-directions case), nlerp and slerp.
// Also RotatePivot, on the scalar Vector and on VectorF32 and VectorF16 for
// Packing 1 and for the widest packing a 3-vector and a quaternion share, and
// ToRadian from :Basic, which an importer such as this unit has to be able to
// name.
#include <cmath>
import Crafter.Math;
@ -145,11 +149,102 @@ std::string* TestSlerp() {
return CheckUnit(mid, "Slerp of nearly identical quaternions");
}
// ---------- ToRadian ----------
std::string* TestToRadian() {
// Lived in :Basic without `export`, so no importer could name it.
if (!Near(ToRadian(180.0f), kPi)) return new std::string(std::format("ToRadian(180) = {}, expected pi", ToRadian(180.0f)));
if (!Near(static_cast<float>(ToRadian(90.0)), kPi / 2)) return new std::string("ToRadian(90.0) is not pi/2");
return nullptr;
}
// ---------- RotatePivot ----------
std::string* TestRotatePivotScalar() {
// +90° about Z around the pivot (1, 0, 0) swings (2, 0, 0) to (1, 1, 0).
if (auto err = CheckV3(V3::RotatePivot(V3(2, 0, 0), AboutZ(kPi / 2), V3(1, 0, 0)), V3(1, 1, 0), "RotatePivot about (1,0,0)")) return err;
// The pivot is a fixed point.
const Q q = Q::QuanternionFromAxisAngle(V3::Normalize(V3(1, 2, 3)), 0.7f);
const V3 pivot(0.5f, -1, 2);
return CheckV3(V3::RotatePivot(pivot, q, pivot), pivot, "RotatePivot fixed point");
}
// `Packing` 3-vectors at stride 3, or quaternions at stride 4, in one register
// of T lanes, and back. The padding lanes stay zero.
template <template <std::uint8_t, std::uint8_t> class VectorType, typename T, std::uint8_t Packing>
VectorType<3, Packing> Pack3(const std::array<V3, Packing>& vs) {
alignas(64) T buf[VectorType<3, Packing>::AlignmentElement] = {};
for (std::uint8_t s = 0; s < Packing; ++s) {
buf[s * 3 + 0] = static_cast<T>(vs[s].x);
buf[s * 3 + 1] = static_cast<T>(vs[s].y);
buf[s * 3 + 2] = static_cast<T>(vs[s].z);
}
return VectorType<3, Packing>(buf);
}
template <template <std::uint8_t, std::uint8_t> class VectorType, typename T, std::uint8_t Packing>
VectorType<4, Packing> Pack4(const std::array<Q, Packing>& qs) {
alignas(64) T buf[VectorType<4, Packing>::AlignmentElement] = {};
for (std::uint8_t s = 0; s < Packing; ++s) {
buf[s * 4 + 0] = static_cast<T>(qs[s].x);
buf[s * 4 + 1] = static_cast<T>(qs[s].y);
buf[s * 4 + 2] = static_cast<T>(qs[s].z);
buf[s * 4 + 3] = static_cast<T>(qs[s].w);
}
return VectorType<4, Packing>(buf);
}
template <template <std::uint8_t, std::uint8_t> class VectorType, typename T, std::uint8_t Packing>
std::array<V3, Packing> Unpack3(VectorType<3, Packing> v) {
const auto lanes = v.template Store<T>();
std::array<V3, Packing> out;
for (std::uint8_t s = 0; s < Packing; ++s) {
out[s] = V3(static_cast<float>(lanes[s * 3 + 0]), static_cast<float>(lanes[s * 3 + 1]), static_cast<float>(lanes[s * 3 + 2]));
}
return out;
}
// Every slot gets its own vector, quaternion and pivot, so a backend that
// reads the quaternion lanes at the wrong stride, or returns the wrong shape,
// disagrees with the scalar Vector somewhere.
template <template <std::uint8_t, std::uint8_t> class VectorType, typename T, std::uint8_t Packing>
std::string* CheckRotatePivotPacked(float epsilon, std::string_view label) {
std::array<V3, Packing> vs, pivots, expected;
std::array<Q, Packing> qs;
for (std::uint8_t s = 0; s < Packing; ++s) {
vs[s] = V3(1.0f + s, 2.0f - 0.5f * s, -1.0f + 0.25f * s);
pivots[s] = V3(0.5f * s, 1.0f, -0.5f);
qs[s] = Q::QuanternionFromAxisAngle(V3::Normalize(V3(1.0f, 2.0f + s, 3.0f)), 0.4f + 0.3f * s);
expected[s] = V3::Rotate(vs[s] - pivots[s], qs[s]) + pivots[s];
}
const std::array<V3, Packing> got = Unpack3<VectorType, T, Packing>(VectorType<3, Packing>::RotatePivot(
Pack3<VectorType, T, Packing>(vs), Pack4<VectorType, T, Packing>(qs), Pack3<VectorType, T, Packing>(pivots)));
for (std::uint8_t s = 0; s < Packing; ++s) {
if (!Near(got[s].x, expected[s].x, epsilon) || !Near(got[s].y, expected[s].y, epsilon) || !Near(got[s].z, expected[s].z, epsilon)) {
return new std::string(std::format("{} slot {}: expected ({},{},{}), got ({},{},{})",
label, s, expected[s].x, expected[s].y, expected[s].z, got[s].x, got[s].y, got[s].z));
}
}
return nullptr;
}
std::string* TestRotatePivotSimd() {
// The x86 VectorF32 and VectorF16 RotatePivot returned a <4, 2> whatever
// the shape and took the quaternion register as a 3-vector register.
constexpr float halfEps = 0.05f; // half precision over a handful of operations on values up to about 5
if (auto err = CheckRotatePivotPacked<VectorF32, float, 1>(kEps, "VectorF32<3,1>::RotatePivot")) return err;
constexpr std::uint8_t packing32 = std::min(VectorF32<3, 1>::OptimalPacking, VectorF32<4, 1>::OptimalPacking);
if (auto err = CheckRotatePivotPacked<VectorF32, float, packing32>(kEps, "VectorF32<3,P>::RotatePivot")) return err;
if (auto err = CheckRotatePivotPacked<VectorF16, _Float16, 1>(halfEps, "VectorF16<3,1>::RotatePivot")) return err;
constexpr std::uint8_t packing16 = std::min(VectorF16<3, 1>::OptimalPacking, VectorF16<4, 1>::OptimalPacking);
return CheckRotatePivotPacked<VectorF16, _Float16, packing16>(halfEps, "VectorF16<3,P>::RotatePivot");
}
} // namespace
int main() {
using Fn = std::string* (*)();
constexpr std::array<std::pair<const char*, Fn>, 9> tests = {{
constexpr std::array<std::pair<const char*, Fn>, 12> tests = {{
{ "NormalizeLen4", TestNormalizeLen4 },
{ "MultiplyIdentity", TestMultiplyIdentity },
{ "ConjugateUndoes", TestConjugateUndoes },
@ -159,6 +254,9 @@ int main() {
{ "Nlerp", TestNlerp },
{ "Slerp", TestSlerp },
{ "SlerpIdentity", [] { return CheckSameRotation(Q::QuanternionSlerp(Q(0, 0, 0, 1), Q(0, 0, 0, 1), 0.5f), Q(0, 0, 0, 1), "Slerp of identities"); } },
{ "ToRadian", TestToRadian },
{ "RotatePivotScalar", TestRotatePivotScalar },
{ "RotatePivotSimd", TestRotatePivotSimd },
}};
for (auto const& [name, fn] : tests) {