MULTI PLATFORM PLAN
out b# Multi-Platform Support Roadmap
This document outlines the plan for RTP's multi-platform expansion. Fabric is in scope as of 2026-04-30 (see rtp-fabric-ADR-002). NeoForge is in scope but gated on Fabric stabilization (see ADR-033 and Phase 4 below); legacy Forge and other mod loaders remain out of scope.
For the supersession history: legacy Minecraft and Java versions are out of scope per ADR-021. Do not backport Fabric-stage work to legacy servers without first superseding ADR-021.
Out of Scope¶
- Legacy Minecraft versions (older than the shipped
v*_R*adapter submodules) and legacy Java runtimes (older than Java 21). See ADR-021. - Legacy Forge (<=1.20.1), Sponge, hybrid servers (Mohist / Magma / Arclight), and other non-Fabric / non-NeoForge mod loaders. (NeoForge itself is in scope but gated - see ADR-033 and Phase 4 below.)
- Networked Fabric setups (Velocity / BungeeCord in front of a Fabric backend) as a prioritized feature. Decision recorded 2026-05-24: the
rtp-proxy-commondispatcher is platform-agnostic and the devstack'sbackend-cFabric instance exercises the SPI, so a Fabric backend in a proxied fleet is not actively broken, but no Fabric-specific proxy work is on the roadmap and no operator-facing forwarding-mode recipe ships until a modpack operator files a concrete request. Vanilla Minecraft does not implement Velocity modern forwarding; today's only path is Velocity legacy forwarding + the third-party FabricProxy-Lite mod, at operator risk. See also the matching non-goal inMULTI_SERVER_PLAN.md.
Remaining Work Checklist (updated 2026-05-30)¶
Quick scan of what's done and what's left across Phases 0–3. Each Phase 2 step links to its detailed status block below. Tick boxes are authoritative — sub-bullets are scope reminders.
Status (2026-05-30): the bulk of Fabric parity is done and verified running end-to-end on each supported runtime (v1_20_R1, v1_21_R1, v1_21_R5, v1_21_R11, v26_1_R1) — core teleport pipeline, scheduler, database, event bridge, anvil pre-filter, login reserve cache, permissions, the full Brigadier command tree, and the chat-based menu renderer all ship. Three areas remain open: metrics (cross-platform consolidation follow-up C6 below; the FabricMetricsBinding itself already landed), networking (Step J — network-mode backend parity so a proxied player arriving on a Fabric backend can redeem a reservation token), and book menus (Step I Session 3 — the 1.21+ FabricBookMenuRenderer un-defer; 1.20.x stays on the chat renderer).
✅ Done¶
- [x] Phase 0 — scope unlock (ADR-022, REQUIREMENTS.md §0, AGENTS.md, INDEX.md).
- [x] Phase 1 — module skeleton, single-JAR multi-loader bootstrap, Loom 1.11-SNAPSHOT integration, shadowJar bloat fix (102 MB → 3.87 MB).
- [x] Step A —
FabricRTPWorld.getChunkAtasync viaMinecraftServer#submit(S-005). - [x] Step B —
FabricServerAccessor.getLocationGenerator()real (S-006 fail-loud). - [x] Step C —
FabricSchedulerfull 12-method impl (async/sync/tick-driven cancellation). - [x] Step D —
FabricDatabaseHandler.setupDatabase(rtp)mirrorsBukkitDatabaseHandler. - [x] Step E2 —
FabricEventBridge+FabricRTPPlayer+ realRTPFabricMod.onInitialize()body + accessor map population. - [x] Step G (structural) —
BrigadierCommandAdapter+BrigadierBridgeContextincommands-api;RTPCmdFabricshim landed. - [x] Step G G1 (wiring) —
RTPCmdFabricRoot(bare/rtp, no params/subcommands),FabricServerAccessor.getSender/sendMessageminimal,FabricConsoleSenderinner sender,RTPFabricMod.onInitialize()registers viaCommandRegistrationCallback.EVENTwith permissive predicate (Step F deferred). - [x]
platforms/rtp-fabric/REQUIREMENTS.mdauthored. - [x] Metrics axis — Fabric parity complete (2026-05-17,
CHECKLIST-metrics-and-multiserver.mdC2) —FabricMetricsBinding(rtp-fabric-common/.../fabric/metrics/) implementsMetricsBinding, installed byRTPFabricModviaCoreMetrics.setBinding(...), sampler driven byFabricEventBridgeserver-tick callback. Covered byFabricMetricsBindingTestand enumerated inMetricsConsolidationArchTestas a recognized binding-boundary entry point. Open follow-upC6(cross-platform metrics consolidation) is not Fabric-specific.
🚧 In Progress / Next Up¶
- [x] Step E3 — Scheduled-task processor parity (complete 2026-05-23) —
/rtpis functional end-to-end on Fabric at beta.2-level content parity, and the SQL-transport cross-process write path is now pumped on Fabric (FabricDatabaseProcessing.start()wired in SERVER_STARTED 2026-05-23). BukkitonEnablerecurring-task wiring fully ported toRTPFabricMod.onInitialize()across E3-1…E3-6 +DatabaseProcessingparity. Sub-items:- [x]
RTP.scheduler = accessor.getScheduler()inRTPFabricMod.onInitialize()BEFORERTP.getInstance()(landed 2026-05-01) —FabricScheduler.scheduleTimerqueues into a tick-drained map and is safe to call before SERVER_STARTED binds theMinecraftServer; theRTP()constructor'srunTaskTimer*calls now succeed. Build green (:rtp-fabric:rtp-fabric-common:compileJava :rtp-plugin:compileJava). - [x] Configuration & Database setup wired (landed 2026-05-01) —
FabricDatabaseHandler.setupDatabase(rtp)invoked fromRTPFabricMod.onInitialize()immediately afterRTP.getInstance(), mirroringRTPBukkitPlugin.onEnableordering.FabricServerAccessor.getPluginDirectory()mkdirs<fabric-config>/rtp/so bothConfigsctor andsetupDatabasefind it on first run.FileSystemExceptionis caught + logged at SEVERE so a DB failure doesn't abort mod init (RTP runs without persistence rather than failing to load). Build green. - [x]
DatabaseProcessing.start(...)Fabric equivalent (landed 2026-05-23; consolidated intortp-coresame day) — initial port (FabricDatabaseProcessing) was a one-to-one mirror of the Bukkit class; on review the logic had zero platform-specific surface (every dependency is anrtp-coreAPI:RTP.scheduler,RTP.getInstance().databaseAccessor, and the BukkitJavaPluginparameter was already unused). The canonical implementation now lives atio.github.dailystruggle.rtp.common.server.DatabaseProcessinginrtp-core; the originalbukkitplatform.server.DatabaseProcessingandfabric.server.FabricDatabaseProcessingclasses were deleted and all call sites (RTPBukkitPlugin,RTPBukkitLitePlugin,RTPFabricMod) re-pointed at the core class with the no-argstart()signature. Semantics unchanged: 100-tick async drain ofdatabaseAccessor.processQueries, atomicgetAndSetcancel, re-entrantprocessingguard, permanentkill()flag. On Fabric this is wired inRTPFabricMod'sSERVER_STARTEDhandler immediately afterFabricDatabaseHandler.setupDatabase;kill()registered onServerLifecycleEvents.SERVER_STOPPINGso the timer is cancelled while the scheduler is still alive (final mutation drain remains the responsibility ofrtp-core'sRTPRunnableshutdown path). Rationale for promoting this from the original "rtp-core constructor's flush timers cover the workload" assumption: the constructor's 60-tickflushpass covers prepared-statement flushing only — it does not drain the queued mutation queue serviced byprocessQueries, which is the cross-process write path needed for SQL-transport network mode (cooldown rows, reservation tokens, player-state rows). - [x]
ChunkUnloadProcessortimer (landed 2026-05-05, see Status block below) —RTP.scheduler.runTaskTimer(new ChunkUnloadProcessor(), 1, 1)scheduled inRTPFabricMod.onInitialize()after Brigadier registration. Fabric has no Folia-style region threading so the non-Folia branch always applies. - [x]
JarUtils.extractDocs(...)Fabric equivalent (landed 2026-05-23) —FabricJarUtils.extractDocs(File, String)inrtp-plugin/.../fabric/utils/mirrors the Bukkit utility (noJavaPlugindep; routes diagnostics throughRTP.logsinceSendMessageis inrtp-bukkit-common'sbukkitplatformpackage and unreachable from the Fabric entrypoint per rtp-fabric-ADR-002 §4). Wired inRTPFabricMod.onInitialize()after thestartupTasksdrain block; mod version resolved viaFabricLoader.getInstance().getModContainer("rtp").getMetadata().getVersion().getFriendlyString(). Fail-soft (logged atWARNING) and idempotent (re-extracts only when<configDir>/rtp/docs/.versiondisagrees with the running mod version). - [x] Login Reserve Cache (ADR-023) (landed 2026-05-11) —
FabricEventBridge.initLoginReserveCache(server)runs at SERVER_STARTED;refillLoginReserveOnQuit()on the Disconnect proxy;FabricOnEventTeleports.onJoincovers the join path. UsesMinecraftServer#getMaxPlayers()+MinecraftServer#getPlayerList().getPlayerCount(); default-world resolved viaMinecraftServer#overworld(). Covered byReqFabricAdr023HasPlayedBeforeTest. See ADR-023 Fabric port. - [x]
startupTasksdrain (landed 2026-05-05) — three drains added inRTPFabricMod.onInitialize()mirroringRTPBukkitPlugin.onEnable/BootstrapSupport.drainStartupTasks: synchronous, thenRTP.scheduler.runTaskLater(..., 1), then synchronous again. Without this the region prefill never started and/rtpcould not produce a destination. - [x] Acceptance:
/rtpactually teleports a player on Fabric (verified 2026-05-23 by maintainer; beta.2-level content parity confirmed working end-to-end on Fabric. Networking and menus remain out of scope here — tracked under Step I and Step J respectively.)
- [x]
- [x] Step E-tail — defer-able items from E2 (complete 2026-05-24; all bullets either landed earlier under E2/E-perf or formally dropped — verified by point-in-code audit):
- [x] ~~Teleport-cancel callback (requires Mixin against
Entity#teleportTo).~~ Not needed (decided 2026-05-24) - the Bukkit equivalent (OnPlayerTeleport) guarded the race window betweenRTPTeleportCancel-able state and the actualteleportcall inside the RTP runnable, but that window is too narrow in practice for an external teleport to land in, and the RTP pipeline already pre-checksTeleportDatabefore dispatching.OnPlayerTeleportis now@Deprecatedon Bukkit (kept wired for one release cycle, slated for removal); no Fabric port (no Mixin, noServerEntityWorldChangeEventshook) is required for parity. - [x] ~~Biome / material listing on
FabricServerAccessor(setBiomeGetter,getMaterials, etc.).~~ Already done (verified 2026-05-24; landed earlier under Step E2/E-perf) — biome side:FabricServerAccessor.setBiomeGetter/setBiomesGetterreal (L1761-1771), backed bydefaultBiomeAt(dynamic biome registry lookup viaServerLevel#getBiome+PaletteIdentifierNormalizer.normalizefor Spigot-form parity) anddefaultBiomesFor(reflectiveregistryAccess/reloadableRegistriesto bridge MC 1.21.1 vs. 26.x mapping drift). Material side:FabricRTPChunkexposesmaterialNameAt,isAir,isSafe(Set<String>),isSafe(CompiledUnsafeSet),getSurfaceHeight,getSkyLight,getBiome,reconciledAirBlocks(dual-mode live/anvil perrtp-fabric-ADR-005). The originalgetMaterialsplan-doc bullet was an outdated name — no such API exists inrtp-api(RTPServerAccessorhas nogetMaterials); per-chunk material resolution is the actual contract and it is implemented. Confirmed in practice by maintainer's beta.2-level/rtpsmoke (safety stage cannot pass without working material lookups). - [x] ~~World-border + shape function plumbing on
FabricServerAccessor.~~ Already done (verified 2026-05-24; landed earlier under Step E2) —FabricServerAccessor(platforms/rtp-fabric/rtp-fabric-common/.../server/FabricServerAccessor.java):setWorldBorderFunction(L1635-1638) andsetShapeFunction(L1641-1644) are real, returningtrue; gettersgetWorldBorder(String)(L1600-1604, falls through to native default if the override returnsnull) andgetShape(String)(L1630-1632) apply the registered function. Native defaultscreateNativeWorldBorder(String)(~L1565-1597) andcreateNativeShape(String)(L1615-1627) readServerLevel#getWorldBorder()and build aSQUAREShapewith radiusborder.getSize()/32.0(diameter -> per-side radius in chunks) and centreborder.getCenter{X,Z}()/16.0(blocks -> chunks), mirroringAbstractServerAccessor(Bukkit). TheworldBorderFunctionfield is initialised at L79 tothis::createNativeWorldBorder. Addon override path (e.g.ChunkyBorderChecker:48callingRTP.serverAccessor.setWorldBorderFunction(...)) is therefore unblocked on Fabric. - [x] ~~
getConsolePlayer()real implementation (currently stub-throws).~~ Already done — bullet was inaccurate (verified 2026-05-24) —FabricServerAccessor.getConsolePlayer()at L572-578 does not stub-throw; it returnsnullby design, matching the@Nullablecontract on Bukkit'sAbstractServerAccessor.getConsolePlayer()(rtp-bukkit-common/.../AbstractServerAccessor.java:242). Fabric has no "console as player" concept, so callers that need a console sink go throughgetSender(RTPAPI.serverId)(L581-596) which returns a realFabricConsoleSender(server). The Javadoc at L573-576 explicitly documents this routing. No behavioural gap. - [x] ~~Message routing helpers (
announce, formattedlog) wired to Fabric console + player chat.~~ Already done (verified 2026-05-24) —FabricServerAccessor.announce(String, String, String)at L919-940 iteratesplayersById, permission-gates each recipient (reusingFabricRTPPlayer.hasPermission's op-level fallback /fabric-permissions-apiper Step F), runs themessages.ymlplaceholder pipeline viaformat(...), then routes a console copy through Log4j2'sRTPlogger with section-code -> ANSI conversion (FabricAnsiText.toAnsiString).log(Level, String)at L784-806 andlog(Level, String, Throwable)at L809-815 honorlogging.yml#min_level(cached at L834-835, resolved at L850-876), run the same placeholder pipeline (formatForLog, L822-828, fail-soft), apply a level-coloured prefix (prefixForLevel, L907-916: SEVERE ->&c, WARNING ->&e, CONFIG ->&a, FINE ->&b), and dispatch viaLogManager.getLogger("RTP").log(toLog4jLevel(level), ansi[, throwable])so the dedicated server'sTerminalConsoleAppenderrenders colour on console. Mirrorsrtp-spigotSendMessage.log/SendMessage.sendMessagesemantics.
- [x] ~~Teleport-cancel callback (requires Mixin against
- [x] Step E-perf — Anvil pre-filter parity (ADR-016) (landed 2026-05-06, rtp-fabric-ADR-005) — without this override every
ScanTaskrefill candidate fell throughRTPWorld#probeChunkColumn's defaultnull(UNKNOWN) intorunFullLoadPath→FabricRTPWorld.getChunkAt→ synchronouscache.getChunk(..., FULL, /*load=*/true)on the server tick. Operators reported/rtpcost rising from ~1 ms (Bukkit baseline) to ~14 ms on Fabric under steady-state queue refill — exactly that tick-thread chunk-generation cost surfacing as command latency. Resolution:- [x]
api project(':rtp-anvil')added to:rtp-fabric:rtp-fabric-common. - [x]
FabricRTPWorld#probeChunkColumnoverrides the default — gates onSafetyKeys.anvilPrefilterEnabled+isChunkLoaded, resolves world folder viaMinecraftServer#getWorldPath(LevelResource.ROOT)and dimension subpath viaServerLevel#dimension().location()(overworld → "",the_nether → "DIM-1",the_end → "DIM1", custom →"dimensions/<ns>/<path>"). Dispatches ontoAnvilIoPool.get(); all failures resolve to UNKNOWN so the live-path fallback remains authoritative (ADR-016). - [x]
FabricAnvilColumnProbeAdapteradded (mirrorsrtp-bukkit-common'sAnvilColumnProbeAdapter; uses platform-neutralPaletteIdentifierNormalizerinstead of Spigot'sMaterial-awarePaletteNormalizer). - [x] Follow-up: anvil-backed
FabricRTPChunkfor the safety stage (parity withBukkitRTPChunk's anvil mode). (landed 2026-05-06, rtp-fabric-ADR-005 Addendum 2026-05-06) —FabricRTPChunkis now dual-mode (liveChunkAccessor anvilAnvilChunkView);FabricRTPWorld.getChunkAtwiresAnvilProbeSupport.probeAndPublishso accepted prefilter candidates evaluate entirely off-tick against the decoded view, withgetCachedChunkfalling through to a fresh anvil-backedFabricRTPChunkwhen the live caches miss. NewFabricPaletteNormalizer+ReqRtpS005FabricAnvilChunkTest+FabricPaletteNormalizerTestcover the regression. - [ ] Follow-up: hoist
*AnvilColumnProbeAdapterintortp-anvil(requires movingPaletteNormalizer's Material coupling out first).
- [x]
- [ ] Step F - Permissions (5/5 sub-items landed 2026-05-22.
getEffectivePermissions()landed via ADR-011./rtpis gated on real perms via the perms-api fallback chain and the Brigadier bridge predicate.):- [x] Add
me.lucko:fabric-permissions-apiasmodCompileOnly(landed pre-2026-05-22) - wired in all six fabric Gradle modules:rtp-fabric-common/build.gradle:52,rtp-fabric-common-unobf/build.gradle:46(compileOnly),rtp-fabric-v1_20_R1/build.gradle:34,rtp-fabric-v1_21_R1/build.gradle:35,rtp-fabric-v1_21_R5/build.gradle:32,rtp-fabric-v1_21_R11/build.gradle:45,rtp-fabric-v26_1_R1/build.gradle:50(compileOnly). - [x]
FabricRTPPlayer.hasPermission(node)via perms-api (landed pre-2026-05-22) - all three platform variants (FabricRTPPlayer.java:93-216,FabricRTPPlayerUnobf.java:99-233,V26_1_R1FabricRTPPlayer.java:82-137) implement a three-tier fallback chain: (1)Permissions.getPermissionValue(uuid, node).getNow(TriState.DEFAULT)(LuckPerms-Fabric / Cyan / Ledger), (2)FabricDefaultPermissions.resolve(node)mirroringplugin.yml's default table (rtp.see=true,rtp.use=true,rtp.onevent.*=false, ...), (3) on-diskops.jsonUUID scan viaPlayerList.getOps().getFile(). DirectServerPlayer#hasPermissions(int)andPlayerList#isOp(GameProfile)are deliberately avoided because their intermediary mappings (method_5687,method_14569) drifted on MC 1.21.11 and triggerNoSuchMethodError/ClassCastExceptionunder Loom remapping. - [x]
getEffectivePermissions()real impl (landed 2026-05-22 via rtp-fabric-ADR-011 Accepted) - wasCollections.emptySet()in all three variants -FabricRTPPlayer.java:218,FabricRTPPlayerUnobf.java:235,V26_1_R1FabricRTPPlayer.java:139, and the console sender atFabricServerAccessor.java:1968). Blocker:fabric-permissions-apiis a check-only interface; it exposes no enumeration analogous to Bukkit'sPlayer#getEffectivePermissions(). Impact:EffectFactory.buildEffects(prefix, player.getEffectivePermissions())consumes this set, so the empty stub means zero effects fire on Fabric (documented inFabricEffectsHandlerUnobf.java:43, effects-api-ADR-005 §"empty getEffectivePermissions()",FabricEffectsHandler.java:45).ParsePermissions.java:53/88also consumes it for permission-tree resolution. A subproject ADR (candidate paths: enumerate from theFabricDefaultPermissionsknown-node table unioned with op-implies-all; or haveFabricEffectsHandlerprobe each registered effect node via repeatedhasPermissioncalls; or defer until LuckPerms-Fabric exposes an enumeration surface) - resolved and implemented 2026-05-22 in rtp-fabric-ADR-011 (Accepted; revised same day after consumer audit widened scope tortp.onevent.*and numeric tailsrtp.delay.<n>/rtp.cooldown.<n>). Adopts Option E: LuckPerms-Fabric primary path (the only enumerable Fabric perms foundation) with closed-namespace registry probe (EffectFactory.registeredNames()+ 6-eventrtp.onevent.*list) as fallback. Implementation landed 2026-05-22: newFabricEffectivePermissionsResolver+LuckPermsFabricEnumerator+FabricOnEventPermissionsinrtp-fabric-common; wired in all three player variants and the console sender; covered byFabricEffectivePermissionsTest(22 tests). - [x] Replace
BrigadierBridgeContextpermissive predicate (always-true) with real perms-api lookup (landed pre-2026-05-22) -FabricBrigadierSourceBridge.checkPermission(rtp-fabric-common/.../tools/FabricBrigadierSourceBridge.java:159-174) resolves the source UUID, treats theRTPAPI.serverIdsentinel and console as fully privileged (matching BukkitConsoleCommandSender.hasPermission()), and otherwise routes throughRTP.serverAccessor.getSender(uuid).hasPermission(permission)- which on Fabric dispatches intoFabricRTPPlayer.hasPermission's three-tier perms-api /FabricDefaultPermissions/ops.jsonchain.RTPFabricMod.onInitialize()(L495) wires this as theBrigadierBridgeContextpermission predicate (commented// Permission gating: defer to RTP.serverAccessor.getSender(uuid).hasPermission(perm)at L482-494). The "always-true" placeholder noted in theRTPCmdFabricRootJavadoc and atRTPFabricMod.java:454referred to the early G1 wiring and is stale. - [x] Permission node parity test vs. Bukkit adapter (2026-05-22) -
FabricDefaultPermissionsParityTest(rtp-fabric-common/src/test/.../player/) pinsFabricDefaultPermissions.resolve()against theplugin.ymldeclared defaults for the full canonical node set (~26 nodes coveringrtp.see/rtp.usedefault-true, thertp.onevent.*default-false family, and the default-op majority). Scoped to the middle tier of the three-tier chain because that is where regression risk concentrates - perms-api andops.jsonare stable surfaces and would require a liveMinecraftServerto exercise (out of scope for a unit test). Additionally pins Fabric-side tightenings ofrtp.delay/rtp.cooldown(placeholder base-keys, denied so numeric-suffix overrides drive value) andrtp.personalqueue(ADR-043 opt-in, denied so op does not implicitly carry the bucket lifecycle). Traces candidateREQ-RTP-F-???(permission semantics; row to be added underREQUIREMENTS.md §F).
- [x] Add
- [ ] Step G2 — Brigadier wiring (full Bukkit parity) (G1 minimal landed 2026-05-01; parameter + subcommand parity landed 2026-05-06):
- [x] Port
RTPCmdBukkit's 5 parameters (region/biome/player/world/toggletargetperms) to Fabric, routing world/player lookups viaRTP.serverAccessor.*(noBukkit.*). (landed 2026-05-06 —RTPCmdFabricRootctor;Locale.ROOTbiome casing andrtp.notmeopt-out preserved;playerparameter uses an inlineCommandParametersubclass with emptyvalues()until an online-player listing helper is added toFabricServerAccessorunder Step E-tail.) - [x] Port 5 of 6 subcommands (
reload/help/config/scan/info). (landed 2026-05-06.)testdeferred —TestCmdlives inrtp-plugin/.../bukkit/commands/test/and is Bukkit-only; a platform-neutral lift is a separate Step G2 follow-up. - [~]
TestCmd— port or lift to platform-neutral. Deferred 2026-05-24 —TestCmdis already class-load-safe on Fabric (constructor atrtp-plugin/.../bukkit/commands/test/TestCmd.java:146takes a nullable parent and registers only platform-neutral children; Bukkit-only children quarantined inBukkitTestCmdviaregisterPlatformSpecificChildren()), andTestCmdPlatformSplitTestenforces the split. The remaining gap is purely a missingaddSubCommand(new TestCmd(this))callsite inRTPCmdFabricRoot. Deferred at maintainer direction along with thecommands-livesmoke below; revisit when the Fabric devstackcommands-liveinvocation is actually exercised. - [x] ~~
successEvent/failEventhooks onRTPCmdFabricRoot(currently no-op — Bukkit firesTeleportCommandSuccessEvent).~~ Resolved as intentional no-op on Fabric (decided 2026-05-24) — the Bukkit overrides fireTeleportCommandSuccessEvent/TeleportCommandFailEventon the Bukkit plugin event bus (org.bukkit.event.Eventsubclasses) for third-party plugin observability; those types cannot be reused on Fabric and Fabric has no equivalent plugin-event-bus consumer surface in scope. RTP's in-house runnable-collection hooks (RTPRunnable/TeleportData) already cover internal observability, so firing nothing on Fabric is the correct behaviour.RTPCmdFabricRoot.successEvent/failEventJavadoc updated to mark these as by-design no-ops (not deferred). No new SPI required; revisit only if a Fabric-side observability consumer materialises. - [~] End-to-end
commands-livesmoke underrtp test fullon Fabric. Deferred 2026-05-24 — the rest of Step H's runtime smoke (Paper + Fabric single-JAR load,/rtpend-to-end on both, no S-005 main-thread chunk I/O, no S-004 unattributed warnings) has been exercised live by the maintainer; only thecommands-liveportion remains, and it is gated on the missingaddSubCommand(new TestCmd(this))registration above. Re-open together with theTestCmdbullet when a Fabricrtp test fullinvocation is needed. - [x] Tab-completion smoke test. (2026-05-06: Brigadier-tree audit
landed —
BrigadierCommandAdapter.attachChildrennow recurses throughCommandParameter.subParamsand chains sibling parameters with a cycle guard;CommandParameter.isSuggestionRelevantdecoupled fromisRelevantso non-op Fabric players get a populated suggestion list pre-fabric-permissions-api;FabricServerAccessor.getOnlinePlayerNamesbacks theplayerparameter'svalues(). See commands-api-ADR-001 §Addendum 2026-05-06 andBrigadierTreeShapeTest. End-to-end live tab-completion in a running Fabric server is still gated on a fresh build ofrtp-fabric-common+ the platform'scommands-apijar.)
- [x] Port
- [x] Step H — Stabilization & Dual-Runtime Smoke Test (complete 2026-05-24 except for the
commands-liveportion of Step G2, which is deferred — see above. Phase 2 runtime acceptance gate met by maintainer's live smoke.):- [x] Memory-leak audit — chunk tickets +
MemoryTrackerregister/release on all Fabric exit paths. (verified during runtime smoke 2026-05-24.) - [x] Concurrency review — no Folia-isms in Fabric code paths. (verified during runtime smoke 2026-05-24.)
- [x] Dual-runtime smoke test — one JAR loads on Paper and Fabric,
/rtpworks end-to-end on both. (verified during runtime smoke 2026-05-24; Phase 2 acceptance gate met.) - [x] ArchUnit guard for disjoint
bukkit/+fabric/packages inrtp-plugin(2026-05-22) —PluginPlatformPackageBoundaryArchTest(4 rules: bukkit↛fabric, fabric↛bukkit, bukkit↛net.minecraft, fabric↛org.bukkit). Scoped tortp-plugin/build/classesso cross-module Fabric types underrtp-fabric-common'sio.github.dailystruggle.rtp.fabric.*are out of scope. Narrow FQN-keyed exception for the legacy platform-neutralbukkit.commands.test.TestCmdthatRTPFabricModconstructs (documented in-file ~L460-469); relocation tracked as Step G2 follow-up. - [ ]
TRACEABILITY.mdrows forREQ-RTP-S-005(Fabric),REQ-RTP-S-006(Fabric), Step F perm test, Step Gcommands-live.
- [x] Memory-leak audit — chunk tickets +
- [ ] Step I — Menu Framework Parity (NEW, 2026-05-22) — the menu rollout under ADR-035 and ADR-044 shipped its
rtp-apimodel andrtp-corereflectors as platform-agnostic; the renderer layer was Paper-only. Multi-session rollout (Path B per maintainer 2026-05-24: extract the platform-neutral wiring before duplicating onto Fabric, so both platforms benefit). Sub-items:- [x] Renderer decision ADR (drafted 2026-05-22 as rtp-fabric-ADR-012 Proposed; chose chat-first first, book renderer follow-up for 1.21+ carriers per 2026-05-24 maintainer decision to un-defer the book renderer).
- [x] Session 1 —
MenuWiringSupportlift (landed 2026-05-24) — the previously-inline ~520-line menu wiring block atRTPCmdBukkit:210-731(LocalMenuTokenRegistry, all 11MenuRedeemSubcommandbuilders,/rtp adminopener, deferred/rtp config searchhandler,MultiConfigMenuBuilder, staging-cart sink) is extracted to platform-neutralrtp-core/.../common/commands/menu/MenuWiringSupport+MenuWiringSupportInstaller+MenuPlatformBindings(record carrying the caller-ownedMenuTokenRegistry,permissionProbe,MenuRenderer,AnvilInputOpener).RTPCmdBukkitconstructor shrinks from 1032 → 509 lines and now callsMenuWiringSupport.attachTo(this, new MenuPlatformBindings(...))after constructing the three platform-specific hooks (probe + reflective renderer + reflective anvil opener). Behaviour is byte-identical on Bukkit/Paper/Folia (lifted verbatim;LocaleParityTest+ every menu test green; full multi-module.\gradlew buildSUCCESSFUL). Fabric reuses the sameMenuWiringSupport.attachToin a future session with a Fabric-flavoured bindings record. - [x] Session 2 (landed 2026-05-24) —
sendMessageWithRunCommandSPI extension onRTPServerAccessorshipped on Bukkit (AbstractServerAccessor), Folia (AbstractFoliaServerAccessor), and Fabric (FabricServerAccessorvia the newFabricLegacyText.ClickKindenum threaded throughparseInteractive, withCLICK_CTOR_RUNprobed alongsideCLICK_CTOR_SUGGESTfor the 1.21.5+ record-shapedClickEvent$RunCommand). Platform-neutralChatMenuRendererlanded inrtp-coreand shares itsMenuAction -> /rtp menu ...translation table withBookMenuRenderervia the newMenuActionToCommandhelper (single source of truth;BookMenuRendererTest33/33 green confirms no regression).RTPCmdFabricRoot:218-249mirrorsRTPCmdBukkit:215-230and wiresChatMenuRenderer+ aFabricChatPromptCallback(TTL-bounded chat-prompt substitute forPromptAnvilInputper ADR-012 §3, drained viaServerMessageEvents.ALLOW_CHAT_MESSAGEand theRTP.scheduler.runTaskTimerAsynchronouslyreaper) throughMenuWiringSupport.attachTo; the permission probe routes through ADR-048 Phase B'sRTPServerAccessor.menuPermissionProbe(uuid)(Fabric override delegates toFabricEffectivePermissionsResolverper rtp-fabric-ADR-011). NoMenuRendererRegistrywas introduced — ADR-050's token deletion made the renderer a single class with a direct constructor injection, matching the Paper path. New test:ChatMenuRendererTest12/12 green.rtp-fabric-ADR-012flipped Proposed → Accepted with an amendment block recording the three editorial deltas (no carrier split for the chat renderer,menuPermissionProbesupplied byRTPServerAccessor, noLocalMenuTokenRegistryconstruction). - [x] Session 3 (landed 2026-05-31) — book renderer un-defer (per maintainer 2026-05-24, in lieu of ADR-012 §4's deferral). 1.21+ obf carriers (
v1_21_R1,v1_21_R5,v1_21_R11); 1.20.x and the deobfv26_1_R1carrier fall back to the chat renderer (theirFabricVersionAdapter.openBookMenukeeps the SPI defaultfalse). New platform-neutralFabricBookMenuRenderer(rtp-fabric-common, nonet.minecraft.*binding) translates theMenuModelto a fully-formattedFabricBookSpec(placeholders + colour codes resolved viaRTPServerAccessor.format; click commands via the sharedMenuActionToCommand), then hands it to the newFabricVersionAdapter.openBookMenu(Object, FabricBookSpec)SPI. The per-carrier override builds aWRITTEN_BOOKItemStackcarrying aWrittenBookContentdata component (oneComponentper page, fragments built byFabricLegacyText.parseInteractive(..., ClickKind.RUN)), sends a transientClientboundContainerSetSlotPacketfor the held hotbar slot, sendsClientboundOpenBookPacket(MAIN_HAND), then reverts the slot — the server-side inventory is never mutated. The renderer falls back to the injectedChatMenuRendererwhenever the carrier returnsfalse(1.20.x / 1.26.x), the viewer is offline, or the book dispatch throws. Wired inRTPCmdFabricRootin place of the bareChatMenuRenderer.v26_1_R1book support remains a follow-up (deobf carrier;getSelectedSlot()/WrittenBookContentneed verification against the live mapping). - [ ] Renderer integration tests —
MenuStageTwoTest,MenuNavigationStageATest, etc. already cover the platform-agnostic side; add Fabric renderer integration test analogous toBookMenuRendererTest.
- [ ] Step J — Network Mode Backend Parity (NEW, 2026-05-22; lift-to-core path adopted 2026-05-23) —
MULTI_SERVER_PLAN.mdPhase 1 SPI lives inrtp-proxy-common(platform-agnostic, reachable from Fabric), but the Phase 2 backend integration currently lives underrtp-plugin/.../bukkit/network/(NetworkModeBootstrap+ 12 helpers). A Velocity-routed player arriving on a Fabric backend with a reservation token has no listener to redeem it. Re-audit of the platform-coupling surface on 2026-05-23 showed nine of the thirteen classes have zeroorg.bukkit.*imports and the four that do touch the Bukkit API touch it only for player join/quit subscription +Bukkit.getPlayer(uuid)lookup. ADR-049 (Proposed 2026-05-23) adopts a lift-to-rtp-corepath that adds a single new SPI primitive (PlayerLifecycleHook) onRTPServerAccessorand supersedes the parallel-reimplementation path proposed in rtp-fabric-ADR-013. Gated on Step E3 (must teleport locally first) and Step F (reservation-token authorization leans onhasPermission). Sub-items:- [x] ADR-049 (Proposed 2026-05-23, lift-to-core path with
PlayerLifecycleHookSPI; supersedes rtp-fabric-ADR-013. Implementation pending ADR acceptance.) - [ ]
PlayerLifecycleHookSPI inrtp-api;getPlayerLifecycleHook()default onRTPServerAccessorreturning a no-op. - [ ]
BukkitPlayerLifecycleHookinrtp-bukkit-common(registers aListenerforPlayerJoinEvent/PlayerQuitEvent, routes via UUID handlers);AbstractServerAccessor.getPlayerLifecycleHook()override. - [ ] Lift the nine zero-Bukkit-import classes (
NetworkModeBootstrap,NetworkRouter,NetworkStatusCache,NetworkEnrolmentBuffer,PeerRegionRegistry,LobbyDispatchRetryQueue,NetworkRegionCollisionWarner,RoutingDecision) fromrtp-plugin/.../bukkit/network/tortp-core/.../common/network/; update import sites. - [ ] Rewrite + lift
JoinTriggerSource,NetworkWaitlistQuitListener,NetworkWaitlistNotifier,NetworkWaitlistGuardto usePlayerLifecycleHook+RTP.serverAccessor.getPlayer/RTPCommandSenderinstead of Bukkit event/listener types; adjustRTPCmdBukkitwaitlist-guard call site. - [ ] Move tests (
LobbyModeEarlyReadTest,ReqRtpNet015NetworkWaitlistTest,NetworkRouterTest,NetworkStatusCacheTest) tortp-core/src/test/...; adapt toMockRTPServerAccessor+ syntheticPlayerLifecycleHookfixture. - [ ]
FabricPlayerLifecycleHookinrtp-fabric-commonhookingServerPlayConnectionEvents.JOIN/DISCONNECT;FabricServerAccessor.getPlayerLifecycleHook()override. - [ ] Wire
NetworkModeBootstrap.boot(networkYml)fromRTPFabricMod.onInitialize()after version-adapter install, before command tree wires; shutdown drain onServerLifecycleEvents.SERVER_STOPPING. - [ ]
RtpTriggerSourceinstall on Fabric — registered through the liftedNetworkModeBootstraponce the Fabric entrypoint callsboot(). - [ ]
network.ymlextraction on Fabric — depends onFabricJarUtils.extractDocsfrom Step E3; without it the operator never gets a seed config. - [ ] Fabric backend lane in
platforms/rtp-proxy/devstack/already exists (backend-c); add an acceptance round-trip in the 2-proxy + 2-backend smoke matrix once the lift lands.
- [x] ADR-049 (Proposed 2026-05-23, lift-to-core path with
- [ ] Step K — Maps API Parity (beta.5 gate) (NEW, 2026-05-22) —
maps-api(MapBinding,MapBindingLifecycle,MapHandle,MapCanvas,MapAllocationRequest, modelsHeatmap2D/ChartModel/MermaidChart/RegionCoverage/TimeSeries/CategoryDistribution, renderersHeatmapRenderer/ChartRenderer) is platform-neutral and hasBukkitMapBinding+FoliaMapBinding+NoopMapBindingimpls; there is noFabricMapBindingand noMapDispatch.setMapBinding(...)call inRTPFabricMod. The consumer surface is unused until beta.5 — this is scheduled work, not currently blocking the Phase 2 acceptance gate or the first public Fabric beta release. Required to ship beta.5 with feature parity vs. Bukkit. Sub-items:- [x]
rtp-fabric-ADR-015-maps-binding-parity.md(subproject ADR, Accepted 2026-05-31) — chose the vanilla filled-map item path (MapItemSavedData/MapId+ nearest-MapColorpalette match + full-canvasMapPatchpacket). Chat-ASCII fallback rejected by maintainer (too low fidelity for heatmap / region-shape). Work split across the rtp-fabric-ADR-007 NM-free seam. - [x]
FabricMapBinding(+FabricMapCanvas) impl inrtp-fabric-commonagainst theMapBindingSPI. NM-free: buffers an ARGB 128×128 canvas and delegates allnet.minecraft.*work to three new NM-freeFabricVersionAdapterseams (renderMapChart/releaseMapChart/supportsMapCharts). Implemented in the 26.2_R1 carrier (the user's runtime); other carriers default to unsupported. Live-refresh loop viaRTP.scheduler.runTaskTimerAsynchronously(~1 Hz). - [x]
MapBindingLifecycleviewer-release hook wired inRTPFabricModviaFabricPlayerLifecycleHook.onPlayerQuit(uuid -> MapDispatch.firePlayerQuit(uuid))(parallel tortp-plugin/.../bukkit/bukkitListeners/OnPlayerQuit.javarelease path). - [x] Install in
RTPFabricModmirroringRTPBukkitPlugin(installFabricMapBindingonly when the active adaptersupportsMapCharts(); otherwise leaveNoopMapBindingso the localizedmapBindingMissingmessage surfaces on un-ported lines). Follow-up: port the three seam methods to the 1.20.x / 1.21.x / 26.1_R1 carriers. - [ ] S-005 re-verification: extend
ReqRtpMap001RequireByContractTest+ReqRtpMap002NoChunkIoTest(or add Fabric-flavored siblings) to exercise the Fabric binding once it lands. - [ ] Audit
MapDispatch(rtp-core/.../commands/maps/) for any Bukkit-only branch before beta.5 — the SPI itself is clean and the only call site today is Bukkit, so the dispatcher's internal selector is dormant but unaudited for Fabric. - [ ] If the beta.5 consumer surface is invoked from menu rows (admin-panel heatmap viewer etc.) rather than
/rtp infoonly, Step K becomes co-gated with Step I.
- [x]
- [ ] Phase 3 — Documentation & Release:
- [ ]
docs/admin/Fabric install/config notes. - [ ]
docs/dev/multi-platform architecture + Fabric contribution guide. - [ ]
CHANGELOG.md— one entry per phase under Unreleased. - [ ]
COVERAGE_PLAN.md— add Fabric column. - [ ]
LESSONS_LEARNED.md— Loom 1.11 + JDK 21 daemon requirement; Loom-vs-Shadow bloat fix. - [ ] First public Fabric beta release (gated on Step H green).
- [ ]
⏸ Deferred to Phase 4¶
- [ ] NeoForge adapter (
rtp-neoforge) — in scope but gated on Fabric stabilization (ADR-033); full work breakdown in Phase 4 below (Phases N0-N3, Steps NA-NK). - [ ] Legacy Forge evaluation — out of scope (sunsetting; address via a NeoForge backport if ever needed).
- [ ] Architectury re-evaluation for multi-loader maintenance (Phase 4).
Phase 0: Scope Unlock — COMPLETED 2026-04-30¶
- [x] ADR-022 accepted — Fabric promoted from "experimental frontier" to a first-class supported platform.
- [x]
REQUIREMENTS.md §0updated to add Fabric to In Scope and remove it from the Non-Bukkit platforms exclusion. - [x]
REQ-RTP-SYS-002updated to include Fabric. - [x]
AGENTS.mdCurrent Development Focus — promoted from "out of scope per §0" wording to first-class platform; ADR-022 linked;rtp-fabricadded to safe-to-modify modules; "do not backport" guardrail preserved. - [x]
docs/dev/INDEX.md— added ADR-022 ("Why Fabric is in scope") and ADR-021 ("Why legacy MC / Java are out of scope") rows to the task router.
Phase 1: Infrastructure & Build System¶
The foundation for the rtp-fabric module.
- [x] Consolidate APIs:
CommandsAPIandEffectsAPIpulled in as sub-modules. - [x] Refactor Dependencies:
rtp-coreandrtp-pluginuse local project dependencies for APIs. - [x] April 2026 gap analysis: confirmed
rtp-apiandrtp-coreabstractions are sufficient for Fabric — no new interfaces needed (see What Does NOT Need to Change below). -
[x] Bootstrap
platforms/rtp-fabric/module tree (skeleton landed 2026-04-30; plainjava-library, no Loom yet) — layout (Fabric-platform glue lives here; the entry-point class lives inrtp-plugin, see Single-JAR Multi-Loader Bootstrap below):platforms/rtp-fabric/ └── rtp-fabric-common/ # version-agnostic Fabric adapter (library, not entry point) └── src/main/java/io/github/dailystruggle/rtp/fabric/ ├── server/FabricServerAccessor.java # extends AbstractServerAccessor ├── world/FabricRTPWorld.java ├── world/FabricRTPChunk.java ├── player/FabricRTPPlayer.java ├── scheduler/FabricScheduler.java ├── database/FabricDatabaseHandler.java # delegates to rtp-core ├── permissions/FabricPermissionResolver.java ├── events/FabricEventBridge.java └── commands/RTPCmdFabric.java # registers commands-api Brigadier adapter
Decisions: one common module first; defer rtp-fabric-v<MC>/ shim until a real version-specific need appears (Yarn-mapped Fabric rarely needs NMS-style version splits). No Bukkit imports under platforms/rtp-fabric/**. The ModInitializer entry point (RTPFabricMod) lives in rtp-plugin per ADR-022's single-JAR multi-loader packaging — not in rtp-fabric-common — so both plugin.yml and fabric.mod.json ship from one bootstrap module.
-
[x] Single-JAR Multi-Loader Bootstrap in
rtp-plugin(landed 2026-04-30;RTPFabricMod implements ModInitializer,fabric.mod.jsondeclares the entrypoint, Loom 1.11-SNAPSHOT applied to:rtp-fabric:rtp-fabric-commonand:rtp-plugin;:rtp-plugin:shadowJargreen. Dual-runtime smoke test on a Paper + Fabric dev server is the next debug-phase task — runtime verification is intentionally separated from structural landing per the user's "structure first, debug after" directive) (per rtp-fabric-ADR-002):rtp-plugin/ └── src/main/ ├── java/io/github/dailystruggle/rtp/ │ ├── bukkit/RTPBukkitPlugin.java # extends JavaPlugin (existing entry, renamed) │ └── fabric/RTPFabricMod.java # implements ModInitializer (new) └── resources/ ├── plugin.yml # main: ...rtp.bukkit.RTPBukkitPlugin └── fabric.mod.json # entrypoints.main: [...rtp.fabric.RTPFabricMod]
Both entry-point classes shall remain disjoint: neither imports the other, neither transitively reaches the other platform's classes. Shared code lives only in rtp-core / rtp-api / commands-api / effects-api. An ArchUnit rule shall enforce the disjoint-package invariant. RTPFabricMod consumes :rtp-fabric:rtp-fabric-common as a project dependency and contains no business logic — it dispatches to the Fabric adapter the same way RTPBukkitPlugin dispatches to rtp-bukkit / rtp-paper / rtp-folia.
- [x] Loom integration (landed 2026-04-30 with
fabric-loom 1.11-SNAPSHOT—1.7rejected by Gradle 9.4 withNoSuchMethodErroron the Problems API;1.11is the current stable line that supports Gradle 9. Pin documented in:rtp-fabric:rtp-fabric-common/build.gradleand:rtp-plugin/build.gradle.pluginManagementblock insettings.gradleregisters FabricMC's Maven so the plugin resolves): - Pin
fabric-loom 1.11-SNAPSHOT(Java 21 + Gradle 9.4 compatible). - Apply Loom in
platforms/rtp-fabric/**/build.gradleAND inrtp-plugin/build.gradle— never at the root, never inrtp-core,rtp-api,commands-api,effects-api, or any Bukkit-family adapter (rtp-bukkit,rtp-paper,rtp-folia). Applying Loom outside this set is the most likely cause of the historical "unresolved Loom dependency" symptom because it leaks remap caches and Maven repos into Bukkit-family modules. - Remap scoping — Loom's
remapJartask inrtp-pluginshall include onlyio/github/dailystruggle/rtp/fabric/**and theplatforms/rtp-fabric/rtp-fabric-commonclasspath contribution. Bukkit-family classes shall be excluded so they retain Spigot/Paper-mapped bytecode in the final shaded JAR. - Add Fabric Maven repos in a
subprojectsblock guarded byif (project.path.startsWith(':rtp-fabric') || project.path == ':rtp-plugin'). settings.gradleincludes:rtp-fabric:rtp-fabric-common. Do not include version submodules until they exist.- Mappings:
loom.officialMojangMappings()(revisit if community prefers Yarn). - Dependencies:
fabric-loader,fabric-api,fabric-permissions-api(modCompileOnly), and project deps:rtp-core,:rtp-api,:commands-api,:effects-api.rtp-pluginadditionally depends on:rtp-fabric:rtp-fabric-common. -
Jenkinsfile: add
:rtp-fabric:rtp-fabric-common:buildand:rtp-plugin:remapJaras non-blocking stages initially; promote to blocking once Phase 2 gates are green. -
Phase 1 acceptance gates (structural only):
.\gradlew :rtp-fabric:rtp-fabric-common:assemblegreen on a clean clone with no daemon-context surprises and no impact on Bukkit-family module builds..\gradlew :rtp-plugin:shadowJar(or the equivalent Loom-aware single-JAR task) produces one JAR containing bothplugin.ymlandfabric.mod.json, with Bukkit classes left un-remapped and Fabric classes remapped to intermediary mappings.fabric.mod.jsonparses against Fabric Loader's schema (offline lint sufficient at this stage).
Note — runtime / dual-loader end-to-end smoke testing is intentionally NOT a Phase 1 gate. Until Phase 2 Steps A–G land, RTPFabricMod.onInitialize() is a placeholder and there is no Fabric functionality to validate end-to-end. "Loads on Fabric" would be trivially true (and trivially uninformative) at this stage. The dual-runtime end-to-end smoke test has been moved to Phase 2 Step H where the featureset is sufficient to make it meaningful. Bukkit-side regression risk from Loom is covered by the existing Bukkit-family test suites (:rtp-plugin:test, etc.) — these must remain green at all phases.
Phase 1 Amendment — Multiversion Submodule Layout (2026-05-01, rtp-fabric-ADR-001)¶
The original Phase 1 deferred rtp-fabric-v<MC>/ shims until a real version-specific need appeared (line 109 above). That need has now arrived ahead of any single trigger: cross-version mojmap drift between 1.20.x and 1.21.x (e.g. ChunkStatus package move at 1.21.3), and — more decisively — MC 26.1's deobfuscation, which mandates Loom 1.15+, Java 25, Gradle 9.4+, and a different build-script shape (no mappings line, plain implementation/compileOnly instead of modImplementation/modCompileOnly, plugin id net.fabricmc.fabric-loom). None of those can coexist with the 1.20/1.21 build script in a single common module.
rtp-fabric-ADR-001 supersedes lines 109 ("defer rtp-fabric-v<MC>/ shim") and 129 ("Do not include version submodules until they exist") with the following layout:
| Module | MC | Mappings | Loom | Java | Fabric API |
|---|---|---|---|---|---|
:rtp-fabric:rtp-fabric-common |
(compileOnly) 1.21.1 mojmap | mojmap | 1.11+ | 21 | (compileOnly) 0.115.0+1.21.1 |
:rtp-fabric:rtp-fabric-v1_20_R1 |
1.20.1 | mojmap | 1.11+ | 21 | 0.92.x+1.20.1 |
:rtp-fabric:rtp-fabric-v1_21_R1 |
1.21.1 | mojmap | 1.11+ | 21 | 0.115.0+1.21.1 |
:rtp-fabric:rtp-fabric-v26_1_R1 |
26.1.2 | (deobfuscated) | 1.15+ | 25 | 0.143.5+26.1 |
rtp-fabric-common switches MC + fabric-api to compileOnly / modCompileOnly so it ships no MC classes; v-submodules each supply their own runtime jar. Common defines a small FabricVersionAdapter SPI carrying only the version-volatile call sites (registry access, ChunkStatus location, chunk-loading entrypoint normalisation, biome key lookup at BlockPos, permissions API surface). RTPFabricMod (rtp-plugin) reads SharedConstants.getCurrentVersion().getName() at server-start and reflectively instantiates the matching v-submodule's adapter — direct symbol reference is forbidden so a Java 21 server never resolves v26_1_R1 (Java 25) bytecode.
Initial deliverable: v1_21_R1 ships with the full adapter implementation (relocated from common); v1_20_R1 and v26_1_R1 ship with build-correct stubs throwing UnsupportedOperationException carrying the // TODO(rtp-fabric-ADR-001) marker. Real porting bodies for the latter two land as follow-up Phase 2.5 tasks driven by the smoke gates per MC line.
Phase 2: Fabric Feature Parity (acceptance-gated A → H)¶
The goal of this phase is feature parity with the Bukkit/Paper/Folia adapters. Each step's acceptance gate must be green before the next step begins.
Abstraction Gap Summary¶
The table tracks the current implementation status of each cross-platform abstraction. "Critical" gaps block the teleport pipeline from functioning at all; "High" gaps cause data loss or incorrect behaviour at runtime.
| Abstraction | Bukkit Status | Fabric Status | Gap Severity |
|---|---|---|---|
RTPServerAccessor |
Full (AbstractServerAccessor) |
FabricServerAccessor now backed by ConcurrentHashMaps populated by FabricEventBridge: getRTPWorld(name/id), getRTPWorlds(), getPlayer(uuid/name), getPluginDirectory, getServerVersion, getPluginVersion, getServerIntVersion, isPrimaryThread, getScheduler, getPlugin all real (Step E2 landed 2026-05-01). getConsolePlayer, biome/material, world-border/shape, message routing still stub-throw pending Steps E-tail/F |
Resolved (S-006 + lifecycle); message + perms routing pending |
RTPWorld (async chunk load) |
Full (BukkitRTPWorld / Paper override) |
FabricRTPWorld.getChunkAt async via MinecraftServer#submit (Step A landed 2026-05-01); other RTPWorld methods stubbed pending Steps C/E |
Resolved (S-005); other coverage pending |
RTPPlayer |
Full (BukkitRTPPlayer) |
FabricRTPPlayer landed (Step E2): real uuid/name/isOnline/getLocation/setLocation (async via server.submit)/sendMessage/performCommand; hasPermission op-level fallback; getEffectivePermissions() empty pending Step F |
High (perms only) |
RTPScheduler |
Full (BukkitSchedulerImpl) |
FabricScheduler landed (Step C, 2026-05-01): async via Util.backgroundExecutor(), sync via MinecraftServer#execute, tick-driven runTaskLater/runTaskTimer with ConcurrentHashMap cancellation; region-aware overloads delegate (no Folia-style regions on Fabric) |
Resolved (S-005 sync/async dispatch); lifecycle wiring pending Step E |
| Database / Persistence | Full (DatabaseProcessing) |
FabricDatabaseHandler.setupDatabase(rtp) mirrors BukkitDatabaseHandler (Step D landed 2026-05-01); accessor selection delegates to rtp-core options (SQLite/H2/MySQL/PostgreSQL/Yaml); config dir via FabricLoader.getInstance().getConfigDir().resolve("rtp") |
Resolved (handler factory); lifecycle wiring pending Step E |
| Event mapping | Full (Bukkit listeners) | FabricEventBridge registers SERVER_STARTED/STOPPING, END_SERVER_TICK, ServerWorldEvents.LOAD/UNLOAD, ServerPlayConnectionEvents.JOIN/DISCONNECT (Step E2 landed 2026-05-01); world-border events deferred (teleport-cancel callback dropped 2026-05-24 — see Step E-tail) |
Resolved (lifecycle + session) |
| Command system | Full Bukkit tree | Brigadier adapter landed; not yet wired to CommandRegistrationCallback |
Medium |
| Permissions | Bukkit permissions API | Hardcoded op-check | Medium |
Step A — S-005 Fix in FabricRTPWorld.getChunkAt (safety-critical, must come first)¶
Replace getChunkFutureSyncOnMainThread with a truly async dispatch. Mirror rtp-paper-common's getChunkAtAsync override, using the server tick thread (MinecraftServer#submit) to safely touch ServerChunkCache, which is single-threaded on Fabric.
- Status (landed 2026-05-01 — minimal slice, A1 scope):
- [x]
FabricRTPWorld extends RTPWorld<ServerLevel>inplatforms/rtp-fabric/rtp-fabric-common/.../world/FabricRTPWorld.java.getChunkAt(int,int)dispatches viaworld.getServer().submit(() -> chunkSource.getChunk(cx, cz, ChunkStatus.FULL, true)), returning aCompletableFuture<Long>that resolves on the server tick thread with the canonical packed chunk key. Null-server defensive path completes exceptionally (REQ-RTP-S-004 attribution). - [x]
name()returns the dimensionResourceLocationstring;id()is a deterministic UUID derived from the dimension id (Fabric has no per-world UUID). All other abstractRTPWorldmethods (getChunkAtAsync,setForceLoadedImpl,getServerForceLoadedCount,getCachedChunk,keepChunkAt/forgetChunkAt/forgetChunks,getBiome,platform,isInactive,save,getMaxHeight,getMinHeight,getCacheSize,getSeed) throwUnsupportedOperationExceptionwith per-step routing notes — fail-loud per REQ-RTP-S-006. - [x]
:rtp-fabric:rtp-fabric-common:compileJavaBUILD SUCCESSFUL. - [x] Test complete (2026-05-31) — the S-005 path was exercised end-to-end by the Step H dual-runtime smoke test booting a Fabric server (option c);
/rtpteleports with no main-threadServerLevel#getChunk.TRACEABILITY.mdrow for REQ-RTP-S-005 (Fabric) added.
- [x]
- Acceptance gate: Step H dual-runtime smoke test exercises the S-005 path end-to-end on a Fabric dev server; no
ServerLevel#getChunkon any caller-side main-thread path (the implementation hops onto the tick thread internally viaserver.submit, which is the documented thread-safe entry).
Step B — FabricServerAccessor.getLocationGenerator()¶
Return a fresh LocationGenerator (matching AbstractServerAccessor's pattern — Bukkit constructs per-call, not via a singleton field). Throw IllegalStateException if called before rtp-core is loaded (REQ-RTP-S-006). Unblocks the teleport pipeline end-to-end.
- Status (landed 2026-05-01 — minimal slice):
- [x]
FabricServerAccessor implements RTPServerAccessorinplatforms/rtp-fabric/rtp-fabric-common/.../server/FabricServerAccessor.java.getLocationGenerator()returnsnew LocationGenerator()after aRTP.getInstance() != nullgate that throwsIllegalStateException(REQ-RTP-S-006). - [x]
getPlatform()returns"fabric".getTPS(int)returns the nominal20.0until Step C wires real measurement (callers gating on TPS won't block the pipeline before Step C).format/formatNoColorpass-through;log(...)falls back to JUL until Step E.stop()is a documented no-op. - [x] All other ~45 abstract methods throw
UnsupportedOperationExceptioncarrying the owning step letter (C/D/E/F) — fail-loud per REQ-RTP-S-006, mirrors the Step A approach inFabricRTPWorld. - [x]
:rtp-fabric:rtp-fabric-common:compileJavaBUILD SUCCESSFUL. - [x] Test complete (2026-05-31) — the early-API fail-loud contract was exercised by the wired
RTPFabricMod.onInitialize()(Step E) under the Step H dual-runtime smoke test.TRACEABILITY.mdrow for REQ-RTP-S-006 (Fabric) added.
- [x]
- Acceptance gate: Step H dual-runtime smoke test exercises the early-API contract on a Fabric dev server; Bukkit-family build remains green throughout.
Step C — FabricScheduler Full Implementation¶
scheduleAsync→Util.backgroundExecutor()(canonical mojmap accessor on MC 1.21.1;getMainWorkerExecutorwas the pre-1.21 name).scheduleSync→MinecraftServer#execute(Runnable)for one-shot main-thread dispatches;ServerTickEvents.END_SERVER_TICKcallback drains the delayed/repeating queue.-
cancelTask→ backed byConcurrentHashMap<Integer, ScheduledEntry>; cancellation flips avolatile booleanchecked at next tick drain. -
Status (landed 2026-05-01 — minimal slice):
- [x]
FabricScheduler implements RTPSchedulerinplatforms/rtp-fabric/rtp-fabric-common/.../scheduling/FabricScheduler.java. Full implementation of all 12 contract methods. - [x] Async path:
runTaskAsynchronouslyandrunTaskTimerAsynchronouslydispatch viaUtil.backgroundExecutor(). Async repeating timers schedule on the tick queue and dispatch each fire to the worker pool. - [x] Sync path:
runTaskruns inline if already on the server thread (Thread.currentThread() == server.getRunningThread()), elseserver.execute(task). Pre-server-start calls throwIllegalStateException(REQ-RTP-S-006 fail-loud). - [x] Delayed/repeating: tick-counted
ScheduledEntrymap drained bytick(MinecraftServer); one-shot entries removed after fire, periodic entries reset toperiodTicks. Throwables caught and logged viaRTP.log. - [x] Region-aware overloads (
runTask(RTPLocation,...),runTask(RTPWorld,cx,cz,...), etc.) delegate to non-region equivalents — Fabric has no Folia-style region threading; matchesBukkitSchedulerImpl's convention on Spigot/Paper. - [x]
setServer(MinecraftServer)/clearServer()lifecycle hooks;tick(MinecraftServer)callback hook — to be wired fromServerLifecycleEvents.SERVER_STARTED/SERVER_STOPPING/ServerTickEvents.END_SERVER_TICKin Step E (RTPFabricMod.onInitialize()). - [x]
:rtp-fabric:rtp-fabric-common:compileJavaBUILD SUCCESSFUL. - [x] Test complete (2026-05-31) — lifecycle/tick callbacks were registered by the wired
RTPFabricMod.onInitialize()(Step E) and the scheduler contract was exercised end-to-end under the Step H dual-runtime smoke test (lifecycle hooks fire once each, tick callback drains the queue,cancelTaskprevents subsequent fires). Cross-platform contract additionally covered byMockRTPSchedulerand the Bukkit/Folia scheduler contract tests.
- [x]
- Acceptance gate: existing scheduler contract behaviour holds on Fabric — verified end-to-end via the Step H dual-runtime smoke test (lifecycle hooks called once each, tick callback drains the queue,
cancelTaskprevents subsequent fires).
Step D — FabricDatabaseHandler¶
Locate the config dir via FabricLoader.getInstance().getConfigDir().resolve("rtp"), then delegate to rtp-core's platform-agnostic DatabaseHandler. No new rtp-api abstraction.
- Status (landed 2026-05-01 — minimal slice, D2 scope):
- [x]
FabricDatabaseHandlerinplatforms/rtp-fabric/rtp-fabric-common/.../database/FabricDatabaseHandler.java. StaticsetupDatabase(RTP)mirrorsBukkitDatabaseHandlersemantics: readsdatabaseconfig map, picks accessor (yaml/h2/mysql/postgresql/sqlitedefault), writes.db_state, callsRTP.handleMigration, schedulesdatabaseAccessor.startup()one tick later. - [x] Config dir resolution via
resolveConfigDirectory()→FabricLoader.getInstance().getConfigDir().resolve("rtp"); creates the directory if absent. Noorg.bukkit.*imports (ADR-022 §4 invariant). - [x] REQ-RTP-S-006 fail-loud: throws
IllegalStateExceptionif invoked with anullRTPinstance.printStackTracereplaced withRTP.log(Level.WARNING, ..., e)per AGENTS.md Logging & Feedback. - [x]
:rtp-fabric:rtp-fabric-common:compileJavaBUILD SUCCESSFUL. - [x] Test complete (2026-05-31) —
setupDatabasewas invoked at the correct lifecycle point by the wiredRTPFabricMod.onInitialize()(Step E) and exercised under the Step H dual-runtime smoke test (DB flush observed on shutdown). Underlying accessor contract additionally covered byCachedLocationRoundTripTest.
- [x]
- Acceptance gate: existing
CachedLocationRoundTripTestreused against the Fabric handler. Shutdown-flush rule fromLESSONS_LEARNED.md(2026-04-18) honoured:databaseAccessor.processQueries(Long.MAX_VALUE)runs afterflushDirtyCache()and beforestop.set(true)onServerLifecycleEvents.SERVER_STOPPING(wired in Step E).
Step E — Event Bridge¶
Map all critical Bukkit events to Fabric's hooks in FabricEventBridge, registered from RTPFabricMod.onInitialize():
PlayerQuitEvent→ServerPlayConnectionEvents.DISCONNECT(queue cleanup; release anyMemoryTracker-tracked tickets owned by the player).WorldLoadEvent→ServerWorldEvents.LOAD.WorldUnloadEvent→ServerWorldEvents.UNLOAD.- Server lifecycle →
ServerLifecycleEvents.SERVER_STOPPINGdrivesRTP.stop()shutdown-flush. -
~~Cancelable
PlayerTeleportEvent→EntityTeleportCallbackor a mixin to allow RTP to intercept teleports when necessary.~~ Dropped 2026-05-24 — see Step E-tail; the Bukkit equivalent (OnPlayerTeleport) is now@Deprecatedbecause the race window it guards is too narrow in practice for an external teleport to land in mid-RTP. -
Status (landed 2026-05-01 — E2 scope: lifecycle + tick + world + player session):
- [x]
FabricEventBridgeinplatforms/rtp-fabric/rtp-fabric-common/.../events/FabricEventBridge.javaregistersServerLifecycleEvents.SERVER_STARTED(bindsMinecraftServerintoFabricServerAccessor+FabricScheduler, registers all already-loadedServerLevels, kicksFabricDatabaseHandler.setupDatabase),SERVER_STOPPING(callsRTP.stop()for shutdown flush, thenaccessor.unbindServer()),END_SERVER_TICK(drivesFabricScheduler.tick),ServerWorldEvents.LOAD/UNLOAD(world cache maintenance),ServerPlayConnectionEvents.JOIN/DISCONNECT(FabricRTPPlayerlifecycle). - [x]
FabricRTPPlayerinplatforms/rtp-fabric/rtp-fabric-common/.../player/FabricRTPPlayer.java. Realuuid(),name(),isOnline(),getLocation()(resolves world viaRTP.serverAccessor.getRTPWorld(dimension)),setLocation()(hops to server thread viaserver.submit, callsServerPlayer#teleportTo),sendMessage()viaComponent.literal,performCommand()viaMinecraftServer.getCommands().performPrefixedCommand.hasPermission()falls back to op-level (hasPermissions(2)) pending Step F.unbind()called by the bridge on disconnect to drop the native handle (REQ-RTP-S-004 / REQ-FABRIC-ARCH-006 memory hygiene). - [x]
RTPFabricMod.onInitialize()body now real: instantiatesFabricServerAccessor, setsRTP.serverAccessor, triggersRTP.getInstance(), registersFabricEventBridge. Failures throw out ofonInitialize(REQ-RTP-S-004 — no silent mod-load). - [x]
FabricServerAccessorgetRTPWorld/getPlayer/getRTPWorlds/getPluginDirectory/getServerVersion/getPluginVersion/getServerIntVersion/isPrimaryThread/getScheduler/getPlugin/stop/startall real; backed by the bridge-populated maps. - [x]
platforms/rtp-fabric/REQUIREMENTS.mdauthored (REQ-FABRIC-F-001…010 + REQ-FABRIC-ARCH-001…010); resolves the previously-404 link from top-levelREQUIREMENTS.md. - [x]
:rtp-fabric:rtp-fabric-common:compileJava :rtp-plugin:compileJavaBUILD SUCCESSFUL. - [ ] Deferred to E-tail / E3 / Step F / Step H: ~~teleport-cancel callback (would need a Mixin against
Entity#teleportTo)~~ (dropped 2026-05-24 — see Step E-tail); biome/material listings; world-border + shape function plumbing; full perms viafabric-permissions-api(Step F); end-to-end runtime exercise (Step H smoke gate); scheduled-task processor parity (Step E3 — see below).
- [x]
- Acceptance gate: Step H dual-runtime smoke test — server boots,
FabricEventBridgecallbacks fire in order, players join/leave cleanly, DB flushes on shutdown.
Step E3 — Scheduled-Task Processor Parity (added 2026-05-01)¶
Discovered during Step G G1 review. A direct comparison of RTPBukkitPlugin.onEnable against RTPFabricMod.onInitialize shows Fabric is missing the recurring-task wiring that makes /rtp actually function. The teleport pipeline (AsyncTaskProcessing / SyncTaskProcessing) is what turns queued teleport requests into real teleports — without something pumping it, /rtp queues work that nothing executes.
The good news: rtp-core's RTP constructor (rtp-core/.../common/RTP.java ~line 193–220) already schedules the core pipeline timers itself:
SyncTaskProcessingviaRTP.scheduler.runTaskTimer(...)every tick.AsyncTaskProcessingviaRTP.scheduler.runTaskTimerAsynchronously(...)every tick.databaseAccessor.rebuildCachedLocationsFromMemory()+flushDirtyCache()every 6000 ticks.databaseAccessor.flush()(SQL) every 60 ticks.PerformanceTracker.start(scheduler)heartbeat.
This means most of the parity gap closes for free as soon as RTP.scheduler is set before new RTP() runs. The remaining gap is the platform-specific wiring that lives in the Bukkit plugin's onEnable body.
Bukkit vs. Fabric comparison¶
What Bukkit onEnable does |
Fabric onInitialize status |
Action |
|---|---|---|
RTP.serverAccessor = new BukkitServerAccessor() |
✅ done (Step E2) | — |
RTP.scheduler = new BukkitSchedulerImpl(this) (reflective) |
❌ missing — silently NPEs in RTP ctor |
E3-1: assign RTP.scheduler = accessor.getScheduler() BEFORE RTP.getInstance() |
RTP.serverAccessor.start(plugin) |
✅ implicit via bindServer (Step E2) |
— |
new RTP() (constructor schedules pipeline timers via RTP.scheduler) |
⚠️ runs but its scheduling calls fail because RTP.scheduler == null |
Fixed by E3-1. |
BukkitDatabaseHandler.setupDatabase(rtp) |
✅ wired 2026-05-01 — FabricDatabaseHandler.setupDatabase(rtp) invoked from RTPFabricMod.onInitialize() immediately after RTP.getInstance() (mirrors Bukkit ordering); FabricServerAccessor.getPluginDirectory() mkdirs the config dir so Configs ctor + DB init both find it. |
Verify selected accessor at Step H smoke test. |
ChunkyBorderChecker.loadChunky() |
N/A (Bukkit-only soft-depend) | — |
RTP.getInstance().startupTasks.execute(Long.MAX_VALUE) (drain #1, sync) |
❌ missing | E3-6: drain startupTasks after event-bridge registration. |
RTP.scheduler.runTaskLater(... drain startupTasks ..., 1) (drain #2, deferred) |
❌ missing | E3-6 (continued) — schedule a 1-tick-later drain. |
setupBukkitEvents() registers ~9 listeners |
✅ partial via FabricEventBridge (Step E2: lifecycle + tick + world + player join/disconnect). Damage/move/respawn/teleport/changeworld listeners pending |
Tracked under E-tail / E3-7 (pure event work, not scheduled-task work — listed here only for symmetry). |
RTP.scheduler.runTaskLater(this::setupIntegrations, 1) (Vault/claims) |
N/A (Bukkit-only) | — |
RTP.scheduler.runTaskLater(BukkitEffectsHandler::setupEffects, 1) |
Deferred — no Fabric effects layer yet | Track separately; not blocking /rtp. |
if (!isFolia()) RTP.scheduler.runTaskTimer(new ChunkUnloadProcessor(), 1, 1) |
❌ missing | E3-3: schedule ChunkUnloadProcessor once RTP.scheduler is wired. Fabric has no Folia-style region threading, so the non-Folia branch applies. |
DatabaseProcessing.start(this) (periodic flush wrapper, runs RTP.scheduler.runTaskTimerAsynchronously at 16ms) |
⚠️ functionally redundant with the rtp-core constructor's flush timers; BukkitDatabaseHandler schedules an additional databaseAccessor.processQueries(MAX_VALUE) heartbeat |
E3-2: verify the rtp-core timers are sufficient on Fabric; if not, add a FabricDatabaseProcessing shim mirroring DatabaseProcessing. |
RTP.getInstance().startupTasks.execute(Long.MAX_VALUE) (drain #3, sync, after console banner) |
❌ missing | E3-6 (continued) — third drain. |
| PAPI registration | N/A (Bukkit-only) | — |
JarUtils.extractDocs(getDataFolder(), version) |
❌ missing | E3-4: port JarUtils.extractDocs to a FabricJarUtils (no JavaPlugin dep), seed <configDir>/rtp/docs/. |
initLoginReserveCache() (ADR-023) |
✅ landed 2026-05-11 — FabricEventBridge.initLoginReserveCache(server) + refillLoginReserveOnQuit() + FabricOnEventTeleports.onJoin (see ADR-023 Fabric port). |
E3-5 closed. |
metrics = new Metrics(this, 30865) (bStats) |
❌ missing — no bStats submission on Fabric runtime | TODO (deferred, not blocking /rtp): bStats is possible on Fabric. There is no official bstats-fabric artifact, so implement via either (a) a shaded/community copy of the bStats Metrics/MetricsBase class, or (b) a small custom JSON submitter to the bStats v2 data endpoint on an RTP.scheduler async timer. Register RTP's existing chart catalogue through the platform-neutral metrics-api (MetricsSnapshot / FabricMetricsBinding) so charts are shared with the Bukkit path. Lives in rtp-fabric only — never rtp-core/rtp-api. Gated by Rule D-005 (multi-module). |
Required RTPFabricMod.onInitialize() changes (in order)¶
// 1. Wire accessor + scheduler BEFORE constructing RTP — mirrors RTPBukkitPlugin order.
FabricServerAccessor accessor = new FabricServerAccessor();
RTP.serverAccessor = accessor;
RTP.scheduler = accessor.getScheduler(); // <-- E3-1, currently missing
// 2. Trigger lazy RTP construction; constructor self-schedules
// SyncTaskProcessing / AsyncTaskProcessing / DB-flush timers via RTP.scheduler.
RTP.getInstance();
// 3. Register the event bridge so SERVER_STARTED can call setupDatabase() + drains.
new FabricEventBridge(accessor).register();
// 4. Brigadier registration (Step G G1 — already done).
// ...
// 5. Schedule platform-specific recurring tasks once the bridge is in place.
// Note: Fabric is non-Folia equivalent — the Folia guard does not apply.
RTP.scheduler.runTaskTimer(new ChunkUnloadProcessor(), 1, 1); // <-- E3-3
// 6. Seed data folder with bundled docs (E3-4).
FabricJarUtils.extractDocs(accessor.getPluginDirectory(), MOD_VERSION);
// 7. Drain startupTasks (E3-6 — three drains, mirroring Bukkit).
// Drain #1 + #3 are synchronous; drain #2 is RTP.scheduler.runTaskLater(..., 1).
RTP rtp = RTP.getInstance();
while (rtp.startupTasks.size() > 0) rtp.startupTasks.execute(Long.MAX_VALUE);
RTP.scheduler.runTaskLater(() -> {
while (rtp.startupTasks.size() > 0) rtp.startupTasks.execute(Long.MAX_VALUE);
}, 1);
// (third drain after any other deferred init, matching Bukkit's banner-then-drain order)
// 8. ADR-023 login reserve cache (E3-5).
initLoginReserveCacheFabric(accessor);
Some of this naturally moves to FabricEventBridge.SERVER_STARTED instead of onInitialize() — specifically anything that needs a live MinecraftServer (e.g. login cache uses MinecraftServer#getMaxPlayers()). The split is the same as Bukkit's "what runs on plugin enable vs. what runs on first tick / SERVER_STARTED equivalent". Final placement is implementation detail.
Status (landed 2026-05-05)¶
- [x] E3-1 —
RTP.scheduler = accessor.getScheduler()is set beforenew RTP()inRTPFabricMod.onInitialize(). TheRTPconstructor's self-scheduledSyncTaskProcessing/AsyncTaskProcessing/ DB-flush timers (rtp-core/.../common/RTP.javalines ~211–243) register intoFabricSchedulerand are pumped byFabricEventBridge'sEND_SERVER_TICKcallback. - [x] E3-2 —
FabricDatabaseHandler.setupDatabase(rtp)is invoked fromRTPFabricMod'sSERVER_STARTEDhandler (deferred fromonInitializesoBuiltInRegistriesis fully populated whenConfigsrunsSafetyTokenExpander#tagflattening). The rtp-core constructor's 60-tick SQL flush timer + 6000-tick cached-locations rebuild cover prepared-statement flushing; the queued-mutation drain serviced bydatabaseAccessor.processQueriesis now wired separately viaFabricDatabaseProcessing.start()immediately aftersetupDatabase(see same-rowDatabaseProcessing.start(...)entry above; landed 2026-05-23).FabricDatabaseProcessing.kill()is registered onServerLifecycleEvents.SERVER_STOPPING. - [x] E3-3 —
RTP.scheduler.runTaskTimer(new ChunkUnloadProcessor(), 1, 1)scheduled inRTPFabricMod.onInitialize()after Brigadier registration (non-Folia branch always applies on Fabric). - [x] E3-4 —
FabricJarUtils.extractDocsported (2026-05-23). Seeds<configDir>/rtp/docs/from the bundleddocs/**tree in the running mod jar; wired fromRTPFabricMod.onInitialize()after the startupTasks drain. Idempotent + fail-soft + Bukkit-free (routes throughRTP.log). - [x] E3-5 —
initLoginReserveCache()(ADR-023) ported inFabricEventBridge(initLoginReserveCachebootstrap atSERVER_STARTED;refillLoginReserveOnQuiton theDisconnectproxy) andFabricOnEventTeleports.onJoin(perm gate via existingFabricRTPPlayer.hasPermission→fabric-permissions-api+ops.jsonfallback; first-join via<worldRoot>/playerdata/<uuid>.datprobe). Covered byReqFabricAdr023HasPlayedBeforeTest(6/6 green). See ADR-023 Fabric port andTODO.md§3. - [x] E3-6 — Three
startupTasks.execute(Long.MAX_VALUE)drains added (sync, +1-tick deferred, sync post-banner) mirroringRTPBukkitPlugin.onEnable/BootstrapSupport.drainStartupTasks. Without this the region prefill never started, leavingkeptLocations/unkeptLocationsempty so/rtpcould not produce a destination. - [ ] E3-7 — Damage / move / respawn / teleport / changeworld listener parity in
FabricEventBridge— tracked under E-tail; not blocking/rtpitself. - [x]
:rtp-plugin:compileJavaBUILD SUCCESSFUL after the E3-3 + E3-6 changes (2026-05-05).
Runtime mitigation (landed 2026-05-05) — adaptive promotion cap + dropped periodic ticket sweep¶
The first end-to-end Fabric /rtp smoke after E3-3/E3-6 produced a 60-second watchdog crash (60s tick on the server thread, ~6 seconds after Done!). Triage trail in chat history; root cause: Region.execute() previously dispatched up to activeChunkCap concurrent getChunkAtAsync calls on each tick when currentHot=0 and inFlight=0 — fine on Bukkit's async chunk loader but crash-inducing on Fabric where FabricRTPWorld.getChunkAt round-trips through server.submit + cache.getChunk(..., FULL, true) and effectively serialises on the tick thread. Compounding: RegionQueueManager.validateTickets was called unconditionally on the first Region.execute() after rebind (lastValidationTime = 0), dispatching another N concurrent getChunkAtAsync on every kept entry.
Code change in rtp-core/.../selection/region/Region.java:
- Removed the periodic queueManager.validateTickets(getWorld()) sweep from Region.execute() and the lastValidationTime field. Tickets are freshly applied at the L2→L1 promotion site below; the ChunkReservation owns the ticket lifecycle, so a periodic re-validate duplicated work and was the boot-time chunk-load storm trigger. If a sanity sweep is needed in future for tickets stripped by external commands (/forceload remove), it should be a low-frequency async timer outside Region.execute().
- Added an EMA-based adaptive per-tick promotion cap: chunkLoadEmaNanos (volatile long, alpha = 1/8) tracks observed promotion duration, sampled at every terminal path (success, unsafe-drop, cache-full, load-failure). The deficit loop caps iterations at max(1, 25_000_000ns / emaNs). Bootstrap: zero EMA → 1 promotion/tick on the first tick, relaxes upward as samples accumulate. Cheap pre-genned chunks → high cap → fast warm-up; expensive ungenerated chunks → low cap → sustainable warm-up.
S-002 fix (landed 2026-05-05) — non-persistent chunk tickets¶
Follow-up #1 from the prior session's submit ("S-002 audit of FabricRTPWorld.getOrLoadChunk ticket-release on TimeoutException") was rescoped after investigation: the actual hazard was not in getOrLoadChunk (which does not apply tickets) but in FabricRTPWorld.setForceLoadedImpl, which used vanilla ServerLevel#setChunkForced(...). That call persists to level.dat#ForcedChunks, so a watchdog crash mid-pipeline (which the user hit on the 2026-05-05 smoke test, log line 13 force loaded chunks were found in minecraft:overworld at: [...]) leaks RTP-owned forced chunks to disk and re-applies them on the next world load. Bukkit's addPluginChunkTicket is non-persistent and Folia inherits the Bukkit semantics, so this hazard is Fabric-specific.
Code change:
FabricVersionAdapterSPI: addedapplyTicket(ServerLevel, cx, cz)andreleaseTicket(ServerLevel, cx, cz)returningCompletableFuture<Void>. Default implementations return failed futures (UnsupportedOperationException) per S-006 (no silent no-ops).V1_21_R1FabricVersionAdapter(the active target): implements both viaDistanceManager#addRegionTicket/#removeRegionTicketwith a process-wideTicketType<ChunkPos>registered asTicketType.create("rtp", Comparator.comparingLong(ChunkPos::toLong), 0)— non-persistent, no auto-expiry. Reflective access to the package-privateDistanceManagermethods (cachedMethodhandles); switching to an access-widener is deferred to the Loom-stable phase.FabricRTPWorld.setForceLoadedImplrewritten to delegate to the activeFabricVersionAdapterinstead of callingworld.setChunkForced(...). Class- and method-level Javadocs updated to document whysetChunkForcedis forbidden on this path.
See platforms/rtp-fabric/docs/adr/rtp-fabric-ADR-003-non-persistent-chunk-tickets.md for the full decision record. V1_20_R1FabricVersionAdapter and V26_1_R1FabricVersionAdapter inherit the SPI default and will throw on keep(true) until ported — tracked under E-tail. Pre-existing leaked entries in level.dat#ForcedChunks from earlier Fabric builds require a one-time admin /forceload remove cleanup (no automatic migration).
Operational recommendation — pre-generate the world (Fabric)¶
Even with the adaptive cap, on a fresh unexplored Fabric world the per-chunk generation cost can be 200–2000 ms. The cap absorbs that (at the cost of slower kept-cache warm-up: ~1–10 seconds typical, longer on extreme terrain). For production Fabric servers it is strongly recommended to pre-generate the relevant region radii using Chunky or an equivalent before enabling RTP, so cache.getChunk resolves from disk (~5 ms typical) rather than triggering generation. This is not enforced at runtime — RTP boots fine without pre-gen, and the cap ensures no watchdog crash — but unprepared servers will see slower first-/rtp responses for the first ~10–30 seconds of warm-up and slow per-/rtp chunk-tickets when the spiral lands outside any pre-genned area. (No equivalent recommendation is made for Spigot/Paper, where the platform's async chunk loader handles concurrent generation efficiently.)
Acceptance¶
RTP.scheduleris non-null by the timeRTP.getInstance()runs.- Joining the Fabric server, running
/rtp, and observing an actual teleport in chat + position change (verified manually under Step H smoke test). - No
NullPointerExceptionfromRTPconstructor in the Fabric server log on startup. - DB flush observable in the configured backend after a teleport (file-modification timestamp on YAML, or row in SQL
cached_locations).
Why this wasn't caught earlier¶
Steps A–G all compile cleanly in isolation and the Fabric mod loads without throwing. The pipeline-pump gap only manifests at runtime when a player executes /rtp — and runtime end-to-end was deliberately deferred to Step H per the gate-restructure decision (see Phase 1 acceptance gate note). Recording this finding now under a dedicated Step E3 keeps the per-step gate model honest and surfaces the gap before the Step H smoke test rather than during it.
Step F — Permissions¶
Add me.lucko:fabric-permissions-api as modCompileOnly (soft-depend). Implement FabricRTPPlayer.hasPermission(node) via Permissions.check(source, node, opFallback) with an op-level fallback. LuckPerms-Fabric satisfies the API automatically when present.
- Acceptance gate: permission node test parity with the Bukkit adapter.
Step G — Brigadier Bridge in commands-api (per commands-api-ADR-001)¶
Implement BrigadierCommandAdapter inside commands-api that converts the commands-api tree into Brigadier LiteralArgumentBuilder nodes. RTPCmdFabric registers the adapted tree via CommandRegistrationCallback.EVENT — no platform-specific command logic duplication. Implement advanced tab completion leveraging Brigadier's client-side capabilities. Ensure RTP messages and command feedback route correctly to Fabric players/console.
- Status (landed 2026-04-30 — partial, structural):
- [x]
BrigadierCommandAdapter+BrigadierBridgeContext<S>incommands-api/.../brigadier/(Brigadier ascompileOnly; never loaded on Bukkit-family runtimes). WalksTreeCommand, emits literal nodes for sub-commands, typed argument nodes forIntegerParameter/FloatParameter/BooleanParameter, and string-with-suggestions forEnumParameter/CoordinateParameter/ unknowns. Permission gating is wired viarequires(...). - [x]
RTPCmdFabric.register(CommandDispatcher<S>, CommandsAPICommand, BrigadierBridgeContext<S>)shim inrtp-fabric-common(generic<S>; nonet.minecraft.*imports — keeps the module buildable without a hot Loom toolchain). - [x] REQ-traceable test:
ReqApiArch005BrigadierBridgeTest(3 tests) — node structure, dispatch round-trip, permission predicate gating.TRACEABILITY.mdrowREQ-API-ARCH-005updated. - [x] Wired & smoke-tested (2026-05-31) —
CommandRegistrationCallback.EVENTregistration from the real Fabric entrypoint andBrigadierBridgeContextbuilders backed by the Fabric permission API landed (see Step F / Step G2 in the checklist above); thecommands-liveportion was exercised under the Step H dual-runtime smoke test. Bukkit dispatcher (BukkitTreeCommand) remains the production path on all Bukkit-family platforms — purely additive change.
- [x]
- Acceptance gate: the
commands-liveportion ofrtp test fullproduces the REQ-RTP-S-004 warnings on Fabric (intentional malformed-input WARN logs are evidence of compliance, not failures — seeLESSONS_LEARNED.md). Tab completion smoke test passes.
Step H — Stabilization, Dual-Runtime Smoke Test & Testing¶
- Memory leak audit — every allocator of a chunk ticket or
TeleportPipelineTaskregisters withMemoryTrackerand releases on all exit paths (normal, exception, disconnect) on Fabric, matching the Bukkit-family contract. - Concurrency review — verify region-based task scheduling is safe on Fabric's threading model. No Folia-isms (
Bukkit.isOwnedByCurrentRegion, region/global schedulers) leak into Fabric code paths. - Fabric test suite — adapt existing unit and integration tests where they exercise platform abstractions.
-
Dual-runtime end-to-end smoke test (moved here from Phase 1) — the single produced JAR loads cleanly on a Paper test server and on a Fabric test server from the same artifact, and
/rtp(or the equivalent command tree) executes end-to-end on each. This guards against Loom remap-scoping regressions (the only single-point-of-failure introduced by single-JAR packaging) and validates that Steps A–G actually compose into a working Fabric mod. This is the gate that proves ADR-022's single-JAR multi-loader packaging works end-to-end; it cannot meaningfully run earlier because no prior phase delivers enough featureset to teleport on Fabric. -
Acceptance gate: all existing S-00x regression guards (
ReqRtpS005ChunkLoadingTest,ReqRtpS004NullChunkAttributionTest, etc.) green when run against Fabric implementations of the relevant abstractions, AND the dual-runtime end-to-end smoke test passes on both Paper and Fabric from the same JAR.
Phase 3: Documentation & Release¶
- [x] Admin documentation (2026-05-31) —
docs/admin/updated with Fabric-specific installation and configuration instructions. - [x] Developer documentation (2026-05-31) —
docs/dev/updated to reflect the multi-platform architecture and Fabric contribution guidelines. - [x] TRACEABILITY.md (2026-05-31) — rows added for every new REQ-traceable Fabric test (Steps A, B, F, etc.).
- [x] CHANGELOG.md (2026-05-31) — one entry per phase added under "Unreleased".
- [x] COVERAGE_PLAN.md (2026-05-31) — Fabric column added to the platform-coverage matrix.
- [x] Beta release (2026-05-31) — first public beta of RTP for Fabric shipped (Phase 2 Step H gate green and Phase 3 docs merged).
Phase 4: NeoForge Adapter (rtp-neoforge)¶
NeoForge is an in-scope target platform per ADR-033; legacy Forge, Sponge, and hybrid servers (Mohist / Magma / Arclight) remain out of scope. The landscape analysis, API-surface delta from Fabric, reuse map, risks, and S-00x mapping live in NEOFORGE_NOTES.md. The phase rows below scope the work; they mirror the Fabric Phases 0-3 / Steps A-K structure so the Fabric experience transfers directly.
Activation gate (ADR-033 Decision §2) — CLEARED 2026-06-01. The gate required the Fabric platform to clear its stability bar: no open S-005 violations in rtp-fabric, the FabricServerAccessor.getLocationGenerator null stub resolved, the Loom dependency resolved, and a green rtp test full on at least one shipped MC carrier. Fabric is confirmed stable as of 2026-06-01 (Phase 2 Step H dual-runtime smoke test passed; Fabric beta shipped Phase 3, 2026-05-31). The ADR-033 §3 bring-up prerequisites (Phase N0) are now in progress: the D-005 proposal (scratch/PROPOSAL-neoforge-bringup.md) and the subproject ADR (rtp-neoforge-ADR-001) are drafted, rtp-neoforge/REQUIREMENTS.md is authored, and TRACEABILITY placeholder rows are added. The D-005 proposal was approved by the project lead on 2026-06-01 and the named maintainer gate is satisfied (project lead, @leaf_26), clearing both prerequisites. The Phase N1 module skeleton landed 2026-06-02 (see Phase N1 below); the platform-adapter implementation surfaces (Phase N2) and documentation/release (Phase N3) are now complete - NeoForge is a runtime-functional, first-class platform.
Ownership. Per ADR-033 / ADR-022, NeoForge bring-up requires a named maintainer who owns the platform end-to-end (build, mappings, CI toolchain, S-00x proofs, ongoing maintenance) before Phase N1 begins. Owner: project lead (@leaf_26), assigned 2026-06-01.
Phase N0 — Scope unlock & bring-up prerequisites (ADR-033 §3)¶
- [x] D-005 proposal (2026-06-01) referencing ADR-033 and
NEOFORGE_NOTES.md, confirming the Fabric activation gate is clear:scratch/PROPOSAL-neoforge-bringup.md. Maintainer assignment still open (see Ownership). - [x] Subproject ADR (2026-06-01)
platforms/rtp-neoforge/docs/adr/rtp-neoforge-ADR-001-platform-in-scope.mdmirroring rtp-fabric-ADR-002 (subproject ADR numbering restarts at001per AGENTS.md Self-Updating Protocol); row added to the Subproject ADRs table indocs/adr/README.md. - [x]
rtp-neoforge/REQUIREMENTS.md(2026-06-01) authored (platforms/rtp-neoforge/REQUIREMENTS.md), mirroringplatforms/rtp-fabric/REQUIREMENTS.md:REQ-NEOFORGE-F-001..0NN(accessor / world / player / scheduler / single-JAR-or-mod metadata / lifecycle wiring / login reserve) andREQ-NEOFORGE-ARCH-001..0NN(no-Bukkit-imports, no-core-pollution, build-plugin scope, S-005 async chunk loading, S-004 failure attribution, memory hygiene, S-006 fail-loud + pre-init guard, config dir resolution, DB delegation), restating the Fabric requirements against NeoForge entry-point / event-bus / toolchain surfaces. - [x] Build-toolchain decision (2026-06-01) captured in rtp-neoforge-ADR-001 §4: ModDevGradle (over NeoGradle), Java 21+ (REQ-RTP-SYS-001). The
run_test/ IntelliJ.rundev-server confirmation spike remains a Phase N1 task. - [x] Obf/unobf carrier decision (2026-06-01) captured in rtp-neoforge-ADR-001 §3: Mojmap-at-runtime, no obf carrier expected, but the per-version structural split (NM-typed surfaces isolated from
rtp-core/rtp-api) is required.
Phase N1 — Module skeleton & mod bootstrap¶
- [x] (2026-06-02) Sibling module tree
rtp-neoforge/(README) withrtp-neoforge-common+ thertp-neoforge-v1_21_R1per-MC carrier (aV1_21_R1NeoForgeVersionAdapterstub reserves the NM-typed-surface isolation seam), mirroring rtp-fabric-ADR-001. Sibling ofrtp-fabric, not nested (ADR-033 alternatives,NEOFORGE_NOTES.md§11). Additional carriers (v1_20_R1, later revs) added as needed. - [x] (2026-06-03) Second carrier
rtp-neoforge-v26_1_R1(MC 26.1) authored and built against the real runtime: pins MC 26.1.2 / NeoForge 26.1.2.71 / ModDevGradle 2.0.141. The 26.x line is fully deobfuscated (Mojmap with native parameter names, Parchment removed) and runs on Java 25, so the carrier pins a Java 25 toolchain and is gated under-PexcludeJdk25(alongside the JDK-25 Fabric carriers) insettings.gradle- a JDK-21-only host still produces a 1.21.x-capable NeoForge jar without it.V26_1_R1NeoForgeVersionAdapteris a faithful clone of the 1.21.1 carrier;RTPNeoForgeMod#installVersionAdapterroutes26.*to it;rtp-pluginmerges its Java 25 bytecode into the unified jar (findProject-guarded). Compiled, linked, and runtime-loaded: built against the real NeoForge 26.1.2 userdev artifacts (26.1.2.71, JDK 25) and the adapter loads cleanly on a 26.1.2 dev server (Active version adapter: 26.1.2boot line), so the Mojmap API surface is verified. Chunk-stall fix (2026-06-03): a live/rtpon 26.1.2 hung with L1 kept-cache stuck at 0, no chunk tickets, and the first teleport never completing;min_level: ALLlogs confirmedgetOrLoadChunk ... TimeoutException, i.e. the live chunk future never resolved. Root cause:requestFullChunkAsyncadded its own transient load-ticket and removed it inside awhenCompletethat ran off the server thread, wedging the 26.x chunk holder. Fixed by dropping the manual add/remove and relying ongetChunkFuture(..., create=true)self-issuing the transient ticket, matching the proven Fabric 26.1 path (rtp-fabric-ADR-008); the persistent kept-cache ticket is still applied on the server thread viaapplyTicket(S-002 unaffected). The/rtpround-trip is verified end-to-end on the JDK-25 host; re-pin versions when 26.1/26.2 bumps. Routing for any rolling 1.21.x-head is unchanged:startsWith("1.21")already serves the whole 1.21 line from thev1_21_R1carrier. - [x] (2026-06-02)
@Mod-annotated entry pointRTPNeoForgeMod+META-INF/neoforge.mods.toml; game-busNeoForge.EVENT_BUSsubscriptions wired for server lifecycle, per-tick scheduler drain, andRegisterCommandsEvent(the mod-busIEventBusis retained for future setup-phase wiring). A complete server-threadNeoForgeScheduler(port ofFabricScheduler) and aNeoForgeCommandRegistrartrampoline scaffold ship. TheNeoForgeServerAccessor(RTPServerAccessorimpl) +RTPAPI.serverAccessor/RTP.schedulerbinding landed in Phase N2 Step NE, so the mod is fully runtime-functional. - [x] (2026-06-02) Build-plugin scoping: ModDevGradle (
net.neoforged.moddev) applied only underrtp-neoforge/**; platform-neutral modules (rtp-core,rtp-api,anvil-api,commands-api) reused 1:1 viaproject(...)deps with no NeoForge coupling. The NeoForge modules are merged into the unifiedLeafRTP-Projar byrtp-plugin(Mojmap bytecode +META-INF/neoforge.mods.tomlappended post-remapJar) and are therefore included by default; network-constrained hosts drop them with-PexcludeNeoforge(orEXCLUDE_NEOFORGEenv), in which case the unified jar carries noneoforge.mods.toml. The maintainer verifies the full build on a network-capable host. - [x] (2026-06-02) Distribution decision: unified multi-loader jar. NeoForge ships inside the released
LeafRTP-Pro-<version>.jar(one artifact loads on Bukkit/Paper/Folia, Fabric, Velocity, and NeoForge), merged post-remapJarlike the deobf MC 26.x carriers. - [x] (2026-06-18) Lite-jar NeoForge merge landed. The same gated
mergeNeoForgeBytecodeIntoJarpass now also runs inrtp-plugin'sremapLiteJar, so the rtp-liteLeafRTP-<version>.jarcarriesMETA-INF/neoforge.mods.toml+ the Mojmap NeoForge carrier bytecode and loads on NeoForge too (one artifact across editions). Without it, NeoForge reported the lite jar as "is a Fabric mod and cannot be loaded" and any addon with arequireddependency on thertpmod (e.g. the LeafRTP GUI addon) failed to load. Still gated on the NeoForge modules being in the build graph (dropped under-PexcludeNeoforge/EXCLUDE_NEOFORGE).
Phase N2 — Platform adapter steps (mirror Fabric Steps A-K)¶
- [x] Step NA - Async chunk load (S-005) (completed 2026-06-13) -
NeoForgeRTPWorld.getChunkAtreturnsCompletableFutureand routes throughMinecraftServer#submit/ the server-thread executor; never a synchronousServerLevel#getChunk(..., load=true)on the tick thread. Ports the Fabric adaptive promotion-cap learnings (Phase 2 Step E3 runtime mitigation). - [x] Step NB -
getLocationGenerator()real (S-006 fail-loud) (completed 2026-06-13) - throwsIllegalStateExceptionpre-init, never null/no-op. - [x] Step NC -
NeoForgeScheduler(completed 2026-06-13) - fullRTPSchedulerimpl (sync / async / delayed / repeating), advanced on the server tick event; all periodic work routes throughRTP.scheduler(no raw executors per AGENTS.md Scheduler Usage). No Folia region-ownership analog (single-main-thread). - [x] Step ND - Database (completed 2026-06-13) - delegates to
rtp-core'sDatabaseHandler/DatabaseProcessing(now platform-neutral inrtp-core); no NeoForge-specific persistence abstraction. Config dir via the NeoForge config path. - [x] Step NE - Event bridge & lifecycle (completed 2026-06-13) -
RTPServerAccessor/RTPPlayer/ world-cache wiring on NeoForge server-start / server-stopping / world-load / world-unload / player-join / player-disconnect events (mod-bus + game-bus), mirroring FabricFabricEventBridge;MemoryTrackerregister/release on all exit paths. TheNeoForgeServerAccessor(RTPServerAccessorimpl) +RTPAPI.serverAccessor/RTP.schedulerbinding (the Phase N1 open carry-over) landed here. - [x] Step NE-perf - Anvil pre-filter parity (ADR-016, rtp-fabric-ADR-005) (completed 2026-06-13) - same
.mcasubstrate, near-verbatim port;probeChunkColumnoverride dispatching ontoAnvilIoPool, dual-mode live/anvil chunk view. - [x] Step NF — Permissions (landed 2026-06-02) —
NeoForgeRTPPlayer.hasPermissionresolves through a three-tier chain ported from Fabric (rtp-fabric-ADR-011): LuckPerms (reflective, no compile-time dep onnet.luckperms:api) →NeoForgeDefaultPermissions(plugin.ymldefault table) → on-diskops.jsonop-level scan. NewNeoForgeDefaultPermissions,NeoForgeOnEventPermissions,NeoForgeEffectivePermissionsResolver(feeds the menu surface + console op-equivalent variant), andLuckPermsNeoForgeEnumerator(node enumeration + cached-data wildcard-aware tri-state check) under.../neoforge/player/;getEffectivePermissionswired on the player and the console sender. LuckPerms soft-depend cataloged inEXTERNAL_HOOKS.md(ADR-026). Verified:.\gradlew -PincludeNeoforge :rtp-neoforge:rtp-neoforge-v1_21_R1:buildSUCCESSFUL. A dedicated permission-node parity unit test (mirroringFabricDefaultPermissionsParityTest) remains a Phase N3 follow-up (Step NH/N3 traceability). - [x] Step NG — Command registration (landed 2026-06-02) — registers the
commands-apitree via the reusableBrigadierCommandAdapter.toBrigadier(commands-api-ADR-001) from theRegisterCommandsEventgame-bus handler inRTPNeoForgeMod.NeoForgeCommandRegistrarbuildsRTPCmdNeoForgeRoot(port ofRTPCmdFabricRoot:region+nested/biome/player/world/toggletargetpermsparams +reload/config/scan/info/clearcachesubcommands) and aBrigadierBridgeContext<CommandSourceStack>backed by the typedNeoForgeBrigadierSourceBridge. Unlike Fabric, no reflectiveProxy/intermediary dance is needed (Mojmap-at-runtime): the source bridge referencesCommandSourceStack/ServerPlayerdirectly./rtp menuwiring deferred to Step NI. Verified:.\gradlew -PincludeNeoforge :rtp-neoforge:rtp-neoforge-v1_21_R1:buildSUCCESSFUL. Live tab-completion smoke pending a running NeoForge dev server (Step NH). - [x] Step NH - Stabilization & runtime smoke (completed 2026-06-13) - memory-leak audit (chunk tickets +
TeleportPipelineTask), concurrency review (no Folia-isms),/rtpend-to-end on a NeoForge dev server, and S-00x regression guards all green. - [x] Step NI — Menu framework parity (landed 2026-06-02) —
RTPCmdNeoForgeRootwires the platform-neutralMenuWiringSupport.attachTowith aNeoForgeBookMenuRenderer(translatesMenuModel→ a fully-formattedNeoForgeBookSpecand dispatches an interactive written-book modal through a newNeoForgeVersionAdapter.openBookMenuSPI; thev1_21_R1carrier implements it with a typedWrittenBookContent+ transient-slotClientboundContainerSetSlotPacket→ClientboundOpenBookPacket→ slot-revert, falling back toChatMenuRendererotherwise — parchment contrast rule honoured) and aNeoForgeChatPromptCallback(TTL-bounded anvil-input substitute backed by the typed, cancelable NeoForgeServerChatEventonNeoForge.EVENT_BUS— no reflective proxy, unlike Fabric). New player capability markerNeoForgeBookOpenerkeeps the rawServerPlayerprivate toNeoForgeRTPPlayer. Verified:.\gradlew -PincludeNeoforge :rtp-neoforge:rtp-neoforge-v1_21_R1:buildSUCCESSFUL. - [x] Step NJ - Network mode backend parity (completed 2026-06-13) - the backend sampler portion landed 2026-06-02: a NM-free
NeoForgeBackendStateSampler(port ofFabricBackendStateSampler; reads TPS/MSPT/player-count fromRTP.metricsand loaded worlds viaRTPServerAccessor.getRTPWorlds()) is installed ontoRTP.backendStateSamplerFactoryinRTPNeoForgeMod.bootCore. The live boot -NetworkModeBootstrap.boot(networkYml), theNeoForgePlayerLifecycleHookjoin-redeem / waitlist-quit wiring, and reservation-token redemption on arrival (ADR-049 Step J) - landed, so a proxied player arriving on a NeoForge backend can redeem a reservation token. - [x] Step NK — Maps API parity (landed 2026-06-02) — NM-free
NeoForgeMapBinding+NeoForgeMapCanvasagainst themaps-apiSPI (128×128 ARGB buffer → carrierrenderMapChartseam), installed inRTPNeoForgeModonly when the active carrier opts in viasupportsMapCharts(), withNoopMapBindingfallthrough otherwise (thev1_21_R1carrier leaves the seam at its default this pass, so MapDispatch stays on the Noop sentinel until a carrier implements the vanilla filled-map write). Per-viewer state released on disconnect (REQ-RTP-MAP-003) via theMapBindingLifecyclehook +MapDispatch.firePlayerQuit. ANeoForgeMetricsBinding(single-region EMA TPS/MSPT sampler, metrics-api) also landed: installed viaRTP.metrics.setBindingand ticked fromonServerTick. Verified:.\gradlew -PincludeNeoforge :rtp-neoforge:rtp-neoforge-v1_21_R1:buildSUCCESSFUL.
Phase N3 — Documentation, traceability & release¶
- [x] TRACEABILITY.md (completed 2026-06-13) - NeoForge REQ-traceable rows added; the S-005 and S-006 guards (
ReqRtpNeoforgeS005ChunkLoadingTest,ReqRtpNeoforgeS006EarlyApiTest) authored. - [x] Admin & developer docs (completed 2026-06-13) - Fabric-style install/config docs for NeoForge added under
docs/admin/;docs/dev/architecture notes andCOVERAGE_PLAN.md(NeoForge column) updated. - [x] CHANGELOG.md (completed 2026-06-13) - entries added under the unreleased heading (marked
**(Pro)**where edition-specific). - [x] Front-page / README (completed 2026-06-13) - the NeoForge rows now read "supported" (native NeoForge on Minecraft 1.21.x / 26.1.x).
- [x] Beta release (completed 2026-06-13) - first public NeoForge beta shipped (Phase N2 Step NH gate green and Phase N3 docs merged). NeoForge is a complete, first-class platform.
Mod-side claim integrations & out-of-scope reminders¶
- [ ] Mod-side land protection (FTB Chunks, OpenPartiesAndClaims, Argonauts, ...) handled identically to Bukkit claim plugins: reflection-gated soft hooks per ADR-026, cataloged in
EXTERNAL_HOOKS.md. No claim-mod code in the pipeline (S-003). - [ ] Lazy claim-space poisoning (cross-platform,
rtp-corepipeline). When the S-003 verifier rejects a coordinate due to a claim hit, expand the rejection into the dirty cache for the entire claim footprint rather than only the single coordinate. Algorithm: (1) binary-search radial expansion from the hit point using only stateless boolean claim checks - no claim object retained between iterations - until a claim-free boundary is found or the shape edge is reached; (2) walk the resulting bounding square at chunk-grid resolution, filtering each grid point throughshape.isInBounds()before issuing a claim check, and mark every in-bounds claimed point dirty inMemoryShape; (3) release all references. This is plugin-agnostic (requires only the existing boolean verifier call), self-healing (dirty points are reclaimed by the normal scan cycle if the claim is removed), and correct under concurrent claim mutation (the final safety check at teleport time remains the authoritative gate per S-003). Net effect: after the first hit on a claim, subsequent spiral draws skip its footprint with zero claim-plugin calls. - Out of scope (unchanged): legacy Forge (<=1.20.1) bring-up, Sponge, hybrid servers (covered transitively via
rtp-paper), and AccessTransformers / Mixins (a red flag if needed - re-examine the public API first). SeeNEOFORGE_NOTES.md§11. - [ ] Architectury? — re-evaluate a common mod-loader abstraction layer (Architectury) only if maintaining parallel Fabric + NeoForge carrier trees proves costly;
NEOFORGE_NOTES.md§11 currently flags a shared tree as a likely trap.
What Does NOT Need to Change in rtp-api or rtp-core¶
The April 2026 gap analysis (referenced in rtp-fabric-ADR-002) confirmed the existing abstractions are sufficient for full Fabric support:
RTPServerAccessor,RTPWorld,RTPPlayer,RTPSchedulerinterfaces require no new methods.DatabaseHandlerinrtp-coreis already platform-agnostic.LocationGenerator,TeleportPipelineTask, andMemoryTrackerare untouched — Fabric wires into them viaRTP.getInstance().
The only potential future rtp-api addition is an RTPPermissionProvider interface to formalize the soft-depend pattern, but this is deferred until the Step F permissions work is complete and the pattern is proven in production.
Risks & Mitigations¶
| Risk | Mitigation |
|---|---|
| Loom plugin pollutes other modules' classpaths | Apply Loom only under platforms/rtp-fabric/**; gate Maven repos by project.path.startsWith(':rtp-fabric'). |
Hidden S-005 violation re-introduced via an unrecognized Mojang getChunk call |
Step A regression test plus a stretch-goal arch guard banning direct ServerLevel#getChunk from platforms/rtp-fabric/**. |
| Fabric tick threading vs. Folia region threading subtle drift | FabricScheduler documented behaviour matches the RTPScheduler contract; no Folia-isms leak into core. |
Brigadier tree drift from the commands-api tree |
Single adapter in commands-api (commands-api-ADR-001); no per-platform branching. |
MemoryTracker leaks on disconnect mid-pipeline |
ServerPlayConnectionEvents.DISCONNECT releases all tickets owned by the player, mirroring Bukkit PlayerQuitEvent cleanup. Step H audit. |
| Scope creep into Forge | Explicitly out of scope until Phase 4. |
Ownership¶
Per ADR-022, a named maintainer owns Fabric end-to-end (build, mappings, CI toolchain, S-00x proofs, ongoing maintenance). Owner: @leaf_26 (project lead; recorded 2026-04-30). Phase 3 (public beta release) gate is satisfied; Phase 1 and Phase 2 work proceeds against this ownership.