The gyrocopter's rotor wobbled on its mast. The bomber's propeller turned about a point a fifth of its radius from its own hub. The catapult threw from the middle of its arm, like a seesaw, with the axle sitting still half a metre away. Every one of those models, opened on its own and measured, was correct.
Adam's report was short: the rotors were not lined up, not cut cleanly, and turned badly. He was right on all three counts, and the third had a cause I spent hours not finding because I was measuring the wrong files.
How the game turns a part
Machines in Shards of Stone have named moving parts. A node called rotor_top is spun about its local Y axis, a node called prop_tail about its local Z, and arms and beams swing the same way. The turn always goes through the node's own origin, and it carries whatever the node holds.
So three things have to be true of a turning part and nothing else matters. Its origin is the hub. Its axis is the shaft. It holds the whole propeller and only the propeller.
The models come from Meshy as one fused surface, which is the limit described in where AI 3D generation stops working, so a propeller first has to be cut free of the hull and given a pivot of its own.
The source models were right and the game was wrong
I audited the source models. The gyrocopter's top rotor sat 1% of a radius off its hub, which is nothing. I passed it, and the rest, and the rotors still wobbled in game.
The game does not load source models. It loads optimised copies: glTF-Transform resizes and compresses the textures and compresses the geometry, and a ladder of lighter copies is made from the result, as covered in cutting the download that happens before a match starts. The catapult's source file is 20.4 MB. The 1024 pixel copy a player downloads is 1.9 MB.
In those copies the same rotor was 4% off its hub. Measured across the fleet, 11 of the 12 rotor parts were off in game, the bomber's by 20% of a radius. The catapult's arm turned about a point 56 cm from its axle. The Crystal Ram's beam, which should swing from a point 1.7 m above it, see-sawed about its own centre.
What quantization does to a node
Part of that compression is mesh quantization. Vertex positions stop being floating point numbers and become small integers, measured from the middle of the mesh's bounding box. To turn the integers back into positions a renderer needs an offset and a scale, and the optimiser stores those in the most natural place it has: the transform of the node that carries the mesh.
That is harmless for a hull. For a part that turns, it moves the pivot. If the node that carries the mesh is also the node the game rotates, its origin has moved from the hub to the middle of the part's bounding box. A propeller that is not perfectly symmetrical about its hub now turns about a point beside it. An arm, whose axle is at one end, turns about its middle.
Nothing in the file is invalid. Drawn at rest it is identical to the source, vertex for vertex. It only goes wrong when something turns.
The fix is a structure
The optimiser leaves a node alone if it has children. So the named node becomes a bare pivot, holding only its transform, and the mesh moves to a child:
before after
rotor_top (mesh) rotor_top pivot, no mesh, never touched
geo_rotor_top mesh, takes the decode offsetThe child must not be named like a part, or the game would spin it a second time about its own origin.
A small tool restructures a source model this way. The eight models already shipped were repaired without re-encoding a single texture: only the JSON at the head of each optimised file changed, putting the pivot back and re-parenting the mesh. I checked that shortcut against a full run of the real optimiser on the restructured source, and the node transforms agreed to within 1e-16.

The cut was wrong as well
Adam's second point, the cut, was a separate fault that the pivot had been hiding. The first split had moved every face whose corners fell inside a cylinder I had typed in by hand, lined up with the model's axes. Real propellers are not centred on round numbers or square to anything. Ten of the twelve parts failed: the bomber's hub was a quarter of a radius off its pivot with its shaft 5 degrees off the axis, and a quarter of one tail propeller had been left behind on the hull.

The replacement finds each part by a minimum cut on the graph of faces: some faces are certainly blade, some are certainly hull, and the boundary falls at the narrowest neck between them, which is the hub. It then fits a disc to what it found, takes the hub and shaft from the fit, and repeats until they stop moving. No face is deleted, and the cut is capped on both sides so neither piece has a hole.
A gate that reads what ships
The check that should have existed reads the shipped files, not the source. For every model it fetches each optimised copy from the CDN and compares the pivot with the source model's. It only needs the JSON chunk at the head of each file, so the whole roster costs a few hundred small range requests.
It fails a part whose origin is more than 2% of the rotor's radius from the fitted hub, or whose spin axis is more than 1.5 degrees from the fitted shaft, and it fails any source model whose named node carries a mesh directly. It also carries nine controls, deliberately broken models that each have to fail, for the same reason given in proving a game runs identically every time: a check that has never been seen to fail is not evidence.
Check your own pipeline
This script has no dependencies. Give it a source GLB, the optimised copy your game loads, and a pattern matching the nodes you rotate at runtime.
// node pivots.mjs master.glb shipped.glb "^(rotor|prop|arm|beam)_"
import { readFileSync } from 'node:fs';
function nodesOf(path) {
const buf = readFileSync(path);
if (buf.readUInt32LE(0) !== 0x46546c67) throw new Error(`${path} is not a GLB`);
const jsonLength = buf.readUInt32LE(12); // the first chunk is always the JSON
const gltf = JSON.parse(buf.toString('utf8', 20, 20 + jsonLength));
return new Map((gltf.nodes ?? []).map((n) => [n.name, n]));
}
const [masterPath, shippedPath, pattern] = process.argv.slice(2);
const part = new RegExp(pattern);
const master = nodesOf(masterPath);
const shipped = nodesOf(shippedPath);
for (const [name, m] of master) {
if (!part.test(name ?? '')) continue;
const s = shipped.get(name);
if (!s) { console.log(`${name}: missing from the shipped file`); continue; }
const a = m.translation ?? [0, 0, 0];
const b = s.translation ?? [0, 0, 0];
const moved = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
const onMesh = s.mesh !== undefined ? ' <- the mesh sits on the pivot node' : '';
console.log(`${name}: origin moved ${moved.toFixed(4)}${onMesh}`);
}Run against the catapult and the gyrocopter as they are served today, it prints this:
arm_swing: origin moved 0.0000
rotor_top: origin moved 0.0000
prop_tail: origin moved 0.0000Run against the source models from before the fix, the same three lines end with the warning. It compares local translations, so it assumes both files keep the same parent chain and store transforms as translation, rotation and scale. If your exporter writes a matrix, compare that.
The rule underneath is wider than propellers. An optimiser is allowed to rewrite anything that leaves the rendered picture unchanged, and a pivot is not part of the picture until something moves. Whatever your runtime reads from a node (an origin, an axis, a name, a custom property) has to be checked in the file that ships.





