Project / how to work on this codebase

Coding notes

The working notebook. Everything here was learned by getting it wrong first, and none of it belongs to a single system, so none of it has an owning page — it is technique and cross-cutting traps rather than how any one gene or class behaves. This page has no line budget, unlike CLAUDE.md, which is why it exists.

What goes here, and what does not

The routing table at the top of CLAUDE.md still governs. This page is not an escape hatch for material that already has a home, and a note that duplicates one of those pages is worse than no note, because it is a second source of truth that nobody will remember to update:

What lands here is the residue: how to find out, how to know a check is real, and the handful of traps that live in the gaps between those pages, where each individual page is correct and the interaction between two of them is not.

Measure it; do not reason about it

Reasoning about body space has been wrong more often than it has been right. The coat pipeline is a stack of coordinate transforms, and every one of them is individually simple enough to feel like it can be held in the head — which is exactly why the errors survive: a wrong mental model produces a confident, specific, testable claim, and it takes a measurement to notice that it is false.

The cheap instrument is a throwaway probe: a test, or a gene file, that paints the quantity in question and is deleted afterwards. Two that have paid for themselves repeatedly:

The rule that generalises: when a claim is about a number the code can print, print it. The probe costs a few minutes and the wrong guess costs an evening, and the guess is more expensive when it is nearly right, because a nearly-right coat looks like a different bug.

A related discipline for coats specifically: after changing a painter, look at a side-view grid of rendered horses rather than at the numbers. Band measurements have agreed with a horse that was visibly wrong at least twice — the measurement answers the question you asked, not the question of whether the horse looks right.

A green check is not evidence until you have watched it go red

This project has now been bitten three separate ways by a gate that reported success while checking nothing. They are worth listing together, because the failure looks identical from outside in all three cases — a tool prints a large number of passing checks — and the causes have nothing in common.

  1. The oracle was stale. check-parity.mjs compares the creator's JavaScript against expected.json, which is a checked-in snapshot of the Java. Forget to re-bake it and the file agrees with the JavaScript by definition. A swapped UV pair hid behind this for a day while the creator drew every horse with its spine and belly patches exchanged.
  2. The oracle was absent. check-parity.mjs runs restrict() and tint() and has never run compose() — the magical cases are seeded with a synthetic flat colour rather than resolved through a chart. So the whole composite path had no net at all, and the creator drew every dark coat at full opacity for months (gap 50). The gate looked comprehensive and had a hole the exact size of the bug.
  3. The corpus could not discriminate. The invert/parts fix was covered by parity and parity stayed green — because no example gene had a layer whose output differed between the two semantics. The check ran, the comparison was real, and the inputs could not tell the two answers apart.
  4. The probe could not reach the detail. The GOO mask (2026-09-11) went in with a layer in prismatic.json, and the gate was watched go red twice - a port returning zero fails 43 checks, a missing end-face wrap fails one. Then two subtle disagreements were tried: multiplying the field by 0.98, and changing the sag bite from 1.4 to 1.1. Both stayed green, because the fixture samples four texels per part and none of them sits in the soft edge or between two drips. The lesson is not "parity is useless" but that a gate's worth is the smallest error it can see, and that number is only known by trying one. See gap 194.

The countermeasure for all three is the same, and it is cheap: deliberately regress the thing the check guards, and confirm the check fails. Then put it back. If it does not fail, the check is decorative and you have just learned that for the price of one edit.

Both recent additions were accepted this way and not before: reintroducing the old rgb === 0 equality made three composer checks fail, one of them on #161515 exactly; regressing the JavaScript twin's invert order made 264 parity checks fail. Neither number was predictable in advance, which is the point — had either come back as zero, the fix would have shipped with a check that proved nothing.

The deeper limit, which no amount of this fixes: parity cannot see a mirrored bug. The two engines agreeing is not either of them being right. Parity catches drift between Java and JavaScript; it says nothing about whether the Java is correct, and a bug introduced into both at once is invisible to it forever.

Scan the corpus before adding a refusal

Adding a rule to GeneSpecParser is adding a rule to every gene file that already exists, and the parser runs at startup, so a refusal that trips a shipped gene is not a warning — it is a mod that will not load. The order that has worked is:

  1. Write the detector as a throwaway scan first and run it over horsegenetics/genes/. Now the migration cost is a number rather than a surprise.
  2. If it is zero, the rule is free — prevention with no migration behind it, which is the cheapest possible moment to add it. Both recent refusals landed here: zero of 513 banded masks and zero of 130 knob-driven ones trip them.
  3. If it is not zero, fix the files in the same change, and say in the commit how many there were. A refusal added on top of known-broken files is a trap for whoever next runs the game.

Two rules that look alike and are not, worth stating because the distinction is the whole reason one of them is exempt:

And the test has to be possibility, not certainty. The worse of the two historical cases pointed to at a knob whose range straddled its own from: about half the horses drew reversed, and nothing about the file looked wrong on any single horse. A knob is a range, two knobs are drawn independently, and “this can come out reversed on some horses” is exactly the defect.

Traps in the gaps between pages

Each of these bit once, and in each case every individual page involved was correct — the defect was in the interaction, which is precisely why none of them has an owning page.

Every layer of one compound element must share its stretch

SPOTS divides the sample point by spacing × stretch before walking the lattice. Squashing the sample is what makes an oval — the cells stay round in the squashed frame and come out stretched on the horse — but it means a different stretch is a different lattice, with its cells in different places.

For a single layer that does not matter. For a compound element built from several layers that are supposed to be concentric — a pupil inside an iris inside a sclera — it is fatal: give the layers different stretches and each one lands on its own grid, so the parts scatter instead of nesting. This produced a neck of drifting fragments that looked like a radius bug and was not. If layers must line up, every parameter feeding the lattice walk has to match, not just the seed.

A mask's meaning depends on its position in the list

The first mask of a layer defines the region; every later one modifies it. So “this mask does not apply here” resolves two different ways — coverage 0 in first position, and skip-it in any later one — and treating them alike breaks something either way. Collapsing both to “skip” made four genes paint everywhere they had excluded; collapsing both to 0 deleted galaxy's black field and tribal ward's pale one. The reasoning is written out in full in SpecPainter.coverage, at the branch itself, and that is the right place for it — the note here exists only so the shape of the mistake is findable from outside the file.

A ground can be the only thing separating two alleles

Removing a decorative ground from a gene is a one-line edit that can silently make two of its alleles byte-identical — if the “coloured” allele differed from the plain one only in the ground, taking the ground out takes the allele with it, and the gene still loads, still passes, and now has a duplicate. Shieldback and tribal ward both did this. The fix is to give the orphaned knob something else to point at, usually the marking itself. After removing a ground, diff the alleles; the coat bake golden will catch it, but only if you read which rows moved rather than just re-baking.

Sub-texel features are arithmetically present and invisible

TEXELS_PER_UNIT is 2, so anything under about half a body unit does not survive to the sheet. This is the commonest way a gene comes out blank and it never looks like a scale problem. It has a proper treatment on the gene format page; it is listed here only because it is the first thing to rule out when a new gene draws nothing.

What the chart actually reads — measured, not assumed

LUMA asks what colour the gradient produced, not what pigment is behind it, which is the whole reason it exists: the LUT locus can hand a horse a chart whose black corner is violet, and that horse has identical pigment and is not black anywhere. So “find the black bits” is a question about the output, and the thresholds have to be measured against the shipped chart rather than reasoned about.

These came from a reading probe on the shipped gradient. They are properties of that chart, not constants — if the gradient art is edited, re-measure before trusting any of them.

Coatdarkwhitesaturation
Black0.8070.02
Bay0.7820.556
Chestnut0.599
Bald face white1.000
Perlino0.871
Old grey0.698
Palomino0.475

The useful finding is that one channel is not enough at either end. A black horse reads 0.807 on dark and a bay reads 0.782 — no threshold separates those without losing half the black, because the difference between them is colour, not brightness. saturation separates them immediately: 0.02 against 0.556. That is why Nyxborn's stars are dark AND unsaturated, and why a bay keeps its brown shoulder unstarred.

The pale end has the mirror-image problem. A palomino is 0.475 on the achromatic floor while a bald face is 1.000, and both are “pale” — so a gene that means “the white markings” and a gene that means “wherever the coat is light” want different channels, and picking the wrong one gives a palomino a body-wide marking it should not have.

“As black as possible” is #151515

The composite is a multiply over a shaded template, so a genuinely black texel would take the shading with it and flatten the horse into a silhouette. CoatTextureComposer therefore lifts shadows to a floor (SHADOW_FLOOR, 0x15) and ramps opacity from PURE_BLACK_ALPHA at black up to fully opaque by NEAR_BLACK (0x30). Two consequences worth knowing before chasing a phantom bug: the blackest a horse gets is about #151515, and a black coat bakes to roughly #161515 rather than #000000 — which is exactly what killed the creator's rgb === 0 equality. Never test a colour against pure black in this codebase.

The mirrors, and how much of each is actually gated

The spread-out contracts say which files to edit together. This is the complementary question, which is easier to get wrong: of the hand-written JavaScript ports of common/, which ones does anything check? The answer has been “fewer than it looks” every time it has been asked.

MirrorOfGated by
spec-engine.jsSpecPainterYes — the bulk of parity
schema.jsSpecSchemaYes — defaults compared
noise.jsBodyNoiseYes, via the engine's cases
fields.jsCoatTextureComposerArithmetic only — new; the composite itself cannot be compared in Node, which has no image decoder for the gradient and template
geometry.js + model3d.jsHorseSkinGeometryNogap 100; posed() exists twice, in two languages, in two files that are not obviously a pair

The technique that closes one of these rows is now proven and cheap, which is the main thing worth recording: bake a table of Java's answers into expected.json from SpecFixtureTool, assert it in parity.js, then regress the twin to prove the table discriminates. Anything that is a pure function of numbers can be gated this way without teaching the Node harness to render. That is exactly how the composer's arithmetic went in, and gap 100 is the same shape — it needs emitPart exported and a table of posed corners, and it stops being an open question.

Hard rule 3 still stands and is not in tension with any of this: do not write new ports. These five exist because the creator predates the WebAssembly build; the designer runs the real common/ via TeaVM. Anything new that needs genetics in the browser gets an @JSExport on DesignerApi, not a sixth mirror.

Drive the browser tools; do not read them

The wiki ships two programs — the gene creator, which runs the spec engine in JavaScript, and the horse designer, which runs common/ as WebAssembly — and no static checker reaches either. check-links.mjs can say the page exists; only a browser can say the page works.

node wiki/tools/check-path-canvas.mjs      # drive the PATH drawing canvas
node wiki/tools/check-glow-control.mjs     # a layer's glow, which nothing can draw
node wiki/tools/check-designer-boots.mjs   # did the wasm instantiate at all?

No Puppeteer, and no dependency at all. Node 24 has a WebSocket and Chrome has --remote-debugging-port, which between them is the whole harness — wiki/tools/chrome.mjs, about forty lines. Adding a devDependency to a repo whose tools are all zero-install would cost more than writing them. Both open the page over file://, because that is the constraint the tools are built to: a check that passed on a local server and failed off disk would be worse than useless. With no Chrome on the machine they skip rather than fail — these are checks you run, not gates anything depends on.

The best case for one of these is a control with nothing to look at

check-glow-control.mjs exists because a layer's glow is the one thing in the format the creator cannot show you: its preview is a flat sheet with no lighting to be brighter than, so a glow written wrongly looks exactly like a glow written correctly. That is how the glow came to be dropped from the creator's export entirely — loading a glowing gene and exporting it again silently put the light out, and no amount of staring at the tool would have said so. Where a control has no picture, the check is the feedback.

Assert on the model, not on the picture

check-path-canvas.mjs found a bug on its first run, and the bug was not in the canvas: every mask parameter is wrapped in a <label>, which forwards a click anywhere inside it to the first control it contains — so each click on the horse was also pressing the canvas's own Reverse button, and the drawing came out backwards one point at a time. It was obvious from a click sequence that printed the points array after every gesture, and would have been baffling to hit by hand. So the checks read that array after each click, drag and delete; the screenshot is a convenience (--shot out.png), never the assertion.

check-designer-boots.mjs is the other half of gap 199. That gap is that nothing builds :web; this is the failure one step later, where the build is green and the artefact is broken. TeaVM compiles only the reachable graph, so a new call in common/ can pull in a JDK method it does not have, and the failure is at instantiation: the Gradle task goes green, the wasm is written, and the page comes up blank. Run it after :web:bakeDesignerAssets, which is to say after any change to common/ or web/.

One trap worth naming, since both checks hit it: “loaded” is not “ready”. The creator runs a parity self-check on boot and the designer paints a frame or two after it publishes itself, so a fixed sleep is flaky against both. Poll for the condition you actually mean — open() takes a readyWhen expression for exactly this.

Working the build

Two goldens, and only one of them is affordable

They answer different questions and cost three orders of magnitude apart, so the choice between them is not a matter of thoroughness:

Read which rows moved, not just whether the file moved. The bake golden's value is that it names the gene, so an edit intended for one gene that silently moved four is visible immediately — but only if the diff is actually read. It also asserts it covers every registered gene, so a new gene cannot quietly fall outside it.

Tests that are supposed to fail eventually

A few tests assert a bound rather than a behaviour, and when one fires the correct response is usually to move the bound and understand why, not to treat it as a regression. EpigenomeSizeTest caps the serialised epigenome, and ShowcaseGenotypesTest asserts a band that moves upward with every magical coat gene added — by construction, since more magical loci means more of them expressed in the showcase. Both fired during 0.3.0 and both were correct to fire. Write down which direction a tripwire is expected to drift when you add one, or the next session cannot tell an expected firing from a real one.

The two hard portability rules now enforce themselves

CommonPortabilityTest scans the sources of common/ for Minecraft, NeoForge and Mojang imports (hard rule 1) and for the Java 9+ APIs that have already been removed once (hard rule 2). This matters because the compiler will not catch either: common/ builds against a full JDK and the NeoForge classpath is right there, so the day the backport starts is the day you would otherwise find out. Note that a non-empty Map.of(k, v) is deliberately allowed — the ban is on the zero-argument and copyOf forms outside CommonMaps.

TeaVM has a related trap that no scan can catch: it compiles only the reachable graph, so adding an @JSExport can surface a missing JDK method that has been sitting there all along. Build :web after adding one.

Shell heredocs lie to you in two different ways

Both cost real time, and neither produces an error that points anywhere near the cause. Anything substantial should be written to a file directly, or put in a patch script in the scratchpad and run by absolute path.

Check that a regeneration actually regenerated

Derived files in this project are checked in and fail silently when stale, so the regeneration step is load-bearing — and a regeneration that quietly does nothing looks exactly like one that worked. Two real instances, a session apart:

The general form: after regenerating, confirm the artefact changed in the way you expected, and confirm the set of things that changed is the set you predicted. When SeededRng gained a seed scramble, 218 of 623 bake-golden lines moved — and the check that mattered was not the count but that the moved genes were exactly the ones that store epigenetics, with acan and b4galt7 correctly untouched.

A ClassNotFoundException for our own class is almost never real

A Gradle build replaces common/build/libs/common.jar in place, and a running runClient holds that jar on its classpath. The JVM keeps every class it has already loaded, so the game carries on quite happily — until it needs one it has not loaded yet, and then throws ClassNotFoundException for a class that is sitting right there in the source tree and in the jar.

It happened on 2026-09-09: jar rewritten at 18:40:13, crash at 18:40:43, ClassNotFoundException: com.example.horsegenetics.common.trait.Viability in a horse tick. That class had not been edited in weeks and was present in both the common jar and the built mod jar. Nothing was wrong.

Check the timestamps before hunting

The failure looks exactly like a packaging bug, and the obvious next move is to go looking for one that does not exist. Compare the jar's mtime against the crash time first. If the jar is newer, that is the whole answer.

The other half is not to cause it: building while somebody is mid-play will do this to them. It is the same family as runServer refusing to start while their client holds the world's DirectoryLock — their game and this build share files.

The log should be able to hear the player

Three reports in one play session - "F8 only turns on", "Make egg does nothing", "the game just crashed" - and two of them left nothing in the log at all, because the code that logs was never reached. Diagnosing those took static reading and a stack trace that arrived four minutes late.

So ActionTrace (and its client half) writes one [trace] line per discrete thing a person does: a horse joining a level with its whole genome, a right-click on an animal, a mod item or block used, a mount or dismount, a taming, a foal, a death, a dimension change, every mod keybind, every mod screen opening and every button pressed inside one. One grep gives the session in order.

The button lines are called from inside the handlers on purpose. A hook that reported presses the screen never received would report the one broken press as if it had worked - and a covered button producing silence where every other button produces a line is exactly the reading that was missing. The rule this encodes: instrument the thing that runs, not the thing that was aimed at.

A debug overlay took the world down, and hid its own bug for four minutes first

F8's horse highlight put herd leads on a scoreboard team and took every nearby horse off it again when the toggle went off. Only leads were ever added, and Scoreboard.removePlayerFromTeam(name, team) throws for an entry that is not on the team - so it threw on the first ordinary horse it reached.

What it looked like from the chair was two unrelated bugs. The throw escaped the packet handler on the OFF press, after the player had already been removed from the on-set and before the "OFF" message, so the owner reported “F8 isn't toggling off, only turning on”. Four minutes later the auto-off ran the same line from ServerTickEvent.Post, where nothing catches it, and the server died with Exception in server tick loop. One cause, two reports, and the crash arrived long enough after the last keypress to look unrelated to it.

Two fixes, and the second is the general one: ask the scoreboard before removing, and wrap anything a debug overlay does in a guard that switches the overlay off rather than ending the tick loop. This one ships in real builds - it was registered outside the production gate on purpose, because testers asked for it - so "it is only a dev tool" was never true of it.

And two Gradle builds in the same module at once corrupt its class files

The same family, one step worse. A seed hunt that boots runServer in a loop is a build - each round compiles the mod before it launches - so running compileJava beside it puts two Gradle invocations in neoforge-26.1.2/build/classes/ at once. What comes back is not a lock error, it is bad class file: …/DebugTestWorldCleanup.class against a class nobody touched, which reads exactly like the ClassNotFoundException above and is a half-written file rather than a missing one.

The fix is the obvious one - wait, then rebuild - and the reason it is worth a note is that the diagnosis is not obvious: the error names your source, not your concurrency. If a background task of yours runs Gradle, treat the whole module's build directory as held until it exits.