Project / how the code is laid out

Architecture & the build

Where the code lives and why it lives there: the three-module split that keeps a 1.12.2 backport cheap, the package layout inside each, how a genome gets from the server to a pixel, and what it takes to build and run the thing. The load-bearing rule is the first one: common/ has no Minecraft imports and no Java 9+ APIs, because it has three targets — NeoForge, a browser, and one day Java 8.

Three modules, split deliberately

Three-module Gradle project, split deliberately:

This is the part that survives a version port unchanged. If you want to import anything Minecraft-related here, stop.

Its job is to translate - build/read common types and shuttle them in and out of Minecraft's systems; the logic stays in common/.

It reads no resources: the page decodes the coat PNGs and the name tables and hands them in, which is TeaVM's weak spot avoided and the better boundary anyway. Note that TeaVM compiles the reachable graph, so adding an @JSExport can surface a missing JDK method that was always there - build after adding one.

When adding a feature, put as much as possible in common/ and keep the NeoForge module thin. That's what makes a future forge-1.12.2/ module cheap.

Data flow: server → client → pixels

  1. Wild spawn / /summon / a dimension pen horse -> one HorseRecord attachment carrying both the genotype code and the epigenome code (HorseRecords.newFounder -> CoatGenerator.generate -> a founder Epigenome.random). There is no separate coat attachment any more; the record default is HorseRecord.unassigned(uuid), whose empty epigenomeCode is the hasGenome() sentinel the join handler tests. Breeding is different: HorseBreedingHandler builds the foal's genome itself from damGenome.breedWith(sireGenome) and writes it into the foal's record, because the inherited epigenetics can only be read while both parents are in hand - the join handler would re-roll them. It then sees hasGenome() true and leaves it alone. The custom horse spawn egg is a third path: ModNetworking's SpawnCustomHorsePayload handler applies a founder record with the player-picked code + sex before addFreshEntity, so the join handler takes its "already has a real record" branch and keeps the genome. Since 2026-09-04 the epigenome comes with it rather than being rolled: the editor previews a live 3D horse, and a preview the spawn re-rolls is not a preview. That is the one founder path that does not call CoatGenerator.generate. The stallion seed jar is a fourth path, and it behaves like breeding: StallionSeedJarHandler builds the foal Horse itself, reads the mare's genome live and the sire's from the jar's StoredGenome, and calls the same HorseBreedingHandler.applyBredFoal - which writes the coat attachment before addFreshEntity, so the join handler leaves it alone.
  2. Not auto-synced -> the handler sends CoatSyncPayload {entityId, code, epigenome} and HorseRecordSyncPayload to trackers (on every join, plus StartTracking).
  3. Client caches in ClientCoatCache (CoatData) / ClientHorseRecordCache, cleared on LoggingOut.
  4. GeneticHorseRenderer.extractRenderState reads ClientCoatCache -> GeneticHorseRenderState.coatData; getTextureLocation -> GeneticCoatTextureFactory.getOrCreate(coatData, renderState.isBaby) - generated for adult and foal alike (HdHorseModel / HdBabyHorseModel handed to the super ctor; no per-entity model swap).

The HD horse models

128px, per-part UV - structural copies of vanilla AbstractEquineModel. createBodyMesh / BabyHorseModel.createBabyMesh with every cube at texScale = 0.5 and the layer baked at 128x128 (ClientSetup.HD_HORSE / HD_HORSE_BABY), so CubeDefinition.bake → effective texture size 128*0.5 = 64 and every normalized UV is identical to vanilla - the 2x sheet just gives each face 2x the texels. The adult model additionally re-texOffs's the four legs / two ears onto their own patches and drops .mirror(); the baby model already has per-leg patches so it's a straight 2x pass. horse_white.png / horse_white_baby.png (in common/.../assets/, with *_vanilla64.png references) are the vanilla white sheets scaled 2x. GeneticHorseRenderer hands both models to its super ctor as adult / baby - no per-entity model swap. It deliberately does not add vanilla's HorseMarkingLayer: that layer paints horse_markings_white.png etc. over the whole texture, so any horse (wild spawn or foal) that rolled Markings.WHITE rendered as a flat white horse on top of a correct generated coat. All white markings here come from the white-pattern loci, inside the generated coat texture.

Riding through water

Vanilla already floats a ridden horse at the water surface (horses are in the minecraft:can_float_while_ridden entity tag) and there is no water-triggered auto-dismount - the only dismount is sneak (Player#wantsToStopRiding = isShiftKeyDown). What's missing is usable speed: in-water travelInWater gives ~0.02 b/t.

The handler, on EntityTickEvent.Post for a tamed, player-ridden AbstractHorse that isInWater() and isLocalInstanceAuthoritative() (so it runs on the controlling client, where movement is simulated, and on the server for non-player control): adds a small upward deltaMovement when the horse is submerged (keeps the rider's head out), and blends horizontal deltaMovement toward a capped WATER_RIDE_SPEED (0.09 b/t) in the direction the rider steers (rider.zza/xxa, rotated the way vanilla's moveRelative does). Feel is a first guess - unverified in-game.

Build & test, in full

./gradlew :common:test               # pure-Java logic, no Minecraft - fastest loop
./gradlew :neoforge-26.1.2:build     # full compile + jar; slow first run (downloads the SDK)
./gradlew :neoforge-26.1.2:runClient # launch the game with the mod
./gradlew :neoforge-26.1.2:runServer # headless dedicated server (DEBUG logging - huge log)

# gene-creator tooling (see "Data-driven genes")
./gradlew :common:bakeSpecFixtures   # what the real Java spec engine produces
node wiki/gene-creator/tools/check-parity.mjs   # ...and does the creator's JS agree?
./gradlew :common:bakeCreatorAssets  # regenerate the creator's inlined textures + examples
node wiki/gene-creator/tools/bake-export-fixtures.mjs   # what the creator EXPORTS,
                                     # for CreatorMetadataRoundTripTest to parse

# breed files (see wiki/breed-format.html)
./gradlew :common:bakeBreedFiles     # rewrite the shipped breed JSONs *and* the
                                     # single-array copy the wiki tools fetch
./gradlew :common:bakeMarkingFacts   # the breed designer's measured facts per
                                     # painting outcome (coverage, parts, colour)

# horse-designer tooling (see the docs split)
./gradlew :web:generateWasmGC       # compile common/ to WebAssembly
./gradlew :web:bakeDesignerAssets   # ...and copy it + the coat PNGs into wiki/horse-designer/
python -m http.server                # the designer needs a server; file:// cannot fetch wasm

# the wiki itself (see wiki/tabs.js, wiki/search.js)
node wiki/tools/build-search-index.mjs   # bake every page's text into wiki/search-index.js
node wiki/tools/sync-page-views.mjs      # write each tabbed page's tabs back into pages.js

# the cowboy's barn (see wiki/villagers.html#rebaking)
python neoforge-26.1.2/tools/barn/bake-barn.py   # re-export from a structure block, then
                                     # this adds the jigsaw connector and the cowboy
                                     # (byte-reproducible: a clean `git status`
                                     #  after a bake proves it is current)

Re-run :common:bakeBreedFiles whenever a breed changes. It writes two spellings of one artefact - the per-breed files the game reads off its classpath, and wiki/horse-designer/assets/breeds.json, the single array the wiki tools fetch because a WebAssembly build cannot walk a classpath. BreedFilesTest checks all three agree (files, index, bundle), which is what stops the breed designer quietly offering yesterday's Friesian.

Re-run :web:bakeDesignerAssets whenever common/ changes, or the designer is running yesterday's mod. It is the designer's equivalent of re-baking the creator's fixtures, with the crucial difference that it cannot drift silently: the artefact is the code, not a snapshot of its output.

A wasm diff does not mean the mod changed The implication only runs one way. common/ changed and wiki/horse-designer/wasm/ unchanged does mean the designer is stale; the reverse proves nothing, because TeaVM's output is not byte-reproducible - two consecutive builds of identical source give different bytes at the same length (measured 2026-09-08). So a git status showing only the wasm is a re-bake, not a change, and there is no point committing one. What settles whether the designer actually moved is running it: wasm/web.wasm-runtime.js loads in Node, and the exported API can be driven from there without a browser or a texture.

The designer borrows only geometry.js and model3d.js from the creator, so check-parity.mjs still covers those - and js/java.js additionally re-checks them against the compiled Java on every page load. Its own files (gui.js, scene.js, animation.js, designer.js) have no automated net.

Run the parity check whenever you touch SpecPainter, SpecSchema, AbilityType, HorseSkinGeometry, BodyNoise/BodyStripes, or any of wiki/gene-creator/js/. It is the only thing standing between the creator and quietly previewing a horse the game will not breed - it has already caught a wrong nextLong() port, four schema defaults that had drifted apart, and (2026-09-06) a missed top/bottom UV swap.

Re-bake the fixtures before trusting a green run. expected.json is a checked-in snapshot of the Java, so a stale one makes the check green by definition. That is exactly how the UV swap hid for a day: the Java changed, the fixture was never re-baked, and the JS port was never updated. bakeSpecFixtures is part of the check, not a separate chore. The creator now also runs js/parity.js on itself at boot and prints the verdict under the coat sheet, so a stale port is visible in the tool rather than only at a terminal someone forgot to open.

Re-run build-search-index.mjs whenever wiki prose changes. The wiki's search reads a baked corpus of every page's text, because the wiki is opened from a file:// path as often as from a server and there is nothing to query at read time. It is a generated artefact that fails silently when stale - search simply stops finding new pages, with no error anywhere. sync-page-views.mjs is the same discipline for a smaller thing: it reads the tab-panel sections out of each page and writes the list back into wiki/pages.js, so which views list a page is never a hand-maintained copy of which tabs it has. Both are in CLAUDE.md's regenerate table.

Run :common:test first when iterating on genetics/stats - it doesn't touch Minecraft. Requires JDK 25; foojay-resolver-convention in settings.gradle.kts auto-provisions it.

The test world. A dev run's title screen carries a Spawn Test Horse World button (client/DebugTitleScreenButton): a fresh creative world, deleted again on shutdown, whose first login hands out a hotbar aimed at whatever is waiting on the checklist and two clickable /tps. The kit is one hotbar at a time - server/DebugTestWorldHandler.BATCHES - and /testkit lists the batches while /testkit n swaps the hotbar for one, so working through them costs no rebuild. Both are dev-only: the button checks FMLEnvironment.isProduction() and the command is not registered in a built mod.

runServer here does not auto-stop - it sits at the console after Done. Kill it (taskkill, or a PowerShell Stop-Process on the recent java pids) when the smoke test has printed Done (...)! For help.

Build setup notes

Cutting a release

A release is a tag, a bumped version and an entry on Releases. The version lives in two places and nothing wires them together: mod_version in gradle.properties, and version in src/main/resources/META-INF/neoforge.mods.toml. Change both or the jar reports one number and the mod list shows the other.

  1. Finish the session first. Re-run every bake in the regenerate table and confirm it moved nothing — a staged artefact looks exactly like a current one, and re-baking an already-staged change set has moved files more than once. Then :common:test, :neoforge-26.1.2:build and check-parity.mjs.
  2. Bump both version strings, add the release’s section to wiki/releases.html, and rebuild the search index.
  3. Tag the clean tree, after the doc commit rather than before it, so the tag is a tree somebody could check out and build: git tag -a v<version> -m "Horse Genetics <version>", then git push origin main --follow-tags.
  4. Create the release object, which the tag does not. This is the step that looks done and is not: git push --follow-tags publishes the tag, and GitHub files that under Tags. A release is a separate object, and until it exists the Releases page stays empty and nobody can download a jar. gh is installed (user scope, %LOCALAPPDATA%\Microsoft\WinGet\Links\gh.exe) and authenticated:
    gh release create v<version>   --title "v<version>"   --notes-file <the releases-page section, as markdown>   --verify-tag   "neoforge-26.1.2/build/libs/horsegenetics-<version>.jar#horsegenetics-<version>.jar (NeoForge 26.1.2)"
    --verify-tag makes it fail rather than invent a tag that was never pushed. Check it afterwards with gh release view v<version> --json isDraft,assets: the asset state must read uploaded, because a release can publish fine with a jar that silently did not attach.

The jar is the part a dev run cannot check. 0.1.0 exists because implementation project(":common") puts common/ on the runClient classpath without folding it into the jar, and the 0.0.1 jar therefore shipped with no genetics classes at all. Everything added since is resources, which fail the same way. Install the built jar into a real NeoForge instance before calling a release good — §0-AQ is the checklist. The cheap half of it needs no game at all: unzip -l the jar and confirm horsegenetics/genes/, horsegenetics/breeds/, horsegenetics/names/, data/horsegenetics/structure/ and com/example/horsegenetics/common/ are all in it, and that a gene renamed this cycle is present under its new name and absent under its old one. Do that before attaching the jar to a release.

Running the game

build only assembles the jar. runClient / runServer launch MC 26.1.2.100 with the mod. IntelliJ Gradle sync generates the run configs.

Crash reports land in neoforge-26.1.2/run/crash-reports/ (crash-<timestamp>-{server,client,fml}.txt) - most recent last. That is where the owner will point for any in-game crash; read the newest one. hs_err_pid* JVM-level dumps land in neoforge-26.1.2/run/ (and are git-ignored).

The current dev desktop has no machine-specific launch blockers - one NVIDIA GTX 1070 Ti, no integrated adapter, nothing to pin. The two below were real on the previous dev laptop (NVIDIA RTX 3050 Ti + AMD integrated, AMD driver from 2023). They are kept because they cost a day to find and would come straight back on any hybrid-graphics machine - but neither workaround is needed here, and the GPU-preference one is not applied here:

The "Spawn Test Horse World" button cleans up after itself

client/DebugTitleScreenButton (dev only) creates a throwaway creative world named by DebugTestWorldCleanup.newDirectoryName() = test_horse_<millis>. On the login that follows, server/DebugTestWorldHandler fills the inventory with one of every gameplay item and teleports the player into the nearest plains village, moving the world spawn there too - a synchronous structure search over 100 chunks, which is only tolerable because it happens once, in a throwaway world. Plains specifically, because that is the only kind of village the cowboy generates with, and the town centre rather than the edge, because from the bell you can see which way the streets run and walk out along each to find their barn. In a dev build a cowboy also announces himself in chat when he founds (CowboyHandler.announce), since that happens as soon as their chunk ticks - usually before you are close enough to see the building. client/DebugTestWorldCleanup deletes those worlds again, in two sweeps over saves/, both matching only test_horse_ + digits so a hand-made world is never a candidate:

Both are ClientLifecycleEvent subclasses, which do not implement IModBusEvent, so @EventBusSubscriber(value = Dist.CLIENT) routes them to the game bus. A failed delete only warns - the next launch retries.