Skip to content

[1.20.1] Add Forge/NeoForge 1.20.1 support via a shared platform core and fix misc bugs - #1022

Open
jmcjm wants to merge 13 commits into
PG85:1.20.1from
jmcjm:1.20.1-forge
Open

[1.20.1] Add Forge/NeoForge 1.20.1 support via a shared platform core and fix misc bugs#1022
jmcjm wants to merge 13 commits into
PG85:1.20.1from
jmcjm:1.20.1-forge

Conversation

@jmcjm

@jmcjm jmcjm commented Jul 13, 2026

Copy link
Copy Markdown

What this does

This PR replaces the abandoned 1.17-era platforms/forge module 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 in platforms/fabric actually 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 — including RegistryLoaderMixin, which does all the world/biome/dimension registration) moved to the already-existing-but-empty platforms/shared architectury common module. Fabric* classes were renamed to Shared*.

platforms/fabric is 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/forge is a new thin wrapper (~120 LOC): @Mod entrypoint with DeferredRegister for the biome source and chunk generator codecs, RegisterCommandsEvent / LevelEvent.Save wiring, ForgeEngine, mods.toml. Registry ids match fabric (otg:otg), so worlds are portable between loaders.

Bugs found and fixed along the way

  • Bundled DefaultPreset never unpacked — the resources folder was renamed DefaultDefaultPreset at some point, but OTGEngine still scanned the jar for resources/Presets/Default/, so first start extracted nothing and no OTG world preset ever appeared. Constants.DEFAULT_PRESET_NAME pointed at the old name too.
  • World presets showed the raw translation key (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 on Language#getOrDefault.
  • Shared mixin config had no refmap entry 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-api was never declared in fabric.mod.json — a missing Fabric API crashed with NoClassDefFoundError at entrypoint instead of a clear loader message.

Build changes

  • universalJar is 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 own build/distributions/otg-<platform>-<version>.jar via the existing copyPlatformJar, which is also what CurseForge/Modrinth expect.
  • Forge dev runs (:platforms:forge:runServer etc.) needed three pieces of plumbing, documented in the commit message: the shared module reaches the modlauncher game layer via the developmentForge configuration (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 explicit Automatic-Module-Name; and jackson-module-jsonSchema is excluded from dev runs (its module graph requires validation.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:

Loader Version
Forge 47.4.21
NeoForge 47.1.106
Fabric Loader 0.19.3, Fabric API 0.92.6+1.20.1

Also smoke-tested as dedicated servers on fabric and forge dev runs (level-type=otg:default_preset, spawn chunks generated by OTG, otg:otg generator confirmed in level.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

  • The commits are ordered so each builds standalone: shared extraction first (no functional change, fabric jar contents stay equivalent), then the forge platform, then the fixes.
  • The old forge module's LICENSE.txt/README.txt were 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.
  • Client GUI code from the old forge module (preset customization screens) was not ported — it predates the fabric-first architecture and had no fabric equivalent to share. World creation works through the vanilla world-type selector, same as on fabric.

jmcjm added 8 commits July 13, 2026 20:44
…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.
@CLAassistant

CLAassistant commented Jul 13, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@jmcjm jmcjm changed the title Add Forge/NeoForge 1.20.1 support via a shared platform core [1.20.1] Add Forge/NeoForge 1.20.1 support via a shared platform core Jul 13, 2026
@jmcjm jmcjm changed the title [1.20.1] Add Forge/NeoForge 1.20.1 support via a shared platform core [1.20.1] Add Forge/NeoForge 1.20.1 support via a shared platform core and fix misc bugs Jul 13, 2026
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.
@jmcjm

jmcjm commented Jul 14, 2026

Copy link
Copy Markdown
Author

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

@authvin

authvin commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

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.
@jmcjm

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.
@jmcjm

jmcjm commented Jul 15, 2026

Copy link
Copy Markdown
Author

Pulled the DefaultPreset unpack out of this PR. OTGEngine is back to its base state, so this PR no longer touches the unpack path at all. The one thing I kept is DEFAULT_PRESET_NAME = "DefaultPreset": that constant isn't unpack logic, it's what LocalPresetLoader.getDefaultPresetFolderName() reads to pick the default among the loaded presets, and the bundled folder is Presets/DefaultPreset. Reverting it to "Default" would make the containsKey check miss and fall back to an arbitrary preset once more than one is on disk, which has nothing to do with unpacking. So the unpack change is gone and that one-line rename stays correct.

Reworked the lang mixin so loaded language files win. It was overriding getOrDefault at HEAD unconditionally, which meant the runtime preset name shadowed anything a language file provided. It now guards on has(key) — if any loaded language already has the key it steps aside, and the runtime map only fills keys nothing else provides.

I checked that against the preset packer specifically, since you asked: WorldPacker's lang uses biome.otg.<preset>.<biome> keys, and the runtime map only ever holds generator.otg.<name> (world-preset names), so there's no overlap to begin with - packed biome names were never touched by the mixin. And now even if a lang file did define a generator.otg.* key, it wins over the runtime name. Preset names still show up in the selector because those keys ship in no static lang file, which was the whole reason for the mixin.

jmcjm added 2 commits July 18, 2026 19:46
… 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.
@jmcjm

jmcjm commented Jul 18, 2026

Copy link
Copy Markdown
Author

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.

./gradlew build mergeJars now produces build/forgix/OpenTerrainGenerator-<version>-fabric-forge.jar alongside the regular jars. The merge is opt-in — plain build and the copyPlatformJar per-loader artifacts are unchanged.

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 <Name>_fabric / <Name>_forge with all references rewritten via tiny-remapper, and the mixin config is split per loader. Each loader only ever loads its own copies: fabric.mod.json points into the fabric set, mods.toml/MixinConfigs into the forge set. Only 47 of ~2000 classes actually differ, so the merged jar is 4.44 MiB vs 4.21 + 4.21 separate.

What it took:

  • Toolchain bump: Gradle 9.4.1, architectury-loom 1.17.491, architectury-plugin 3.5.169, shadow 8.3.9, JDK 24 build daemon (pinned via gradle-daemon-jvm.properties; the foojay resolver auto-provisions it). Loom 1.17 is load-bearing beyond the merge: it statically remaps mixin bytecode per platform with no refmaps, which also sidesteps the loom 1.8–1.11 regression that leaves @Shadow members unmapped and crashes production fabric at boot.
  • Two merge gaps patched in the build: loom 1.17 makes the shared mixin config byte-identical in both jars, so Forgix would dedupe it into a single config that can't serve either loader's renamed classes — each platform's shadowJar stamps a loader marker into its copy so the merge splits them. And Forgix misses the bare class names inside mixins/client lists, so a fixMergedMixinConfigs task suffixes them post-merge and hard-fails the build if any entry doesn't resolve to a class actually in the jar.
  • One real bug flushed out (second commit): javac's synthetic $SwitchMap inner class from the enum switches in RegistryLoaderMixin got renamed to RegistryLoaderMixin$1_fabric, which breaks Mixin's Outer$Inner companion-class detection → IllegalClassLoadError on world creation. Fix: registry bootstrap moved verbatim into a plain OTGRegistryHelper class outside the mixin package, where the renames are harmless. The mixin is now a thin @Inject with an unchanged injection signature.

I have tested it on Fabric, Forge and NeoForge.

There is the universal jar (it is zipped because of gh file upload restrictions):
OpenTerrainGenerator-0.2.0-dev16-fabric-forge.zip

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants