Skip to content

feat: NeoForge support#8480

Draft
obus-globus wants to merge 16 commits into
CCBlueX:nextgenfrom
obus-globus:feat/neoforge-support
Draft

feat: NeoForge support#8480
obus-globus wants to merge 16 commits into
CCBlueX:nextgenfrom
obus-globus:feat/neoforge-support

Conversation

@obus-globus

Copy link
Copy Markdown

Adds a NeoForge distribution as an additive neoforge/ Gradle module. The root project stays the Fabric project with the same build and dev workflow; all client code stays in src/main, nothing is duplicated per loader. ./gradlew :neoforge:build produces the jar; :neoforge:runClient boots a dev client with the full browser UI.

How it works

  • The Fabric-only sources moved to src/fabric (appended to the root main source set, compiled as before). The neoforge/ module includes src/main into its own source set.
  • A small Platform interface (ServiceLoader) abstracts the loader; checkLoaderPurity keeps loader imports out of src/main.
  • The shared liquidbounce.mixins.json is used unchanged. A handful of injectors target vanilla methods that NeoForge's patches reshape (renamed fields, added overloads, split or re-signatured methods); those live in per-loader companion configs that delegate to shared handlers.
  • The access widener stays the source of truth; a buildSrc task generates the NeoForge access transformer from it (with validateAccessTransformers on).
  • The NeoForge jar bundles the Kotlin runtime (no fabric-language-kotlin on NeoForge) and nests mcef-neoforge, plus everything the Fabric jar nests.

Fabric impact

The Fabric jar's file listing only gains the companion mixin config and classes, the Platform/FabricPlatform types, two duck interfaces and a lazy dispatcher class (and loses the HideAppearanceKt facade). ConfigSystem.rootFolder and two class-init points now resolve through the loader instead of the Minecraft instance, because NeoForge runs the registry bootstrap before the client exists; the resulting paths/behavior are unchanged in production.

Notes

  • On NeoForge, hiding the mod from the mod list and custom tab pagination degrade (NeoForge has no ModMenu/FabricCreativeModeTab equivalent); everything else is at parity.
  • In case NeoForge lags behind a Minecraft update, the module disables its own tasks with a warning until its version matches: Fabric is the more important target, so its build is never held up by NeoForge.
  • mcef-neoforge needs a published artifact (feat: NeoForge artifact mcef#24; currently mavenLocal); a NeoForge CI job follows once it is published.

Tested on both loaders: clean build, dev client boots, no mixin errors; buildSrc has tests for the access widener conversion.

@obus-globus

Copy link
Copy Markdown
Author

Heads up: this is still a draft. Some functionality is still being tested, and it depends on CCBlueX/mcef#24 (the mcef-neoforge artifact) being merged and published before CI can resolve it and go green. I'll mark it ready once that lands.

…code

Moves Fabric-specific code (ModMenu integration, FabricCreativeModeTab,
FabricLoader access) behind a ServiceLoader-discovered Platform interface
implemented in the new src/fabric source set. The loader-agnostic sources
in src/main are kept honest by a new checkLoaderPurity verification task.
Adds a :neoforge subproject built with ModDevGradle that compiles the
shared loader-agnostic sources in src/main against NeoForge, registers
its own Platform implementation and bundles the Kotlin runtime libraries
that fabric-language-kotlin provides on Fabric.

The access widener remains the single source of truth for member access:
ConvertAccessWidenerTask now also propagates widened methods to their
overrides, which is required because access transformers are applied to
sources, where javac rejects overrides with weaker access than their
super method.
Mixins whose vanilla injection points are patched away by NeoForge move
into per-loader companion mixin configs (liquidbounce-fabric.mixins.json,
liquidbounce-neoforge.mixins.json), with shared handler logic exposed
through duck interfaces where needed. Affected hooks: LocalPlayer#aiStep
mayfly read, Player#doSweepAttack and Player#getDestroySpeed (patched
into added overloads), Gui#extractHotbarAndDecorations (split into GUI
layers) and the screen extraction in GameRenderer#extractGui.

NeoForge runs the registry bootstrap thread before the Minecraft
instance exists, which the initialization path now tolerates: the config
system resolves the game directory through the new Platform#gameDirectory
instead of the Minecraft instance, the Minecraft coroutine dispatcher
resolves the instance on first dispatch instead of class initialization,
and the HTTP client no longer requires the MCEF class to be present.

Client resource reload listeners register through the Platform; NeoForge
collects them via AddClientReloadListenersEvent, falling back to the
direct reload when the event has already fired. Creative tabs are built
with a free top-row column on NeoForge to pass the position validation,
and the Kotlin runtime is available in NeoForge dev runs.
Nests the mcef-neoforge artifact the same way the Fabric jar includes
mcef, making the JCEF-based UI fully functional on NeoForge.

The MCEF class captures the Minecraft instance when it is loaded, so the
HTTP client handoff moves out of the HttpClient class initializer (which
NeoForge can run from the registry bootstrap thread, before the instance
exists) to the point where the browser backend initializes.
The NeoForge dev runtime now loads the NeoForge variants of the same
sodium, lithium, iris and ImmediatelyFast versions the Fabric dev
runtime uses. DiscordIPC's transitive Reflect dependency is constrained
to 1.6.2: the dev classpath copy wins over the 1.6.2 that
ImmediatelyFast bundles, and 1.6.1 does not support Java 25, crashing
the client during startup.

Resource reload listeners now take part in manual reloads (F3+T):
AddClientReloadListenersEvent registers a lazy wrapper per known
listener id, and the platform binds the actual listener as the
wrapper's delegate once the client registers it during startup. The
previous buffering approach could not register anything because the
event fires before the client start hook.

The access widener conversion handles records: component fields only
exist in class files, so their access transformers never applied to the
recompiled sources and are dropped, and a widened record class also
widens its canonical constructor, which must not keep a weaker access
level than the class. Access transformer validation is enabled to catch
such entries from future rebases.

git.properties is generated for the NeoForge module, so GitInfo reports
the real version and commit instead of "unofficial"/"unknown" in the
window title, logs and config metadata.
Resource reload listeners register through a single all-or-nothing
call, with the listener ids shared between the call site and the
NeoForge wrapper registration, so a partially registered state cannot
exist and new listeners cannot silently miss the NeoForge list. The
unused Platform#loaderName is removed and the service loading rejects
multiple implementations.

The NeoForge manifest accepts only the exact Minecraft version, like
the Fabric manifest: the mixins do not survive game updates. Jar
deletion uses Files.deleteIfExists, which actually reports failures,
and building the creative tab no longer assumes that a top-row tab
exists.

The NeoForge module is now covered by detekt and writes git.properties
to a dedicated directory: generating into the resources output overlaps
with processResources' outputs, which Gradle cleans up as stale when
tasks re-execute. The access widener conversion is covered by tests.
…ersion

NeoForge releases lag new Minecraft versions, so right after a
Minecraft update the shared sources are not compilable for NeoForge.
Disabling the module's tasks keeps the build green during that window;
NeoForge distributions are built from the last commit where the
versions still aligned.
NeoForge pre-releases carry dotted suffixes (e.g. 26.1.0.0-alpha.1),
which the previous parse turned into 26.1.0.0-alpha and misread as a
version mismatch. Taking the first three components handles both
shapes.
…dule

Mirrors the root project, which removed -Xexplicit-backing-fields and
-Xcontext-parameters with the update to Kotlin 2.4.
Upstream nextgen advanced to Minecraft 26.2, so bump the NeoForge subproject
to the matching NeoForge 26.2 beta and the 26.2 dev-runtime compat mods
(Sodium, Lithium, ImmediatelyFast). Iris has no NeoForge build for 26.2 yet,
so it is dropped from the NeoForge dev runtime until it is published; the
Fabric side keeps it. The mcef dependency follows upstream's 3.3.2-26.2
snapshot (mcef-neoforge published from the matching mcef branch).
… changes

Minecraft 26.2 reshaped several vanilla targets, and NeoForge patches some of
them into different shapes than Fabric, so the loader-specific companions are
re-derived against the 26.2 NeoForge-patched jar:

- Gui -> Hud split: the HUD hooks (overlay start, exp-level tweak) moved to the
  new Hud class. The shared mixin is renamed accordingly and the companions
  (MixinGui -> MixinHud) now target Hud; NeoForge still splits the hotbar and
  experience extraction, so it keeps its own anchors.
- Screen render: upstream now fires ScreenRenderEvent from Gui#extractRenderState.
  NeoForge routes that screen extraction through ClientHooks#extractScreen, so
  the hook is split out of the shared client MixinGui into per-loader companions
  (Fabric anchors the vanilla Screen#extractRenderStateWithTooltipAndSubtitles
  call, NeoForge the ClientHooks#extractScreen call). This replaces the old
  GameRenderer#extractGui companions, which upstream removed.
- Slime bounce: NeoForge moves Entity#restituteMovementAfterCollisions into an
  added BlockPos overload, so the Math#max hook is split out of the shared
  MixinEntity into per-loader companions.

Both loaders boot to the LiquidBounce UI on Minecraft 26.2 with no injection
errors; an offline descriptor-exact scan of the shared mixins against the 26.2
NeoForge-patched jar reports only the known lambda false positive.
@obus-globus obus-globus force-pushed the feat/neoforge-support branch from 86f8aec to 3cc26a8 Compare July 1, 2026 14:12
…r change

NeoForge inserts a self-position BlockPos into the ModelBlockRenderer
shouldRenderFace overload that the block tesselation path calls, so the shared
@ModifyReturnValue captured-argument signature no longer matches on NeoForge
(it crashes when the first chunk renders). Move injectXRayDrawSide out of the
shared mixin into per-loader companions: Fabric keeps the vanilla 4-argument
overload, NeoForge targets the 5-argument overload explicitly. Verified in-world
on both loaders (chunks render, no InvalidInjectionException).
A per-mixin audit against the NeoForge-patched 26.2 jar (which is stripped of
local-variable names, unlike the Fabric mojmap jar) found three shared hooks
that misbehave on NeoForge:

- MixinFireworkRocketEntity#hookExtendedFirework captured two Vec3 locals by
  name; with names stripped and multiple Vec3 candidates in scope the @Local is
  ambiguous on NeoForge (throws when a firework is ticked). Switch to ordinal
  capture (0 = lookAngle, 1 = movement), which is stable across both loaders.
- MixinLivingEntity#hookEquipmentChange and MixinBlock#injectXRay used bare
  method selectors that also match NeoForge-added overloads (setItemSlot(...,
  boolean) and shouldRenderFace(BlockGetter,BlockPos,...)), double-firing the
  equipment event / double-applying the XRay result. Qualify both selectors to
  the vanilla descriptor so they bind exactly one overload on each loader.

Verified: both loaders still boot and enter a world with zero injection errors;
the rest of the 156-mixin audit came back clean.
…hed jar

Adds a MixinDivergenceCheckTask (buildSrc, ASM) that verifies every shared
mixin injector against the NeoForge-patched Minecraft jar at build time, so the
class of bug where NeoForge reshapes a vanilla method and an injector silently
breaks (only crashing in-world) is caught in CI instead. Wired into
:neoforge:check.

It reports BLOCKERs (selector resolves to no method; an @at INVOKE/FIELD/NEW
ref is absent from every matched overload; or an @Inject/@ModifyReturnValue/
@ModifyExpressionValue handler's captured args are not a valid prefix/suffix of
a matched overload — the exact InvalidInjectionException trigger) and advisory
SUSPECTs (bare selectors that match a NeoForge-added overload; name-based @Local
that cannot bind by name on the LVT-stripped patched jar). BLOCKERs fail the
build; SUSPECTs are logged unless -Pstrict. Includes buildSrc unit tests for the
prefix/suffix compatibility detection.
@Coerce

Addresses defects found in adversarial review of the previous commit:

- The @Local(name=...) rule was built on a false premise ("the patched jar
  strips local-variable names"). The NeoForge-patched 26.2 jar actually retains
  LocalVariableTables with names, so the rule flagged 44 false suspects. The
  check now reads the target method's real LVT and only flags a name-only @Local
  when the name is genuinely absent (or the type fallback is ambiguous). Suspect
  count drops from 48 to 3 (all legitimate multi-overload advisories).
- @Coerce params in the middle of a captured-arg sequence were dropped instead
  of kept as positional wildcards, which could emit a spurious BLOCKER on a
  valid injector once someone uses interior @Coerce. They are now treated as
  wildcards that match any target type at their position.
- Added an @OutputFile report so the task is cacheable / UP-TO-DATE instead of
  re-running every build.

Positive control still fails the build on the real ModelBlockRenderer suffix
mismatch; buildSrc tests cover the @Coerce case.
…replaces them

On Fabric the status-bar HUD component cancels Hud#extractPlayerHealth (via the
shared mixin) so the vanilla health/hunger/armor/air are hidden and only the
client's styled bars render. On NeoForge that aggregator is never called — the
status bars are registered as individual GUI layers that invoke
extract{Health,Armor,Food,Air}Level directly — so the vanilla hearts and hunger
were still drawn underneath the client's bars. Cancel those four layer methods
in the NeoForge companion under the same DISABLE_STATUS_BAR tweak. Verified
in-world: NeoForge now renders only the styled bars, matching Fabric.
@obus-globus obus-globus force-pushed the feat/neoforge-support branch from 950510b to 468ab3e Compare July 2, 2026 07:09
@obus-globus

obus-globus commented Jul 2, 2026

Copy link
Copy Markdown
Author

Rebased onto nextgen (MC 26.2). Both loaders build and boot to the LiquidBounce UI and into a world — side-by-side of the Fabric vs NeoForge dev client on this branch:

lb-sidebyside.mp4

Known limitations, all from 26.2 being new — none affect the Fabric build:

  • NeoForge beta: no stable 26.2 NeoForge yet, so it targets 26.2.0.7-beta. The version-skew guard disables the module whenever NeoForge lags MC, so it can never break Fabric.
  • Iris: no 26.2 NeoForge build yet, so it's left out of the NeoForge dev runtime (Sodium/Lithium/ImmediatelyFast have 26.2 builds and are wired in).
  • Protocol translation is Fabric-only: the Via integration is written against ViaFabricPlus and guarded by isModLoaded("viafabricplus") (always false on NeoForge). The NeoForge equivalent, ViaForge, hasn't updated past 1.21.11 yet, so there's nothing to wire up.
  • MCEF: mcef-neoforge isn't published for 26.2 upstream yet — CI stays red until it's on maven.ccbluex.net.

@obus-globus

Copy link
Copy Markdown
Author

Trying it locally

The only piece not yet on maven.ccbluex.net is mcef-neoforge, so there's one extra step to publish it into ~/.m2 first — no LiquidBounce-side edits needed (the build already reads mavenLocal(), which is stock upstream config).

# 1. Publish the missing dep (mcef-neoforge 26.2)
git clone -b neoforge-26.2 https://github.com/obus-globus/mcef.git
cd mcef && ./gradlew :neoforge:publishToMavenLocal && cd ..

# 2. Clone this branch and run
git clone -b feat/neoforge-support https://github.com/obus-globus/LiquidBounce.git
cd LiquidBounce
./gradlew :neoforge:runClient      # NeoForge
./gradlew :runClient               # Fabric (step 1 not required)

Needs JDK 21+. Step 1 drops mcef-neoforge:3.3.2-26.2-SNAPSHOT into ~/.m2; skipping it makes :neoforge:runClient fail on that one unresolved dependency and nothing else. Once mcef-neoforge is published upstream for 26.2, even step 1 goes away.

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

Labels

conflict PR has merge conflicts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants