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

#PhaseWho runsDataDirection
1Natural (melanin)Every visible natural gene, in Genes.naturalOrder()PigmentField — per texel red, black in [0,1]Down only
2ResolveThe composerGradientLutColorField
3Magical (RGB)Every visible magical gene, in Genes.magicalOrder(), then the shadow passColorField — signed int r/g/b + opacitySigned, uncapped
4CompositeThe composerAlpha-aware multiply onto the white template
5EyesThe composerCoatRegions.redrawEyes — copied verbatim
6OverlayEvery gene implementing CoatOverlayContribution, in Genes.codeOrder()CoatOverlay — final ARGB writes + an emissive level per texelAbsolute; last writer wins
Two rules that hold the design up

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

common/genetics/Gene.java
/** 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;
}

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

common/coat/pattern/PigmentField.java
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.

House rule

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

common/coat/pattern/PigmentField.java
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 channelswhiten
black (1, 1)#5F330B #73522C #9C958D#080808 #414142 #A6A6A7
chestnut (1, 0)unchanged — nothing was masked, and the op collapses to red × keep
Two properties worth keeping

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).

The LUT is swappable — one locus, and only one

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.

CornerColour
(red 1, black 1)black
(red 1, black 0)chestnut
(red 0, black 0)white
middle columnchampagne gold
The red = 0 column is the only neutral ramp in the LUT

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

common/coat/pattern/CoatTextureComposer.java
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;
Why that is a ramp and not 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.

Painting a white horse

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

CallSemanticsOrder-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.NoPainting where the coat is transparent.
set(px, py, a, r, g, b)Flat paint that replaces the accumulator.YesA 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):

common/coat/pattern/ColorView.java
/**
 * 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.

common/coat/pattern/CoatTextureComposer.java
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.

Why this is a capability and not a very high priority

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

common/coat/pattern/CoatTextureComposer.java
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.

One mechanism per layer, on purpose

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.

Debugging tip

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:

Cross-locus reads — phase 1, one gene only

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.

Why a texel mask and not a part list

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.

CoatTextureId — do not go back to lower-casing

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/ee/e, W/ww/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 rest is one page

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:bakeCoatSamplesWrites build/coat-samples/*.png and *_foal.png. No game launch.
./gradlew :common:testPure 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 toplinebounds(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 toplineAnatomy
0.05coronet
0.10fetlock
0.26knee and hock
0.52underline / elbow
1.00spine
Fixed 2026-09-06 — it was a defect, not a tuning miss

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:

OutcomeStrengthLineMeasured white
PAX3 SW2/N — 90% of founders0.34upper cannon14% overall, 41% of the legs, 0% belly, 0% back
MITF SW1/N splash0.38just under the knee17% / 48% legs
splash-bold0.62past the elbow, onto the barrel41% / 95% legs / 31% belly
splash-extensive0.86well up the barrel69% / 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:

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.

common/coat/pattern/WhitePattern.java
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:

ComponentWhereDetached?
starthe forehead, just above the eyes (t = 0.30)yes, unless a stripe runs out of it
stripea band down the bridge of the nose; this is the component that becomes a blaze
snipthe 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.

StrengthTypical 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

common/coat/pattern/WhitePattern.java
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.

Source: common/src/main/java/com/example/horsegenetics/common/coat/pattern/