feat: Modern Forge (FML2/FML3) login relay for 1.13-1.20.1#680
Merged
robinbraemer merged 7 commits intomasterfrom Apr 4, 2026
Merged
feat: Modern Forge (FML2/FML3) login relay for 1.13-1.20.1#680robinbraemer merged 7 commits intomasterfrom
robinbraemer merged 7 commits intomasterfrom
Conversation
Enable Forge mod negotiation through the proxy when using Velocity modern forwarding, which was previously unsupported (same as upstream Velocity). The proxy now relays fml:loginwrapper LoginPluginMessages between the backend Forge server and the client during the LOGIN phase. For initial connections, LoginSuccess is delayed to keep the client in LOGIN state while the FML handshake runs. For server switches, cached client responses are replayed to the new backend.
Simulates a Forge 1.20.1 (FML3) client connecting through a real Gate proxy (with real TCP connections) to a mock Forge backend. Verifies the complete relay flow: 3 fml:loginwrapper messages relayed from backend to client, 3 responses forwarded back, and LoginSuccess sent after the FML handshake completes.
ff1a4c4 to
378e7aa
Compare
Deploying gate-minekube with
|
| Latest commit: |
466aee6
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://61402eca.gate-minekube.pages.dev |
| Branch Preview URL: | https://feat-modern-forge-relay.gate-minekube.pages.dev |
The previous approach ran connectToInitialServer in a goroutine so the client's read loop could process LoginPluginResponse packets. But this meant the read loop blocked in Decode holding the decoder mutex, making SetActiveSessionHandler hang for 30s (the read timeout). Fix: read client responses directly from the backend goroutine via Reader().ReadPacket(). Since the client goroutine is blocked in connectToInitialServer (not in Decode), the decoder mutex is free. handleJoinGame can then switch the client to PLAY without blocking.
The decoder held its mutex during blocking network I/O in Decode(). When the backend goroutine called SetActiveSessionHandler on the client (to switch from LOGIN to PLAY after the FML relay), SetState blocked waiting for the decoder mutex — causing a 30s hang. Fix: use atomic.Pointer for the decoder's registry and state fields so SetState/SetProtocol can proceed without the mutex. This is safe because the registry is only read (not mutated) during decode, and the atomic swap ensures the next decode uses the updated registry. This restores the async goroutine approach for the FML relay, which correctly handles client LoginPluginResponses via the auth session handler's read loop.
- Fix deadlock: CurrentServer() called while holding player.mu write lock in server.go — use connectedServer_ field directly - Remove .DS_Store files and config-forge-test.yml from PR - Unexport forgeLoginExchange fields, remove redundant Success field (derivable from response != nil) - Remove dead first loginState.Store (immediately overwritten) - Simplify replayRelay cleanup in handleServerLoginSuccess - Remove no-op init() and dead var _ assertion from integration test - Remove unused imports
Document the built-in FML login relay for Forge 1.13-1.20.1, covering PCF setup for Velocity forwarding, server switching behavior, and the distinction from 1.20.2+ (which uses CONFIG phase natively).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Enable Forge 1.13-1.20.1 mod negotiation through Gate when using Velocity modern forwarding — a combination that was previously unsupported in both Gate and upstream Velocity.
Verified working: tested with a real Forge 1.20.1 client + PCF backend + Velocity forwarding. Master rejects with
{multiplayer.disconnect.unexpected_query_response}, this branch connects successfully.The Problem
For Forge 1.13-1.20.1, mod negotiation happens via
fml:loginwrapperLoginPluginMessages during the LOGIN phase. By the time the proxy connects to the backend, the client has already completed login with the proxy and is in PLAY state — so the proxy can't forward these messages back to the client. The backend interprets the proxy's rejection (Success: false) as a protocol failure and kicks the player.See #676 (comment) for the full investigation.
The Solution
Delayed LoginSuccess + FML Message Relay — the same approach used by Ambassador for Velocity, but built natively into Gate:
Initial connection: When a Modern Forge client (FML2/FML3) connects to a Forge backend on pre-1.20.2:
ServerLoginSuccessto keep the client in LOGIN statefml:loginwrapperLoginPluginMessages from backend → client vialoginInboundConnServerLoginSuccessto the clientServer switch: Cached FML exchanges from the initial connection are replayed to the new backend, allowing silent switches between compatible Forge servers.
Decoder lock-free SetState: The packet decoder's
SetState/SetProtocolnow useatomic.Pointerinstead of holding the mutex. This allows switching the client from LOGIN to PLAY state from the backend goroutine without blocking on the decoder mutex (which is held during blocking network I/O inDecode).Compatibility Matrix
Architecture
Files Changed
proto/codec/decoder.go— Makeregistry/stateatomic pointers soSetStatedoesn't block on the decoder mutex during network I/Oproxy/forge_login_relay.go(new) — Core relay mechanism:modernForgeLoginRelayfor initial connections,modernForgeReplayRelayfor server switchesproxy/session_client_auth.go— Delay LoginSuccess for Modern Forge pre-1.20.2; handleLoginPluginResponsein auth session handlerproxy/session_backend_login.go— Relayfml:loginwrappermessages; complete relay onServerLoginSuccess; switch client to PLAYproxy/login_inbound.go— AddclearOnAllMessagesHandled()to prevent PreLogin callback from re-firing during relayproxy/player.go— AddforgeLoginRelay,forgeReplayRelay,forgeLoginCachefieldsproxy/server.go— Set up replay relay on server switch for Modern Forge playersTest Plan
Unit tests:
TestModernForgeRelay_Complete— LoginSuccess sent on relay completionTestModernForgeReplayRelay— cached response replay for server switchTestModernForgeReplayRelay_ExhaustedCache—Success=falsewhen cache runs outTestModernForge_BackendLoginHandler_ReplayOnSwitch— server switch replay via handlerTestModernForge_VanillaClientUnaffected— vanilla clients unaffectedTestModernForge_DelayedLoginSuccess_Detection— version/type detection matrixTestModernForge_TestSetup_VersionCheck— version comparison sanity checkIntegration test (real TCP connections):
TestModernForgeIntegration_FullJoinFlow— full wire-level test: FML3 client → Gate proxy → mock Forge backend, 3fml:loginwrappermessages relayed bidirectionally, LoginSuccess sent after FML handshake, JoinGame forwardedManual test:
{multiplayer.disconnect.unexpected_query_response}All existing Forge tests (
TestForgeE2E_*) continue to pass.Closes #613