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 a session built → session log.
- A defect or an unchecked assumption → known gaps.
- How a mask, op or header field behaves → gene format.
- An API quirk of this SDK → API notes.
- What the phases do → the pipeline.
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:
- A four-band
FLATprobe settles any question about where a body region actually lands on the sheet, in one render. Reasoning about it was wrong three times in a single day; the probe has never been wrong. - A reading test — paint nothing, print the channel — settles questions about what a mask sees. The LUMA table below came from one, and every number in it contradicted a guess that had already been written down as fact.
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.
- The oracle was stale.
check-parity.mjscompares the creator's JavaScript againstexpected.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. - The oracle was absent.
check-parity.mjsrunsrestrict()andtint()and has never runcompose()— 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. - 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.
- The probe could not reach the detail. The
GOOmask (2026-09-11) went in with a layer inprismatic.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:
- 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. - 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.
- 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:
- A banded mask with its edges reversed is always a bug.
AXIS,PIGMENT,LUMAandWAVESfeedsmoothstep, andsmoothstepwithedge1 <= edge0is a hard step in the original direction. It does not reverse the ramp; it removes it. There is no author intent that this expresses. - A
RAMPwith its ends reversed is a gradient the other way round, which is a thing somebody might mean. Itsfrom/toare the ends of a linear interpolation, not the edges of a step, so it is deliberately exempt from the check above.
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.
| Coat | dark | white | saturation |
|---|---|---|---|
| Black | 0.807 | — | 0.02 |
| Bay | 0.782 | — | 0.556 |
| Chestnut | 0.599 | — | — |
| Bald face white | — | 1.000 | — |
| Perlino | — | 0.871 | — |
| Old grey | — | 0.698 | — |
| Palomino | — | 0.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.
| Mirror | Of | Gated by |
|---|---|---|
spec-engine.js | SpecPainter | Yes — the bulk of parity |
schema.js | SpecSchema | Yes — defaults compared |
noise.js | BodyNoise | Yes, via the engine's cases |
fields.js | CoatTextureComposer | Arithmetic only — new; the composite itself cannot be compared in Node, which has no image decoder for the gradient and template |
geometry.js + model3d.js | HorseSkinGeometry | No — gap 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.
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.
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:
coat-golden.txtcomposes a full 128×128 texture for a set of whole horses. It is the one that would catch a phase interacting wrongly with another phase, and regenerating it needs a full:common:test— nine or ten minutes (gap 97).coat-bake-golden.txthashes every registered gene, every variant allele, one at a time, composed through the real gradient. It runs in seconds, and it is the right instrument for “did this edit move any gene I did not mean to touch”.
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.
- A heredoc past roughly a hundred lines is silently truncated. The
error is
unexpected EOF while looking for matching quote, which reads as a quoting mistake and sends you hunting through your own string literals. - A quoted delimiter does not protect backslashes.
<<'PY'is supposed to pass the body through untouched, and does not: a Python'a\\nb'arrives asa, newline,b— the escape has already been collapsed before Python sees it. The failure mode is the dangerous part: a patch script matching on a string that contains\\n(which is every patch that touches a Java or JavaScript string literal) simply fails to match, and if the script does not assert on its match count it reports success having changed nothing. Always assert the count. Every patch script in this project opens withassert s.count(old) == 1for that reason, and that assertion is what caught this.
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:
- A golden test that only wrote its output when the golden was missing. The documented recipe was “run the test, copy the file it writes”, but a mismatch wrote nothing, so the file sitting there was from some earlier run. Copying it back reinstated a stale golden and the test went green having compared nothing (gap 128). The tell was a file that “moved by zero lines” while the test was still red — two facts that cannot both be true, which is the kind of contradiction worth stopping on.
- Gradle reporting
processTestResources UP-TO-DATEafter the source resource changed, so the test ran against the previously staged copy. If a resource edit does not change a test's result, check the staged file's timestamp before concluding anything about the code.
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.
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.
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.
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.
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.