[1.20.1] Add Forge/NeoForge 1.20.1 support via a shared platform core and fix misc bugs - #1022
[1.20.1] Add Forge/NeoForge 1.20.1 support via a shared platform core and fix misc bugs#1022jmcjm wants to merge 13 commits into
Conversation
…red module Move biome loading, chunk generation (incl. noise pipeline), materials, commands, NBT/logging utils and 8 vanilla-target mixins from platforms/fabric to platforms/shared. Fabric keeps only loader wiring: entrypoint, engine, world-save callback, mod-loaded checker and 2 fabric-specific mixins (registry bootstrap registration, world save hook). Fabric* classes are renamed to Shared*. Block tag data and lang files move to shared resources. No functional change; the fabric jar contents stay equivalent.
…red core Replace the abandoned 1.17-era forge module (predating the noise pipeline) with a thin platform layer: @mod entrypoint with DeferredRegister for the otg:otg biome source and chunk generator codecs, RegisterCommandsEvent and LevelEvent.Save wiring, ForgeEngine and a mod-loaded checker. All terrain generation, biome loading and mixins come from platforms/shared; registry ids match fabric, so worlds are portable between loaders. Dev-run plumbing (production jars are unaffected): - shared reaches the modlauncher game layer via the developmentForge configuration, otherwise its mixin configs are unreadable - the common library modules split java packages between each other, which the JPMS module layer rejects when each jar is a separate automatic module; they are merged into a single dev jar with an explicit Automatic-Module-Name (the derived one would clash with the forge module) - jackson-module-jsonSchema is excluded from dev runs: its module graph requires validation.api and production jars do not ship it either Tested: dedicated server boots on both loaders with level-type=otg:default_preset, spawn chunks are generated by OTG (otg:otg generator in level.dat), no errors in logs.
The single-jar distribution is a relic of the Forge+Spigot era, where each platform had its own class tree and could coexist in one file. With the shared module referencing Minecraft classes directly, fabric and forge production jars contain the same class names remapped differently (intermediary vs SRG), so merging them into one jar cannot work. Each platform already ships its own jar via copyPlatformJar (build/distributions/otg-<platform>-<version>.jar); mod hosting sites require per-loader files anyway. The -Xmaxerrs compiler-args loop now covers forge too.
The bundled preset folder was renamed Default -> DefaultPreset in resources, but the unpack code in OTGEngine still looked for resources/Presets/Default/, so nothing was ever extracted on first start and no OTG world preset showed up. Point Constants.DEFAULT_PRESET_NAME at the actual folder name (its only other user, LocalPresetLoader's default-preset fallback, expected the real folder name too) and build the jar paths from constants instead of string literals.
World presets registered from OTG presets showed the raw translation key (generator.otg.<name>) in the world-type selector, because presets are user content and their names cannot ship in a static lang file. Register the preset's DisplayName (falling back to the folder name) in a runtime translation map during world preset registration, served by a mixin on Language#getOrDefault.
Split unpackDefaultPresetAndExamples into openModJar / shouldSkipDefaultPresetUnpack / unpackJarEntries, close readers and streams with try-with-resources, stop scanning jar entries once the bundled preset config is found, and log I/O failures through the OTG logger instead of printStackTrace. No behavior change.
Fabric-loom injects the refmap entry only into mixin configs from the module it is remapping, and the Forge path gets it injected by the architectury transformer — so the shared config ended up refmap-less in the production fabric jar. Mixin then resolved members by their named (mojmap) names against the intermediary runtime and crashed on the first accessor (ChunkAccessAccessor#sections). Reference the refmap explicitly; the file itself was already shipped in both jars.
OTG uses fabric-api (EventFactory for the world-save callback) but never declared it, so a missing Fabric API crashed with NoClassDefFoundError at entrypoint instead of a clear loader message.
The Game rules config section was read and written but never applied — the code that consumed it died with the old forge module. With OverrideGameRules: true the overworld preset's GameRules now apply on both loaders: a mixin on Level#getGameRules serves per-level rules from GameRuleManager, and GameRuleApplier builds them from the preset's GameRuleSettings on server start (the preset config is the source of truth, so manual /gamerule changes don't survive a restart when the preset overrides them). The rule list was frozen at the 1.16 set (31 rules); the 14 rules added between 1.15 and 1.19.4 are now supported: doImmediateRespawn, reducedDebugInfo, freezeDamage, playersSleepingPercentage, doWardenSpawning, block/mob/tntExplosionDropDecay, snowAccumulationHeight, commandModificationBlockLimit, doVinesSpread, globalSoundEvents, lava/waterSourceConversion. Verified against 1.20.1's GameRules class. Ported from the jmcjm fork's 1.21.1 GameRules system.
|
I've also backported the runtime dimension support from my 1.21.1 fork — /otg dimension create/delete/list plus cross-dimension portals. It builds on the shared core from this PR, so can open it as a follow-up once this one is merged. The branch is already up if you want a look: https://github.com/jmcjm/OpenTerrainGenerator/tree/1.20.1-dimensions |
|
Have you researched the possibility of a universal jar with the architectury shared code setup? In my eyes, separate jars is a feature regression. I'm not against a lang mixin, but has this been tested against lang files loaded with the preset packer? User presets is "Preset Dev" space, not "OTG User" space, so ID strings are not a critical issue. Default Preset Unpack should be a separate feature PR, as there is still design being done there - it's not good to have separate unpack logic in the jar and the unpacker. Plus, we haven't considered if we should expand otg pack to include biome and config files to make it a true one-file preset, which would be loadable directly into OTG rather than through an unpack. Moving to shared code is good though - I've been planning it for a while, but haven't spent dev time on it |
Per review, default-preset unpacking is a separate feature still under design, and shipping jar-side unpack logic alongside the external preset packer is undesirable. Restore OTGEngine's unpack code to the base 1.20.1 state, removing the changes from 9c6d58e (fix) and d2a54c2 (refactor). DEFAULT_PRESET_NAME stays "DefaultPreset": it is read by LocalPresetLoader to resolve the default preset among loaded presets, and the bundled folder is resources/Presets/DefaultPreset. Reverting it to "Default" would break default-preset resolution when multiple presets are on disk, which is unrelated to unpacking.
This comment was marked as outdated.
This comment was marked as outdated.
… map LanguageMixin overrode Language#getOrDefault at HEAD unconditionally, so a runtime-registered preset display name shadowed any translation loaded from a language file (resource pack, or the preset packer's bundled lang). Guard the override with has(key): a loaded translation always wins, and the runtime map only fills keys that no loaded language provides. World preset names still resolve, because their generator.otg.<name> keys ship in no static lang file.
|
Pulled the DefaultPreset unpack out of this PR. Reworked the lang mixin so loaded language files win. It was overriding I checked that against the preset packer specifically, since you asked: WorldPacker's lang uses |
… 9 toolchain) './gradlew build mergeJars' produces a single fabric-forge jar (4.44 MiB vs 4.21+4.21 separate). Toolchain: Gradle 8.8 -> 9.4.1, architectury-loom 1.7-SNAPSHOT -> 1.17.491, architectury-plugin 3.4-SNAPSHOT -> 3.5.169, shadow 8.3.0 -> 8.3.9, daemon pinned to JDK 24 (gradle-daemon-jvm.properties + foojay resolver for auto-provisioning) so the Java-24 Forgix plugin loads. Loom 1.8-1.11 could not be used: they leave @shadow member names unmapped in mixin bytecode with no refmap entry, crashing production fabric at boot. Loom 1.17 statically remaps mixin bytecode per platform (intermediary/SRG inlined, no refmaps emitted), restoring loom-1.7 behavior on a modern Gradle. CopyFile @TaskAction made public (Gradle 9 validation). Forgix merges by renaming same-path-different-content classes to <Name>_fabric/_forge and rewriting references. Two gaps are handled in the build: (1) loom 1.17 makes the shared mixin config byte-identical in both jars, which Forgix would deduplicate into one config that cannot serve both loaders' renamed classes - each platform's shadowJar stamps a loader marker into its copy (and drops the stale refmap key) so the merge splits them per loader; (2) Forgix misses the bare class names in mixin config mixins/client lists - fixMergedMixinConfigs suffixes those entries after the merge and hard-fails if any entry resolves to no class in the jar. A third gap surfaced on the first real client run: javac's synthetic $SwitchMap inner class (enum switches in RegistryLoaderMixin) got renamed to RegistryLoaderMixin$1_<loader>, breaking Mixin's Outer$Inner companion-class detection - the class is never conformed and world creation dies with IllegalClassLoadError. The enum switches are replaced with == chains so the mixin compiles to a single top-level class, and fixMergedMixinConfigs now fails the build if any Forgix-renamed nested class appears under a mixin package. Reproduced and verified on a headless fabric server with DefaultPreset installed (empty-preset boots never execute the switch, which is why earlier boot tests passed).
…RegistryHelper Mixin classes live under a classload-policed package and every javac synthetic (enum-switch $SwitchMap, anonymous class) becomes a landmine when the Forgix merge suffix-renames it (RegistryLoaderMixin$1_fabric broke Outer$Inner companion detection and crashed world creation). Move all logic into the regular class shared.registry.OTGRegistryHelper (same FQCN as 1.21.1), restore the enum switches the hotfix flattened, and drop the dead mixin.util.RegistryUtil (WorldPresetTagsMixin already carries its own private getAsReference). The mixin package now holds only thin @Mixin/accessor classes; fixMergedMixinConfigs enforces this at build time.
|
I did some more digging, and it turns out there's a way to make a universal JAR: a relocation merge performed after Loom finishes remapping.
Forgix merges the two remapped jars. Identical entries are deduplicated; classes present in both jars with different bytecode (the shared module — intermediary refs on one side, SRG on the other) are kept twice as What it took:
I have tested it on Fabric, Forge and NeoForge. There is the universal jar (it is zipped because of gh file upload restrictions): |
What this does
This PR replaces the abandoned 1.17-era
platforms/forgemodule with a working Forge 1.20.1 platform, built on a new loader-agnostic core extracted from the fabric module. Since NeoForge 47.x for MC 1.20.1 is binary-compatible with Forge 47, the same jar runs on both — tested on all three loaders (details below).The README mentions the plan to "port to other platforms" after the fabric-first rewrite — this is that port for Forge/NeoForge, done in a way that makes any future platform a thin wrapper instead of a re-port.
How it's structured
platforms/shared(new home of ~90% of the fabric module). After the fabric-first rewrite, only 4 files inplatforms/fabricactually imported Fabric API — everything else was pure vanilla/mojmap code. That code (chunk generator incl. the noise pipeline, biome loading, materials, commands, NBT/logging utils, and 8 vanilla-target mixins — includingRegistryLoaderMixin, which does all the world/biome/dimension registration) moved to the already-existing-but-emptyplatforms/sharedarchitectury common module.Fabric*classes were renamed toShared*.platforms/fabricis now a thin wrapper: entrypoint,FabricEngine, world-save callback, mod-loaded checker, and 2 fabric-specific mixins (registry bootstrap codec registration, world save hook).platforms/forgeis a new thin wrapper (~120 LOC):@Modentrypoint withDeferredRegisterfor the biome source and chunk generator codecs,RegisterCommandsEvent/LevelEvent.Savewiring,ForgeEngine,mods.toml. Registry ids match fabric (otg:otg), so worlds are portable between loaders.Bugs found and fixed along the way
Default→DefaultPresetat some point, butOTGEnginestill scanned the jar forresources/Presets/Default/, so first start extracted nothing and no OTG world preset ever appeared.Constants.DEFAULT_PRESET_NAMEpointed at the old name too.generator.otg.default_preset) in the world-type selector. Presets are user content, so their names can't ship in a static lang file — display names are now registered in a runtime translation map (OTGTranslations) during world preset registration and served by a small mixin onLanguage#getOrDefault.refmapentry in the production fabric jar — fabric-loom only injects the refmap key into configs of the module it remaps, and the Forge path gets it from the architectury transformer, so the shared config ended up refmap-less on fabric. In production (intermediary runtime) mixin then resolved members by mojmap names and crashed on the first accessor. Dev runs never catch this, because the dev runtime uses named mappings. The config now references the refmap explicitly.fabric-apiwas never declared infabric.mod.json— a missing Fabric API crashed withNoClassDefFoundErrorat entrypoint instead of a clear loader message.Build changes
universalJaris removed. It made sense in the Forge+Spigot era, where each platform had its own class tree and both could live in one file. With the shared module referencing Minecraft classes directly, the fabric and forge production jars contain the same class names remapped differently (intermediary vs SRG) — they cannot be merged. Each platform ships its ownbuild/distributions/otg-<platform>-<version>.jarvia the existingcopyPlatformJar, which is also what CurseForge/Modrinth expect.:platforms:forge:runServeretc.) needed three pieces of plumbing, documented in the commit message: the shared module reaches the modlauncher game layer via thedevelopmentForgeconfiguration (its mixin configs are unreadable otherwise); the common library modules split java packages between each other, which the JPMS module layer rejects, so for dev runs they're merged into one jar with an explicitAutomatic-Module-Name; andjackson-module-jsonSchemais excluded from dev runs (its module graph requiresvalidation.api, and production jars don't ship it either). Production jars are unaffected by all of this.Testing
Manually tested in-game (world creation with the "DefaultPreset" world type, OTG terrain generation confirmed) on MC 1.20.1 with:
Also smoke-tested as dedicated servers on fabric and forge dev runs (
level-type=otg:default_preset, spawn chunks generated by OTG,otg:otggenerator confirmed inlevel.dat, no log errors).Built against: Forge 1.20.1-47.2.1, fabric-loader 0.16.14, fabric-api 0.92.6+1.20.1 (unchanged), Java 17.
Notes for review
LICENSE.txt/README.txtwere Forge MDK boilerplate (Minecraft Forge's own LGPL notice), not OTG's license, and were removed with the module. The project license at the repo root covers everything.