The coat engine
The three-phase coat pipeline
Every coat pixel starts as a maximally pigmented black horse. Natural genes push pigment down; the survivors are resolved through a hand-authored red/black gradient; magical genes then add signed RGB on top. The result is multiplied onto a white template. Adult and foal go through the same pipeline.
“Gene” below means anything in the registry — a hand-written class
or a gene loaded from a JSON file. A data-driven gene
goes through SpecPainter, which is an ordinary caller of the two hooks
described here; nothing in this page is special-cased for it.
The phases
| # | Phase | Who runs | Data | Direction |
|---|---|---|---|---|
| 1 | Natural (melanin) | Every visible natural gene, in Genes.naturalOrder() | PigmentField — per texel red, black in [0,1] | Down only |
| 2 | Resolve | The composer | GradientLut → ColorField | — |
| 3 | Magical (RGB) | Every visible magical gene, in Genes.magicalOrder(), then the shadow pass | ColorField — signed int r/g/b + opacity | Signed, uncapped |
| 4 | Composite | The composer | Alpha-aware multiply onto the white template | — |
| 5 | Eyes | The composer | CoatRegions.redrawEyes — copied verbatim | — |
| 6 | Overlay | Every gene implementing CoatOverlayContribution, in Genes.codeOrder() | CoatOverlay — final ARGB writes + an emissive level per texel | Absolute; last writer wins |
A gene is natural or magical, never both — declared
via Gene.isNatural(), not inferred. A gene that wants both
registers as two genes. Natural is reserved for genes that exist in
real life (Philosophy §6).
Both coat hooks are pure. A gene is handed read-only views
of the state so far and returns its contribution; it never draws
into shared scratch. CoatBuildContext therefore carries no
mutable fields at all — the composer owns both accumulators.
The two hooks
/** Phase 1 - a natural gene pushes pigment down. null = no contribution. */
default PigmentField restrict(AllelePair pair, CoatBuildContext ctx, PigmentView coat) {
return null;
}
/** Phase 3 - a magical gene returns a signed RGB delta. null = no contribution. */
default ColorField tint(AllelePair pair, CoatBuildContext ctx,
PigmentView coat, ColorView colour) {
return null;
}
restricttakescoat.mutableCopy(), paints into that, and returns it. Never write throughcoat— it is the previous gene’s output and the next gene’s input. (A multiplicative “delta” is not a representable object, so phase 1 hands back the state it built rather than a difference.)tintreturnsColorField.deltaLike(colour)filled withadd. It gets the resolved natural coat as well as the accumulated colour, so a gene can find a region — all the black, all the white — before painting it.
Phase 1 is not handed the magical field: it does not exist yet at that point in the bake.
Phase 1 — the pigment field
Every texel starts at (red = 1, black = 1). Natural genes only ever
take pigment away.
setRed / setBlack(px, py, v) | Clamp a texel to an absolute value. |
|---|---|
restrictRed / restrictBlack(px, py, amount) | value *= (1 - amount). 0 keeps it, 1 removes it. |
dilute(px, py, keepRed, keepBlack, blackTint) | The shared dilution move — see below. |
whiten(px, py, amount) | The shared whitening move — see below. Every white marking goes through it. |
forEach(op) | Visit every texel, mapped or not. |
mutableCopy() | A private, writable copy. This is how a gene starts. |
Why dilute exists, and why restrictBlack was not enough
public void dilute(int px, int py, float keepRed, float keepBlack, float blackTint) {
int i = py * size + px;
float b = black[i];
red[i] = clamp01(red[i] * keepRed + b * blackTint);
black[i] = clamp01(b * keepBlack);
}
Bay paints its points absolutely:
black = 1, red = 0. The gradient’s
zero-red column stays visually jet black all the way down to
black ≈ 0.4, so a dilution that only scaled black moved the
sample without changing the colour — single cream’s
black *= 0.7 landed on #111111, double pearl on
#272727. Every “diluted” bay still had jet points.
The blackTint term feeds a fraction of the removed
eumelanin back in as pheomelanin, which walks the sample sideways off that
column into the warm browns where a real diluted black lives. Amber champagne
keeps chocolate points over a gold body, perlino rusty ones, a buckskin dark
brown.
No cream horse keeps a pitch-black point. Dark brown is as far as it goes — so single cream carries a full tint, not the token amount a real-world buckskin’s black points would argue for.
Why whiten exists, and why scaling both channels was not enough
public void whiten(int px, int py, float amount) {
if (amount <= 0f) return;
int i = py * size + px;
float keep = 1.0f - clamp01(amount);
float b = black[i];
float visibleRed = red[i] * (1.0f - b); // red only shows where black isn't
float newBlack = b * keep;
float room = 1.0f - newBlack;
red[i] = room <= 1e-4f ? 0f : clamp01(visibleRed * keep / room);
black[i] = clamp01(newBlack);
}
A black horse is (red = 1, black = 1). It carries a full load
of pheomelanin that is simply masked: the gradient's entire bottom row is
#000000, so at black = 1 the red level cannot be seen, and
at no other black level is that true. Take the black away and the red is unmasked
on the way out — and because the diagonal of this chart runs through the
golds, the sample lands in the browns. The old ramp put #5F330B and
#885517, chocolate and tan, through the soft edge of every white
marking on an otherwise jet-black horse.
So what whiten holds constant is the visible red,
red × (1 − black), not the stored red.
It scales that by keep alongside the black, and stores whatever
reproduces it against the black that is left. On a black horse the visible red is
zero, so the stored red drops straight to zero and the fade runs down the
red = 0 column — the chart's one neutral ramp. Shades of grey,
which is what white hairs through black hairs look like.
| base, whitened 25 / 50 / 75% | scaling both channels | whiten |
|---|---|---|
black (1, 1) | #5F330B #73522C #9C958D | #080808 #414142 #A6A6A7 |
chestnut (1, 0) | unchanged — nothing was masked, and the op collapses to red × keep | |
It is an identity at amount = 0, which is what lets a soft
edge meet unmarked coat without a seam — and it is why the correction has
to be expressed against the black that is there rather than applied
flat to every marked texel.
whiten(1) lands on exactly (0, 0), so the hard
white markings — tobiano, splash, sabino, frame, dominant white —
use the same verb and still take the composer's transparent path. That is worth
one line each: there should be one way to make a horse white, not seven.
Phase 2 — resolve through the gradient
GradientLut wraps
assets/horsegenetics/textures/coat/redblackgradient.png — a
hand-authored 500×500 image. Left = more red, bottom = more black.
sample(red, black) reads
x = (1-red)·(w-1), y = black·(h-1).
Before it samples, the composer asks whether any
LutContribution gene reports a homozygous variant.
Exactly one gene — LUT — ever answers,
and only when the horse carries two identical copies of a variant allele; it
then resolves this horse’s pigment against that variant’s own
GradientLut (e.g. lutbluepink.png) instead of the
red/black one. Everything else about the phase is unchanged — the
(red, black) pair is exactly what the melanin genes produced; it
just lands on a different chart. The composer takes a
LutSet (base gradient + keyed alternates); a missing alternate
falls back to the base.
| Corner | Colour |
|---|---|
(red 1, black 1) | black |
(red 1, black 0) | chestnut |
(red 0, black 0) | white |
| middle column | champagne gold |
The diagonal runs through the golds — an equal
keep of 0.4 on a black horse samples (150,109,56),
a tan. So any effect that should read grey has to walk the
sample toward that column, not scale both pigments. That is the
entire reason GreyCoat is a remap
rather than a restriction.
Transparency and near black
private static final int PURE_BLACK_ALPHA = 0xCC; // 80%
private static final int NEAR_BLACK = 0x30; // ...ramping out by here
private static final float TRANSPARENT_EPS = 0.001f;
- A texel goes fully transparent (the bald white template
shows through) only when both pigments are essentially zero —
i.e. a white-pattern gene, all of which
setRed(0)/setBlack(0)exactly. - A texel that resolves to black composites at 80% opacity,
ramping to fully opaque by
NEAR_BLACK. The composite is a multiply, so an opaque black texel scales the template to nothing and the coat loses its hair shading exactly where the horse is darkest. Letting a fifth of the template through is what gives a black horse its#2C2C2C-ish strand detail.
rgb == 0
It was an equality test, which quietly made a rendering rule depend
on one pixel of the gradient art being exactly zero. Both bottom corners of
the chart were black, so a black horse (red 1, black 1) and a
bay’s points (red 0, black 1)
both softened. Then the chart’s low-red column was recoloured toward
blue-grey, its corner moved to #000104, and every bay, seal brown
and bay dun started wearing dead-flat black points
while a plain black horse kept its shading — a rule switched off by an
art edit, with nothing to say so. The ramp is wide enough that the effective
multiply is near-constant across it (0.200 at black, dipping to 0.184 around
#202020), so nothing gets a visible step where it crosses.
The cutoff is deliberately far below any dilution floor: grey keeps 0.15, and grey on a double-dilute cream still lands near 0.012 — a near-white coat that must resolve in the gradient, not vanish. A 0.02 cutoff here once turned grey cremellos and grey perlino bays into flat white horses.
Phase 3 — the colour field
ColorField holds, per texel, a signed int
red, green and blue plus a separate opacity, seeded from the resolved
colour. Nothing is capped until argb().
Why the headroom is the point
A gene can add so much blue that no combination of other genes pulls it back
under 255 — the horse is blue unconditionally, and its author never had to
know what else it carries. Magic zebra is the
same trick with the sign flipped: -200% on all three channels, so a
stripe lands hard on 0 and reads black over a cremello, a chestnut or a grey
alike.
add saturates at
Integer.MIN_VALUE/MAX_VALUE rather than wrapping, so an
author’s “obviously large” number stacked twice cannot flip an
always-blue horse black.
Opacity is its own channel, deliberately
Transparency used to ride on the pigment channels — both ≈ 0 means the bald template shows. Once phase 3 can add colour to a texel carrying no pigment, “no pigment” and “no paint” stop being the same statement.
A magical gene may paint a dominant-white horse — white is
natural, and every magical gene runs after every natural one — but it
has to say so with addOpacity or set. Colour alone
on a transparent texel shows nothing.
Add vs. set
| Call | Semantics | Order-dependent? | Use for |
|---|---|---|---|
add(px, py, dr, dg, db) | Signed integer addition into the accumulator. | No — associative and exact. | Every ordinary magical gene. |
addOpacity(px, py, da) | Makes the texel more (or less) solid. | No | Painting where the coat is transparent. |
set(px, py, a, r, g, b) | Flat paint that replaces the accumulator. | Yes | A magical gene that must read identically on any base — magic zebra is the clearest, painting black over dominant white. |
Reading before writing
ColorView exposes the raw signed channels, argb() (the
capped pixel), and — the one a gene usually wants —
visible(px, py, channel):
/**
* What this texel will actually LOOK like, per channel, 0-255:
* colour * opacity + white * (1 - opacity)
*
* A transparent texel reads as WHITE here - because that is what a viewer
* sees, the bald template - not as the black that argb() reports. A texel
* that resolved to NEAR BLACK reads as roughly 20% grey, for the same
* reason - black composites at less than full opacity.
*/
default int visible(int px, int py, int channel);
Mane colour is the shape that proved this
necessary: a blind add cannot reach a chosen hue on a black mane
without pushing hard enough to saturate a pale one to white. Reading first is the
whole point of phase-3 read access — and the cost is that such a gene is
order-dependent by choice.
Why magicalOrder() is not arbitrary
naturalOrder() and magicalOrder() are not
hand-written lists — both are Genes.codeOrder() (every
registered gene sorted on (priority(),
key)) filtered by isNatural(). The magical genes sort to
SUIT (102), MAGIC_ZEBRA (120),
TEST (900); a data-driven magical gene slots in by its own
priority. Those numbers are chosen, not incidental: additive genes commute,
so mane colour and magic zebra could in principle go either way — except
mane colour reads what it is painting over, and zebra stripes should
black out a coloured mane rather than the other way round. Test paints flat and is
COMPLETE_DOMINANT, so it must run last (hence
the large gap to 900) or the genes after it would show through its overlay.
In the natural band the same logic applies to absolute setters before
dilutions — agouti (20) must precede
PigmentField.dilute. See
Genetics model § Gene
priority.
The white lock — the one gene that changes the rule
Extreme white dominant paints nothing at all. What it does instead is change the rule phase 3 is folded with: while it is present, a white texel is final, and every gene above it is discarded wherever the coat is already white.
boolean[] locked = whiteLock(genotype, pigment, skin); // null unless a gene asks
for (Gene gene : Genes.magicalOrder()) {
...
ColorField delta = expression.tint(ctx, pigment, colour);
if (delta != null) {
colour.apply(delta, locked); // locked texels refuse the fold
if (locked != null) {
extendWhiteLock(locked, colour, skin); // white it just painted is final too
}
}
}
...
overlay.lock(locked); // and phase 6 cannot get round it
The mask is seeded from phase 1: every texel the melanin genes left with essentially no pigment, which is exactly the horse’s natural white markings — a tobiano patch, a splash, a blaze, a stocking. It then grows after each magical gene paints, so white a magical gene puts down is locked from the moment it lands. The eye rectangles are cut back out of it and never re-added: a blue eye on a white face is pigment biology rather than a marking, and locking it would delete every eye-colour gene on precisely the horses whose eyes are worth looking at.
A priority is a slot — "apply me after these and before those" —
and a marking sitting above the slot paints over the white exactly as before.
There is no number high enough, because "highest" is a position and the rule is
not positional. So it is WhiteLockContribution, read out of band by
the composer the way LutContribution is, and it applies at
every step of the order at once. The gene’s own priority is
therefore only a code-order slot, and it does not appear on
the paint order at all.
The shadow pass — nothing phase 3 paints is true black
private static final int SHADOW_FLOOR = 0x15;
...
colour.liftShadows(SHADOW_FLOOR); // after every magical gene, before the composite
A last sweep over the accumulator, once every magical gene has folded its
delta in. Any texel phase 3 painted that has come out at or below
SHADOW_FLOOR in all three channels is scaled up so its
brightest channel lands exactly on the floor. Hue survives
— the channels are scaled together, not floored one at a time, so a
near-black blue stays blue and only a texel with nothing at all in it comes
out a neutral grey.
Phase 4 below is a multiply, so the darker a texel is painted the
less of the template’s hair shading survives it — and at pure
black the multiply is by zero and all of it is gone, leaving
a flat slab where the strand detail should be.
Magic zebra is the case that forced this:
subtracting more than any coat can hold is exactly how a stripe is made black
over anything, including dominant white, and it bottoms out at
#000000 at full opacity.
NEAR_BLACK is phase 2’s
answer to the same problem, and the shadow pass deliberately does
not touch the texels it covers: that one softens the
alpha of a coat the gradient resolved dark, this one floors the
colour a magical gene painted — including flat paint,
which sets its own opacity and so never meets the alpha ramp at all.
Running both over one texel lifts it twice, and measurably: with the
floor applied to everything, a black mane composited
brighter than the dark bay body under it. So
ColorField tracks which texels an applyd delta
wrote, and liftShadows considers only those.
Consequence worth knowing when reading a coat: a magical black
(#151515 × the template) is darker than a
natural black (#000000 at 80% opacity, so ~20% of the template),
not lighter. A zebra stripe is meant to be the darkest thing on the horse.
Phase 4 — compositing
The colour field is composited onto the white template with an alpha-aware multiply, keeping the template’s own alpha. Because it is a multiply, the template’s dark detail — hooves, nostrils, the shading between mane strands — survives on every coat, cremello included.
Near-black texels in a composed cremello are expected and
are not a gene failing to dilute — they are the template
showing through the multiply. When chasing a dilution bug, check the
PigmentField values, not the composed pixels.
Phase 5 — eyes
CoatRegions.redrawEyes(skin, out, template) copies the eye texels
back verbatim. Adult: 2×2 pupil + 2×2 sclera at {6,42}
and {30,42} — the head’s L/R faces. Foal: 2×2 pupil
at {6,20} and {40,20} (the baby texture has no bright
sclera), and again the head’s side faces, not the front blob.
Fixed 2026-09-06: the east-face rect was two texels short of the true
pixels ({28,42} instead of {30,42}), grabbing 2
background texels and only the pupil. The template's raw column order is
deliberately mirrored between the two eyes (inherited from vanilla's own
horse.png), which is what makes the pupil land nose-side on
both faces once the standard box-UV unwrap reverses one face's U-axis
relative to the other; cropping short broke that compensation and rendered
the east eye backwards.
There is no genetic eye colour gene yet — a real one is a wanted gene, and the classic hook is blue eyes on cream double-dilutes. Light gilds them, but it does it from phase 6, not here.
Phase 6 — the overlay
The last phase, added 2026-09-04, and the only one that writes finished
pixels: each gene implementing CoatOverlayContribution is
handed a CoatOverlay carrying the composed coat, and may replace
texels outright, tint them, or light them emissive - by however much, since a glow is a level and the mask’s coverage scales it.
Almost nothing runs here, and it exists for exactly two things the earlier phases structurally cannot do:
- The eyes. Phase 5 restores them from the template as the last act of the bake — precisely so a gene painting a wide white pattern can never blind a horse. A gene that wants to colour them on purpose therefore has to run after that, or it is simply overwritten.
- Emissiveness. “This texel glows” is not a colour,
so there is no channel for it in either accumulator. It is carried here as its
own boolean mask and handed to the renderer, which draws exactly those texels
a second time at full brightness through
client/EmissiveCoatLayer. - The appaloosa triad. The leopard complex draws striped hooves and a white-rimmed eye here — the hoof stripes as a phase-1 pigment change would be lost under the leopard white, and the eye rim has the same after-the-redraw requirement as light’s gold eyes.
The phase-1 hook the pipeline calls is
gene.expressionIn(pair, genotype), and the genotype is complete
by then, so a gene can read another locus. Exactly one does: the
leopard complex reads PATN1 and
PATN2 to pick which of eight painters runs. Because those
modifiers paint nothing themselves they are not in
Genotype.coatCode() by default — LP’s
new Gene.coatDependsOn() declaration folds their alleles into the
texture key, but only for a horse that actually carries LP. The
roadmap steers third-party genes away
from this.
A data-driven gene’s glow
effect names body parts, so the finest region it can light is a
whole leg. Light needs to glow four
hooves and two eyes — neither of which
is a part, and both of which the bake already knows exactly. So the mask is
per texel, and the renderer folds the two sources together.
CoatTextureComposer.bake(…) returns both halves as a
Baked(int[] argb, float[] emissive); compose(…)
is the pixels alone and is what every caller but the emissive layer wants, so the
golden test and every existing consumer were untouched by
the addition.
It is a sink rather than a returned contribution — the same
shape as TraitBuilder, and for the same reason: these are absolute
writes, not additions, so there is nothing to fold. Purity is kept where it
matters: CoatOverlay.base is the composed coat and never changes, so
no gene can read another’s overlay, and the result does not depend on visit
order except where two genes deliberately claim the same texel.
Two blend modes, and the difference matters. blendToward is a
straight lerp toward a colour. shadeToward scales the target by the
texel’s own brightness first, so dark detail stays dark —
which is what makes a gold eye read as an eye rather than as one gold rectangle
with the pupil painted out.
Texture identity and caching
On the client, GeneticCoatTextureFactory loads the adult and foal
templates plus the gradient once, runs compose, uploads a
DynamicTexture, and caches by textureKey() plus
:adult / :foal. The cache is a
TexelBudgetCache: a least-recently-used cache whose capacity is measured
in texels rather than in entries, since one entry is a whole sheet and
what has to stay bounded as HorseSkinGeometry.SHEET_SIZE changes is the
memory rather than the horse count. Everything is released on world exit and on a
client resource reload alike — the templates and the gradient are pack
resources, so CoatAssetReload has to be able to take them back.
textureKey() runs on Genotype.coatCode(), not the full
code: a gene whose every outcome is a wild type can never paint anything
(Gene.affectsCoat()), so leaving it out means two horses that differ
only there share one baked texture. The sex locus is
the only such gene today — without this the cache would carry every coat
twice, once per sex.
A glow gene adds a second
consumer: getOrCreateEmissive re-runs compose, copies
the resulting colour on the gene's emissive parts into a fresh mask (transparent
elsewhere), and caches it under coat_glow/<hash>.
EmissiveCoatLayer draws that mask full-bright over the base coat.
The pipeline itself is untouched — the emissive path only reads its output.
The Identifier a texture registers under is coat/ +
CoatTextureId.encode(key): an injective map into
Minecraft’s legal path charset — a-z0-9 verbatim,
A-Z → . + lower-case, everything else →
_ + 4 hex.
Case distinguishes one allele from another. An older
sanitize() lower-cased the key, folding all 19,683 genotypes onto
27 ids (E/e ≡ e/e,
W/w ≡ w/w, …).
TextureManager#register is a silent Map.put that
closes the loser, so every deterministic coat in a bucket rendered
whichever one baked last — a plain white horse whenever that was a
dominant-white one. That was the real
“chestnut renders as the default white horse” bug, and the
victim’s own bake was correct, so a “flat white” debug line
never caught it. KEY_BY_ID in the factory is now a tripwire that
throws if two keys ever share an id again.
Eye colour — a channel with ranked claims
A horse has one iris colour, so “what colour are its eyes” has exactly one answer and several genes have a claim on it. It is therefore a channel with a single owner rather than a gene that other genes fight with — the same shape as the LUT and the cutie mark.
Where it sits in the bake is the part that belongs on this page. It runs at the
top of the overlay phase, after
CoatRegions.redrawEyes has copied the template's eyes back over the
finished coat — the only point in the bake where an eye survives being written
— and before the general overlay pass, so a gene that wants the whole eye
(light's glowing gold, the
leopard complex's white sclera rim) still gets the
last word over the iris tint.
Three layers, in this order: the winning pigment claim over both whole irises; the
winning depigmenting claim over as much of each iris as it actually reached; then
every EyePatchContribution's patches, in code order.
The three ranks and why they are the three biological routes, which locus claims which colour, where green and hazel come from, complete and sectoral heterochromia, the wedge shapes, what it costs the coat cache, and how to write a gene that changes eyes — all of it lives on Eye colour & heterochromia, which is the single source of truth for the channel.
The golden test
CoatPipelineGoldenTest hashes every genotype in its own CODES
list × 3 seeds × adult/foal and compares against
common/src/test/resources/coat-golden.txt. It exists to prove that a
change to the pipeline leaves every horse byte-identical — which is
how the three-phase refactor shipped with no coat moving at all.
When a gene deliberately changes, regenerate the file: delete
it, run the test, copy common/build/coat-golden.txt back — and
say so in the commit.
Its one blind spot: it runs against a synthetic 16×16 gradient and a flat
template, not the real 500×500 redblackgradient.png and
horse_white*.png.
Dev tools
./gradlew :common:bakeCoatSamples | Writes build/coat-samples/*.png and *_foal.png. No game launch. |
|---|---|
./gradlew :common:test | Pure Java, no Minecraft — the fastest loop when iterating on genetics or coats. |
How high white climbs: the topline reference
Every vertical constant in WhitePattern is expressed as a fraction of
the topline — bounds(skin, Part.BODY).yMax(),
which on the adult mesh is 20.99 units of a whole-horse box 33.75 tall. Against that
reference the fractions mean something anatomical:
| Fraction of topline | Anatomy |
|---|---|
| 0.05 | coronet |
| 0.10 | fetlock |
| 0.26 | knee and hock |
| 0.52 | underline / elbow |
| 1.00 | spine |
Both painters used to measure against bodyBounds, the
whole-horse AABB — hoof to ear tip — on
which the legs reach only 0.326 and the belly 0.326. A splash waterline written
as “0.35 of the horse” therefore landed at 0.35×33.75 = 11.8
units: above the underline, so four entirely white legs and white onto
the barrel, for what the gene calls a minimal marking. With
PAX3's SW2 on 90% of
founders, that was most horses in the world.
The ramp is convex
White spotting does not grow linearly with allele dose: one copy of a mild allele
buys a sock, and the jump to a belly-deep horse happens at the top of the range. The
splash waterline rises as s1.7 off a coronet floor, which
puts each outcome at the anatomy its own description names:
| Outcome | Strength | Line | Measured white |
|---|---|---|---|
PAX3 SW2/N — 90% of founders | 0.34 | upper cannon | 14% overall, 41% of the legs, 0% belly, 0% back |
MITF SW1/N splash | 0.38 | just under the knee | 17% / 48% legs |
splash-bold | 0.62 | past the elbow, onto the barrel | 41% / 95% legs / 31% belly |
splash-extensive | 0.86 | well up the barrel | 69% / 100% legs / 100% belly / 11% back |
Sabino's belly patch uses the same reference but is anchored at the
underline (0.44) rather than at the ground, because sabino draws its
leg white per leg and this component is the belly spot alone. A classic
SB1 reaches 0.62 — the underline and a hand up the flank —
and even sabino-white stops at 0.83, short of the spine, so a near-white sabino keeps
a coloured topline rather than becoming a dominant white.
Two calibration items are still outstanding and both are the same mistake elsewhere:
EDNRB's frame band and every cover knob are
still whole-horse fractions. See the roadmap.
White finds white
The six white-pattern loci all paint in phase 1, one after
another, and two of them share a painter apiece:
WhitePattern.sabino (the KIT shape — ragged margins
growing inward from legs, belly and face) and WhitePattern.splash (the
MITF / PAX3 shape — a sharply bounded
waterline rising up the horse). Each takes one strength in
[0, 1], and each outcome of each gene picks a number on that
ramp. An allele has no phenotype of its own; a combination lands on an outcome, and an
outcome is a number.
Both painters read the coat they are handed and paint harder the more of it is already white. That is not a flourish. It is the single most-repeated observation in the real genetics, and without it the model gets its own headline cases wrong:
MITFandPAX3splash are different genes, so a horse carries one copy at each and should be markedly whiter than either alone. Painted blindly it is not — two waterlines drawn at roughly the same height are one waterline. Measured: 45% and 42% separately, 44% together before the rule and 70% after it.W20is described as a booster: subtle alone, adding white beside another spotting variant. Reading the coat is what lets it be that.- A frame or tobiano horse that is also splash is louder than either.
Splash reads the signal through a saturating curve,
w / (w + 0.10), rather than using the coverage directly — and that
became necessary the moment the waterline ramp went convex. The two cases the rule has
to serve differ by more than three times in coverage and should differ far less in
response: a second splash locus arrives with the horse only ~14% white (socks) and
must lift the line to belly-deep, while a splash allele on a tobiano or a frame
arrives with the horse 45–70% white already and must not take the rest.
A straight multiple of coverage cannot do both — tuned for the first it turns
every tobiano into a white horse, and tuned for the second it makes the two splash
loci indistinguishable from one.
private static final double SPLASH_STACKING = 0.50;
private static final double SABINO_STACKING = 0.35;
private static final double STACKING_HALF = 0.10;
// sabino - the coverage directly
double s = clamp01(strength + SABINO_STACKING * alreadyWhite(coat, ctx.skin()));
// splash - through the saturating signal
double s = clamp01(strength + SPLASH_STACKING * stackingSignal(alreadyWhite(coat, ctx.skin())));
private static double stackingSignal(double white) {
return white / (white + STACKING_HALF);
}
One line in each painter and no interaction table anywhere. Both genes
stay pure functions of what they are handed, and phase 1 is already an ordered fold, so
nothing about the pipeline’s contract moves. alreadyWhite counts
only texels whose pigments are exactly spent, far below anything a dilution
leaves behind — a pale horse is not a white-spotted one.
The splash waterline is a hard cut with no fade, for the same reason tobiano’s patches are: the gradient has no grey between a coloured texel and a spent one, so a half-scaled black texel samples the warm diagonal and reads gold. A one-pixel fade paints a tan fringe along the whole waterline. The irregularity comes from wobbling where the line falls instead.
Face markings: star, stripe, snip
Every white locus draws its face marking from one shared
vocabulary, WhitePattern.faceMarking(…), so that a star is
the same shape whichever gene produced it. Before it, all four loci drew the same
thing — a centreline band starting at the nose and running some distance back,
wider and longer as the outcome got stronger. That covers stripe, blaze and bald face
and it cannot express a star or a snip,
because both of those are patches with coloured face on every side of them.
Three components, not eight named shapes
Horsemen name eight or nine markings, but those are not eight shapes. They are three independent components plus one width, and every named marking is a combination of them:
| Component | Where | Detached? |
|---|---|---|
| star | the forehead, just above the eyes (t = 0.30) | yes, unless a stripe runs out of it |
| stripe | a band down the bridge of the nose; this is the component that becomes a blaze | — |
| snip | the nostrils (t = 0.90) | yes, unless the stripe reaches it |
A width on the stripe carries the rest: past 1.15 body units it reads as a
blaze, past 2.55 as a bald face —
which is also the point at which white reaches the sides of the head and takes the
eyes. FaceMarking.describe() reads the horseman’s name back off the
components; nothing ever chose it, which is the check that three booleans and a width
really do span the vocabulary.
Face space
t runs 0 at the poll to 1 at the nose tip, measured along body-space
x over the head and muzzle together, so the same numbers mean the
same anatomy on the adult (which has a separate MUZZLE box) and on the
foal (which does not). z is the distance off the centreline in body units.
The eyes sit near t = 0.4 on both meshes, which is what anchors
the star above them and the snip down at the nostrils.
The underside of the jaw and chin (Face.BOTTOM) is only
ever white on a true bald face. An ordinary blaze runs down the front of the face and
stops there; the old painter tested the centreline on every plane of the box, so every
blaze it drew wrapped under the jaw.
Strength picks the distribution, not the marking
A locus does not decide that a horse has a snip. It decides how much white the horse
tends toward, and the marking falls out of that — which is the honest shape of it,
and what finally makes KIT’s weak end
mean something. W20/N has always been described as “a star and a
sock”; now it can actually be one.
| Strength | Typical outcome |
|---|---|
0.12 (KIT minimal) | star 37%, nothing at all 29%, snip 9%, star and snip 12% |
0.24 (KIT modest) | star and stripe 23%, star 21%, stripe 15%, nothing 13% |
0.42 (KIT sabino) | star and stripe 44%, with a snip 22%, stripe 22%; a bare face is essentially gone |
0.62 (MITF bold splash) | blaze 60%, blaze to the nostrils 40% |
0.80 (EDNRB frame) | blaze 49%, blaze to the nostrils 41%, bald face 10% |
0.93 (KIT near-white) | bald face 61% |
The one parameter a gene passes beside strength is jag — how far the
margin wanders, in body units. That is the sabino/splash difference carried onto the
face: 0.42 for KIT, 0.11 for the two splash loci,
0.34 for frame. A KIT star has torn edges and a splash blaze
has clean ones, for the same reason their body margins do.
Presence is a propensity, not a decision
long seed = epi.seed(FACE_SEED); // the margin wobble
double starRoll = epi.get(STAR); // propensity, not a flag
double stripeRoll= epi.get(STRIPE);
double snipRoll = epi.get(SNIP);
double widthRoll = epi.get(FACE_WIDTH); // stripe -> blaze -> bald face
double offsetRoll= epi.get(FACE_OFFSET); // markings are rarely dead centre
double reachRoll = epi.get(FACE_REACH); // how far down the face the stripe runs
double starSize = epi.get(STAR_SIZE);
double snipSize = epi.get(SNIP_SIZE);
Whether a horse has a star is its stored star propensity tested against a threshold that moves with the locus's strength — so one horse's numbers show a star on a strongly marked locus and none on a faint one, and a line can be bred star-prone. Storing the decision instead would have thrown that away.
One long and eight floats, every time, including for the components
that turn out absent. This is the lesson the particle locus paid for: a draw made only
when a flag is set silently repaints every horse in every save the first time that
flag’s odds move. FaceMarkingTest pins it by running an empty marking
and a bald face through a FakeRng holding exactly nine values and asserting
both exhaust it.
common/src/main/java/com/example/horsegenetics/common/coat/pattern/