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:
common/- pure Java, zero Minecraft/NeoForge imports (not even DFUCodecs). Since 2026-09-06 it is also kept free of Java 9+ APIs, because it now has two targets that lack them: the intended Java 8 1.12.2 backport, and TeaVM, which compiles it for the browser.System.getLoggerandLong.parseUnsignedLonghave both been removed for that reason - seeCommonLogandEpigenome.parseUnsignedHex. Subpackages by concern:genetics/-Gene+Allele+AllelePair,Expression(one outcome a gene can produce, with its own paint function - what replacedDominancePattern),FounderTable+FounderContext(the wild-population weight per allele combination - what replacedGene.randomPair),Genotype(+breedWith),Epigenome/AlleleEpigenetics(the priority + the literal per-copy values),epi/(EpiValue/EpiSchema/EpiValues/EpiRoll/EpiDrift/EpiCodec- what a gene declares it writes on an allele copy, the numbers themselves, and the rolling, drift and text codec over them),Genome(the two together, and the breeding that keeps them aligned),GenomeSample(aGenomefrozen to code strings and taken off the horse - what the stallion seed jar carries;breedInto(mareGenome, rng)runs the ordinary draw),ShowcaseGenotypes(a founder draw with a floor under it - the horse dimension's stock; see the pens section),AbilityContribution(the capability a built-in gene implements to grant game behaviour, using the sameGeneAbilityvocabulary a data-driven gene'seffectsblock parses into) and its per-horse twinEpigeneticAbilityContribution(the effect's magnitude is on the allele copy - the particle locus),GeneEpigenetics(one gene’s per-horse numbers, by expressing copy or by slot; it lives here rather than intrait/so both the trait and the ability sides can use it without the two packages depending on each other),GeneRarity(the §19 tier enum),GameteBias(a per-parent modifier onGenome.breedWith- what a breeding carrot does,wiki/carrots.html) +CarrotEffect(the sealed effect set +fold->GameteBias),LutContribution(the capability a gene implements to swap the phase-2 colour LUT - exactly one gene, the LUT locus, and permanently so; a new palette is a new allele, never a new gene),WhiteLockContribution(the capability a gene implements to make a white texel final - also exactly one gene, and deliberately not a priority: it applies at every step of phase 3 at once rather than taking a slot in it),CoatPhenotype,GeneticCodeCombiner,BreedingPreview(the per-locus Punnett square behind the browser's Breeding preview tab, with the grouping derived fromTraitBreakdownandGene.coatDependsOn()rather than from a list),BreedingReport(one line per gene describing a draw that already happened - the dev build's breeding log) andEpigenomeReadout(the shared formatter for a copy's stored numbers, so every screen that shows epigenetics shows it identically).coat/-CoatData,CoatGenerator;coat/pattern/holds the pipeline (CoatTextureComposer, thePigmentField/ColorFieldaccumulators and their read-onlyPigmentView/ColorViewfaces, plusCoatOverlay+CoatOverlayContribution- the phase-4 sink for final pixels and the emissive texel mask, andLutSet- the baseGradientLut+ keyed alternates the LUT locus resolves phase 2 against) and the reusable body-space noise:BodyNoise(single-octave value + Worley),BodyStripes(X-oriented stripe field, now only the data-drivenSTRIPESmask),ZebraStripes(the zebra body map - vertical bands, hip arcs, leg rings, per region) andBlaschkoStripes(brindle's broken, per-side-rolled streaks),PatchNoise(warped 3-octave fractal for white-spotting patches -field+fbm2) andHairPattern(the mane/tail painter: bands across a part's own longest axis, a centre stripe, and the bright-hue draw).name/-HorseNameGenerator+HorseNames(breed= one-half-each;breedNth= varied by a pairing's foal count) + word tables undersrc/main/resources/horsegenetics/names/.stable/-StableSpawn: what lives in a generated stable - how many horses, where they stand, what breed, and the genetic guarantees, applied as three narrowing passes over an ordinaryBreedFounderdraw. Data rather than code, so adding a stable is an NBT and a JSON; the codec is a shell over it indata/StableDefinitionsand the rolling is here, testable without a game. ->wiki/stables.html.horse/- the pedigree domain model (Sex,HorseRecord,ParentStats,HorseDatabase,InMemoryHorseDatabase), plus the table model behind the browser's My horses tab:HorseListing(one horse flattened to a row, with its coat-in-words, its four body numbers and its disorders resolved once - a table redraws every frame and a sort compares thousands of times) andHorseQuery(the filter language and the sort comparators; unit-tested without a game, and game-free so the same predicate can move server-side when the owned-horse index exists) ->wiki/breeding.html.Sexis an enum the rest of the code reads, not a stored fact:HorseRecordhas nosexfield and derivessex()from the sex locus in its genetic code. It has nospeed/healthfields either, for the same reason -traits()resolves them from the genotype. It does storeOptional<String> breed(aBreedLineagetoken) - ancestry, not a derived value - but nothing feeds it intoHorseTraits: a breed shapes its founders and nothing after them, so a horse's body comes from its own genome alone.breed/- the breed system, which since the breed-file pass is data rather than code:Breed+ itsBuilder(the parsed form, and the Java escape hatch),Breeds(the registry - the shipped files, the drop-in folder, andFERAL_MIXED, the only breed still written in Java because it is the absence of one),BreedSource(the wild / cowboy / spawn-egg / stable checklist),Commonness,BreedStatCurve(score/hh -> band),BreedBands(the epigenetic numbers a breed pins on its founders - the general form of the stat bands),BreedFounder(breed-aware founder roll),BreedLineage(the pure/cross/spliced/mixed/feral label +combine; purely a label, it pins no stats). Depends ongenetics/+trait/;horse/depends on it. ->wiki/breeds.html.breed/spec/- the data-driven breed path, deliberately the twin ofgenetics/spec/:BreedSpecParser(JSON ->Breed, warning-and-skipping anything this install has not got rather than throwing),BreedSpecWriter(the way back, omitting every field at its default),BreedSpecLoader(a classpath index and a drop-in folder), and the dev toolBreedFileTool. It reusesgenetics/spec/Jsonrather than growing a second reader. ->wiki/breed-format.html.trait/- the non-coat body:HorseTraits(the one walk),Traits/Condition/Severity/Viability(the result),TraitBuilder(the sink), the capability interfacesTraitContribution/EpigeneticTraitContribution/HealthContribution, plus three readers built on them:TraitBreakdown(the same walk into separate builders, so the information screen can say which gene is worth which part of the body without re-deriving the total),GeneCategory(coat / body / other, decided from what a gene implements - it is what sorts a gene onto a tab, and a drop-in gene sorts itself) andMiscarriageSigns(one sentence per embryonic lethal describing what the loss looked like, deliberately without naming the gene). Depends ongenetics/and nothing depends on it except the genes that contribute - no cycle. ->wiki/horse-body.html.genetics/spec/- the data-driven gene path:GeneSpec(the format as records),Json(a hand-rolled parser -common/takes no dependencies),GeneSpecParser,SpecSchema(the one declaration of what each mask and op accepts),SpecValues(the per-horse knob draw),SpecGene(aGenethat reads a spec),GeneSpecLoader, and the two dev toolsSpecFixtureTool/CreatorAssetTool. The painting iscoat/pattern/SpecPainter.Rng- the randomness seam (nextFloat/nextBoolean/nextInt(bound)/nextLong), implemented byNeoRng(wrapsRandomSource) and, in tests,FakeRng.CommonLog- the one waycommon/says something went wrong. Four warnings use it. It exists becauseSystem.getLoggeris Java 9+ and this module has to run on Java 8 and in a browser; a host may take the messages over withsetSink.
This is the part that survives a version port unchanged. If you want to import anything Minecraft-related here, stop.
neoforge-26.1.2/- everything Minecraft-specific, by concern:client/- renderer, texture compositing (incl.EmissiveCoatLayerfor aglowgene's full-bright coat parts, and the LUT locus's alternate gradients loaded into aLutSetinGeneticCoatTextureFactory),CutieMarkLayer+FlatItemCatalog(the cutie-mark emblem - item models on the flanks, drawn last so it beats every white pattern; the catalog is every non-BlockItemitem, built once per session), client caches, the inventory hooks +FamilyTreeScreen, keybind, lifecycle cleanup.HorseBrowserScreenis now anAbstractContainerScreen<HorseBrowserMenu>(was a plainScreen) with a My horses tab, a Gene DB tab, a Breeding preview tab and a Crafting tab;ClientSetupbinds it viaRegisterMenuScreensEvent. Both roster tabs read one roster (ClientHorseRoster, filled byHorseRosterPayloadfromHorseRoster), turned intoHorseListings once on arrival rather than per frame.HorseInfoScreenis the per-horse counterpart, opened by the i buttonHorseScreenHookshangs off the left edge of the vanilla horse inventory. Both custom screens draw their chrome before the widget pass, not after - they paint near-opaque panels across the window, and the default order (widgets, then the screen) washed out anything of ours that overlapped one.HorsePortraitis the shared row portrait (one throwaway adult and one foal for a whole table, coats fromClientHorseCoats- keyed by record UUID and fetched a screenful at a time, deliberately notClientCoatCache, which is the renderer's and keyed by entity id).data/- Data Attachments on a horse:horsegenetics:horse_record(aHorseRecord, the whole genome),horsegenetics:horse_care(HorseCareAttachment, bond / herd / daily-cap),horsegenetics:horse_cooldowns(HorseCooldownsAttachment, aMap<String,Long>of last-fired game times -"shear","yield:<geneKey>"; replacedGeneYieldHandler's static map)horsegenetics:carrot_window(CarrotWindowAttachment, a live breeding-carrot window - notcopyOnDeath) andhorsegenetics:cowboy_brand(CowboyBrand- whose horse this is while it is nobody's; the only synced attachment, because refusing a client-predicted interaction server-only rubber-bands). Plus the ancestrySavedData+GeneDatabaseData(server-global per-player gene database,wiki/carrots.html#database); codecs;ModDataComponents(item data components:stored_genome,bound_horse,carrot_effects(aList<String>ofCarrotEffectids)research_gene(a gene key on aresearch_paper),paper_bearerandhorse_deed(the two transfer paper components));StallData/StallRecord; anddata/loot/(AddResearchPaperModifier+ModLootModifiers- the chest-loot paper injection - plusSetRandomGeneFunction+GenePool+ModLootFunctions, which is what lets the horseman's data-driven trades sell "a random uncommon gene carrot" with no trade-generation code).network/- custom payloads +ModNetworking.entity/- the mod's own entities. One:Cowboy(a customAbstractVillager- always mounted, sells their herd as transfer papers) +ModEntities. They are never spawned naturally; the barn structure carries them. See Villagers & transfer papers.village/-ModPoiTypes(horsemans_table) +ModVillagerProfessions(the horseman). The profession is code becauseRegistries.VILLAGER_PROFESSIONis built-in; the trades it names are datapack JSON underdata/horsegenetics/{villager_trade,trade_set}/.worldgen/-BarnPoolInjector, which appends the cowboy's barn to the vanilla plains-village terminator pool at server start. The only thing in the mod's gameplay path that needs an access transformer.
menu/- the mod's one container menu.ModMenus(aDeferredRegister<MenuType<?>>),HorseBrowserMenu(the Horse Browser, opened with the H key viaOpenHorseBrowserPayload- a 3x3TransientCraftingContainer+ aResultContainer+ the player inventory; every slot goes inactive off the Crafting tab viasetCraftingVisible, so the full-window Gene database tab shows none), andHorseBrowserRecipes(the result logic - book + a discovered gene -> that gene'sresearch_paper, then the sharedKnownGeneSpliceRecipe/CarrotCombineRecipe; only this mod's recipes, no vanilla lookup).SelectBrowserGenePayloadcarries the picked gene from the client;SpliceRecipeDisplayis the canonical 9-slot Known Gene Splice layout (client-safe), andViewSpliceRecipePayload->HorseBrowserMenu.fillSplicePreviewpulls the ingredients a player has into the grid and the screen ghosts the rest (never crafts).server/- event handlers, the horse-dimension builder, the portal manager, the record adapter (HorseRecords, which now also resolves and writes the four body attributes);LethalFoalHandler(foals that do not make it - seewiki/horse-body.html);HorseBreedingHandler(natural breeding + the sharedapplyBredFoal, now also combining the parents'BreedLineage),StallionSeedJarHandler(seed collection + mare impregnation, reusingapplyBredFoal),BreedSpawnHandler(a one-lineFinalizeSpawnEventhook - flag natural spawns) +HerdManager(deferred: founder record + proximity herd formation, every clump = one herd of one breed) +WildHerdGoal(a herd member trails its lead) +HorseAggroHandler(wolf-style herd aggro + the horseATTACK_DAMAGEattribute) +DebugHighlightHandler(the F8 glow toggle), the stall trioStallSignHandler(bind / sign-break cleanup) +StallDetector(the enclosed-area flood-fill) +StallDebug(particle-outline overlay), and the horse-care pairHorseCareHandler(the one 30-tick scan for gated healing + bond + tamed-horse herd formation; also exposesawardBondForfor one-off bond like shearing) +BondFollowGoal(the single bond-tier AI goal). Seewiki/horse-care.html. Roadmap §§7/12/14/16:HorseShearHandler(shears -> hair, once/day);GeneYieldHandler(theyieldtranslator + thedeniedDamage/deniedMessageelse-branch);BreedingCarrotHandler(feed a carrot -> aCarrotWindowAttachment+ love);GeneDiscoveryHandler(fill the gene DB on tame / breed);ResearchPaperWriter(the browser "write paper" button's server side);recipe/(ModRecipes+ the parameterisedKnownGeneSpliceRecipe+CarrotCombineRecipe+RarityItems). The cowboy's outfit is five more:CowboyHandler(founds them on their first tick - name, home, mount, herd - and attaches both goals to every horse),CowboyHerdGoal(their string trails the cowboy - they do not ride, and the whole mounted subsystem was deleted with the goals that served it),CowboyHitchHandler(the hitch takes on a cowboy) +HorsemanHandler(names the horseman into the nearest cowboy's family) andHorsePrices(breed rarity -> emeralds, kept offcommon/likeRarityItems).TransferPaperHandlerowns crafting, signing and redemption.block/-ModBlocks(HayPortalBlock, andHORSE_TRADERS_POST- the horseman's workstation),ModBlockEntities+HayPortalBlockEntity(drives the animatedhay_portal.pngslab renderer).item/-ModItems(the custom horse spawn egg - a creative tool, a reskin of the horse spawn egg that opens a gene-list / preview / genome editor first; the editor screen isclient/CustomHorseSpawnScreen, the spawn itself goes throughnetwork/SpawnCustomHorsePayloadand is creative-gated on the server - plus the gameplay-layer items, roadmap §§11-19; the twoSeedJarItems, threeWhistleItems, twoStallSignItems, the four breeding carrots + the parameterisedKNOWN_GENE_SPLICE_CARROT,ResearchPaperItem(right-click -> gene DB + carrot unlock),braided_rope+hair_cloth, the two transfer papers (TransferPaperItem/SignedTransferPaperItem), andHORSE_HAIR(from shearing) all have behaviour; the tickets do not yet),SeedJarItem(tooltip),WhistleItem(use-> recall your tamed horses in a radius),StallSignItem(useOn-> place an oak wall sign + flood-fill the stall behind it), andModCreativeTabs(one Horse Genetics tab). Recipes are datapack JSON underresources/data/horsegenetics/recipe/.
Its job is to translate - build/read common types and shuttle them in and out of Minecraft's systems; the logic stays in common/.
web/(new 2026-09-06) -common/compiled to WebAssembly by TeaVM, for the wiki's horse designer and, since the gene pages grew a preview window and a carrot card, for those too - one wasm, three consumers. Three classes and no logic:DesignerApi(the@JSExportfacade - JSON out for structure,int[]out for coats, ints in for edits, plus a stateless half that takes a genotype and hands back a coat without touching the editor, which is what the gene pages use),HorseEditor(a deliberate twin ofCustomHorseSpawnScreen's state machine - keep them in step) andJson(a writer;common/has no dependency to borrow one from)../gradlew :web:bakeDesignerAssetsbuilds it and copies the artefacts intowiki/horse-designer/. It is not shipped in the mod and nothing inneoforge-26.1.2/depends on it.
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
- Wild spawn /
/summon/ a dimension pen horse -> oneHorseRecordattachment carrying both the genotype code and the epigenome code (HorseRecords.newFounder->CoatGenerator.generate-> a founderEpigenome.random). There is no separate coat attachment any more; the record default isHorseRecord.unassigned(uuid), whose emptyepigenomeCodeis thehasGenome()sentinel the join handler tests. Breeding is different:HorseBreedingHandlerbuilds the foal's genome itself fromdamGenome.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 seeshasGenome()true and leaves it alone. The custom horse spawn egg is a third path:ModNetworking'sSpawnCustomHorsePayloadhandler applies a founder record with the player-picked code + sex beforeaddFreshEntity, 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 callCoatGenerator.generate. The stallion seed jar is a fourth path, and it behaves like breeding:StallionSeedJarHandlerbuilds the foalHorseitself, reads the mare's genome live and the sire's from the jar'sStoredGenome, and calls the sameHorseBreedingHandler.applyBredFoal- which writes the coat attachment beforeaddFreshEntity, so the join handler leaves it alone. - Not auto-synced -> the handler sends
CoatSyncPayload{entityId, code, epigenome}andHorseRecordSyncPayloadto trackers (on every join, plusStartTracking). - Client caches in
ClientCoatCache(CoatData) /ClientHorseRecordCache, cleared onLoggingOut. GeneticHorseRenderer.extractRenderStatereadsClientCoatCache->GeneticHorseRenderState.coatData;getTextureLocation->GeneticCoatTextureFactory.getOrCreate(coatData, renderState.isBaby)- generated for adult and foal alike (HdHorseModel/HdBabyHorseModelhanded 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
common/build.gradle.ktsdeclares its ownrepositories { mavenCentral() }andtestRuntimeOnly("org.junit.platform:junit-platform-launcher")- both required under Gradle 9.- Mod metadata is
src/main/resources/META-INF/neoforge.mods.toml;minecraftdependencyversionRange = "[26.1.2,)"(the MDK's[1.26.1,1.27)does not match the version string26.1.2and makes FML refuse the mod). There's a stray un-packaged duplicate atsrc/main/neoforge.mods.toml(wrong dir, from agit mv) - delete it or move it underresources/META-INF/; don't let the two drift. - Config: three files, all in
.minecraft/phc/beside thegenes/andbreeds/drop-in folders rather than inconfig/(ModBreedSpecs.configFile, and api-notes on how):client.toml(ClientConfig, CLIENT),server.toml(ServerConfig, SERVER -health.mode,body.size,debug.announce) andbreed-spawning.toml(BreedSpawningConfig, COMMON, generated from the breed registry - see breeds). All registered from theHorseGenetics(IEventBus, ModContainer)constructor. The client file: One key so far:familyTree.scrollBar(defaultfalse= shrink the Family Tree to fit;true= full-size + scroll). Read viaClientConfig.familyTreeScrollBar()which swallows the not-yet-loadedIllegalStateException. .gitignore(repo root) coversbuild/,.gradle/,.idea/,**/run/*, anysaves/, and the JVM'shs_err_pid*.logdroppings - the tracked tree is down from 2 446 files to 148 (source, docs, the gradle wrapper). The one deliberate exception isrun/config/fml.toml, kept tracked by a!**/run/config/fml.tomlnegation because it carries this laptop'searlyWindowControl = falselaunch fix; the negation needs the!**/run/config/+**/run/config/*pair above it to work, so don't collapse those three lines.src/main/resources/META-INF/accesstransformer.cfgis the mod's one access transformer. ModDevGradle 2.x auto-detects that exact path - there is nothing to add tobuild.gradleor the mods.toml. Three lines are onStructureTemplatePool, for the barn's pool append; one is onAbstractHorse.generateSpeed, for the speed floor; one is onVillager.increaseMerchantCareer, for a dev-only shortcut. See API notes before adding another.generate_file_list.pyand the README file-tree block were removed this session (README is user-facing now).
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.
- 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:buildandcheck-parity.mjs. - Bump both version strings, add the release’s section to
wiki/releases.html, and rebuild the search index. - 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>", thengit push origin main --follow-tags. - Create the release object, which the tag does not. This is the
step that looks done and is not:
git push --follow-tagspublishes 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.ghis 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-tagmakes it fail rather than invent a tag that was never pushed. Check it afterwards withgh release view v<version> --json isDraft,assets: the assetstatemust readuploaded, 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:
- JVM hard-crash (
EXCEPTION_ACCESS_VIOLATIONinatio6axx.dll) atglfwCreateWindow- the process ran on the old AMD integrated driver. Fix: Windows per-app GPU preference pinningjava.exe/javaw.exeto "High performance" (HKCU\Software\Microsoft\DirectX\UserGpuPreferences,GpuPreference=2;). Durable; a driver update would also fix it. - FML early-loading splash window hits the same crash one step earlier.
run/config/fml.tomlhasearlyWindowControl = false.run/is otherwise git-ignored, but that one file is deliberately still tracked (see "Build setup notes") so agit cleancan't reset it totrue. Stillfalse, and harmless on a machine that does not need it - so it has been left alone rather than flipped back.
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:
ClientStoppedEvent- the normal path. It's posted on the game bus fromMinecraft#destroyafter the disconnect has halted the integrated server and spun waiting for it to finish saving, and afterclose()- so nothing still holds the save folder, and it's the last hook beforeSystem.exit. (ClientStoppingEventwould be too early: the server is still running.)ClientStartedEvent- the safety net, for a crash, ataskkill, or a delete Windows refused because a handle lingered. Runs before anything opens a world.
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.