Skip to content

feat: Modern Forge (FML2/FML3) login relay for 1.13-1.20.1#680

Merged
robinbraemer merged 7 commits intomasterfrom
feat/modern-forge-relay
Apr 4, 2026
Merged

feat: Modern Forge (FML2/FML3) login relay for 1.13-1.20.1#680
robinbraemer merged 7 commits intomasterfrom
feat/modern-forge-relay

Conversation

@robinbraemer
Copy link
Copy Markdown
Member

@robinbraemer robinbraemer commented Apr 3, 2026

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:loginwrapper LoginPluginMessages 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:

  1. Initial connection: When a Modern Forge client (FML2/FML3) connects to a Forge backend on pre-1.20.2:

    • Gate detects the FML marker in the handshake hostname
    • Delays sending ServerLoginSuccess to keep the client in LOGIN state
    • Connects to the initial backend server in a background goroutine
    • Relays fml:loginwrapper LoginPluginMessages from backend → client via loginInboundConn
    • Forwards client responses back to the backend via consumer callbacks
    • When the backend completes login, sends the delayed ServerLoginSuccess to the client
    • Client transitions to PLAY and normal flow continues
  2. Server switch: Cached FML exchanges from the initial connection are replayed to the new backend, allowing silent switches between compatible Forge servers.

  3. Decoder lock-free SetState: The packet decoder's SetState/SetProtocol now use atomic.Pointer instead 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 in Decode).

Compatibility Matrix

Setup Status
Forge 1.20.2+ with any forwarding mode Works (FML uses CONFIG phase — unchanged)
Forge 1.13-1.20.1 with BungeeCord forwarding Works (mod list in handshake — unchanged)
Forge 1.13-1.20.1 with Velocity Modern Forwarding Works (NEW — this PR)
Vanilla with any forwarding mode Works (unchanged)
Legacy Forge 1.8-1.12 Works (existing state machine — unchanged)

Architecture

Before (broken):
  Client LOGIN → LoginSuccess → Client PLAY
  Proxy → Backend LOGIN → fml:loginwrapper → Proxy rejects → Player kicked

After (this PR):
  Client LOGIN → [LoginSuccess delayed]
  Proxy → Backend LOGIN → fml:loginwrapper → Proxy relays to client (still LOGIN)
  Client responds → Proxy forwards to backend → FML handshake completes
  Backend ServerLoginSuccess → Proxy sends delayed LoginSuccess → Client PLAY

Files Changed

  • proto/codec/decoder.go — Make registry/state atomic pointers so SetState doesn't block on the decoder mutex during network I/O
  • proxy/forge_login_relay.go (new) — Core relay mechanism: modernForgeLoginRelay for initial connections, modernForgeReplayRelay for server switches
  • proxy/session_client_auth.go — Delay LoginSuccess for Modern Forge pre-1.20.2; handle LoginPluginResponse in auth session handler
  • proxy/session_backend_login.go — Relay fml:loginwrapper messages; complete relay on ServerLoginSuccess; switch client to PLAY
  • proxy/login_inbound.go — Add clearOnAllMessagesHandled() to prevent PreLogin callback from re-firing during relay
  • proxy/player.go — Add forgeLoginRelay, forgeReplayRelay, forgeLoginCache fields
  • proxy/server.go — Set up replay relay on server switch for Modern Forge players

Test Plan

Unit tests:

  • TestModernForgeRelay_Complete — LoginSuccess sent on relay completion
  • TestModernForgeReplayRelay — cached response replay for server switch
  • TestModernForgeReplayRelay_ExhaustedCacheSuccess=false when cache runs out
  • TestModernForge_BackendLoginHandler_ReplayOnSwitch — server switch replay via handler
  • TestModernForge_VanillaClientUnaffected — vanilla clients unaffected
  • TestModernForge_DelayedLoginSuccess_Detection — version/type detection matrix
  • TestModernForge_TestSetup_VersionCheck — version comparison sanity check

Integration test (real TCP connections):

  • TestModernForgeIntegration_FullJoinFlow — full wire-level test: FML3 client → Gate proxy → mock Forge backend, 3 fml:loginwrapper messages relayed bidirectionally, LoginSuccess sent after FML handshake, JoinGame forwarded

Manual test:

  • Forge 1.20.1 client + PCF mod + Velocity forwarding — player joins successfully
  • Same setup on master — confirms {multiplayer.disconnect.unexpected_query_response}

All existing Forge tests (TestForgeE2E_*) continue to pass.

Closes #613

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.
@robinbraemer robinbraemer force-pushed the feat/modern-forge-relay branch from ff1a4c4 to 378e7aa Compare April 3, 2026 22:11
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 3, 2026

Deploying gate-minekube with  Cloudflare Pages  Cloudflare Pages

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

View logs

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).
@robinbraemer robinbraemer merged commit df65927 into master Apr 4, 2026
7 checks passed
@robinbraemer robinbraemer deleted the feat/modern-forge-relay branch April 4, 2026 09:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant