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.
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.
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.
- Never register a libdatachannel callback from PHP. Foreign-thread callback into PHP is a VM crash, ZTS or not. Everything is polled.
- 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.
- 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 asServerIdentity.php, signing only; nothing verifies the client's side. The key is what returning clients pin, so persist the .pem. - 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.
- 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.
- PHP: your own PM5 binary. It lacks ext-ffi, but the stock
php_ffi.dllfrom the matching windows.php.net release (same version, ZTS, same VS toolchain) drops intobin/php/ext/and loads. For your own builds it is--with-ffiin the pipeline, one line; ext-sockets style, nothing exotic. - 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.
- Drop
demo-plugin's phar (build it withphp -d phar.readonly=0 demo-plugin/build-phar.php) intoplugins/, setLIBDATACHANNEL_DLLandNETHERNET_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.