Files
ForgeMultipart/docs/migration

Java migration plan and audit findings

Status: living document
Initial audit: 2026-08-14
Audited revision: f10595d (master)

Decision summary

Converting ForgeMultipart to Java is realistic. It should be treated as a staged compatibility migration, not as an automatic source conversion or a ground-up rewrite.

The main difficulty is preserving behavior and compatibility, not translating Scala syntax. In particular, ForgeMultipart dynamically builds TileMultipart subclasses from combinations of part capabilities, and its published bytecode exposes Scala types and Scala compiler artifacts to other mods.

The current direction is:

  • Preserve observable behavior unless a divergence is an intentional, documented bug fix or performance fix.
  • Deliver a complete, documented Java API for GTNH consumer integrations, reusing the existing Java surface.
  • Keep compatibility bridges for the old Scala-facing API while consumers migrate; deprecate each entry only with a usable replacement. Remove FMP's remaining Scala implementation and dependencies after the consumer and internal removal gates pass.
  • Use the Hodgepodge/GTNH convention for any new Sponge Mixins: UniMixins plus explicit normal, early, and late registration where the target lifecycle requires it.
  • Allow GTNHLib as a dependency. Use fastutil selectively for measured primitive-collection or allocation problems, not as a blanket collection replacement.
  • Keep ForgeMultipart's runtime composite-tile generator separate from the Sponge Mixin decision until it is proven replaceable. They solve different problems.

No trustworthy tool will produce a maintainable Java port automatically. A decompiler can help recover control flow or compare generated bytecode, but its output should be used as a reference rather than committed as the implementation.

Current status

Work continues on algent/java. The low-coupling queue, core part/tile types, registries, handlers, factories, placement/render helpers, both generators and all built-in tile traits are Java. Common, face, corner, edge, post, hollow and occlusion microblock traits retain Scala inheritance/state/super declarations over Java behavior, including client bounds/mask updates. StackAnalyser initialization and constant-type classification delegate to Java; retain its coordinated Scala class/companion/models. FieldMixin.accessName also delegates to Java while retaining its case class. MixinInfo.linearise and ScalaSignature.Bytes.section delegate traversal and array-copy behavior to Java. TypeRef.jName, TypeRef.jDesc and TMethodType.jDesc delegate name/descriptor conversion and method assembly while preserving virtual lookup and evaluation order. ClassSymbolRef.jInterfaces delegates ordered interface-name mapping while retaining its Scala List contract. Class and method symbol formatting also delegate to Java. The active priority is completing the consumer-facing Java API and migration documentation in Phases 2, 9 and 10. Further compiler/model extraction must unblock that milestone or address a demonstrated issue. Java-trait rewriting and compiler startup also delegate to Java helpers. Abstract Java mixins and Java-path side-only filtering are complete; multiple Scala-trait inheritance still needs its metadata. Retain trait state/accessor/super bridges and the compiler/signature/analyser model shells.

Start with the working handoff for the exact source/test baseline, workflow and Java-source limitations. Read both the ABI inventory and consumer audit before changing compatibility surfaces. Completed-target evidence is in the history; the divergence ledger records only effective compatibility differences.

The source-only consumer guards, ordered tile lifecycle/NBT/packet fixtures and generated Scala/Java trait fixtures are in place. Schematica's private registry-map view is restored. The tile equality/callback regressions and the generated client-render dispatch crash are fixed; the user confirmed placement no longer crashes. Broader manual client checks and full-pack profiling remain release gates.

Focused traversal/redstone allocation improvements are recorded in the profile. GTNHLib/UniMixins remain conditional on concrete need. Removing Scala from FMP remains the final target, gated by released consumer migrations and retirement of FMP's own Scala-dependent implementation.

Remaining work overall

Current checkpoint: the core runtime implementation is Java; all ten Phase 9.1 API-table entries and Phase 9.2's internal-boundary documentation are complete. Nine Scala files / 747 nonblank lines remain, plus Scala-dependent storage and compatibility references in Java. A source-file count is not a measure of the remaining migration effort. The phase checklists include recurring verification rules, historical work and conditional ideas; this is the active queue.

Workstream Remaining deliverables Gate
Complete the supported Java surface Physical-client validation of the documented illuminated microblock example; keep the completed audited reflection/legacy-use map current as consumer revisions change Direct typed calls and documented override behavior are available for the identified FMP-side gaps; physical-client evidence remains
Migrate consumers and adopt releases Patch GuideNH/Schematica to their documented APIs; migrate ProjectRed's external trait and other Scala/helper users, including OpenComputers, ProjectBlue and ForgeRelocationFMP; direct Galacticraft integration; Et Futurum/Iguana migrations; UtilitiesInExcess enumeration and mat/material fix; complete the full adoption ledger beyond these hotspots Source patches, released versions and actual target-pack jars must all be recorded; reference checkouts are not migrated
Measure and improve performance Fresh realistic profiles, ranked candidates, bounded improvements and repeated paired measurements; distinguish FMP implementation gains from migrated-consumer gains and report variability/regressions Phase 4b starts when API/extension workloads are stable; it can overlap consumer adoption
Validate releases Complete client rendering/particles/lighting/interaction and integration checklist; old-world, multiplayer, movement and preview checks; packaged/obfuscated artifacts on supported runtimes; optional-mod absent/present loading; actual patched-world MCPC hook where supported Automated Forge tests do not replace physical-client/full-pack evidence; repeat affected checks after consumer migration and Scala removal
Prepare the repository for merge Move Java sources where joint compilation permits; reassess forced Scala compilation/version freshness; refresh README; organize durable migration docs; expand modern Java syntax only across verified compilation boundaries Preserve packaged Java 8 compatibility and document any deferred source/toolchain constraints
Remove FMP's Scala requirement Replace remaining trait/model shells, Scala storage and internal users; retire eligible legacy bridges/signature decoding; remove Scala compiler/runtime dependencies; scan shipped/generated classes and validate migrated consumers Last milestone: consumer releases/adoption and removal policy first, then internal cleanup and final release validation

Extra Utilities remains an active supported consumer. UtilitiesInExcess is the intended replacement, but changing the support target awaits approval and actual pack adoption. Keep Extra Utilities compatibility until then; also verify that the replacement has migrated the relevant FMP contracts before removing them.

GTNHLib/fastutil, UniMixins, a new generator design and further mechanical Scala-shell extraction are conditional work, not mandatory tasks to add without a concrete need. The existing generator is retained. See the detailed phases below, consumer adoption ledger, manual checklist and performance protocol.

Scope and compatibility target

“Behave the same” means preserving the following unless a change is explicitly entered in a divergence log:

  • Multipart placement, removal, occlusion, collision, selection, ticking, redstone, rendering, and scheduled-tick behavior.
  • Client/server separation and Forge/FML lifecycle timing.
  • Part and tile registration order and lookup behavior.
  • Part identifiers, NBT keys and structures, saved-world loading, and conversion of existing multipart tiles.
  • Packet identifiers, field order, encoding, dispatch timing, and client update behavior.
  • Dynamically generated tile interfaces and capabilities expected by parts and integrations.
  • Public class names, method descriptors, static entry points, and reflective lookups that supported downstream mods use.
  • Behavior in both development and obfuscated production environments.

Allowed divergences are:

  • Confirmed bug fixes.
  • Removal of avoidable allocation, boxing, exception-based control flow, or other performance problems.
  • Better failure messages where success-path behavior is unchanged.

Each divergence should record the old behavior, new behavior, reason, compatibility impact, and how it was manually verified. Unintentional behavior changes are regressions even if the new implementation appears cleaner.

Java runtime policy

The main GTNH release targets modern Java across the Java 17–26 range. A Java 8 artifact must remain available for legacy launchers, with the same functional behavior even if it cannot match the modern runtime's performance.

  • Prefer modern Java syntax where it makes the code easier to read and the compilation path supports it. Java 8 artifact compatibility does not require Java 8 source syntax everywhere.
  • Reuse the scoped JVM Downgrader path established in 5f0e329b; extend it per source unit when justified by a concrete readability improvement, with normal and clean-build verification. Do not enable modern compilation globally while it breaks retained Scala compilation.
  • Java 8 startup, serialization, networking, registration, and gameplay semantics are compatibility requirements. Equal throughput or allocation behavior between Java 8 and modern Java is not required.
  • Validate routinely in the Java 8 deobfuscated Forge runner. Validate the packaged release in a representative modern-Java server before release, because the current GTNH deobfuscated runServer17/runServer21/runServer25 tasks do not provide a working dedicated-server path in this checkout.
  • Avoid modern runtime APIs whose downgrader stubs are unavailable or whose semantics materially differ on Java 8, especially in compatibility-critical and hot code.

Modern Java readability policy

Commit 5f0e329b (build: support scoped modern Java compilation) is an implemented precedent, not just a proposed experiment. It replaced the instruction-type dispatch in StackAnalyserLogic.visitInsn with a Java 21 pattern switch. A JDK 25 task compiles the helper with --release 21; JVM Downgrader supplies Java 8 bytecode to tests, Forge and both packaged jars. Scala 2.11.5 and joint Scala/Java compilation stay on Java 8. The checkpoint passed normal/clean builds, frozen consumers and Forge tests with unchanged retained Scala classes and generated tile dumps. The arrangement, eligibility rules and limits are below; per-batch verification records are in the history.

  • Prefer clearer pattern matching, switch expressions and local type inference where supported and appropriate; retain explicit types or ordinary control flow when they communicate the intent better.
  • Apply readability improvements during relevant work or in focused follow-ups. They need not wait for consumer migration or complete Scala removal, but a broad syntax rewrite is not a prerequisite for delivering the Java API.
  • Preserve behavior, virtual dispatch, evaluation order, public descriptors and generated-class contracts. Modern declarations such as records/sealed types are not automatic replacements for compatibility-sensitive models.
  • Distinguish syntax from library APIs: syntax may downgrade without a runtime dependency, whereas a newer API can require runtime stubs or change behavior. Verify that separately before adopting it.
  • When the old Scala parser, circular compilation, a later compilation stage, or downgrader/runtime support blocks a change, keep the readable working form and defer that specific modernization. Record the concrete blocker and what would unblock it in the handoff; do not introduce fragile compilation workarounds solely to change syntax.
  • Keep enableModernJavaSyntax = false while global mode moves Scala 2.11.5 onto an incompatible toolchain. Expand the explicit modern source set only after declaration visibility, compile order and downgraded output are verified.

For a new modern source unit or syntax feature, use the existing characterization/compatibility checks and verify the clean compilation path, packaged Java 8 output and relevant Forge behavior. This is a readability preference with a compatibility gate, not an obligation to modernize every file immediately.

Build arrangement

  1. Normal Java and joint Scala/Java compilation remain on Java 8. compileScala excludes the modern helpers from javac's inputs, while scalac resolves their declarations through -sourcepath. This handles the circular Scala references without asking Java 8 javac to compile the modern method bodies.
  2. compileModernJava uses JDK 25 with --release 21, against the fresh Java and Scala output directories.
  3. downgradeModernJava converts the helpers to Java 8. Only the downgraded directory joins the main class outputs, so tests, Forge, dev/reobfuscated jars and downstream compilation receive Java 8 bytecode.

build.gradle keeps a single modernJavaPaths list that both selects the modern sources and excludes them from compileScala; do not reintroduce a second copy. An include without a matching exclude fails loudly at the Java 8 joint compile, but an exclude without a matching include means the file is never compiled at all and nothing reports it. The -sourcepath declaration is an explicit Scala-task input, scalaCompileOptions.force = true prevents stale joint-compiled Java annotations after generated Tags.VERSION changes, and outputs.dirs(outputMap.values()) declares the downgrade output for clean builds.

This per-file routing is temporary. These Java files still live in the Scala source set, whose joint compiler would otherwise send them to Java 8 javac. Once the retained Scala/Java dependency cycle is gone, move the modern cohort behind one source-set or directory boundary; once Scala is gone, compile all Java with the modern toolchain and drop the exclusions, -sourcepath bridge and force guard together.

Use Java 21 as the source ceiling. The downgrader can accept Java 22 bytecode, but the validated task uses --release 21 and Java 22 does not add enough here to justify moving the boundary.

Eligibility: what may move and what may not

A disposable experiment compiled all 224 Java files under src/main/scala through the Java 21 task. Everything compiled, downgraded and packaged, but one characterization test failed: a missing standalone T string constant after the newer javac changed its string-concatenation shape. That is why sources move in small reviewable batches rather than all at once. Even source-identical classes can acquire observable bytecode differences.

  • Prefer package-private helpers called directly by retained Scala. The eight current members of the cohort are the proven shape: pattern variables and switch expressions that remove casts without touching Scala-facing declarations.
  • Rejected, and should stay rejected without a new reason: RedstoneInteractions$ pulls its facade and then the transformer-sensitive registered TRedstoneTile; BlockMultipart immediately pulls its companion, cache, event handler, server proxy and renderer resolver; RenderPartResolver cannot move alone because joint-compiled MultipartRenderer$ calls it before the modern task runs. Small cast cleanups do not justify widening the boundary.
  • Deferred: registered Java trait inputs under scalatraits/, whose transformer forbids or rewrites inner classes, lambdas, string switches and primitive-array allocation, so syntax changes need transformer-specific fixtures; the src/main/java bootstrap sources and src/mixin/java, which are not outputs of downgradeModernJava; and PostMicroblockTraitLogic, whose explicit Microblock cast is required because the retained Scala declaration does not expose getShape() through PostMicroblock.
  • Keep Scala companions, $class helpers and descriptor-preservation facades conservative. Their binary shape is worth more than stylistic uniformity. ScalaTraitRegistration should not move merely to restyle erased Some checks.

Each batch must compare ABI, class inventory, generated ASM output and relevant behavior. Before declaring the branch ready for consumer migration, verify every packaged class is version 52, that no unexpected downgrader API reference exists, and run the JVM and Forge suites on Java 8 plus packaged smoke tests on the preferred modern runtime.

fastutil

GTNHLib contributes it.unimi.dsi:fastutil to compileClasspath only, because GTNHLib is compileOnly. Using fastutil from ordinary FMP code would turn an optional relationship into an undeclared runtime requirement. Do not do that implicitly; require GTNHLib/fastutil explicitly first if a measured core use justifies it.

No current candidate justifies it. JInventoryTile's slot lists are small and it is a transformer-sensitive registered trait; both registries' nameMaps are small lifecycle-built maps whose missing-key behavior is deliberately characterized; ControlKeyModifer.map is published as a live Map; and TileCache could only become primitive-keyed by replacing BlockCoord, which is an API redesign rather than a substitution. PacketScheduler is the one plausible future candidate because its private collection boxes long masks, so a later change can preserve the consumer ABI. Revisit it only as separately profiled work with a dedicated semantic test and an explicit runtime dependency decision. Fastutil adoption is out of scope for the Java conversion.

Audit findings

Codebase size

At the audited revision:

Area Scala files Approximate Scala lines
Multipart core 38 4,076
Multipart ASM 8 1,933
Microblocks 28 3,013
Total 74 9,022

There are also 19 Java source files with approximately 1,148 lines. The existing README explicitly describes the API as Java-friendly, and the bundled Minecraft multipart implementation already serves as a Java consumer of it.

The current Scala source compiles successfully with Scala 2.11.5. This gives the migration a working reference implementation rather than requiring recovery of a broken codebase.

Reference classfile inventory

The compiled mod contains 635 class files and is approximately 1.64 MiB before its Scala runtime dependency. Compiler-generated artifacts include:

  • 236 anonfun closure classes, approximately 448 KiB.
  • 39 trait helper $class files, approximately 98 KiB.
  • 121 object/companion-related class files, approximately 415 KiB.

These figures support a maintainability and allocation argument for Java, but not the claim that Scala wraps most methods in try/catch.

A scan of 5,126 Scala methods found 52 methods with exception tables and 53 total entries. The important groups were:

  • 33 catch-all/finally handlers, mostly compiler machinery for synchronization and lazy initialization.
  • 9 scala.runtime.NonLocalReturnControl handlers caused by return from inside closures.
  • 11 ordinary exception handlers covering Exception, ClassCastException, IllegalStateException, and IOException.

The 222 methods emitted from the Java sources had no exception-table entries in the same scan.

Exception-table metadata does not impose a meaningful steady-state cost merely by existing. The nine non-local-return sites are different: when the early return is taken, Scala implements it by throwing and catching NonLocalReturnControl. Those sites are worth removing, but they do not justify assuming exception overhead across the whole mod.

More credible performance risks

The source and bytecode audit identified these allocation patterns; the named ports have now resolved them:

  • The earlier Java TileMultipart.updateEntity() traversed through operate(Function1), whose parts() helper materialized an ArrayList, backing array, Scala iterator and Java-conversion wrappers per call.
  • The former Scala TRedstoneTile queries created closures and scala.runtime.IntRef boxes; the Java trait now walks the normal immutable list without allocation while preserving an arbitrary-Seq fallback.
  • Several Scala 2.11 collection, range, and JavaConversions paths create closure objects, boxed primitives, iterators, or temporary collections.
  • The former TSlottedTile.canAddPart and MicroRecipe scans used exception-based non-local returns; both are now ordinary Java loops.
  • Rendering code in TileMultipart already uses cached arrays and while loops in important paths. It should be preserved as evidence of deliberate optimization, not mechanically rewritten into streams or higher-level collection pipelines.

The focused Forge/JFR baseline confirmed both risks. With eight parts, updateEntity and operate allocated about 184 bytes per call, while a three-query redstone iteration allocated 80.5 bytes. The completed ports reduce the normal paths to effectively zero measured allocation; these remain focused measurements, not whole-pack TPS. See docs/migration/README.md#phase-4b--measured-performance-pass.

Runtime code generation is architectural, not incidental

MultipartGenerator selects a tile class from the interfaces required by the parts in a block. ASMMixinFactory caches generated constructors by a trait bit set, so class generation is primarily a registration/first-combination cost rather than a per-tick cost.

ASMMixinCompiler already supports two inputs:

  • registerScalaTrait, which reads Scala signature data and understands Scala trait output.
  • registerJavaTrait, which transforms a concrete or abstract Java mixin class into the interface/static-helper form used by the generator.

The Java path rejects Java interfaces, inner classes, static initializers, and constructors with arguments. Abstract inputs retain their abstract methods as interface contracts while later mixins provide the implementation. Those restrictions must be accounted for when converting built-in tile traits, but the path proves that Java implementations can participate in the current composition system.

MultipartGenerator.registerPassThroughInterface also explicitly supports interfaces that generated tiles must expose. This is part of the compatibility surface and must not be lost in a static subclass rewrite.

The least risky plan is therefore to keep the current composition model during the first Java migration. Port or redesign its ASM implementation only after the rest of the behavior is stable.

Public ABI exposes Scala

Several public descriptors contain Scala runtime types. Examples include:

  • TileMultipart.partList exposing scala.collection.Seq through generated accessors.
  • TileMultipart.operate accepting scala.Function1.
  • Occlusion methods accepting Scala Seq values.
  • Tile conversion helpers returning scala.Tuple2.
  • MultiPartRegistry overloads accepting Scala Function2 or Seq, and returning Seq.
  • Scala object singletons (MODULE$), companion/static forwarders, and trait $class helpers.

Changing these descriptors directly to java.util or java.util.function types can cause NoSuchMethodError, NoClassDefFoundError, or reflective integration failures in already compiled mods. Source compatibility alone is not sufficient.

This means “all source is Java” and “the Scala runtime is gone” are separate milestones. Deprecated bridges may still require Scala library types even after the implementation is Java.

API migration design

Complete the existing Java API with equivalent capabilities, then make legacy entry points adapt to the shared Java implementation. Avoid two independently maintained implementations. Where an old entry is an overridable hook, preserve its virtual dispatch while it remains supported: moving an algorithm behind a new Java method must not bypass old subclass overrides. Define and test the dispatch path for each such pair, without recursive forwarding.

Direct consumer access

Supported integration tasks must be possible through direct, statically typed public calls, without reflection, private-field accessor mixins or Scala companion lookup. Reuse existing methods first; expose a missing capability only when an audited consumer needs it. Do not turn unrelated implementation details into public contracts. Optional integrations should isolate FMP-typed code behind mod-presence and supported-version checks; optionality alone is not a reason to require reflection. Validate both absent and present mod loading in consumer adoption. Existing reflective integrations remain supported during migration. Reflection snippets in the guides describe legacy interoperability options, not the intended completion point; consumer patches should use the typed examples.

Consumer API milestone and retained Scala stopping point

The first release milestone is a complete, documented Java API that every supported GTNH integration can migrate to, while the existing supported binaries continue to work. Counting remaining Scala declarations is not an exit criterion. A Java API may initially run over Scala-backed storage and retained compatibility shells.

  • Reuse existing Java classes, interfaces and entry points; add only the missing capabilities. No wholesale new facade or separate API artifact is required unless a concrete compilation or packaging constraint calls for it.
  • Cover extension contracts as well as calls: part/material subclasses, lifecycle and super dispatch, generated tile capabilities, microblock trait registration, and supported replacements for reflection into internals.
  • Document collection ownership and mutation, ordering, nulls, callback/reentrancy behavior, override hooks, registration timing, client/server restrictions, and persistence/network responsibilities where applicable.
  • Provide buildable Java examples using only the supported surface, with Forge fixtures for generated extensions. Use ProjectRed's illuminated microblocks to prove the Java extension path end to end; a Java-shaped signature alone does not establish a usable replacement. Check emitted consumer bytecode for legacy FMP references.
  • Scala-authored consumers can use this Java API without converting their whole mods to Java. The migration target is their dependency on FMP's Scala-facing contracts, including compiler-emitted trait helper calls.

Pause mechanical extraction of retained Scala models, accessors and trait declarations once their compatibility role is understood and tested, unless an extraction enables the Java API, fixes a demonstrated defect, or provides a measured benefit. Keep those shells supported during consumer migration. Resume their final replacement/removal when the relevant consumer gates pass; an internal dependency may still need implementation work at that point.

Execution order is complete and document the Java API → migrate and release consumers → adopt those releases in the target pack → retire FMP's Scala dependencies. Work can overlap by capability, but the removal gate cannot be skipped. Phases 9 and 10 now drive the remaining work; Phase 8 is the final gated milestone.

After the Java API and representative extension workloads are stable, run the focused performance pass in Phase 4b. It can overlap consumer migration and must not wait for final Scala removal. Attribute implementation gains and consumer migration gains separately; performance work does not relax any compatibility or adoption gate.

Candidate mappings, with final names and mutability contracts to be decided from existing behavior:

Existing Scala-facing shape Java-first equivalent
Seq<TMultiPart> List<TMultiPart> or a purpose-specific read-only view
Function1<TMultiPart, Unit> Consumer<TMultiPart> or direct indexed traversal methods
Function2 factory callbacks A named Java functional interface with domain-specific arguments and return type
Tuple2<TileMultipart, Boolean> A small immutable Java result class with named accessors
Scala collection overloads in registries Collection, List, arrays, or Java Iterable, according to actual ordering and mutation semantics

Bridge rules:

  • Preserve old JVM descriptors, not merely old source names.
  • Mark source-visible legacy APIs deprecated and point their documentation to the Java replacement.
  • Preserve ordering, nullability, mutability, exception, and callback timing semantics.
  • Keep legacy singleton/static entry points when downstream bytecode may reference them.
  • Do not leak new Scala types into the Java API.
  • Do not remove Scala library dependencies until an ABI inventory shows that no retained bridge needs them.
  • Use javap plus an API/ABI comparison tool such as japicmp or Revapi against the reference dev jar at every public-API phase.

Before implementing bridges, inventory real consumers in the target modpack. Some Scala compiler artifacts may be technically public but unused; others may be load-bearing despite looking internal.

Mixin strategy

Follow the pattern currently used by Hodgepodge for fixed class transformations:

  • Set the GTNH build convention's usesMixins = true, which supplies UniMixins integration and declares UniMixins as a required dependency.
  • Use a normal/pre-init mixin configuration for transformations that do not require special early or optional-mod timing.
  • Use an IEarlyMixinLoader configuration only for targets that must be selected before ordinary mod loading.
  • Use an @LateMixin/ILateMixinLoader configuration for optional-mod targets whose presence is known later.
  • Centralize selection through the GTNHMixins builder pattern, including side, loaded-coremod, loaded-mod, and configuration gates.
  • Keep early and late lists minimal. A mixin should not be early or late merely because those mechanisms are available.
  • Prefer Sponge Mixins for fixed transformations of known classes. Retain raw ASM only where a mixin cannot express the required behavior safely.

The inspected Hodgepodge reference uses:

  • HodgepodgeCore implements IFMLLoadingPlugin, IEarlyMixinLoader and delegates early selection to IMixins.getEarlyMixins(...).
  • HodgepodgeLateMixins, annotated with @LateMixin, implementing ILateMixinLoader and delegating late selection to IMixins.getLateMixins(...).
  • Separate normal, early, and late JSON configurations.
  • Explicit dependencies on GTNHMixins and UniMixins.

Important distinction: Sponge/UniMixins modifies known classes during class loading. ForgeMultipart's current generator creates different tile classes for runtime combinations of part interfaces. The Hodgepodge registration pattern should be used for ordinary patches introduced by this port, but UniMixins is not assumed to replace the composite-tile generator.

GTNHLib and fastutil

GTNHLib is an acceptable dependency for the port. The inspected GTNHLib checkout declares fastutil 8.5.18 as compileOnlyApi and uses its dependency-loader/GTNHExtLib arrangement for external runtime libraries. Hodgepodge consumes GTNHLib as an API dependency and imports fastutil through that environment.

Integration requirements:

  • Select the GTNHLib version aligned with the target pack/release train rather than copying Hodgepodge's current version blindly.
  • Verify both compile and production runtime resolution in ForgeMultipart's actual build after adding the dependency.
  • Add the Forge mod dependency declaration with a compatible version range.
  • Use primitive fastutil collections where they remove boxing or object-key allocation in a demonstrated hot path.
  • Prefer JDK collections for small, cold, or API-facing collections unless fastutil provides a concrete benefit.
  • Do not expose fastutil implementation classes from the stable public API without a strong reason; ordinary Java interfaces keep consumers decoupled.

Candidate areas to measure include redstone query state, coordinate-keyed caches, scheduler structures, registry lookups, and render/tile scratch collections. A primitive map is only useful if the key can be represented without first allocating a wrapper such as BlockCoord.

GTNHLib's ASM utilities may also reduce low-level boilerplate when the runtime generator is eventually ported. Evaluate them against the generator's exact needs instead of changing ASM APIs during the earlier source migration.

Migration phases

Every implementation phase follows the same test-first gate:

  1. Write characterization tests against the current Scala implementation for the area about to change.
  2. Run them on the untouched implementation and retain any golden fixtures they produce.
  3. Commit the tests separately from the port so the baseline is reviewable.
  4. Replace the implementation while leaving the tests unchanged.
  5. Require the same tests to pass before moving to the next area.

If a characterization test captures a confirmed bug that the port intentionally fixes, record it in the divergence log and change that expectation in a separate, explicit bug-fix step. This prevents accidental changes from being presented as fixes.

Phase 0 - Freeze and measure the reference behavior

  • Produce and retain a reference dev jar from f10595d or the selected migration base.
  • Dump the public/protected JVM API and important generated tile class shapes.
  • Inventory downstream mods that compile against or reflect into ForgeMultipart. See docs/migration/COMPATIBILITY.md.
  • Record source-level consumers of part IDs, NBT, packets, registration order, reflection, and lifecycle behavior.
  • Freeze representative part IDs, NBT trees, packet bytes, registration order, and lifecycle behavior as tests.
  • Add a Java-8-compatible JUnit/Jupiter setup to the existing Gradle test task.
  • Add a separate Forge integration-test source set/runner for behavior that cannot execute in a plain JVM.
  • Write the first characterization suite against the untouched Scala implementation.
  • Create a remaining manual compatibility checklist for rendering, input, and other behavior that cannot be asserted reliably by the automated harness. See docs/migration/MANUAL_CHECKS.md.
  • Capture representative CPU and allocation profiles before optimization work. See docs/migration/README.md#phase-4b--measured-performance-pass.
  • Start a divergence log.

Exit condition: there is a reproducible behavior and ABI baseline, the test layers run in CI or an equivalent repeatable command, and the initial suite passes against the Scala implementation.

Phase 1 - Add infrastructure only when first needed

  • Add GTNHLib with a pack-aligned version when the first Java migration or data-structure change uses it.
  • Enable UniMixins when the first fixed class transformation is introduced.
  • Copy the Hodgepodge loader/config structure selectively: normal by default, early/late only for demonstrated lifecycle needs.
  • Confirm client and dedicated-server startup after each dependency or loader change.

Exit condition: dependencies and loaders are present with no unintended gameplay behavior change.

Phase 2 - Establish the Java-first API and compatibility bridges

  • Inventory every public Scala descriptor and compiler-generated entry point used by downstream code.
  • Add ABI snapshots and compiled consumer fixtures before changing public descriptors.
  • Define Java collection, callback, and result types with documented behavior equivalent to the current API.
  • Implement the canonical behavior once in Java.
  • Keep deprecated Scala signatures and singleton/static entry points as thin adapters.
  • Compare the produced jar with the reference API and verify representative Java and Scala consumers can load.

Status: implementation conversion alone does not close the two open items. Complete the missing consumer entry points, documented contracts and extension examples in Phase 9, then map every legacy use to a supported replacement in Phase 10. Every changed public boundary remains diffed against the reference dev jar. For example, getIdMap keeps its scala.Tuple2 array while Extra Utilities still uses it; the new API must offer an alternative alongside it.

Exit condition: new code can use a clean Java API while existing supported binaries still link through the deprecated surface.

Phase 3 - Convert low-coupling code

Status: complete. The low-risk queue is empty. All eight load-bearing $class helpers are Java with their bridges retained, as are both registries, the central part/tile types, scheduler/helper utilities, BlockMultipart and MultipartRenderer. Each leaf group was characterized before conversion, JavaConversions was replaced with explicit Java collections or controlled adapters, and iteration order and null behavior were preserved throughout. Per-type evidence is in the history.

Immediate compatibility gate - complete

Status: complete. Every source-only constraint that can silently fail now has an automated structural or behavioral guard. Schematica's reflective MultiPartRegistry$ type-map view is restored with its exact field name and Scala mutable-map shape over canonical Java state. Member-level guards cover GuideNH's BlockMicroMaterial.block/meta mixin fields, Et Futurum's mutable ButtonPart.metaSideMap/sideMetaMap, Iguana's ItemSaw.harvestLevel and Galacticraft's name-only registerMaterial lookup. TileMultipart order and lifecycle observations are frozen, as are compact mixed-part NBT/description-packet fixtures and one generated Scala trait, one Java trait and representative pass-through interfaces.

Phase 4 - Convert measured hot paths

Status: complete for the measured targets. Traversal, redstone queries, recipe scans and the generated TSlottedTile slot scan were characterized, then rewritten without per-call collection allocation, closure allocation, IntRef boxing or non-local returns, and re-profiled. Fastutil stayed out of these paths because profiling did not justify another dependency.

operate now preserves its captured-sequence mutation semantics without collection materialization, and the six-interface IRedstonePart/RedstoneInteractions$ unit is ported with its class list and descriptors intact. The comparison proved its allocation was owned by the generated TRedstoneTile, which is now Java and allocation-free on the normal immutable-list path. MicroRecipe is also Java: all five recipe forms and precedence are characterized, its published Scala façade remains, and its closure-backed scans and non-local returns are gone. TPartialOcclusionTile completed the simple no-field Java-trait pilot, and TSlottedTile completed the stateful field/lifecycle checkpoint while removing the remaining slot-scan closures and exception-backed return. The paired TRedstoneTile run removed 80.5 B per three-query iteration and improved throughput by 20.3% with an identical checksum.

Exit condition: identified steady-state Scala allocation sites are removed with compatible results.

Phase 4b - Measured performance pass

Status: planned. Phase 4's focused historical results are complete; they do not establish current whole-pack performance. Re-profile the corrected Java branch and representative consumer integrations, then improve the measured costs. The goal is useful gains from both the port and adoption of its API, not a faster language label.

Prioritize hot paths by CPU/allocation evidence and user impact, while also checking intermittent and retained costs:

Representative scenario Costs to investigate
Typical and dense mixed multipart bases with ticking parts, ProjectRed wiring and redstone activity Main-thread CPU, tick-time median/tails, allocation and GC pauses
Placement/replacement, occlusion previews and changing OpenComputers print shapes Query/interaction latency, callback and snapshot costs, allocations
Chunk load/unload, old-world reconstruction, schematic/GuideNH previews and frame moves Load/transition latency, NBT work, cache churn and peak/retained memory
Client views of ordinary and dense microblocks, including illuminated ProjectRed parts Frame-time distributions, chunk rebuilds, render CPU and GPU limits
Description updates and multiplayer chunk watches Serialization time, bytes/packets, burst allocation and tick-time spikes
Fresh startup, first trait combinations and repeated combinations Startup/first-use latency, class generation, cache hit behavior and retained heap

These are coverage candidates, not assumed bottlenecks. Include a modest/common scene, a representative busy scene and an explicitly labeled stress case. Reuse the existing Forge/JFR harness for attribution; add a workload only when it represents an observed consumer path. Use actual client/pack runs to validate user-facing claims.

  • Capture a fresh baseline with exact FMP/consumer revisions, runtime, pack/config/world and repeatable actions; record CPU, allocation and memory profiles before selecting targets.
  • Rank candidates by measured cost and expected user impact, including worthwhile startup/memory improvements outside steady-state hot paths. Set a measurable acceptance criterion before each edit.
  • Compare unchanged consumers on baseline versus optimized FMP to isolate implementation gains. On the same FMP artifact, compare a consumer's old calls with its migrated calls to isolate migration benefits and bridge overhead. Include a compatible Scala/reference artifact as context where feasible; report non-comparable cases explicitly.
  • Make one bounded improvement per commit, with characterization/regression checks and paired measurements. Preserve ordering, snapshots, callback/override behavior, packets, NBT and generated-extension compatibility. Do not remove Scala shells or weaken an API contract just to improve a benchmark.
  • Publish absolute and relative before/after results, run variability, unchanged controls and regressions using the measurement protocol below. Validate CPU wins against allocation/GC, retained memory, startup and client/server tails rather than trading one cost silently for another.
  • Repeat the representative pack scenarios after integrating the changes and relevant consumer migrations. Retain only justified improvements; record below-noise results and rejected candidates without claiming a speedup.

Exit condition: representative scenarios have repeatable before/after evidence, selected improvements meet their declared criteria without unresolved correctness or material performance regressions, and consumer migration benefits are separately demonstrated where present. There is no preset percentage target or promised whole-pack TPS gain.

Measurement protocol

A workload manifest must identify the world/seed or reproducible snapshot, pack and mod versions, FMP/consumer commits, hardware, exact JVM/flags/heap, view distance and graphics settings, part/tile/trait counts, and the action sequence or replay command. Restore equivalent world state before each run.

Use the pack's selected modern Java runtime for primary results; check the supported Java 8 artifact separately for behavior and regressions. Hold runtime and configuration fixed within every A/B comparison. Never attribute a JVM, renderer, pack-version or hardware change to the Java port.

Measure fresh-process startup and cold first-use/class generation separately from warmed steady-state play. Per variant, start with at least five independent runs, alternate baseline/candidate order, use identical warm-up and measurement windows, and extend runs when results are noisy. Keep profiling overhead matched, and confirm end-to-end timing without profiling when overhead is material. Preserve raw reports under a named ignored evidence directory rather than overwriting the only baseline.

Report median and variability across runs. Derive p95/p99 tick or frame times from enough samples within each run, not from five aggregate timings. Include absolute units and relative differences: CPU time, ms/tick, ms/frame, allocation bytes per operation or second, GC pause time, peak/retained heap, cold latency and network bytes where relevant. TPS alone can hide an improvement below the 50 ms tick budget. Separate CPU-bound and GPU-bound client scenes, and do not turn a helper microbenchmark ratio into an FPS/TPS claim.

Use two controlled comparisons: baseline versus optimized FMP with consumer code unchanged, then legacy versus migrated consumer calls on the same FMP artifact. Where compatible, also compare with the Scala reference release on the same runtime and inputs. Compare equivalent results/world state, packet/NBT fixtures and callback behavior, and explicitly exclude scenarios whose behavior cannot be matched.

Each accepted change records: hypothesis and targeted profile site; scenario/manifest and commands; before/after commits; raw evidence location; absolute/relative measurements with variability; correctness checks; controls, regressions and tradeoffs; and the acceptance decision. Define practical benefit above noise before editing. A below-noise result or a bottleneck outside FMP is a valid finding, not a reason to invent more optimization work.

Running the focused harness

The local server EULA must already contain eula=true. From PowerShell at the repository root:

.\gradlew.bat runFunctionalTestServer "-Pforgemultipart.profileFunctionalTests=true"

The property is quoted because PowerShell otherwise splits the dotted Gradle property. Normal runFunctionalTestServer runs are unchanged. Profile mode overwrites these ignored files:

  • run/server/forgemultipart-profile.txt - exact timings and per-thread allocation counts;
  • run/server/forgemultipart-baseline.jfr - JFR CPU, allocation-site and GC events.
jfr summary run/server/forgemultipart-baseline.jfr
jfr view --width 220 hot-methods run/server/forgemultipart-baseline.jfr
jfr view thread-allocation run/server/forgemultipart-baseline.jfr

The startEpochMillis and nanos values in the text file define the exact time window for each phase. Java 8 recordings expose TLAB allocation events rather than ObjectAllocationSample, so jfr view allocation-by-site from a recent JDK is not a valid summary of this recording.

The fixture uses Zulu OpenJDK 8, eight parts per tile, 1,000,000 warm-up iterations and 50,000,000 measured iterations per phase, covering updateEntity, operate, lightValue, getTile and redstoneQueries. The checksum consumes all observable work. Allocation bytes come from HotSpot's per-thread allocation counter, not from sampled JFR events; JFR remains the source for CPU samples and allocation-site ranking. Recorded results for the Phase 4 focused baseline are in the history.

Phase 5 - Convert built-in tile traits through the existing Java path

Status: complete. Traits were converted in small related groups behind characterization of the generated interface, super dispatch, field accessors, initialization, copying, lifecycle callbacks and class caching, starting from a no-field pilot and the stateful TSlottedTile checkpoint. Consumer behavior was covered before the risky ports: OpenComputers' TSlottedTile.v_partMap mutation/rebinding, ProjectRed and Extra Utilities redstone calls before TRedstoneTile, and AE2's TIInventoryTile.rebuildSlotMap before the inventory trait.

All built-in tile traits are Java. The client pair required narrowly extending registerJavaTrait for a registered Java trait extending another registered Java trait, explicit Java field accessors, and transient runtime-only fields that must not participate in generated copyFrom. The generated runtime interfaces and class cache remain reference-identical; pass-through-interface coverage remains green.

Phase 6 - Convert multipart core and microblocks

Status: runtime behavior for core code, factories, ordinary helpers and common/face/corner/edge/post/hollow/occlusion microblock traits is Java. The remaining Scala trait declarations retain inheritance, state/accessor and super-call contracts. Their final replacement belongs behind the consumer-adoption gate; abstract Java mixins and side filtering alone do not replace multiple-trait inheritance metadata. The checklist below records per-conversion obligations, not an instruction to repeat completed ports; completed per-type evidence is in the history.

  • Add characterization coverage for each subsystem immediately before its conversion.
  • Convert the central tile, part, registry, placement, rendering, networking, scheduler, and microblock code in dependency order.
  • Treat NBT, packets, registration, client/server separation, and rendering state as compatibility-critical.
  • Keep recipe scan order and output semantics stable while replacing exception-based early exits.
  • Check source and binary compatibility at each public boundary.

Exit condition: normal runtime implementation is Java; any remaining Scala is isolated to compatibility or ASM support.

Phase 7 - Port the ASM subsystem last

  • Freeze structural and behavioral fixtures for generated classes before changing the compiler.
  • Translate the existing compiler/generator into readable Java while preserving emitted class shapes and cache keys.
  • Evaluate GTNHLib ASM helpers where they reduce boilerplate without altering semantics.
  • Keep Scala-trait registration while deprecated bridges or supported external Scala traits require it.
  • Allow abstract Java mixins whose base type is abstract, skipping their abstract members instead of rejecting the class. Required before any Java mixin over Microblock is possible. See Phase 10.
  • Implement @SideOnly member stripping on the Java mixin path, matching what listSideOnly does for Scala signatures. Required before a client-only Java mixin method is safe on a dedicated server. See Phase 10.
  • Run ProjectRed's LightMicroblock external Scala-trait fixture and pass-through-interface fixtures against the ported generator.
  • Decide separately whether a future generator design can avoid custom bytecode generation. Do not couple that experiment to the source-language port.

Exit condition: the composition subsystem is maintainable Java with behavior and generated ABI verified against the reference.

Pre-merge cleanup

  • Relocate Java sources from src/main/scala to src/main/java once their remaining Scala dependencies no longer require joint compilation; document any compatibility/ASM Java sources that must stay in the joint source set.
  • Revisit compileScala.scalaCompileOptions.force = true after relocation and keep it only if generated-version or remaining joint-compilation inputs still require it.
  • Refresh README.md for the Java-first codebase while retaining Scala IDE guidance for any sources that remain.
  • Move the durable migration documents under docs/migration/ and decide whether the historical running plan is archived or removed before merge.
  • Complete and record the relevant checks in docs/migration/MANUAL_CHECKS.md against a representative client and GTNH pack before release.

Exit condition: source layout, contributor guidance and durable migration documentation match the codebase being merged, and all release-required manual checks have recorded results.

Phase 8 - Remove FMP's Scala implementation and dependencies after consumer migration

Status: planned, gated by consumer adoption. The inventory showed shipping mods linking against trait helpers, companion singletons and Scala descriptors, plus ProjectRed registering an external Scala trait. The first release therefore delivers the Java API with compatibility retained. Complete Scala removal from FMP follows migration of those integrations and adoption of their released versions in the target GTNH pack.

This removes FMP's own Scala requirement. Other mods may still use Scala internally; they need not be converted to Java. Consumer migration also does not remove FMP's internal dependencies automatically: retained built-in trait inputs, storage, models and compiler support must be replaced or retired before their dependency can be dropped.

  • Measure actual downstream use of Scala descriptors, Scala traits, object singletons, and $class helpers.
  • Close every legacy-use row in the Phase 10 adoption ledger against the actual target-pack release jars, including reflection, mixin targets and compiler-emitted helper calls. Retire or replace unmodifiable consumers.
  • Publish and complete a deprecation window if compatibility policy permits removal.
  • Replace or retire FMP's remaining Scala storage, trait inputs and model shells; remove bridges and signature decoding only after their external and internal users are gone, in a release allowed to break the old ABI.
  • Remove FMP's Scala compilation and compiler/runtime dependencies; verify no FMP-owned Scala references remain in shipped classes, descriptors, generated classes or runtime loading paths.
  • Validate the resulting artifact with the migrated consumer jars, old-world loading, packets, client rendering and dedicated-server behavior before release.

Exit condition: FMP builds and runs without its own Scala requirement, and the supported target-pack integrations pass against that artifact. Any unfinished gate keeps the corresponding compatibility support in place.

Phase 9 - Deprecate the Scala-shaped API and mark the internal boundary

Status: in progress: all ten Phase 9.1 table entries and Phase 9.2 are complete; broader extension/API guidance remains. This phase does not redesign the API. A surface audit against all 28 consumer checkouts found the published API is already largely Java-shaped: TMultiPart is an ordinary abstract Java class with conventional lifecycle, render, NBT, and packet methods, and IPartFactory2, IPartConverter, IMicroMaterial, PartMap, RedstoneInteractions, and the occlusion interfaces need no replacement. The remaining problems are a short list of signatures that still carry Scala types, and a boundary problem that is larger than it looks.

The governing rule is: deprecate only where a replacement already exists or is added in the same change. A deprecation with nowhere to go is noise a consumer cannot act on. Bridge lifetime follows consumer adoption and the removal policy. Adding the Java sibling and marking the Scala-shaped entry deprecated is one change, not two.

Apply siblings, deprecations and internal-marker javadoc together when the relevant API is being changed. Most implementations are already Java; remaining work is at the specific boundaries below. Keep unrelated API changes separate from mechanical compiler extraction, and do not add a deprecation without its replacement.

9.1 - Scala-typed signatures

Each row keeps its existing descriptor for binary compatibility and gains a Java-shaped sibling. The registry's existing Java registerParts overload still required Scala during javac resolution; registerPartFactory avoids it.

Deprecate Java-shaped replacement Notes
TileMultipart.partList(): scala.collection.Seq jPartList(): java.util.List Getter deprecated; captured-sequence view documented in the guide and compiling example
MultiPartRegistry.registerParts(IPartFactory2, scala.collection.Seq) and companion entry registerPartFactory(IPartFactory2, String...) Implemented over existing Java registration; distinct name compiles without Scala. Guide and example also cover the retained Boolean/function adapters and their migration
MicroMaterialRegistry.getIdMap(): scala.Tuple2[] materialCount(): int plus existing materialName(int) and getMaterial(int) Implemented with static/companion deprecation; guide and compiling example. Consumer release/adoption remains pending
TileMultipart.operate(scala.Function1<TMultiPart, BoxedUnit>) forEachPart(java.util.function.Consumer<TMultiPart>) Implemented through the legacy virtual hook; preserves captured traversal, detached-part filtering and callback failures. Lifecycle still calls operate
TileMultipart.occlusionTest(scala.collection.Seq, TMultiPart) testOcclusion(Collection<? extends TMultiPart>, TMultiPart) Implemented with an input snapshot and legacy virtual dispatch, including generated partial occlusion; guide. Distinct Java name avoids Scala overload resolution
NormalOcclusionTest.apply(scala.collection.Traversable<Cuboid6>, scala.collection.Traversable<Cuboid6>) and companion entry testBoxes(Iterable<? extends Cuboid6>, Iterable<? extends Cuboid6>) Implemented over the existing Java loop; both inputs snapshotted before geometry tests. Guide and compiling example. ForgeRelocationFMP/OpenComputers adoption remains pending
TileMultipart.loadParts(scala.collection.Iterable) loadPartList(Collection<TMultiPart>) Implemented through the legacy virtual hook; binding, world notifications, input iteration and partial failure retained. Loading guide
TileMultipart.partList_$eq(scala.collection.Seq) setPartList(List<TMultiPart>) Implemented through the legacy setter; Java list copied without binding, null sentinel supported. GuideNH's old reflective name remains supported
TileMultipart.renderID() / renderID_$eq(int) and companion entries getRenderID() / setRenderID(int) Implemented over the same global value; four legacy entries deprecated. Guide and compiling example. Reading does not initialize the client renderer
TileMultipart.getOrConvertTile2(): scala.Tuple2<TileMultipart, Object> and companion entry getOrConvertTileResult(World, BlockCoord): TileConversionResult with getTile() / isConverted() Implemented over the retained tuple path; guide and compiling example distinguish existing tiles, uninstalled placeholders and failed conversion. No audited direct tuple caller

Explicitly not renamed: TMultiPart.world(), x(), y(), z(), and tile(). These carry Scala accessor naming but no Scala type, so renaming is cosmetic churn across 27 consumers with no compatibility or performance payoff. tile() is additionally unsafe to rename toward getTile(), which already exists with a different return type; a same-name overload there invites a silent wrong-overload bind.

  • Add the ten table rows' Java-shaped siblings over shared behavior, preserving legacy override dispatch and avoiding recursive forwarding as required by the API migration design.
  • Add and document materialCount() with existing indexed lookups; retain and deprecate both getIdMap() entries.
  • Document jPartList() and add forEachPart(Consumer); deprecate the legacy getter/callback entries while retaining their override dispatch. JVM and generated-tile Forge cases cover the documented contract.
  • Add Java loading and storage assignment, with legacy override/reflection coverage, explicit ownership and lifecycle contracts, and generated server/client tile checks; guide, API index.
  • Add testOcclusion(Collection, candidate) with snapshot ownership, subtype inputs, legacy hook dispatch and generated partial-occlusion coverage; guide and compiling example.
  • Add direct testBoxes(Iterable, Iterable) with eager input snapshots, ordered intersection callbacks and retained static/companion bridges; migration guide.
  • Add registerPartFactory(IPartFactory2, String...), deprecate the remaining Scala sequence entries, and document initialization/factory/payload contracts with a compiling example and real Forge initialization coverage.
  • Add Java render-ID accessors with shared global-state/sentinel checks, retained static/companion entries and explicit client initialization versus assignment semantics; guide.
  • Add the named tile conversion result, preserving both tuple descriptors and documenting placeholder versus installed-tile lifecycle; guide. All ten table entries now have replacements.
  • For these table siblings, compile external Java examples with Scala excluded from the compile classpath before finalizing names. The proposed loadParts(Collection) overload required scala.collection.Iterable during javac overload resolution; the implemented loadPartList(Collection) avoids that dependency. Apply this gate to the remaining APIs as well as checking the emitted consumer bytecode. Registration hit the same constraint with scala.collection.Seq and scala.Function2; registerPartFactory passes this gate.
  • Mark the legacy table entries above @Deprecated with javadoc naming a working replacement.
  • Confirm every original descriptor still exists in the ABI fixture after these table changes.
  • Document converter registration, candidate ownership and committed lifecycle with a compiling Java example and registry/Forge characterization; guide.
  • Characterize raw Java tile-trait linkage failures and document stable interface/base access with a compiling example; guide.
  • Add a supported refreshPartSlots operation for OpenComputers' live slot-array mutation, preserving equality, virtual bind dispatch, storage/ownership and caller-controlled notification behavior; guide.
  • Document custom Java tile-trait authoring with a compiling marker/trait/capability example, registration and state/lifecycle contracts, and generated server/client Forge coverage; guide.
  • Document the supported API with compiling usage examples and an old-to-new migration guide. Validate Java subclasses and generated extensions on the actual Forge path, including both sides where relevant.

9.2 - Mark the internal boundary

Scala's private[multipart] compiles to public. As a result the published surface currently advertises implementation hooks as though they were API, and a consumer author cannot distinguish addPart from addPart_do. This is a more practical API defect than the Scala types above, and the fix is javadoc only: no rename, no descriptor change, no deprecation, and no new dependency for a marker annotation.

Completed with a fresh 2026-09-05 consumer audit covering 28 source checkouts and active Extra Utilities compatibility: no external calls to these FMP members were found. The installed +719 binary scan retains the same baseline. Javadocs on the declarations below and the five matching material-registry companion bridges mark them internal; see the API boundary guide.

TileMultipart.addPart_impl, addPart_do, remPart_impl, writeAddPart, partAdded, partRemoved, from, copyFrom, loadFrom, setValid; MicroMaterialRegistry.setupIDMap, calcMaxCuttingStrength, loadIcons, writeIDMap, readIDMap.

TileMultipart.operate also has no audited external callers, but remains a supported legacy override hook. It is deprecated for callers in favor of forEachPart; lifecycle dispatch still uses it. Keep that explicit contract rather than applying the internal-only marker to it. The new convenience method does not replace the lifecycle override hook.

getOrConvertTile2 also has no audited external callers. Its retained static/companion entries are deprecated for callers in favor of the supported getOrConvertTileResult API; keep the explicit placeholder contract instead of marking that lookup internal. Internal placement tuple users, including MicroblockPlacement.gtile(), still need their own migration before Scala removal. Completing the table does not finish the broader extension API audit.

Two similar-looking members are externally load-bearing and must not be marked internal:

Member Consumer Call site
TileMultipart.bindPart OpenComputers li/cil/oc/integration/fmp/PrintPart.scala:171
TileMultipart.internalPartChange ProjectRed mrtjp/projectred/integration/gatepartrs.scala:74
  • Add an internal-marker javadoc line to each zero-caller member listed above, including registry companion bridges.
  • Leave bindPart and internalPartChange documented as supported API and add them to the consumer audit's cross-cutting map.
  • Do not add an annotations dependency for this; javadoc is sufficient and changes no descriptor.

The registry-wide loadIcons dispatcher is internal; IMicroMaterial.loadIcons remains a supported material extension callback. Internal tile lifecycle hooks still participate in trait overrides and generated state copying. This slice changes documentation only, and does not complete the broader extension-example exit condition below.

Exit condition: every Scala-typed public entry has a documented Java-shaped replacement and a deprecation pointing at it, supported extension paths have buildable examples and Forge coverage, implementation-hook members are documented as internal, and the retained ABI fixture is unchanged.

Phase 10 - Upstream consumer cleanup

Status: planned, required for final Scala removal. Individual migrations can start as soon as their supported Java replacements exist. They need not wait for all internal source conversion; their release and pack adoption block removal of the corresponding legacy surface in Phase 8.

Several consumers reach FMP through private fields, Scala-mangled names, name-only reflection, or third-party Scala traits. Each dependency requires FMP to preserve the corresponding compatibility contract during migration. Editable GTNH consumer projects can adopt the supported Java API upstream. Extra Utilities remains an active, supported consumer; an upstream rewrite is not part of this work. UtilitiesInExcess is the intended replacement, but switching the support target still needs approval and actual pack adoption. Until then, preserve Extra Utilities compatibility. Approval alone does not authorize dropping contracts still used by the supported pack's jars.

Apply the following sequence per capability:

  1. FMP adds a public, supported equivalent. Additive, no ABI break, safe to land at any time.
  2. The consumer is patched to use it and released.
  3. The target pack adopts the released consumer; scan its actual jar and validate the integration.
  4. FMP can retire the old shape once every supported user has migrated and the removal policy permits it. Feeds Phase 8.

Maintain an adoption ledger in the consumer audit: legacy member/extension/reflection use, supported Java replacement, consumer source revision, first released version using it, version actually included in the target pack, and verification evidence. Cover all audited consumers, including OpenComputers, ProjectBlue and ForgeRelocationFMP; the cleanup table below is a list of hotspots, not the complete removal checklist. A source patch or merged PR alone does not close a row, and scans must distinguish a consumer's own Scala code from its legacy FMP dependencies.

Cleanup targets

Consumer What FMP is currently forced to preserve Upstream fix Can start
ProjectRed Runtime Scala-signature decoding and external trait registration, reached through MicroblockGenerator.registerTrait(classOf[LightMicroblock]) Rewrite the ~60-line LightMicroblock trait as a Java mixin registered through the registerJavaTrait path FMP compiler prerequisites complete; consumer rewrite and release remain
Schematica Private Scala-mangled field codechicken$multipart$MultiPartRegistry$$typeMap, cast to scala.collection.mutable.Map MultiPartRegistry.getPartFactory(String) is implemented; guide and example. Schematica replaces map/Option lookup with the public method and retains its microblock creation/rejection policy FMP lookup ready; consumer patch/release/adoption pending
GuideNH Companion MultipartGenerator$.MODULE$.generateCompositeTile and MicroblockGenerator$.create; partList_$eq(scala.collection.Seq); mixin into private BlockMicroMaterial.block and .meta Direct Java tile generation/loading, microblock creation and existing block() / meta() access are documented/tested; typed material query. Remove reflection/accessor mixins when migrating These FMP paths ready; broader consumer migration/release/adoption pending
Et Futurum Requiem Mutable static int[] ButtonPart.metaSideMap and sideMetaMap must stay public and mutable ButtonPart.setOrientation(int, ForgeDirection) is implemented and documented; Et Futurum adopts it FMP API ready; consumer patch/release/adoption pending
IguanaTweaksTConstruct Private ItemSaw.harvestLevel field name and type ItemSaw.setHarvestLevel(int) is implemented and documented; Iguana uses it before ForgeMicroblock post-init FMP API ready; consumer lifecycle/source patch, release and adoption pending
Galacticraft Selects the first public method named registerMaterial without checking its signature, so FMP cannot add any overload of that name Call registerMaterial directly from gated compatibility code; exact-signature reflection is an interim hardening option Existing public API; consumer migration can start
UtilitiesInExcess MicroMaterialRegistry.getIdMap(): scala.Tuple2[] Both call sites read only the name, so materialCount() from Phase 9.1 plus the existing materialName(int) covers them; no material object is needed After Phase 9.1, before it enters the pack
UtilitiesInExcess Not an FMP constraint, but a shipping hazard: the server factory reads NBT key material while MaterialBasedPart.save writes mat Fix on the UtilitiesInExcess side; FMP must not special-case it Immediately; no FMP change needed

Galacticraft needs no FMP change for direct registration. As interim hardening, its reflection can check exact parameter types; the current wrong-overload failure is silent because it sits inside an empty catch (Exception).

ProjectRed's LightMicroblock is the audited external constraint on retiring runtime Scala-signature decoding. Its Java rewrite removes that external dependency only after the updated consumer is released into the pack; decoder removal also requires retiring internal Scala trait inputs and resolving retained model bridges. The rewrite has two Phase 7 prerequisites:

  1. Abstract Java mixins over an abstract base - complete. registerJavaTrait now admits ACC_ABSTRACT classes, retains declared abstract members on the generated interface, and omits them from the static helper and concrete method metadata until a later mixin supplies an implementation. A no-argument mixin constructor may call an argument-taking base constructor; that direct call is discarded because the generated composite invokes the real base constructor before trait initialization.
  2. @SideOnly stripping on the Java path - complete. registerJavaTrait checks both visible and invisible annotations before collecting fields, signatures or methods. Opposite-side members are omitted; an excluded no-argument constructor produces an empty $init$ so generated composites remain valid without executing its body, including when Forge stripped it before registration. Current-side members retain their generated behavior. LightMicroblock.renderDynamic can therefore remain @SideOnly(Side.CLIENT) when ProjectRed rewrites the trait to Java, even though its addTraits ignores the client argument.

Both compiler prerequisites for the single-parent ProjectRed rewrite are complete. FMP's own microblock traits also use multiple Scala-trait inheritance: their implementation can move to Java helpers, while removing their Scala declarations needs a separate inheritance strategy. Removing external Scala-signature support still waits for a released ProjectRed Java rewrite.

UtilitiesInExcess is the cheapest case in the table because it is not yet in the pack. Anything fixed before it ships never becomes a compatibility obligation at all.

Its NBT key mismatch is real but currently mild, and the detail matters for whoever fixes it. loadPart is always followed by part.load(tag) on every audited path, including BuildCraftCompat's schematic restore, so the factory's wrong material is corrected immediately afterwards. The visible symptom is therefore one logged Missing mapping for part with ID: per part per load, from materialID("") falling through to the missing-material placeholder, rather than lost material data. Extra Utilities writes mat as well, in PartMicroBlock, PartConnecting, and PartPipeJacket, so correcting the key also matches the legacy tags reached through UtilitiesInExcess's extrautils:* aliases; there is no legacy-conversion risk in the fix.

  • Land the two Phase 7 Java-mixin prerequisites with generated Java-trait fixtures.
  • Provide the Java illuminated microblock example, including real Forge registration, all shape families, material/persistence/light contracts, server stripping and a headless retained client-body probe. The three example sources compile without Scala. Physical-client rendering remains a release gate.
  • Land the ProjectRed LightMicroblock Java rewrite with a fixture proving equivalent generated microblocks on both sides.
  • Migrate Galacticraft to direct registration in gated compatibility code; exact-signature reflection is an interim option.
  • Fix the UtilitiesInExcess mat/material key mismatch and its getIdMap() use before it enters the pack.
  • Add supported button-orientation mapping for Et Futurum as additive API, preserving the legacy arrays; guide and example. Consumer migration/release/adoption remain pending.
  • Add the supported harvest-level setter needed by Iguana, preserving the legacy field name/type; guide and example. Iguana must finish changes before ForgeMicroblock post-init so its cached maximum sees the final saw tiers. Consumer migration/release/adoption remain pending.
  • Provide Schematica's registered-factory lookup without exposing the mutable map: getPartFactory(String), with identity/missing-name checks, exact public reflection, a compiling example and the retained private field. This does not complete Schematica's consumer migration.
  • Provide static Java client/server composite generation for Schematica and GuideNH, preserving exact reuse, staged loading, input traversal/failure behavior and the old companion reflection path; guide. Both consumers' release/adoption remain pending.
  • Document and validate the existing Java microblock creation entry for GuideNH, including exact companion/static reflection, material trait callbacks, fresh-part/shape ownership and a compiling example. Keep physical-client validation and public-setter versus private-field override behavior explicit; guide.
  • Document and validate GuideNH's direct typed material query through existing block() / meta() accessors; guide. Consumer adoption and physical-client validation of the Java extension example remain open.
  • Patch those four consumers and record the released versions that no longer need the private shapes.
  • Track all legacy FMP dependencies in the adoption ledger, including trait helper/companion calls and reflection outside the cleanup table; verify the released jars actually selected for the target pack.
  • Only after a consumer's released version is in the pack, move its retained private shape onto the Phase 8 removal list.

Consumers can migrate and release independently while bridges keep mixed versions working. The final Scala-removal release requires coordinated pack-version selection and a closed adoption ledger; it does not require every consumer to make its source changes at once.

Exit condition: every supported consumer in the target pack uses the supported Java surface for the contracts being retired, with source, released-jar and runtime evidence. Remaining dependencies are explicit blockers for Phase 8.

Risk order

Risk Work
Low Leaf utilities, value holders, straightforward packets, isolated Java API facades
Medium Registries, recipes, placement logic, render helpers, schedulers, hot-loop rewrites
High TileMultipart, networking/NBT compatibility, tile trait conversion, external API bridges
Very high Runtime composite generator, Scala trait compatibility, removal of Scala runtime

The work should move from low to high risk while keeping a buildable mixed-language repository. A one-shot conversion would combine source, ABI, lifecycle, data-format, and bytecode-generation failures and make regressions difficult to localize.

Characterization and compatibility test strategy

Extensive tests are both possible and recommended here. Their purpose is not to prove that the current Scala behavior is ideal; it is to make its observable behavior explicit before translation, so every later difference is intentional.

The repository uses Java-8-compatible JUnit Jupiter and the existing Forge integration runner; the current counts are in the handoff. Minecraft 1.7.10 has no modern GameTest framework, so initialization-dependent tests use this Forge harness. The dedicated server is headless and does not establish client rendering correctness.

Layer 1 - Plain JVM characterization tests

Run fast tests through the normal Gradle test task, using real implementation classes and small fakes only at world, network, or rendering boundaries. Good targets include:

  • Slot masks, part lookup, occlusion and collision decisions.
  • Placement grids, side transforms, microblock shape calculations, and recipe matching.
  • Registry ordering, duplicate handling, missing-entry behavior, and callback order.
  • Scheduler ordering and cancellation with a deterministic clock or tick source.
  • Redstone connection and power matrices for all six sides and relevant masks.
  • Packet codec logic that does not require a live channel.
  • NBT read/write logic that can use real NBTTagCompound values without starting Forge.

Finite input spaces such as sides, slot masks, and small recipe grids should be covered exhaustively. Larger combinations should use named regression cases plus deterministic property/fuzz tests with a recorded seed. Avoid tests that merely restate the implementation or assert interactions with a large graph of mocks.

Layer 2 - Golden compatibility fixtures

Store compact fixtures under test resources when behavior has a stable serialized or structural representation:

  • Canonicalized NBT trees including names, types, values, missing fields, and legacy data; do not depend on unspecified compound-key iteration order.
  • Exact packet bytes for representative updates and malformed/truncated input behavior.
  • Part identifiers, registration order, and conversion results.
  • Public/protected JVM descriptors and required Scala bridge entry points.
  • Generated tile superclass, interfaces, fields, methods, access flags, and cache identity. Compare structure and behavior rather than raw class bytes or constant-pool order.
  • Small precompiled Java and Scala consumer fixtures built once against the reference dev jar, then loaded against the migrated jar to catch binary linkage failures that source recompilation would hide.

Golden fixtures must be reviewed rather than regenerated automatically when they fail. Otherwise an accidental change can silently redefine the baseline.

Layer 3 - Real Forge integration tests

Use a dedicated test mod/source set launched in an actual Forge environment for behavior coupled to FML, the classloader, worlds, or networking. The harness should fail the Gradle task or process when an assertion fails. It should cover:

  • Client and dedicated-server startup, mod lifecycle order, mixin selection, and side-only class safety.
  • Registration and creation of representative dynamic tile combinations.
  • Add/remove part sequences, ticking, scheduled ticks, redstone neighbor updates, and chunk unload/reload.
  • Save with the reference implementation and load with the migrated implementation.
  • Server-to-client part description and incremental update packets.
  • Loading representative downstream binaries compiled against the reference API.

Keep this harness narrow: a tiny test mod and deterministic scenarios are preferable to a general automation framework. Run server cases routinely; reserve the slower client cases for changes that touch rendering, client packets, or side-specific initialization.

Layer 4 - Irreducible manual and performance checks

Some behavior remains poorly captured by assertions in a legacy client. Retain focused manual checks for visual correctness, selection boxes, particles, input interaction, resource reload, and third-party rendering compatibility. Screenshot comparisons can help, but GPU and renderer differences make them unsuitable as the only oracle.

Performance fixes require the same CPU/allocation scenario before and after the change. Functional tests can prove equivalent results, but they cannot prove reduced allocations or better tick time.

Coverage rule

Coverage percentage is not the goal. The required evidence for an area is:

  • Normal behavior, edge cases, invalid input, ordering, and state transitions are represented.
  • Compatibility-sensitive bytes, NBT, identifiers, descriptors, and generated interfaces are frozen where applicable.
  • At least one real Forge scenario covers code that depends on lifecycle or world state.
  • The tests demonstrably pass against the old Scala area before any Java replacement begins.
  • The same tests pass unchanged against the Java implementation, except for separately documented bug fixes.

Decisions

Resolved from the consumer audits:

  1. All 27 binaries in the target pack must keep working without recompilation; UtilitiesInExcess is an additional forward-compatibility target.
  2. Third-party Scala trait registration is public in practice: ProjectRed registers LightMicroblock.
  3. The first-release milestone is a complete, documented Java API with supported legacy binaries still working. Removing FMP's Scala implementation and dependencies is the final milestone, after consumer releases are adopted in the target pack and FMP's internal Scala users are retired.

Still open, and only to be decided when they become necessary:

  1. How long deprecated Scala bridges remain supported after the initial Java release. Phase 9 fixes the policy for adding deprecations (only alongside a replacement); the removal window is still open and is gated by Phase 10.
  2. Which known bugs are intentionally fixed during the port rather than after compatibility is established.
  3. Which GTNHLib release is the minimum supported version once code actually needs it.
  4. Whether runtime tile composition merits redesign after the Java port is stable.

Effort tracking

Track remaining work by completed compatibility gates, not the retired pre-audit calendar estimates:

  1. Complete the supported Java entry points, extension contracts, documentation and buildable examples.
  2. Prove the consumer migration paths and record released-version adoption in the target pack.
  3. Retain tested Scala shells until their removal gates pass; extract further behavior only for a concrete benefit.
  4. Retire remaining internal Scala users and dependencies after adoption; complete the applicable pre-merge, client, packaged-pack and performance validation with recorded results.

Reflection/member guards, core ordering/data fixtures, the first profiles and built-in tile-trait ports are complete. Scala removal is gated by the compatibility evidence above, rather than by a remaining-source-line target.

Findings log

The dated evidence log is archived in the history, including reference revisions, test results, ABI comparisons and resolved regressions. Add future per-target findings there. The working handoff carries the current baseline and next target.