Skip to content

Instantly share code, notes, and snippets.

@NiclasOlofsson
Last active August 12, 2026 00:01
Show Gist options
  • Select an option

  • Save NiclasOlofsson/0f4a24c896dc2240d5350c6cb17b7cdc to your computer and use it in GitHub Desktop.

Select an option

Save NiclasOlofsson/0f4a24c896dc2240d5350c6cb17b7cdc to your computer and use it in GitHub Desktop.
NetherNet (WebRTC) transport for PocketMine-MP via PHP FFI over libdatachannel - verified with a real Bedrock client

NetherNet transport for PocketMine-MP, on libdatachannel through PHP FFI

A working stand-in for the RakNet layer that speaks NetherNet (Bedrock's WebRTC transport), built as a pocketmine\network\NetworkInterface on pure PHP FFI over prebuilt libdatachannel binaries. No PHP extension to build or maintain; the one native file is a ~100-line event shim that any C toolchain compiles.

The attached php-nethernet.zip is the whole project: transport, signaling, PocketMine glue, demo plugin, tests, shim source and its README with the details.

Verified with a real vanilla client

Running inside a stock PocketMine-MP 5.44.3 (PHP-Binaries PM5, PHP 8.2 ZTS, with FFI enabled), RakNet unregistered so nothing else could carry the traffic:

signaling: refused TLS, client will fall back to plaintext
signaling <<< GET /v1/join
signaling <<< POST /v1/join/16700003363312956180 (2685B body)
signaling >>> answer for 16700003363312956180 (1607B)
[NetworkSession: nethernet:16700003363312956180 0] Session opened
Session closed: Incompatible protocol version (2168)

A real Bedrock client probed, negotiated, brought up ICE + DTLS + SCTP, opened its two data channels in-band, and delivered RequestNetworkSettings into a real NetworkSession. The close at the end is PocketMine's own version gate (the test client was a protocol 2168 build, ahead of BedrockProtocol's 1001 / 1.26.30); a client matching the supported protocol proceeds into normal login.

Architecture in one paragraph

libdatachannel fires callbacks from its own native threads, and calling into PHP from a foreign thread kills the VM. So the packet path never registers a single callback: it uses the C API's buffered polling mode (rtcReceiveMessage + rtcGetAvailableAmount, legal only when no message callback is set) driven from a tick loop, the same shape RakLib already has. The few events that exist only as callbacks (DCEP channel opens, state changes) go through rtc_events_shim.c: native callbacks push fixed-size records into a mutex-guarded ring, PHP polls the ring. Deployment is two sockets on the game port number: TCP 19132 for the one-round-trip HTTP signaling (GET /v1/join, POST /v1/join/{networkId}) and UDP 19132 for every peer's traffic through one ICE-mux socket.

Hard-won facts you want before touching this

  1. Never register a libdatachannel callback from PHP. Foreign-thread callback into PHP is a VM crash, ZTS or not. Everything is polled.
  2. The anchor rule: FFI handles are per-thread. If a pmmpthread worker uses the binding and its teardown drops the DLL's last reference while libdatachannel's own threads are alive, the process segfaults. The main thread must hold one binding instance for the process lifetime.
  3. The SDP answer must carry a signed a=identity (self-signed ES384 token naming the operator key, plus a detached JWS over the fingerprint lines) or the client refuses with "You're not invited to play on this server". Included as ServerIdentity.php, signing only; nothing verifies the client's side. The key is what returning clients pin, so persist the .pem.
  4. The client tries TLS on the signaling port first. Answer the ClientHello with a fatal handshake_failure alert (7 bytes) and it retries in plaintext.
  5. On Windows, bind-probing a UDP port lies (SO_REUSEADDR lets a second bind succeed over a live socket). Read the OS socket table when checking teardown.

Quickstart (Windows)

  1. PHP: your own PM5 binary. It lacks ext-ffi, but the stock php_ffi.dll from the matching windows.php.net release (same version, ZTS, same VS toolchain) drops into bin/php/ext/ and loads. For your own builds it is --with-ffi in the pipeline, one line; ext-sockets style, nothing exotic.
  2. libdatachannel: upstream publishes no prebuilt binaries; for development we used the Julia ecosystem's (libdatachannel_jll 0.20.2 mingw build, plus OpenSSL 3 and the gcc runtime DLLs from OpenSSL_jll and CompilerSupportLibraries_jll). For a release, build current libdatachannel in CI and compile the shim in the same pass; build one-liners are in the shim header. On Windows the shim resolves libdatachannel at runtime, so it links against nothing and toolchains need not match.
  3. Drop demo-plugin's phar (build it with php -d phar.readonly=0 demo-plugin/build-phar.php) into plugins/, set LIBDATACHANNEL_DLL and NETHERNET_SHIM_DLL, have the DLL's dependencies on PATH, and start the server. The plugin unregisters RakNet, serves NetherNet on tcp+udp 19132, and runs a built-in loopback client through the full HTTP + DCEP path at boot as a self-test.

The zip's README covers the rest: per-file layout, the test suite (loopback, DCEP, teardown, pmmpthread threading shape), and the open items, of which the real ones are LAN discovery / franchise WebSocket signaling (the dedicated-server add-by-address path is done) and moving the tick off the main thread onto a worker, which the threading test already proves safe under the anchor rule.

Questions to gnat (Niclas Olofsson). The design mirrors MiNET's NetherNet implementation, which is where the signaling protocol and identity assertion details were reverse engineered.

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