From d85ce79bc3a171ff5b384ccab4f3d62db5cf220a Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:26:46 -0700 Subject: [PATCH 01/18] Add -ipv6 flag so IPv6 works under PowerShell. PowerShell treats bare -6 as a number, so the flag never reaches the process (#124). Prefer -ipv6 and keep -6 as a legacy alias for other shells. Co-authored-by: Cursor --- README.md | 4 +++- cmd/phantom.go | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3b2dcde..a2d8671 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ you did something wrong. Or I did ;) Usage: ./phantom- [options] -server Options: - -6 Optional: Enables IPv6 support on port 19133 (experimental) + -6 Optional: Same as -ipv6 (legacy; broken in PowerShell — use -ipv6) -bind string Optional: IP address to listen on. Defaults to all interfaces. (default "0.0.0.0") -bind_port int @@ -43,6 +43,8 @@ Options: Note that phantom always binds to port 19132 as well, so both ports need to be open. -debug Optional: Enables debug logging + -ipv6 + Optional: Enables IPv6 support on port 19133 (experimental) -remove_ports Optional: Forces ports to be excluded from pong packets (experimental) -server string diff --git a/cmd/phantom.go b/cmd/phantom.go index 87e6450..dbb9f79 100644 --- a/cmd/phantom.go +++ b/cmd/phantom.go @@ -25,10 +25,15 @@ func main() { bindPortArg := flag.Int("bind_port", 0, "Optional: Port to listen on. Defaults to 0, which selects a random port.\nNote that phantom always binds to port 19132 as well, so both ports need to be open.") timeoutArg := flag.Int("timeout", 60, "Optional: Seconds to wait before cleaning up a disconnected client") debugArg := flag.Bool("debug", false, "Optional: Enables debug logging") - ipv6Arg := flag.Bool("6", false, "Optional: Enables IPv6 support on port 19133 (experimental)") removePortsArg := flag.Bool("remove_ports", false, "Optional: Forces ports to be excluded from pong packets (experimental)") workersArg := flag.Uint("workers", 1, "Optional: Number of workers, useful for tweaking performance (experimental)") + // Prefer -ipv6: PowerShell treats bare -6 as a number, so the flag never reaches + // the process (see #124). Keep -6 as a legacy alias for cmd.exe / POSIX shells. + var enableIPv6 bool + flag.BoolVar(&enableIPv6, "ipv6", false, "Optional: Enables IPv6 support on port 19133 (experimental)") + flag.BoolVar(&enableIPv6, "6", false, "Optional: Same as -ipv6 (legacy; broken in PowerShell — use -ipv6)") + flag.Usage = usage flag.Parse() @@ -65,7 +70,7 @@ func main() { bindPortInt, serverAddressString, idleTimeout, - *ipv6Arg, + enableIPv6, *removePortsArg, *workersArg, }) From 770943d7d7460555967fcb1d1aed32c9ec23c438 Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:27:36 -0700 Subject: [PATCH 02/18] Fix stuck sessions when upstream UDP write returns connection refused. LAN pings were refreshing read deadlines while Write consumed ICMP errors, so the server never got marked offline and warnings spammed (#79). Co-authored-by: Cursor --- internal/proxy/offline_error_test.go | 69 ++++++++++++++++++++++++++++ internal/proxy/proxy.go | 58 ++++++++++++++++++++--- 2 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 internal/proxy/offline_error_test.go diff --git a/internal/proxy/offline_error_test.go b/internal/proxy/offline_error_test.go new file mode 100644 index 0000000..5f42e5d --- /dev/null +++ b/internal/proxy/offline_error_test.go @@ -0,0 +1,69 @@ +package proxy + +import ( + "fmt" + "net" + "syscall" + "testing" + "time" +) + +func TestIsOfflineError(t *testing.T) { + t.Parallel() + + if !isOfflineError(syscall.ECONNREFUSED) { + t.Fatal("expected syscall.ECONNREFUSED to be offline") + } + if !isOfflineError(fmt.Errorf("write udp: %w", syscall.ECONNREFUSED)) { + t.Fatal("expected wrapped ECONNREFUSED to be offline") + } + if !isOfflineError(&net.OpError{Op: "write", Err: syscall.ECONNREFUSED}) { + t.Fatal("expected net.OpError ECONNREFUSED to be offline") + } + if !isOfflineError(timeoutError{}) { + t.Fatal("expected net.Error timeout to be offline") + } + if !isOfflineError(fmt.Errorf("read udp: i/o timeout")) { + t.Fatal("expected legacy timeout string to be offline") + } + if !isOfflineError(fmt.Errorf("write: connection refused")) { + t.Fatal("expected legacy connection refused string to be offline") + } + if isOfflineError(nil) { + t.Fatal("nil should not be offline") + } + if isOfflineError(fmt.Errorf("something else")) { + t.Fatal("unrelated error should not be offline") + } +} + +type timeoutError struct{} + +func (timeoutError) Error() string { return "i/o timeout" } +func (timeoutError) Timeout() bool { return true } +func (timeoutError) Temporary() bool { return true } + +// Ensure timeoutError satisfies net.Error at compile time. +var _ net.Error = timeoutError{} + +// Keep the idle deadline behavior documented: a timeout longer than the +// per-write read deadline should still classify as offline. +func TestIsOfflineErrorDeadline(t *testing.T) { + t.Parallel() + + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Millisecond)) + buf := make([]byte, 16) + _, _, err = conn.ReadFrom(buf) + if err == nil { + t.Fatal("expected read deadline error") + } + if !isOfflineError(err) { + t.Fatalf("expected deadline error to be offline, got %v", err) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a4ce5b8..b27c4d9 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -1,10 +1,12 @@ package proxy import ( + "errors" "fmt" "math/rand" "net" "regexp" + "syscall" "time" "github.com/jhead/phantom/internal/clientmap" @@ -46,6 +48,24 @@ var randSource = rand.NewSource(time.Now().UnixNano()) var serverID = randSource.Int63() var offlineErrorRegex = regexp.MustCompile("(timeout)|(connection refused)") +// isOfflineError reports whether err indicates the remote Bedrock server is +// unreachable. Connected UDP sockets surface ICMP port-unreachable as +// ECONNREFUSED on a later Read/Write — normal UDP behavior, not a proxy bug. +func isOfflineError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, syscall.ECONNREFUSED) { + return true + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return true + } + // Fallback for platforms / wrappers that only expose the message text. + return offlineErrorRegex.MatchString(err.Error()) +} + func New(prefs ProxyPrefs) (*ProxyServer, error) { bindPort := prefs.BindPort @@ -230,9 +250,32 @@ func (proxy *ProxyServer) processDataFromClients(listener net.PacketConn, packet // Write packet from client to server _, err = serverConn.Write(data) + if err == nil { + return nil + } + + // Write often consumes the pending ICMP error before processDataFromServer's + // ReadFrom sees it. Meanwhile LAN pings keep refreshing SetReadDeadline and + // ClientMap lastActive, so the session never times out and never marks the + // server offline — producing a spam of "connection refused" warnings (#79). + if isOfflineError(err) { + proxy.markServerOffline() + proxy.clientMap.Delete(client) + return nil + } + return err } +func (proxy *ProxyServer) markServerOffline() { + if proxy.serverOffline { + return + } + log.Warn().Msgf("Server seems to be offline :(") + log.Warn().Msgf("We'll keep trying to connect...") + proxy.serverOffline = true +} + // Proxies packets sent by the server to us for a specific Minecraft client back to // that client's UDP connection. func (proxy *ProxyServer) processDataFromServer(remoteConn *net.UDPConn, client net.Addr) { @@ -247,14 +290,15 @@ func (proxy *ProxyServer) processDataFromServer(remoteConn *net.UDPConn, client // Read error if err != nil { - log.Warn().Msgf("%v", err) - - offlineError := offlineErrorRegex.MatchString(err.Error()) + // Conn closed by idle cleanup / offline write-path Delete — expected. + if errors.Is(err, net.ErrClosed) { + break + } - if offlineError && !proxy.serverOffline { - log.Warn().Msgf("Server seems to be offline :(") - log.Warn().Msgf("We'll keep trying to connect...") - proxy.serverOffline = true + if isOfflineError(err) { + proxy.markServerOffline() + } else { + log.Warn().Msgf("%v", err) } break From d0eb0e7139e57c0b1786945b2986214ce35856b2 Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:27:36 -0700 Subject: [PATCH 03/18] Fix UDP socket exhaustion from self-echoed LAN pings. When -server targets this host's :19132, SO_REUSEADDR can deliver DialUDP traffic back to the ping listener; ignore those sources so each echo does not open another socket until EMFILE. Co-authored-by: Cursor --- internal/clientmap/clientmap.go | 34 +++++++++ internal/clientmap/clientmap_test.go | 110 +++++++++++++++++++++++++++ internal/proxy/proxy.go | 8 ++ 3 files changed, 152 insertions(+) create mode 100644 internal/clientmap/clientmap_test.go diff --git a/internal/clientmap/clientmap.go b/internal/clientmap/clientmap.go index a6d5fc2..f0a7774 100644 --- a/internal/clientmap/clientmap.go +++ b/internal/clientmap/clientmap.go @@ -95,6 +95,40 @@ func (cm *ClientMap) Delete(clientAddr net.Addr) { cm.mutex.Unlock() } +// IsOwnAddress reports whether addr is the local address of an outbound +// server connection tracked by this map. +// +// This matters when the remote server shares a host/port with phantom's own +// UDP listeners (commonly :19132 with SO_REUSEADDR): outbound datagrams can +// be delivered back to our ping listener. If those echoes are treated as new +// clients, each one DialUDP()s again and the process can hit EMFILE +// ("too many open files"). +func (cm *ClientMap) IsOwnAddress(addr net.Addr) bool { + if addr == nil { + return false + } + + key := addr.String() + + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + for _, client := range cm.clients { + if client.conn.LocalAddr().String() == key { + return true + } + } + + return false +} + +// Len returns the number of active client connections. +func (cm *ClientMap) Len() int { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + return len(cm.clients) +} + // Get gets or creates a new UDP connection to the remote server and stores it // in a map, matching clients to remote server connections. This way, we keep one // UDP connection open to the server for each client. The handler parameter is diff --git a/internal/clientmap/clientmap_test.go b/internal/clientmap/clientmap_test.go new file mode 100644 index 0000000..1ce28f0 --- /dev/null +++ b/internal/clientmap/clientmap_test.go @@ -0,0 +1,110 @@ +package clientmap + +import ( + "net" + "testing" + "time" +) + +func startUDPServer(t *testing.T) (*net.UDPConn, *net.UDPAddr) { + t.Helper() + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatalf("ListenUDP: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return conn, conn.LocalAddr().(*net.UDPAddr) +} + +func TestIsOwnAddress(t *testing.T) { + _, remote := startUDPServer(t) + cm := New(time.Minute, time.Minute) + defer cm.Close() + + clientAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:34567") + if err != nil { + t.Fatal(err) + } + + serverConn, err := cm.Get(clientAddr, remote, func(*net.UDPConn) {}) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if !cm.IsOwnAddress(serverConn.LocalAddr()) { + t.Fatalf("expected outbound local addr %s to be recognized as own", serverConn.LocalAddr()) + } + + other, err := net.ResolveUDPAddr("udp", "127.0.0.1:34568") + if err != nil { + t.Fatal(err) + } + if cm.IsOwnAddress(other) { + t.Fatalf("did not expect unrelated addr %s to be own", other) + } + if cm.IsOwnAddress(nil) { + t.Fatal("nil addr should not be own") + } +} + +// Documents the failure mode behind issue #184: if an echoed outbound packet is +// keyed as a new client, Get opens another UDP socket. Repeating that (as the +// ping listener can when -server is this host:19132) exhausts file descriptors. +func TestGetOnOwnLocalAddrCreatesExtraConnection(t *testing.T) { + _, remote := startUDPServer(t) + cm := New(time.Minute, time.Minute) + defer cm.Close() + + clientAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:34569") + if err != nil { + t.Fatal(err) + } + + conn1, err := cm.Get(clientAddr, remote, func(*net.UDPConn) {}) + if err != nil { + t.Fatalf("Get: %v", err) + } + if cm.Len() != 1 { + t.Fatalf("Len=%d, want 1", cm.Len()) + } + + conn2, err := cm.Get(conn1.LocalAddr(), remote, func(*net.UDPConn) {}) + if err != nil { + t.Fatalf("Get(own local addr): %v", err) + } + if cm.Len() != 2 { + t.Fatalf("Len=%d, want 2 after treating own local addr as a client", cm.Len()) + } + if conn1.LocalAddr().String() == conn2.LocalAddr().String() { + t.Fatal("expected a distinct outbound socket for the echoed addr") + } +} + +func TestSkippingOwnAddressPreventsConnectionStorm(t *testing.T) { + _, remote := startUDPServer(t) + cm := New(time.Minute, time.Minute) + defer cm.Close() + + clientAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:34570") + if err != nil { + t.Fatal(err) + } + + conn, err := cm.Get(clientAddr, remote, func(*net.UDPConn) {}) + if err != nil { + t.Fatalf("Get: %v", err) + } + + // Proxy fix: never call Get for IsOwnAddress sources. + for i := 0; i < 64; i++ { + echo := conn.LocalAddr() + if !cm.IsOwnAddress(echo) { + t.Fatalf("iteration %d: expected own address", i) + } + // skip Get — connection count must stay flat + } + + if cm.Len() != 1 { + t.Fatalf("Len=%d, want 1 after ignoring own-address echoes", cm.Len()) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a4ce5b8..66e893c 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -195,6 +195,14 @@ func (proxy *ProxyServer) processDataFromClients(listener net.PacketConn, packet data := packetBuffer[:read] log.Trace().Msgf("client recv: %v", data) + // Drop echoes of our own DialUDP traffic. When -server points at this same + // host's :19132 (typical LAN setup), SO_REUSEADDR can deliver our outbound + // packets back to the ping listener. Proxying those again would open a new + // UDP socket per echo until dial fails with "too many open files". + if proxy.clientMap.IsOwnAddress(client) { + return nil + } + // Handler triggered when a new client connects and we create a new connetion to the remote server onNewConnection := func(newServerConn *net.UDPConn) { log.Info().Msgf("New connection from client %s -> %s", client.String(), listener.LocalAddr()) From 15bbc4c68c6ed29f68c5d54aaca5baca3f488104 Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:28:26 -0700 Subject: [PATCH 04/18] Always inject bind port into LAN pongs for Geyser. Geyser MOTDs omit Port4/Port6, so consoles never learned phantom's proxy port and the Friends/LAN entry failed to show reliably. Co-authored-by: Cursor --- internal/proxy/pong_rewrite_test.go | 122 ++++++++++++++++++++++++++++ internal/proxy/proxy.go | 12 +-- 2 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 internal/proxy/pong_rewrite_test.go diff --git a/internal/proxy/pong_rewrite_test.go b/internal/proxy/pong_rewrite_test.go new file mode 100644 index 0000000..4034396 --- /dev/null +++ b/internal/proxy/pong_rewrite_test.go @@ -0,0 +1,122 @@ +package proxy + +import ( + "testing" + + "github.com/jhead/phantom/internal/proto" +) + +// geyser-style MOTD: 10 fields, no Port4/Port6 (see GeyserMC advertisement format). +func geyserStylePong() []byte { + pong := proto.UnconnectedPing{ + PingTime: []byte{0, 0, 0, 0, 0, 0, 0, 1}, + ID: []byte{0, 0, 0, 0, 0, 0, 0, 2}, + Magic: []byte{0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78}, + Pong: proto.PongData{ + Edition: "MCPE", + MOTD: "Geyser", + ProtocolVersion: "557", + Version: "1.19.40", + Players: "0", + MaxPlayers: "20", + ServerID: "12345", + SubMOTD: "Geyser", + GameType: "Survival", + NintendoLimited: "1", + // Port4/Port6 intentionally empty — matches Geyser + }, + } + buf := pong.Build() + return buf.Bytes() +} + +func TestRewriteUnconnectedPongInjectsPortsWhenUpstreamOmitsThem(t *testing.T) { + t.Parallel() + + p := &ProxyServer{ + boundPort: 55997, + prefs: ProxyPrefs{}, + } + + rewritten := p.rewriteUnconnectedPong(geyserStylePong()) + packet, err := proto.ReadUnconnectedPing(rewritten) + if err != nil { + t.Fatalf("parse rewritten pong: %v", err) + } + if packet.Pong.Port4 != "55997" { + t.Fatalf("Port4 = %q, want 55997", packet.Pong.Port4) + } + if packet.Pong.Port6 != "55997" { + t.Fatalf("Port6 = %q, want 55997", packet.Pong.Port6) + } +} + +func TestRewriteUnconnectedPongOverwritesExistingPorts(t *testing.T) { + t.Parallel() + + p := &ProxyServer{ + boundPort: 19133, + prefs: ProxyPrefs{}, + } + + src := proto.UnconnectedPing{ + PingTime: []byte{0, 0, 0, 0, 0, 0, 0, 1}, + ID: []byte{0, 0, 0, 0, 0, 0, 0, 2}, + Magic: []byte{0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78}, + Pong: proto.PongData{ + Edition: "MCPE", + MOTD: "BDS", + ProtocolVersion: "557", + Version: "1.19.40", + Players: "0", + MaxPlayers: "10", + ServerID: "99", + SubMOTD: "world", + GameType: "Survival", + NintendoLimited: "0", + Port4: "19132", + Port6: "19132", + }, + } + + buf := src.Build() + rewritten := p.rewriteUnconnectedPong(buf.Bytes()) + packet, err := proto.ReadUnconnectedPing(rewritten) + if err != nil { + t.Fatalf("parse rewritten pong: %v", err) + } + if packet.Pong.Port4 != "19133" || packet.Pong.Port6 != "19133" { + t.Fatalf("ports = %q/%q, want 19133/19133", packet.Pong.Port4, packet.Pong.Port6) + } +} + +func TestRewriteUnconnectedPongRemovePorts(t *testing.T) { + t.Parallel() + + p := &ProxyServer{ + boundPort: 55997, + prefs: ProxyPrefs{RemovePorts: true}, + } + + src := proto.UnconnectedPing{ + PingTime: []byte{0, 0, 0, 0, 0, 0, 0, 1}, + ID: []byte{0, 0, 0, 0, 0, 0, 0, 2}, + Magic: []byte{0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78}, + Pong: proto.PongData{ + Edition: "MCPE", + MOTD: "x", + Port4: "19132", + Port6: "19132", + }, + } + + buf := src.Build() + rewritten := p.rewriteUnconnectedPong(buf.Bytes()) + packet, err := proto.ReadUnconnectedPing(rewritten) + if err != nil { + t.Fatalf("parse rewritten pong: %v", err) + } + if packet.Pong.Port4 != "" || packet.Pong.Port6 != "" { + t.Fatalf("expected empty ports, got %q/%q", packet.Pong.Port4, packet.Pong.Port6) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a4ce5b8..433914e 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -294,13 +294,15 @@ func (proxy *ProxyServer) rewriteUnconnectedPong(data []byte) []byte { // If we don't do this, the client will get confused if you restart phantom. packet.Pong.ServerID = fmt.Sprintf("%d", serverID) - // Overwrite port numbers sent back from server (if any) - if packet.Pong.Port4 != "" && !proxy.prefs.RemovePorts { - packet.Pong.Port4 = fmt.Sprintf("%d", proxy.boundPort) - packet.Pong.Port6 = packet.Pong.Port4 - } else if proxy.prefs.RemovePorts { + // Always advertise phantom's bind port. Upstream MOTDs (notably Geyser) + // often omit Port4/Port6; leaving them empty makes consoles fall back to + // 19132 or skip the LAN/Friends entry entirely. + if proxy.prefs.RemovePorts { packet.Pong.Port4 = "" packet.Pong.Port6 = "" + } else { + packet.Pong.Port4 = fmt.Sprintf("%d", proxy.boundPort) + packet.Pong.Port6 = packet.Pong.Port4 } packetBuffer := packet.Build() From 1516d531b3626142d15b97da7e2ffad238cbc888 Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:30:14 -0700 Subject: [PATCH 05/18] Fix multi-server LAN discovery by fanning out from one :19132. SO_REUSEADDR/PORT cannot share Bedrock discovery across processes, so a second phantom instance stayed idle. Accept multiple -server values in one process and reply for each upstream from a single exclusive discovery listener. Co-authored-by: Cursor --- README.md | 24 +++-- cmd/phantom.go | 121 +++++++++++++++++++------- internal/proxy/discovery.go | 103 ++++++++++++++++++++++ internal/proxy/discovery_test.go | 145 +++++++++++++++++++++++++++++++ internal/proxy/proxy.go | 123 +++++++++++++++++++------- 5 files changed, 446 insertions(+), 70 deletions(-) create mode 100644 internal/proxy/discovery.go create mode 100644 internal/proxy/discovery_test.go diff --git a/README.md b/README.md index 3b2dcde..ad0e101 100644 --- a/README.md +++ b/README.md @@ -79,14 +79,22 @@ Same as above but bind the proxy server to local IP 10.0.0.5 and port 19133: ./phantom- -bind 10.0.0.5 -bind_port 19133 -server lax.mcbr.cubed.host:19132 ``` -**Running multiple instances** - -If you have multiple Bedrock servers, you can run phantom multiple times on -the same device to allow all of your servers to show up on the LAN list. All -you have to do is start one instance of phantom for each server and set the -`-server` flag appropriately. You don't need to use `-bind` or change the port. -But you probably do need to make sure you have a firewall rule that allows -all UDP traffic for the phantom executable. +**Running multiple servers** + +If you have multiple Bedrock servers, pass each one to a **single** phantom +process with repeated or comma-separated `-server` flags: + +```bash +./phantom- -server 192.168.1.13:19134 -server 192.168.1.13:19136 +# or +./phantom- -server 192.168.1.13:19134,192.168.1.13:19136 +``` + +Phantom binds LAN discovery (`:19132`) once and answers for every upstream +server, so they all appear in the Friends/LAN list at the same time. Running +multiple phantom *processes* cannot share `:19132` correctly — only one will +see traffic — so use multiple `-server` flags instead. You probably also need +a firewall rule that allows all UDP traffic for the phantom executable. **A note on `-bind`:** diff --git a/cmd/phantom.go b/cmd/phantom.go index 87e6450..f0290f8 100644 --- a/cmd/phantom.go +++ b/cmd/phantom.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "strings" "time" "github.com/jhead/phantom/internal/proxy" @@ -12,17 +13,33 @@ import ( "github.com/rs/zerolog/log" ) -var bindAddressString string -var serverAddressString string -var bindPortInt uint16 +// serverList accumulates repeated and/or comma-separated -server values. +type serverList []string + +func (s *serverList) String() string { + return strings.Join(*s, ",") +} + +func (s *serverList) Set(value string) error { + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + *s = append(*s, part) + } + return nil +} func main() { - // Required - serverArg := flag.String("server", "", "Required: Bedrock/MCPE server IP address and port (ex: 1.2.3.4:19132)") + var servers serverList + + // Required (repeatable / comma-separated) + flag.Var(&servers, "server", "Required: Bedrock/MCPE server IP:port. Repeat or comma-separate for multiple servers on one process (ex: 1.2.3.4:19132)") // Optional bindArg := flag.String("bind", "0.0.0.0", "Optional: IP address to listen on. Defaults to all interfaces.") - bindPortArg := flag.Int("bind_port", 0, "Optional: Port to listen on. Defaults to 0, which selects a random port.\nNote that phantom always binds to port 19132 as well, so both ports need to be open.") + bindPortArg := flag.Int("bind_port", 0, "Optional: Port to listen on. Defaults to 0, which selects a random port.\nNote that phantom always binds to port 19132 as well, so both ports need to be open.\nOnly valid with a single -server.") timeoutArg := flag.Int("timeout", 60, "Optional: Seconds to wait before cleaning up a disconnected client") debugArg := flag.Bool("debug", false, "Optional: Enables debug logging") ipv6Arg := flag.Bool("6", false, "Optional: Enables IPv6 support on port 19133 (experimental)") @@ -32,10 +49,10 @@ func main() { flag.Usage = usage flag.Parse() - if *serverArg == "" { + if len(servers) == 0 { // Maybe it only has the server IP? if len(os.Args) == 2 { - *serverArg = os.Args[1] + _ = servers.Set(os.Args[1]) } else { fmt.Println("Did you forget -server?") flag.Usage() @@ -43,54 +60,100 @@ func main() { } } - bindAddressString = *bindArg - serverAddressString = *serverArg + if len(servers) == 0 { + fmt.Println("Did you forget -server?") + flag.Usage() + return + } + + if *bindPortArg != 0 && len(servers) > 1 { + fmt.Println("-bind_port cannot be used with multiple -server values; omit it so each server gets a random data port") + return + } + idleTimeout := time.Duration(*timeoutArg) * time.Second - bindPortInt = uint16(*bindPortArg) + bindPortInt := uint16(*bindPortArg) logLevel := zerolog.InfoLevel if *debugArg { logLevel = zerolog.DebugLevel } - fmt.Printf("Starting up with remote server IP: %s\n", serverAddressString) + fmt.Printf("Starting up with remote server(s): %s\n", strings.Join(servers, ", ")) // Configure logging output log.Logger = log. Output(zerolog.ConsoleWriter{Out: os.Stdout}). Level(logLevel) - proxyServer, err := proxy.New(proxy.ProxyPrefs{ - bindAddressString, - bindPortInt, - serverAddressString, - idleTimeout, - *ipv6Arg, - *removePortsArg, - *workersArg, - }) + proxies := make([]*proxy.ProxyServer, 0, len(servers)) + for _, remote := range servers { + prefs := proxy.ProxyPrefs{ + BindAddress: *bindArg, + BindPort: bindPortInt, + RemoteServer: remote, + IdleTimeout: idleTimeout, + EnableIPv6: *ipv6Arg, + RemovePorts: *removePortsArg, + NumWorkers: *workersArg, + DisableDiscoveryListener: len(servers) > 1, + } + + proxyServer, err := proxy.New(prefs) + if err != nil { + fmt.Printf("Failed to init server %s: %s\n", remote, err) + closeAll(proxies, nil) + return + } + proxies = append(proxies, proxyServer) + } + + // Single server: original blocking Start() (owns :19132 itself). + if len(proxies) == 1 { + watchForInterrupt(proxies, nil) + if err := proxies[0].Start(); err != nil { + fmt.Printf("Failed to start server: %s\n", err) + } + return + } + + // Multiple servers: bind data planes first, then one shared discovery hub. + for _, p := range proxies { + if err := p.StartAsync(); err != nil { + fmt.Printf("Failed to start server %s: %s\n", p.RemoteServer(), err) + closeAll(proxies, nil) + return + } + } + hub, err := proxy.StartDiscoveryHub(proxies, *ipv6Arg) if err != nil { - fmt.Printf("Failed to init server: %s\n", err) + fmt.Printf("Failed to start discovery: %s\n", err) + closeAll(proxies, nil) return } - // Watch for CTRL + C - watchForInterrupt(proxyServer) + watchForInterrupt(proxies, hub) + select {} +} - if err := proxyServer.Start(); err != nil { - fmt.Printf("Failed to start server: %s\n", err) +func closeAll(proxies []*proxy.ProxyServer, hub *proxy.DiscoveryHub) { + if hub != nil { + hub.Close() + } + for _, p := range proxies { + p.Close() } } func usage() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options] -server \n\nOptions:\n", os.Args[0]) + fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options] -server [-server ...]\n\nOptions:\n", os.Args[0]) flag.PrintDefaults() } // Watches for CTRL + C signals and shuts down the server // A second CTRL + C will force it to exit immediately -func watchForInterrupt(proxyServer *proxy.ProxyServer) { +func watchForInterrupt(proxies []*proxy.ProxyServer, hub *proxy.DiscoveryHub) { signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, os.Interrupt) @@ -107,7 +170,7 @@ func watchForInterrupt(proxyServer *proxy.ProxyServer) { fmt.Println("\nPress CTRL + C again to force quit") once = true - proxyServer.Close() + closeAll(proxies, hub) } }() } diff --git a/internal/proxy/discovery.go b/internal/proxy/discovery.go new file mode 100644 index 0000000..ab8b87d --- /dev/null +++ b/internal/proxy/discovery.go @@ -0,0 +1,103 @@ +package proxy + +import ( + "fmt" + "net" + + "github.com/jhead/phantom/internal/proto" + "github.com/rs/zerolog/log" + "github.com/tevino/abool" +) + +// DiscoveryHub owns the exclusive :19132 (and optional :19133) LAN discovery +// sockets and fans each Unconnected Ping out to every registered proxy. +// +// Multiple OS processes cannot reliably share :19132: SO_REUSEADDR/PORT either +// load-balances or delivers unicast to a single socket, so only one phantom +// instance sees traffic (GitHub #175). One process + fan-out is the supported +// way to advertise multiple Bedrock servers on the same host. +type DiscoveryHub struct { + ping4 net.PacketConn + ping6 net.PacketConn + proxies []*ProxyServer + dead *abool.AtomicBool +} + +// StartDiscoveryHub binds discovery ports exclusively and starts read loops. +func StartDiscoveryHub(proxies []*ProxyServer, enableIPv6 bool) (*DiscoveryHub, error) { + if len(proxies) == 0 { + return nil, fmt.Errorf("no proxies to register for discovery") + } + + hub := &DiscoveryHub{ + proxies: proxies, + dead: abool.New(), + } + + log.Info().Msgf("Binding shared ping server to port 19132") + ping4, err := net.ListenPacket("udp4", ":19132") + if err != nil { + return nil, fmt.Errorf("bind :19132: %w (only one phantom can own LAN discovery; pass multiple -server flags to one process instead of running multiple instances)", err) + } + hub.ping4 = ping4 + go hub.readLoop(ping4) + + if enableIPv6 { + log.Info().Msgf("Binding shared IPv6 ping server to port 19133") + ping6, err := net.ListenPacket("udp6", ":19133") + if err != nil { + log.Warn().Msgf("Failed to bind IPv6 ping listener: %v", err) + } else { + hub.ping6 = ping6 + go hub.readLoop(ping6) + } + } + + return hub, nil +} + +// Close stops discovery listeners. +func (hub *DiscoveryHub) Close() { + hub.dead.Set() + if hub.ping4 != nil { + _ = hub.ping4.Close() + } + if hub.ping6 != nil { + _ = hub.ping6.Close() + } +} + +func (hub *DiscoveryHub) readLoop(listener net.PacketConn) { + log.Info().Msgf("Discovery listener starting up: %s", listener.LocalAddr()) + buf := make([]byte, maxMTU) + + for !hub.dead.IsSet() { + n, from, err := listener.ReadFrom(buf) + if err != nil { + if hub.dead.IsSet() { + break + } + log.Warn().Msgf("Discovery read error: %v", err) + continue + } + if n < 1 || buf[0] != proto.UnconnectedPingID { + continue + } + + // Copy once for fan-out; each proxy may hold the slice until Write returns. + packet := append([]byte(nil), buf[:n]...) + log.Info().Msgf("Received LAN ping from client: %s (fanning out to %d servers)", from.String(), len(hub.proxies)) + + for _, p := range hub.proxies { + proxy := p + data := append([]byte(nil), packet...) + go func() { + if err := proxy.HandleUnconnectedPing(data, from); err != nil { + log.Warn().Msgf("Discovery fan-out to %s failed: %v", proxy.prefs.RemoteServer, err) + } + }() + } + } + + log.Info().Msgf("Discovery listener shut down: %s", listener.LocalAddr()) +} diff --git a/internal/proxy/discovery_test.go b/internal/proxy/discovery_test.go new file mode 100644 index 0000000..81445ea --- /dev/null +++ b/internal/proxy/discovery_test.go @@ -0,0 +1,145 @@ +package proxy + +import ( + "encoding/binary" + "fmt" + "net" + "testing" + "time" + + "github.com/jhead/phantom/internal/proto" +) + +func TestHandleUnconnectedPingOfflineReply(t *testing.T) { + remote, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatal(err) + } + defer remote.Close() + + client, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + p, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 0, + RemoteServer: remote.LocalAddr().String(), + IdleTimeout: time.Minute, + NumWorkers: 1, + DisableDiscoveryListener: true, + }) + if err != nil { + t.Fatal(err) + } + if err := p.StartAsync(); err != nil { + t.Fatal(err) + } + defer p.Close() + + p.serverOffline = true + + ping := []byte{proto.UnconnectedPingID, 0, 0, 0, 0, 0, 0, 0, 0} + if err := p.HandleUnconnectedPing(ping, client.LocalAddr()); err != nil { + t.Fatalf("HandleUnconnectedPing: %v", err) + } + + _ = client.SetReadDeadline(time.Now().Add(time.Second)) + buf := make([]byte, 2048) + n, from, err := client.ReadFrom(buf) + if err != nil { + t.Fatalf("client ReadFrom: %v", err) + } + if n < 1 || buf[0] != proto.UnconnectedPongID { + t.Fatalf("expected UnconnectedPong, got n=%d id=%v", n, buf[:n]) + } + fromUDP, ok := from.(*net.UDPAddr) + if !ok { + t.Fatalf("unexpected from type %T", from) + } + if uint16(fromUDP.Port) != p.boundPort { + t.Fatalf("pong from port %d, want boundPort %d", fromUDP.Port, p.boundPort) + } + + // Ping is still forwarded to the remote even when offline. + _ = remote.SetReadDeadline(time.Now().Add(time.Second)) + rn, _, err := remote.ReadFrom(buf) + if err != nil { + t.Fatalf("remote did not receive forwarded ping: %v", err) + } + if rn < 1 || buf[0] != proto.UnconnectedPingID { + t.Fatalf("remote expected ping, got n=%d id=%v", rn, buf[:rn]) + } +} + +func TestHandleUnconnectedPingRejectsBadInput(t *testing.T) { + p, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 0, + RemoteServer: "127.0.0.1:19132", + IdleTimeout: time.Minute, + DisableDiscoveryListener: true, + }) + if err != nil { + t.Fatal(err) + } + defer p.Close() + + client := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9} + if err := p.HandleUnconnectedPing([]byte{proto.UnconnectedPingID}, client); err == nil { + t.Fatal("expected error when proxy not started") + } + + if err := p.StartAsync(); err != nil { + t.Fatal(err) + } + + if err := p.HandleUnconnectedPing([]byte{proto.UnconnectedPongID}, client); err == nil { + t.Fatal("expected error for non-ping packet") + } + if err := p.HandleUnconnectedPing([]byte{proto.UnconnectedPingID}, nil); err == nil { + t.Fatal("expected error for nil from") + } +} + +func TestPerProxyServerIDsDistinct(t *testing.T) { + a, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 0, + RemoteServer: "127.0.0.1:19132", + IdleTimeout: time.Minute, + DisableDiscoveryListener: true, + }) + if err != nil { + t.Fatal(err) + } + b, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 0, + RemoteServer: "127.0.0.1:19133", + IdleTimeout: time.Minute, + DisableDiscoveryListener: true, + }) + if err != nil { + t.Fatal(err) + } + if a.serverID == b.serverID { + t.Fatalf("expected distinct serverIDs, both %d", a.serverID) + } + + rewritten := a.rewriteUnconnectedPong(proto.OfflinePong.Bytes()) + parsed, err := proto.ReadUnconnectedPing(rewritten) + if err != nil { + t.Fatal(err) + } + gotGUID := binary.BigEndian.Uint64(parsed.ID) + if gotGUID != uint64(a.serverID) { + t.Fatalf("binary GUID %d, want %d", gotGUID, uint64(a.serverID)) + } + want := fmt.Sprintf("%d", a.serverID) + if parsed.Pong.ServerID != want { + t.Fatalf("MOTD ServerID %q, want %q", parsed.Pong.ServerID, want) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a4ce5b8..0ae8c5c 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -1,6 +1,7 @@ package proxy import ( + "encoding/binary" "fmt" "math/rand" "net" @@ -30,6 +31,7 @@ type ProxyServer struct { prefs ProxyPrefs dead *abool.AtomicBool serverOffline bool + serverID int64 } type ProxyPrefs struct { @@ -40,10 +42,12 @@ type ProxyPrefs struct { EnableIPv6 bool RemovePorts bool NumWorkers uint + // DisableDiscoveryListener skips binding :19132/:19133. Used when a + // DiscoveryHub owns discovery and fans pings into HandleUnconnectedPing. + DisableDiscoveryListener bool } var randSource = rand.NewSource(time.Now().UnixNano()) -var serverID = randSource.Int63() var offlineErrorRegex = regexp.MustCompile("(timeout)|(connection refused)") func New(prefs ProxyPrefs) (*ProxyServer, error) { @@ -78,34 +82,56 @@ func New(prefs ProxyPrefs) (*ProxyServer, error) { prefs, abool.New(), false, + randSource.Int63(), }, nil } func (proxy *ProxyServer) Start() error { - // Bind to 19132 on all addresses to receive broadcasted pings - // Sets SO_REUSEADDR et al to support multiple instances of phantom - log.Info().Msgf("Binding ping server to port 19132") - if pingServer, err := reuse.ListenPacket("udp4", ":19132"); err == nil { - proxy.pingServer = pingServer + if err := proxy.listen(); err != nil { + return err + } - // Start proxying ping packets from the broadcast listener - go proxy.readLoop(proxy.pingServer) - } else { - // Bind failed + // Start processing everything else using the proxy listener + proxy.startWorkers(proxy.server) + + return nil +} + +// StartAsync is like Start but runs all workers in goroutines and returns once +// listening. Used when one process hosts multiple proxies behind a DiscoveryHub. +func (proxy *ProxyServer) StartAsync() error { + if err := proxy.listen(); err != nil { return err } - // Minecraft automatically broadcasts on port 19133 to the local IPv6 network - if proxy.prefs.EnableIPv6 { - log.Info().Msgf("Binding IPv6 ping server to port 19133") - if pingServerV6, err := reuse.ListenPacket("udp6", ":19133"); err == nil { - proxy.pingServerV6 = pingServerV6 + log.Info().Msgf("Starting %d workers", proxy.prefs.NumWorkers) + for i := uint(0); i < proxy.prefs.NumWorkers; i++ { + go proxy.readLoop(proxy.server) + } + return nil +} - // Start proxying ping packets from the broadcast listener - go proxy.readLoop(proxy.pingServerV6) - } else { - // IPv6 Bind failed - log.Warn().Msgf("Failed to bind IPv6 ping listener: %v", err) +func (proxy *ProxyServer) listen() error { + if !proxy.prefs.DisableDiscoveryListener { + // Exclusive bind: SO_REUSEPORT/ADDR cannot correctly share discovery + // across processes (see DiscoveryHub / #175). + log.Info().Msgf("Binding ping server to port 19132") + pingServer, err := net.ListenPacket("udp4", ":19132") + if err != nil { + return fmt.Errorf("bind :19132: %w (only one phantom can own LAN discovery; pass multiple -server flags to one process instead of running multiple instances)", err) + } + proxy.pingServer = pingServer + go proxy.readLoop(proxy.pingServer) + + // Minecraft automatically broadcasts on port 19133 to the local IPv6 network + if proxy.prefs.EnableIPv6 { + log.Info().Msgf("Binding IPv6 ping server to port 19133") + if pingServerV6, err := net.ListenPacket("udp6", ":19133"); err == nil { + proxy.pingServerV6 = pingServerV6 + go proxy.readLoop(proxy.pingServerV6) + } else { + log.Warn().Msgf("Failed to bind IPv6 ping listener: %v", err) + } } } @@ -125,19 +151,24 @@ func (proxy *ProxyServer) Start() error { log.Info().Msgf("Proxy server listening!") log.Info().Msgf("Once your console pings phantom, you should see replies below.") - - // Start processing everything else using the proxy listener - proxy.startWorkers(proxy.server) - return nil } +// RemoteServer returns the upstream address this proxy forwards to. +func (proxy *ProxyServer) RemoteServer() string { + return proxy.prefs.RemoteServer +} + func (proxy *ProxyServer) Close() { log.Info().Msgf("Stopping proxy server") // Stop UDP listeners - proxy.server.Close() - proxy.pingServer.Close() + if proxy.server != nil { + proxy.server.Close() + } + if proxy.pingServer != nil { + proxy.pingServer.Close() + } if proxy.pingServerV6 != nil { proxy.pingServerV6.Close() @@ -179,6 +210,21 @@ func (proxy *ProxyServer) readLoop(listener net.PacketConn) { log.Info().Msgf("Listener shut down: %s", listener.LocalAddr()) } +// HandleUnconnectedPing processes a discovery ping fanned out from a +// DiscoveryHub-owned :19132 listener. Replies are sent from this proxy's data port. +func (proxy *ProxyServer) HandleUnconnectedPing(data []byte, from net.Addr) error { + if proxy.dead.IsSet() || proxy.server == nil { + return fmt.Errorf("proxy not running") + } + if from == nil { + return fmt.Errorf("nil client address") + } + if len(data) < 1 || data[0] != proto.UnconnectedPingID { + return fmt.Errorf("not an unconnected ping") + } + return proxy.handleClientPacket(from, data) +} + // Inspects an incoming UDP packet, looking up the client in our connection // map, lazily creating a new connection to the remote server when necessary, // then forwarding the data to that remote connection. @@ -192,12 +238,17 @@ func (proxy *ProxyServer) processDataFromClients(listener net.PacketConn, packet return nil } - data := packetBuffer[:read] + return proxy.handleClientPacket(client, packetBuffer[:read]) +} + +// handleClientPacket is the shared discovery/data-plane path used by both the +// local UDP read loops and DiscoveryHub fan-out via HandleUnconnectedPing. +func (proxy *ProxyServer) handleClientPacket(client net.Addr, data []byte) error { log.Trace().Msgf("client recv: %v", data) // Handler triggered when a new client connects and we create a new connetion to the remote server onNewConnection := func(newServerConn *net.UDPConn) { - log.Info().Msgf("New connection from client %s -> %s", client.String(), listener.LocalAddr()) + log.Info().Msgf("New connection from client %s -> %s", client.String(), proxy.bindAddress) proxy.processDataFromServer(newServerConn, client) } @@ -214,14 +265,16 @@ func (proxy *ProxyServer) processDataFromClients(listener net.PacketConn, packet // Wait 5 seconds for the server to respond to whatever we sent, or else timeout _ = serverConn.SetReadDeadline(time.Now().Add(time.Second * 5)) - if packetID := data[0]; packetID == proto.UnconnectedPingID { + if len(data) > 0 && data[0] == proto.UnconnectedPingID { log.Info().Msgf("Received LAN ping from client: %s", client.String()) if proxy.serverOffline { replyBuffer := proto.OfflinePong replyBytes := proxy.rewriteUnconnectedPong(replyBuffer.Bytes()) - proxy.server.WriteTo(replyBytes, client) + if proxy.server != nil { + _, _ = proxy.server.WriteTo(replyBytes, client) + } log.Info().Msgf("Sent server offline pong to client: %v", client.String()) } @@ -290,9 +343,13 @@ func (proxy *ProxyServer) rewriteUnconnectedPong(data []byte) []byte { log.Debug().Msgf("Received Unconnected Pong from server: %v", data) if packet, err := proto.ReadUnconnectedPing(data); err == nil { - // Overwrite the server ID with one unique to this phantom instance. - // If we don't do this, the client will get confused if you restart phantom. - packet.Pong.ServerID = fmt.Sprintf("%d", serverID) + // Overwrite the server ID with one unique to this proxy. + // If we don't do this, the client will get confused if you restart phantom, + // and multiple -server backends in one process would look identical. + id := make([]byte, 8) + binary.BigEndian.PutUint64(id, uint64(proxy.serverID)) + packet.ID = id + packet.Pong.ServerID = fmt.Sprintf("%d", proxy.serverID) // Overwrite port numbers sent back from server (if any) if packet.Pong.Port4 != "" && !proxy.prefs.RemovePorts { From dad4e2999e18c5ac3ce074cd218945bfb0e0810a Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:32:55 -0700 Subject: [PATCH 06/18] Document Pi ARM binary selection; pin arm64 to GOARM64=v8.0. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit arm8 is aarch64, so 32-bit Pi OS users who pick it for "ARMv8" hit Illegal instruction / Exec format error — clarify uname -m mapping and add an arm64 alias. Co-authored-by: Cursor --- Makefile | 31 +++++++++++++++++++++---------- README.md | 25 ++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 9343b14..e34ef4d 100644 --- a/Makefile +++ b/Makefile @@ -1,56 +1,67 @@ SHELL=/bin/bash .PHONY: prep -OUT=bin/phantom-windows.exe bin/phantom-windows-32bit.exe bin/phantom-macos bin/phantom-macos-arm8 bin/phantom-linux bin/phantom-linux-arm5 bin/phantom-linux-arm6 bin/phantom-linux-arm7 bin/phantom-linux-arm8 +# ARM note: +# arm5/arm6/arm7 = 32-bit (GOARCH=arm + GOARM) +# arm8/arm64 = 64-bit aarch64 (GOARCH=arm64). "arm8" does NOT mean +# "any ARMv8 Raspberry Pi" — most Pi OS installs are still +# 32-bit and need arm7 (or arm6 on Pi Zero / Pi 1). +OUT=bin/phantom-windows.exe bin/phantom-windows-32bit.exe bin/phantom-macos bin/phantom-macos-arm8 bin/phantom-linux bin/phantom-linux-arm5 bin/phantom-linux-arm6 bin/phantom-linux-arm7 bin/phantom-linux-arm8 bin/phantom-linux-arm64 CMDSRC=phantom.go build: prep ${OUT} bin/phantom-windows.exe: pushd cmd && \ - GOOS=windows GOARCH=amd64 go build -o ../bin/phantom-windows.exe ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o ../bin/phantom-windows.exe ${CMDSRC} && \ popd bin/phantom-windows-32bit.exe: pushd cmd && \ - GOOS=windows GOARCH=386 go build -o ../bin/phantom-windows-32bit.exe ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -o ../bin/phantom-windows-32bit.exe ${CMDSRC} && \ popd bin/phantom-macos: pushd cmd && \ - GOOS=darwin GOARCH=amd64 go build -o ../bin/phantom-macos ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o ../bin/phantom-macos ${CMDSRC} && \ popd bin/phantom-macos-arm8: pushd cmd && \ - GOOS=darwin GOARCH=arm64 go build -o ../bin/phantom-macos-arm8 ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o ../bin/phantom-macos-arm8 ${CMDSRC} && \ popd bin/phantom-linux: pushd cmd && \ - GOOS=linux GOARCH=amd64 go build -o ../bin/phantom-linux ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ../bin/phantom-linux ${CMDSRC} && \ popd bin/phantom-linux-arm5: pushd cmd && \ - GOOS=linux GOARCH=arm GOARM=5 go build -o ../bin/phantom-linux-arm5 ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -o ../bin/phantom-linux-arm5 ${CMDSRC} && \ popd bin/phantom-linux-arm6: pushd cmd && \ - GOOS=linux GOARCH=arm GOARM=6 go build -o ../bin/phantom-linux-arm6 ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o ../bin/phantom-linux-arm6 ${CMDSRC} && \ popd bin/phantom-linux-arm7: pushd cmd && \ - GOOS=linux GOARCH=arm GOARM=7 go build -o ../bin/phantom-linux-arm7 ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o ../bin/phantom-linux-arm7 ${CMDSRC} && \ popd +# 64-bit ARM / aarch64 only (64-bit Raspberry Pi OS, etc.) +# GOARM64=v8.0 keeps the baseline compatible with Pi 3/4 (no LSE requirement). bin/phantom-linux-arm8: pushd cmd && \ - GOOS=linux GOARCH=arm64 go build -o ../bin/phantom-linux-arm8 ${CMDSRC} && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GOARM64=v8.0 go build -o ../bin/phantom-linux-arm8 ${CMDSRC} && \ popd +# Clearer alias for arm8 (same aarch64 binary). +bin/phantom-linux-arm64: bin/phantom-linux-arm8 + cp -f bin/phantom-linux-arm8 bin/phantom-linux-arm64 + prep: mkdir -p bin diff --git a/README.md b/README.md index 3b2dcde..551af96 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,22 @@ $ chmod u+x ./phantom- Just replace `` with macos, linux, etc. for the correct OS you're using. +**Raspberry Pi / ARM** + +Pick the binary from your *OS* architecture (`uname -m`), not the marketing +CPU name. A Pi 3/4/5 is "ARMv8" hardware, but a 32-bit Raspberry Pi OS still +needs the 32-bit build: + +| `uname -m` | Download | +|---|---| +| `aarch64` or `arm64` | `phantom-linux-arm8` (alias: `phantom-linux-arm64`) | +| `armv7l` | `phantom-linux-arm7` | +| `armv6l` (Pi Zero / Pi 1) | `phantom-linux-arm6` | + +`phantom-linux-arm8` is **64-bit only**. Using it on 32-bit Pi OS typically +fails with `Illegal instruction` or `Exec format error` — use `arm7` (or +`arm6`) instead. + ## Usage Open up a command prompt (Windows) or terminal (macOS & Linux) to the location @@ -138,7 +154,7 @@ computer, a VM, or even with a Minecraft hosting service. ## Supported platforms - This tool should work on Windows, macOS, and Linux. -- ARM builds are available for Raspberry Pi and similar SOCs. +- ARM builds are available for Raspberry Pi and similar SOCs (see Installing for which binary to use). - Minecraft for Windows 10, iOS/Android, Xbox One, and PS4 are currently supported. - **Nintendo Switch is not supported.** @@ -148,6 +164,13 @@ your Windows Firewall settings and open up all UDP ports for phantom. ## Troubleshooting +**`Illegal instruction` (or `Exec format error`) on a Raspberry Pi** + +You almost certainly downloaded the wrong ARM build. `phantom-linux-arm8` is +64-bit (`aarch64`). On 32-bit Raspberry Pi OS (`uname -m` shows `armv7l` or +`armv6l`), use `phantom-linux-arm7` or `phantom-linux-arm6` instead — even if +the board itself is ARMv8. + **My server isn't showing up on the list but it's online and phantom is showing connections!** Make sure "Visible to LAN players" is turn **ON** in your server's settings: *(below shows setting OFF)* From d86301ad3891754403a2076ee72c0b3fb19aed28 Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:33:06 -0700 Subject: [PATCH 07/18] Fix silent UDP truncation that stalls resource-pack loading. A 1472-byte receive buffer truncated larger datagrams (often with err=nil), corrupting full-MTU pack transfer traffic. Use a full-size UDP buffer and fail closed on truncation. Co-authored-by: Cursor --- internal/proxy/proxy.go | 39 +++++-- internal/proxy/udp_recv_buffer_test.go | 141 +++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 internal/proxy/udp_recv_buffer_test.go diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a4ce5b8..c52fe83 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -15,7 +15,15 @@ import ( reuse "github.com/libp2p/go-reuseport" ) -const maxMTU = 1472 +// udpRecvBufferSize is the per-read buffer for proxied UDP datagrams. +// +// RakNet advertises MTU up to 1492 including IP(20)+UDP(8) headers, so a +// well-formed max UDP payload is 1464. Some Bedrock stacks still emit slightly +// larger datagrams (observed ≥1474), and a too-small ReadFrom buffer silently +// truncates on several platforms — including macOS, where the error is nil. +// Resource-pack transfer is mostly full-MTU traffic, so truncation surfaces as +// clients stuck on "Loading resources...". Use the full UDP datagram limit. +const udpRecvBufferSize = 65535 var idleCheckInterval = 5 * time.Second @@ -135,6 +143,10 @@ func (proxy *ProxyServer) Start() error { func (proxy *ProxyServer) Close() { log.Info().Msgf("Stopping proxy server") + // Stop loops before closing sockets so readLoop does not busy-spin on + // "use of closed network connection" while dead is still unset. + proxy.dead.Set() + // Stop UDP listeners proxy.server.Close() proxy.pingServer.Close() @@ -145,9 +157,6 @@ func (proxy *ProxyServer) Close() { // Close all connections proxy.clientMap.Close() - - // Stop loops - proxy.dead.Set() } func (proxy *ProxyServer) startWorkers(listener net.PacketConn) { @@ -167,11 +176,14 @@ func (proxy *ProxyServer) startWorkers(listener net.PacketConn) { func (proxy *ProxyServer) readLoop(listener net.PacketConn) { log.Info().Msgf("Listener starting up: %s", listener.LocalAddr()) - packetBuffer := make([]byte, maxMTU) + packetBuffer := make([]byte, udpRecvBufferSize) for !proxy.dead.IsSet() { err := proxy.processDataFromClients(listener, packetBuffer) if err != nil { + if proxy.dead.IsSet() { + break + } log.Warn().Msgf("Error while processing client data: %s", err) } } @@ -187,10 +199,18 @@ func (proxy *ProxyServer) readLoop(listener net.PacketConn) { // data from the server and send it back to the client. func (proxy *ProxyServer) processDataFromClients(listener net.PacketConn, packetBuffer []byte) error { // Read the next packet from the client - read, client, _ := listener.ReadFrom(packetBuffer) + read, client, err := listener.ReadFrom(packetBuffer) + if err != nil { + return err + } if read <= 0 { return nil } + // A full buffer often means the kernel truncated a larger datagram + // (and on some platforms ReadFrom still returns err == nil). + if read == len(packetBuffer) { + return fmt.Errorf("UDP datagram filled receive buffer (%d bytes); possible truncation", read) + } data := packetBuffer[:read] log.Trace().Msgf("client recv: %v", data) @@ -236,7 +256,7 @@ func (proxy *ProxyServer) processDataFromClients(listener net.PacketConn, packet // Proxies packets sent by the server to us for a specific Minecraft client back to // that client's UDP connection. func (proxy *ProxyServer) processDataFromServer(remoteConn *net.UDPConn, client net.Addr) { - buffer := make([]byte, maxMTU) + buffer := make([]byte, udpRecvBufferSize) for !proxy.dead.IsSet() { // Read the next packet from the server @@ -265,6 +285,11 @@ func (proxy *ProxyServer) processDataFromServer(remoteConn *net.UDPConn, client continue } + if read == len(buffer) { + log.Warn().Msgf("UDP datagram from server filled receive buffer (%d bytes); dropping possibly truncated packet", read) + continue + } + if proxy.serverOffline { log.Info().Msgf("Server is back online!") proxy.serverOffline = false diff --git a/internal/proxy/udp_recv_buffer_test.go b/internal/proxy/udp_recv_buffer_test.go new file mode 100644 index 0000000..c603d76 --- /dev/null +++ b/internal/proxy/udp_recv_buffer_test.go @@ -0,0 +1,141 @@ +package proxy + +import ( + "bytes" + "net" + "testing" + "time" +) + +func TestUDPRecvBufferCoversOversizedRakNetDatagrams(t *testing.T) { + // Historical phantom buffer was 1472. go-raknet reads with 1492; some + // servers have been observed sending ≥1474-byte UDP payloads. + if udpRecvBufferSize < 1492 { + t.Fatalf("udpRecvBufferSize=%d; want at least 1492 to avoid truncating RakNet MTU probes", udpRecvBufferSize) + } + + pc, err := net.ListenPacket("udp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer pc.Close() + + client, err := net.DialUDP("udp4", nil, pc.LocalAddr().(*net.UDPAddr)) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + for _, size := range []int{1464, 1472, 1474, 1492, 2048, 4096} { + payload := make([]byte, size) + for i := range payload { + payload[i] = byte(i % 251) + } + payload[0] = 0xFE + payload[size-1] = 0xAA + + if _, err := client.Write(payload); err != nil { + t.Fatalf("write %d: %v", size, err) + } + + _ = pc.SetReadDeadline(time.Now().Add(time.Second)) + buf := make([]byte, udpRecvBufferSize) + n, _, err := pc.ReadFrom(buf) + if err != nil { + t.Fatalf("read %d: %v", size, err) + } + if n != size { + t.Fatalf("size %d: got n=%d (truncated)", size, n) + } + if !bytes.Equal(buf[:n], payload) { + t.Fatalf("size %d: payload mismatch", size) + } + } +} + +func TestProxyForwardsLargeDatagramsBothWays(t *testing.T) { + remote, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatal(err) + } + defer remote.Close() + + // Exclusive :19132 bind in Start() — skip cleanly if another phantom owns it. + probe, err := net.ListenPacket("udp4", "127.0.0.1:19132") + if err != nil { + t.Skipf("port 19132 unavailable: %v", err) + } + probe.Close() + + p, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 0, + RemoteServer: remote.LocalAddr().String(), + IdleTimeout: time.Minute, + NumWorkers: 1, + }) + if err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { done <- p.Start() }() + defer p.Close() + + // Wait until data plane is listening. + deadline := time.Now().Add(2 * time.Second) + for p.server == nil && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if p.server == nil { + select { + case err := <-done: + t.Fatalf("Start failed: %v", err) + default: + t.Fatal("proxy server not listening") + } + } + + client, err := net.DialUDP("udp4", nil, p.bindAddress) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + sizes := []int{1400, 1474, 1492, 2048} + for _, size := range sizes { + up := make([]byte, size) + up[0] = 0x8C // non-pong game/control byte + up[size-1] = 0x11 + if _, err := client.Write(up); err != nil { + t.Fatalf("client write %d: %v", size, err) + } + + _ = remote.SetReadDeadline(time.Now().Add(time.Second)) + rbuf := make([]byte, udpRecvBufferSize) + rn, from, err := remote.ReadFrom(rbuf) + if err != nil { + t.Fatalf("remote read %d: %v", size, err) + } + if rn != size || !bytes.Equal(rbuf[:rn], up) { + t.Fatalf("upstream got n=%d want %d (truncated or corrupt)", rn, size) + } + + down := make([]byte, size) + down[0] = 0x8D + down[size-1] = 0x22 + if _, err := remote.WriteTo(down, from); err != nil { + t.Fatalf("remote write %d: %v", size, err) + } + + _ = client.SetReadDeadline(time.Now().Add(time.Second)) + cbuf := make([]byte, udpRecvBufferSize) + cn, _, err := client.ReadFrom(cbuf) + if err != nil { + t.Fatalf("client read %d: %v", size, err) + } + if cn != size || !bytes.Equal(cbuf[:cn], down) { + t.Fatalf("client got n=%d want %d (truncated or corrupt)", cn, size) + } + } +} From b35661a6d72052b3e63e9b4780f9bc44298c96f5 Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:33:06 -0700 Subject: [PATCH 08/18] Add cross-version compatibility harness (T0 + T1) Validates phantom against 51 Minecraft protocol versions, fully automated in CI. Design in test/compat/DESIGN.md. Two tiers, split by testing philosophy and enforced by imports: T0 (white box, ~0.3s, no network) replays real captured pong bytes from all 51 bedrock-protocol versions plus hand-authored shape fixtures through the parser and rewriter, asserting field-level invariants. Adds a fuzz target for the pong parser, which eats untrusted network bytes with no length or magic validation. T1 (black box, ~40s) drives a real phantom subprocess over real UDP against a scriptable fake upstream and, for sampled versions, a real bedrock-protocol server and client. It may not import phantom's implementation packages; TestBlackBoxRuleHolds enforces that with go list -deps rather than trusting convention. Subprocess rather than in-process because serverID is a package global, so "two instances must advertise different ids" is not observable from inside one process. CI shards T1 four ways for isolation as much as speed: phantom binds UDP :19132 unconditionally with SO_REUSEPORT, so two concurrent instances on one host silently load-balance each other's datagrams. One shard per runner gives each its own network stack. Eight invariants XFAIL today. These are the open protocol bugs in TODO.md, now measured against real wire bytes rather than asserted: every real server sends 13 pong fields and phantom drops the 13th. The registry fails the build if a registered case starts PASSING, so fixing a bug forces deleting its entry in the same change. Also: bump go 1.12 -> 1.21 (go:embed needs >=1.16, fuzzing >=1.18) and fix the unkeyed struct literal that made go vet fail. Not verified: the full connect-session tests never execute on darwin/arm64, where bedrock-protocol's native RakNet addon crashes. They use a control connection to the upstream so they skip rather than falsely blame phantom, but their first real run will be on Linux CI. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 110 ++ .github/workflows/nightly.yml | 74 + .gitignore | 4 + Makefile | 25 +- cmd/phantom.go | 14 +- go.mod | 9 +- internal/corpus/corpus.go | 277 ++++ internal/corpus/data/captured.json | 1241 +++++++++++++++++ internal/corpus/data/known_failures.json | 55 + internal/corpus/data/synthetic.json | 161 +++ internal/corpus/gen/capture.js | 195 +++ internal/corpus/gen/drift.js | 63 + internal/corpus/gen/package-lock.json | 1565 ++++++++++++++++++++++ internal/corpus/gen/package.json | 12 + internal/corpus/xfail.go | 59 + internal/proto/corpus_test.go | 243 ++++ internal/proto/fuzz_test.go | 60 + internal/proxy/rewrite_test.go | 249 ++++ test/compat/DESIGN.md | 312 +++++ test/compat/e2e_test.go | 461 +++++++ test/compat/fakeserver/fakeserver.go | 204 +++ test/compat/harness.go | 365 +++++ test/compat/matrix.json | 31 + test/compat/node/connect.js | 82 ++ test/compat/node/package-lock.json | 1565 ++++++++++++++++++++++ test/compat/node/package.json | 9 + test/compat/node/ping.js | 43 + test/compat/node/serve.js | 53 + test/compat/node_test.go | 329 +++++ test/compat/slow_test.go | 100 ++ 30 files changed, 7961 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/nightly.yml create mode 100644 internal/corpus/corpus.go create mode 100644 internal/corpus/data/captured.json create mode 100644 internal/corpus/data/known_failures.json create mode 100644 internal/corpus/data/synthetic.json create mode 100644 internal/corpus/gen/capture.js create mode 100644 internal/corpus/gen/drift.js create mode 100644 internal/corpus/gen/package-lock.json create mode 100644 internal/corpus/gen/package.json create mode 100644 internal/corpus/xfail.go create mode 100644 internal/proto/corpus_test.go create mode 100644 internal/proto/fuzz_test.go create mode 100644 internal/proxy/rewrite_test.go create mode 100644 test/compat/DESIGN.md create mode 100644 test/compat/e2e_test.go create mode 100644 test/compat/fakeserver/fakeserver.go create mode 100644 test/compat/harness.go create mode 100644 test/compat/matrix.json create mode 100644 test/compat/node/connect.js create mode 100644 test/compat/node/package-lock.json create mode 100644 test/compat/node/package.json create mode 100644 test/compat/node/ping.js create mode 100644 test/compat/node/serve.js create mode 100644 test/compat/node_test.go create mode 100644 test/compat/slow_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..262794e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: vet + race + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: gofmt + run: | + unformatted="$(gofmt -l ./cmd ./internal ./test)" + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: go test -race + run: go test -race ./... + + # T0: white-box unit tests over the cross-version corpus. Pure Go, no network, + # no Node, ~1s. The OS matrix is nearly free here and catches path, endianness + # and line-ending surprises that only ever show up off Linux. + t0: + name: T0 corpus (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Corpus + rewrite tests + run: go test ./internal/... + + - name: Fuzz the pong parser (short) + if: matrix.os == 'ubuntu-latest' + run: go test ./internal/proto/ -run=Fuzz -fuzz=FuzzReadUnconnectedPing -fuzztime=30s + + # T1: black-box end-to-end against a real phantom subprocess. + # + # Sharded for ISOLATION as much as for speed: phantom binds UDP :19132 + # unconditionally with SO_REUSEPORT, so two concurrent instances on one host + # silently load-balance each other's datagrams. One shard per runner gives + # each its own network stack; cases run serially within a shard. + t1: + name: T1 e2e (shard ${{ matrix.shard }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [0, 1, 2, 3] + env: + PHANTOM_SHARD: ${{ matrix.shard }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: test/compat/node/package-lock.json + + - name: Install bedrock-protocol + working-directory: test/compat/node + run: npm ci + + - name: End-to-end + run: go test -tags='e2e slow node' ./test/compat/... -timeout=20m -v + + cross-compile: + name: cross-compile + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build every release target + run: make build diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..92b39bc --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,74 @@ +name: Nightly compatibility + +on: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + # Runs the live tier against EVERY supported version rather than the sampled + # subset CI uses, by clearing PHANTOM_SHARD. + full-sweep: + name: Full version sweep + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: test/compat/node/package-lock.json + + - name: Install bedrock-protocol + working-directory: test/compat/node + run: npm ci + + - name: Every version, unsharded + run: go test -tags='e2e slow node' ./test/compat/... -timeout=45m -v + + extended-fuzz: + name: Extended fuzzing + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Fuzz the pong parser (10m) + run: go test ./internal/proto/ -run=Fuzz -fuzz=FuzzReadUnconnectedPing -fuzztime=10m + + - name: Upload any crashers + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-corpus + path: internal/proto/testdata/fuzz/ + + # The mechanism that keeps "N versions" honest over time: when Mojang ships a + # version and bedrock-protocol adds support, this goes red and tells you + # exactly which file to regenerate. + matrix-drift: + name: Version matrix drift + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install generator deps + working-directory: internal/corpus/gen + run: npm install --no-audit --no-fund + + - name: Check for new Minecraft versions + working-directory: internal/corpus/gen + run: node drift.js diff --git a/.gitignore b/.gitignore index 00b41c6..d36d319 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ bin/ .DS_Store + +# Compatibility harness: Node deps are installed by `npm ci` from the committed +# lockfiles, never committed themselves. +node_modules/ diff --git a/Makefile b/Makefile index 9343b14..3cdbc3b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ SHELL=/bin/bash -.PHONY: prep +.PHONY: prep build build-target clean test test-e2e test-all fuzz corpus drift OUT=bin/phantom-windows.exe bin/phantom-windows-32bit.exe bin/phantom-macos bin/phantom-macos-arm8 bin/phantom-linux bin/phantom-linux-arm5 bin/phantom-linux-arm6 bin/phantom-linux-arm7 bin/phantom-linux-arm8 CMDSRC=phantom.go @@ -57,5 +57,28 @@ prep: clean: rm -rf bin +# T0 - white-box unit tests over the cross-version corpus. Fast, no deps. test: go test ./... + +# T1 - black-box end-to-end against a real phantom subprocess. +# Needs `npm ci` in test/compat/node first for the `node` tag to do anything; +# without it those cases skip rather than fail. +test-e2e: + go test -tags='e2e slow node' ./test/compat/... -timeout=20m + +test-all: test test-e2e + +fuzz: + go test ./internal/proto/ -run=Fuzz -fuzz=FuzzReadUnconnectedPing -fuzztime=60s + +# Regenerates the captured pong corpus from real bedrock-protocol servers, one +# per supported Minecraft version. Dev-time only: CI replays the committed +# bytes and never runs this. Commit the diff. +corpus: + cd internal/corpus/gen && npm install --no-audit --no-fund && node capture.js + +# Reports Minecraft versions that bedrock-protocol supports but this repo does +# not yet test. +drift: + cd internal/corpus/gen && node drift.js diff --git a/cmd/phantom.go b/cmd/phantom.go index 87e6450..697d1d2 100644 --- a/cmd/phantom.go +++ b/cmd/phantom.go @@ -61,13 +61,13 @@ func main() { Level(logLevel) proxyServer, err := proxy.New(proxy.ProxyPrefs{ - bindAddressString, - bindPortInt, - serverAddressString, - idleTimeout, - *ipv6Arg, - *removePortsArg, - *workersArg, + BindAddress: bindAddressString, + BindPort: bindPortInt, + RemoteServer: serverAddressString, + IdleTimeout: idleTimeout, + EnableIPv6: *ipv6Arg, + RemovePorts: *removePortsArg, + NumWorkers: *workersArg, }) if err != nil { diff --git a/go.mod b/go.mod index c803faa..f7d0ef4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/jhead/phantom -go 1.12 +go 1.21 require ( github.com/libp2p/go-reuseport v0.0.1 @@ -8,3 +8,10 @@ require ( github.com/stretchr/testify v1.3.0 github.com/tevino/abool v1.2.0 ) + +require ( + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/pkg/errors v0.8.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.0.0-20190228124157-a34e9553db1e // indirect +) diff --git a/internal/corpus/corpus.go b/internal/corpus/corpus.go new file mode 100644 index 0000000..21e2465 --- /dev/null +++ b/internal/corpus/corpus.go @@ -0,0 +1,277 @@ +// Package corpus loads the cross-version compatibility test fixtures. +// +// It contains NO phantom protocol logic - only fixture data and the XFAIL +// registry - which is why both test tiers may import it without the black-box +// tier learning anything about phantom's implementation. See test/compat/DESIGN.md. +package corpus + +import ( + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + _ "embed" +) + +//go:embed data/captured.json +var capturedJSON []byte + +//go:embed data/synthetic.json +var syntheticJSON []byte + +//go:embed data/known_failures.json +var knownFailuresJSON []byte + +// Magic is the 16-byte RakNet offline-message magic constant. +var Magic = []byte{ + 0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, + 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78, +} + +// PongID and PingID are the RakNet offline packet IDs phantom cares about. +const ( + PongID = 0x1c + PingID = 0x01 + PingOpenID = 0x02 +) + +// headerLen is the fixed part of an Unconnected Pong: ID(1) + pingTime(8) + +// serverGUID(8) + magic(16) + strLen(2). +const headerLen = 35 + +// CapturedEntry is one real pong captured from a bedrock-protocol server. +type CapturedEntry struct { + MC string `json:"mc"` + Protocol int `json:"protocol"` + PongHex string `json:"pongHex"` + EchoedPingTime string `json:"echoedPingTime"` + ServerGUID string `json:"serverGUID"` + MOTD string `json:"motd"` + Fields []string `json:"fields"` +} + +// Frame returns the raw pong datagram bytes. +func (e CapturedEntry) Frame() []byte { + b, err := hex.DecodeString(e.PongHex) + if err != nil { + panic(fmt.Sprintf("corpus: entry %s has invalid pongHex: %v", e.MC, err)) + } + return b +} + +// FieldCount is the number of real MOTD fields, excluding the empty element +// produced by the conventional trailing semicolon. +func (e CapturedEntry) FieldCount() int { + return realFieldCount(e.MOTD) +} + +type capturedDoc struct { + GeneratedBy string `json:"generatedBy"` + PingTime string `json:"pingTime"` + ClientGUID string `json:"clientGUID"` + Entries []CapturedEntry `json:"entries"` +} + +// SyntheticEntry is a hand-authored fixture: either a MOTD that gets wrapped in +// a well-formed frame, or verbatim (possibly malformed) bytes. +type SyntheticEntry struct { + ID string `json:"id"` + Desc string `json:"desc"` + MOTD *string `json:"motd"` + MOTDRepeat *motdRepeat `json:"motdRepeat"` + RawHex *string `json:"rawHex"` + FieldCount int `json:"fieldCount"` + WantParseError bool `json:"wantParseError"` + NoRoundTrip bool `json:"noRoundTrip"` + Tags []string `json:"tags"` + + // Frame surgery. These build an otherwise complete, well-formed pong with + // exactly one thing wrong, so a fixture tests the defect it names rather + // than tripping an unrelated length check first. + PacketIDOverride *int `json:"packetIDOverride"` + MagicOverride *string `json:"magicOverride"` + + frame []byte + motd string +} + +type motdRepeat struct { + Prefix string `json:"prefix"` + Unit string `json:"unit"` + Count int `json:"count"` + Suffix string `json:"suffix"` +} + +// Frame returns the datagram bytes for this fixture. +func (e SyntheticEntry) Frame() []byte { return e.frame } + +// IsRaw reports whether this fixture supplies verbatim bytes rather than a +// generated well-formed frame. +func (e SyntheticEntry) IsRaw() bool { return e.RawHex != nil } + +// MOTDString returns the MOTD this fixture carries. Meaningless for raw fixtures. +func (e SyntheticEntry) MOTDString() string { return e.motd } + +// HasTag reports whether the fixture carries the given tag. +func (e SyntheticEntry) HasTag(tag string) bool { + for _, t := range e.Tags { + if t == tag { + return true + } + } + return false +} + +type syntheticDoc struct { + PingTime string `json:"pingTime"` + ServerGUID string `json:"serverGUID"` + Entries []SyntheticEntry `json:"entries"` +} + +// KnownFailure is one registered XFAIL. +type KnownFailure struct { + ID string `json:"id"` + Reason string `json:"reason"` + TODO string `json:"todo"` +} + +type knownFailuresDoc struct { + Failures []KnownFailure `json:"failures"` +} + +var ( + captured capturedDoc + synthetic syntheticDoc + known knownFailuresDoc +) + +func init() { + mustUnmarshal(capturedJSON, &captured, "captured.json") + mustUnmarshal(syntheticJSON, &synthetic, "synthetic.json") + mustUnmarshal(knownFailuresJSON, &known, "known_failures.json") + + pingTime := mustHex(synthetic.PingTime, "synthetic pingTime") + guid := mustHex(synthetic.ServerGUID, "synthetic serverGUID") + + for i := range synthetic.Entries { + e := &synthetic.Entries[i] + switch { + case e.RawHex != nil: + e.frame = mustHex(*e.RawHex, "entry "+e.ID) + case e.MOTDRepeat != nil: + r := e.MOTDRepeat + e.motd = r.Prefix + strings.Repeat(r.Unit, r.Count) + r.Suffix + e.frame = e.build(pingTime, guid) + case e.MOTD != nil: + e.motd = *e.MOTD + e.frame = e.build(pingTime, guid) + default: + panic(fmt.Sprintf("corpus: synthetic entry %q has none of motd/motdRepeat/rawHex", e.ID)) + } + } +} + +func mustUnmarshal(data []byte, v interface{}, name string) { + if err := json.Unmarshal(data, v); err != nil { + panic(fmt.Sprintf("corpus: parsing %s: %v", name, err)) + } +} + +func mustHex(s, what string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(fmt.Sprintf("corpus: %s is not valid hex: %v", what, err)) + } + return b +} + +// build assembles this fixture's frame, applying any single-field surgery. +func (e SyntheticEntry) build(pingTime, serverGUID []byte) []byte { + id := byte(PongID) + if e.PacketIDOverride != nil { + id = byte(*e.PacketIDOverride) + } + magic := Magic + if e.MagicOverride != nil { + magic = mustHex(*e.MagicOverride, "magicOverride for "+e.ID) + if len(magic) != len(Magic) { + panic(fmt.Sprintf("corpus: %s magicOverride must be %d bytes, got %d", + e.ID, len(Magic), len(magic))) + } + } + return buildPong(id, pingTime, serverGUID, magic, e.motd) +} + +// BuildPong assembles a well-formed Unconnected Pong datagram around a MOTD. +func BuildPong(pingTime, serverGUID []byte, motd string) []byte { + return buildPong(PongID, pingTime, serverGUID, Magic, motd) +} + +func buildPong(id byte, pingTime, serverGUID, magic []byte, motd string) []byte { + out := make([]byte, 0, headerLen+len(motd)) + out = append(out, id) + out = append(out, pingTime...) + out = append(out, serverGUID...) + out = append(out, magic...) + + var lenBuf [2]byte + binary.BigEndian.PutUint16(lenBuf[:], uint16(len(motd))) + out = append(out, lenBuf[:]...) + return append(out, motd...) +} + +// SplitMOTD extracts the MOTD string from a well-formed pong datagram. +func SplitMOTD(frame []byte) (string, error) { + if len(frame) < headerLen { + return "", fmt.Errorf("frame too short: %d bytes", len(frame)) + } + n := int(binary.BigEndian.Uint16(frame[33:35])) + if len(frame) < headerLen+n { + return "", fmt.Errorf("declared MOTD length %d exceeds %d remaining bytes", n, len(frame)-headerLen) + } + return string(frame[headerLen : headerLen+n]), nil +} + +// realFieldCount counts MOTD fields, ignoring the empty element produced by a +// conventional trailing semicolon. +func realFieldCount(motd string) int { + parts := strings.Split(motd, ";") + if n := len(parts); n > 0 && parts[n-1] == "" { + return n - 1 + } + return len(parts) +} + +// RealFieldCount is the exported form of realFieldCount. +func RealFieldCount(motd string) int { return realFieldCount(motd) } + +// Captured returns every per-version captured pong, ascending by protocol number. +func Captured() []CapturedEntry { return captured.Entries } + +// CapturedPingTime is the ping time the generator sent; servers must echo it. +func CapturedPingTime() []byte { return mustHex(captured.PingTime, "captured pingTime") } + +// GeneratedBy identifies the tool and version that produced the captured corpus. +func GeneratedBy() string { return captured.GeneratedBy } + +// Synthetic returns every hand-authored fixture. +func Synthetic() []SyntheticEntry { return synthetic.Entries } + +// SyntheticPingTime and SyntheticGUID are the values baked into generated frames. +func SyntheticPingTime() []byte { return mustHex(synthetic.PingTime, "synthetic pingTime") } +func SyntheticGUID() []byte { return mustHex(synthetic.ServerGUID, "synthetic serverGUID") } + +// KnownFailures returns the whole XFAIL registry. +func KnownFailures() []KnownFailure { return known.Failures } + +// LookupKnownFailure returns the registered failure for an id, if any. +func LookupKnownFailure(id string) (KnownFailure, bool) { + for _, f := range known.Failures { + if f.ID == id { + return f, true + } + } + return KnownFailure{}, false +} diff --git a/internal/corpus/data/captured.json b/internal/corpus/data/captured.json new file mode 100644 index 0000000..48ea299 --- /dev/null +++ b/internal/corpus/data/captured.json @@ -0,0 +1,1241 @@ +{ + "_comment": [ + "GENERATED FILE - do not edit by hand. Regenerate with `make corpus`.", + "Real RakNet Unconnected Pong bytes captured from a bedrock-protocol server,", + "one entry per supported Minecraft version.", + "", + "CAVEAT: these are bedrock-protocol advertisements, not Mojang BDS ones.", + "The MOTD field COUNT is constant across every version here (bedrock-protocol", + "uses one serializer); only the protocol number and version string vary.", + "Real per-version shape diversity lives in synthetic.json." + ], + "generatedBy": "bedrock-protocol@3.57.0", + "pingTime": "0011223344556677", + "clientGUID": "8877665544332211", + "entries": [ + { + "mc": "1.16.201", + "protocol": 422, + "pongHex": "1c0011223344556677f3ccb98933ab0fc600ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3432323b312e31362e3230313b303b333b313738353030363435343933353b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330303b31393330303b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "f3ccb98933ab0fc6", + "motd": "MCPE;Bedrock Protocol Server;422;1.16.201;0;3;1785006454935;bedrock-protocol;Creative;1;19300;19300;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "422", + "1.16.201", + "0", + "3", + "1785006454935", + "bedrock-protocol", + "Creative", + "1", + "19300", + "19300", + "0", + "" + ] + }, + { + "mc": "1.16.210", + "protocol": 428, + "pongHex": "1c001122334455667730b99ff633c357cc00ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3432383b312e31362e3231303b303b333b313738353030363435363532363b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330313b31393330313b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "30b99ff633c357cc", + "motd": "MCPE;Bedrock Protocol Server;428;1.16.210;0;3;1785006456526;bedrock-protocol;Creative;1;19301;19301;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "428", + "1.16.210", + "0", + "3", + "1785006456526", + "bedrock-protocol", + "Creative", + "1", + "19301", + "19301", + "0", + "" + ] + }, + { + "mc": "1.16.220", + "protocol": 431, + "pongHex": "1c0011223344556677ac5082cc33dba9f300ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3433313b312e31362e3232303b303b333b313738353030363435383132303b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330323b31393330323b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "ac5082cc33dba9f3", + "motd": "MCPE;Bedrock Protocol Server;431;1.16.220;0;3;1785006458120;bedrock-protocol;Creative;1;19302;19302;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "431", + "1.16.220", + "0", + "3", + "1785006458120", + "bedrock-protocol", + "Creative", + "1", + "19302", + "19302", + "0", + "" + ] + }, + { + "mc": "1.17.0", + "protocol": 440, + "pongHex": "1c0011223344556677e6fc66ac33f41f2900ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3434303b312e31372e303b303b333b313738353030363435393732333b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330333b31393330333b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "e6fc66ac33f41f29", + "motd": "MCPE;Bedrock Protocol Server;440;1.17.0;0;3;1785006459723;bedrock-protocol;Creative;1;19303;19303;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "440", + "1.17.0", + "0", + "3", + "1785006459723", + "bedrock-protocol", + "Creative", + "1", + "19303", + "19303", + "0", + "" + ] + }, + { + "mc": "1.17.10", + "protocol": 448, + "pongHex": "1c0011223344556677d74e0137340c8a5300ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3434383b312e31372e31303b303b333b313738353030363436313332333b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330343b31393330343b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "d74e0137340c8a53", + "motd": "MCPE;Bedrock Protocol Server;448;1.17.10;0;3;1785006461323;bedrock-protocol;Creative;1;19304;19304;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "448", + "1.17.10", + "0", + "3", + "1785006461323", + "bedrock-protocol", + "Creative", + "1", + "19304", + "19304", + "0", + "" + ] + }, + { + "mc": "1.17.30", + "protocol": 465, + "pongHex": "1c0011223344556677b14356fc3424e0f900ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3436353b312e31372e33303b303b333b313738353030363436323931383b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330353b31393330353b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "b14356fc3424e0f9", + "motd": "MCPE;Bedrock Protocol Server;465;1.17.30;0;3;1785006462918;bedrock-protocol;Creative;1;19305;19305;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "465", + "1.17.30", + "0", + "3", + "1785006462918", + "bedrock-protocol", + "Creative", + "1", + "19305", + "19305", + "0", + "" + ] + }, + { + "mc": "1.17.40", + "protocol": 471, + "pongHex": "1c00112233445566778c81af73343d4ac100ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3437313b312e31372e34303b303b333b313738353030363436343531383b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330363b31393330363b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "8c81af73343d4ac1", + "motd": "MCPE;Bedrock Protocol Server;471;1.17.40;0;3;1785006464518;bedrock-protocol;Creative;1;19306;19306;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "471", + "1.17.40", + "0", + "3", + "1785006464518", + "bedrock-protocol", + "Creative", + "1", + "19306", + "19306", + "0", + "" + ] + }, + { + "mc": "1.18.0", + "protocol": 475, + "pongHex": "1c00112233445566775c9f13183455d90400ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3437353b312e31382e303b303b333b313738353030363436363132383b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330373b31393330373b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "5c9f13183455d904", + "motd": "MCPE;Bedrock Protocol Server;475;1.18.0;0;3;1785006466128;bedrock-protocol;Creative;1;19307;19307;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "475", + "1.18.0", + "0", + "3", + "1785006466128", + "bedrock-protocol", + "Creative", + "1", + "19307", + "19307", + "0", + "" + ] + }, + { + "mc": "1.18.11", + "protocol": 486, + "pongHex": "1c001122334455667730be2871346e5ca800ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3438363b312e31382e31313b303b333b313738353030363436373733343b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330383b31393330383b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "30be2871346e5ca8", + "motd": "MCPE;Bedrock Protocol Server;486;1.18.11;0;3;1785006467734;bedrock-protocol;Creative;1;19308;19308;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "486", + "1.18.11", + "0", + "3", + "1785006467734", + "bedrock-protocol", + "Creative", + "1", + "19308", + "19308", + "0", + "" + ] + }, + { + "mc": "1.18.30", + "protocol": 503, + "pongHex": "1c00112233445566775195059334871d8300ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3530333b312e31382e33303b303b333b313738353030363436393335363b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393330393b31393330393b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "5195059334871d83", + "motd": "MCPE;Bedrock Protocol Server;503;1.18.30;0;3;1785006469356;bedrock-protocol;Creative;1;19309;19309;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "503", + "1.18.30", + "0", + "3", + "1785006469356", + "bedrock-protocol", + "Creative", + "1", + "19309", + "19309", + "0", + "" + ] + }, + { + "mc": "1.19.1", + "protocol": 527, + "pongHex": "1c0011223344556677be47e5bd34a02af900ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3532373b312e31392e313b303b333b313738353030363437303939383b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331303b31393331303b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "be47e5bd34a02af9", + "motd": "MCPE;Bedrock Protocol Server;527;1.19.1;0;3;1785006470998;bedrock-protocol;Creative;1;19310;19310;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "527", + "1.19.1", + "0", + "3", + "1785006470998", + "bedrock-protocol", + "Creative", + "1", + "19310", + "19310", + "0", + "" + ] + }, + { + "mc": "1.19.10", + "protocol": 534, + "pongHex": "1c0011223344556677ed24ea8d34b8b42a00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3533343b312e31392e31303b303b333b313738353030363437323630363b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331313b31393331313b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "ed24ea8d34b8b42a", + "motd": "MCPE;Bedrock Protocol Server;534;1.19.10;0;3;1785006472606;bedrock-protocol;Creative;1;19311;19311;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "534", + "1.19.10", + "0", + "3", + "1785006472606", + "bedrock-protocol", + "Creative", + "1", + "19311", + "19311", + "0", + "" + ] + }, + { + "mc": "1.19.20", + "protocol": 544, + "pongHex": "1c0011223344556677b9aee6a534d131d700ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3534343b312e31392e32303b303b333b313738353030363437343231313b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331323b31393331323b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "b9aee6a534d131d7", + "motd": "MCPE;Bedrock Protocol Server;544;1.19.20;0;3;1785006474211;bedrock-protocol;Creative;1;19312;19312;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "544", + "1.19.20", + "0", + "3", + "1785006474211", + "bedrock-protocol", + "Creative", + "1", + "19312", + "19312", + "0", + "" + ] + }, + { + "mc": "1.19.21", + "protocol": 545, + "pongHex": "1c0011223344556677c672fd9e34e9af4c00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3534353b312e31392e32313b303b333b313738353030363437353831363b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331333b31393331333b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "c672fd9e34e9af4c", + "motd": "MCPE;Bedrock Protocol Server;545;1.19.21;0;3;1785006475816;bedrock-protocol;Creative;1;19313;19313;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "545", + "1.19.21", + "0", + "3", + "1785006475816", + "bedrock-protocol", + "Creative", + "1", + "19313", + "19313", + "0", + "" + ] + }, + { + "mc": "1.19.30", + "protocol": 554, + "pongHex": "1c00112233445566778c2c9cc23502297e00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3535343b312e31392e33303b303b333b313738353030363437373432303b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331343b31393331343b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "8c2c9cc23502297e", + "motd": "MCPE;Bedrock Protocol Server;554;1.19.30;0;3;1785006477420;bedrock-protocol;Creative;1;19314;19314;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "554", + "1.19.30", + "0", + "3", + "1785006477420", + "bedrock-protocol", + "Creative", + "1", + "19314", + "19314", + "0", + "" + ] + }, + { + "mc": "1.19.40", + "protocol": 557, + "pongHex": "1c0011223344556677eedf5c50351aa2f000ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3535373b312e31392e34303b303b333b313738353030363437393032343b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331353b31393331353b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "eedf5c50351aa2f0", + "motd": "MCPE;Bedrock Protocol Server;557;1.19.40;0;3;1785006479024;bedrock-protocol;Creative;1;19315;19315;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "557", + "1.19.40", + "0", + "3", + "1785006479024", + "bedrock-protocol", + "Creative", + "1", + "19315", + "19315", + "0", + "" + ] + }, + { + "mc": "1.19.50", + "protocol": 560, + "pongHex": "1c001122334455667723ba69cf3533269e00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3536303b312e31392e35303b303b333b313738353030363438303633313b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331363b31393331363b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "23ba69cf3533269e", + "motd": "MCPE;Bedrock Protocol Server;560;1.19.50;0;3;1785006480631;bedrock-protocol;Creative;1;19316;19316;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "560", + "1.19.50", + "0", + "3", + "1785006480631", + "bedrock-protocol", + "Creative", + "1", + "19316", + "19316", + "0", + "" + ] + }, + { + "mc": "1.19.62", + "protocol": 567, + "pongHex": "1c0011223344556677baf6e3ea354bc37c00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3536373b312e31392e36323b303b333b313738353030363438323234343b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331373b31393331373b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "baf6e3ea354bc37c", + "motd": "MCPE;Bedrock Protocol Server;567;1.19.62;0;3;1785006482244;bedrock-protocol;Creative;1;19317;19317;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "567", + "1.19.62", + "0", + "3", + "1785006482244", + "bedrock-protocol", + "Creative", + "1", + "19317", + "19317", + "0", + "" + ] + }, + { + "mc": "1.19.60", + "protocol": 567, + "pongHex": "1c001122334455667769ca7a8435643d4c00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3536373b312e31392e36303b303b333b313738353030363438333834383b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331383b31393331383b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "69ca7a8435643d4c", + "motd": "MCPE;Bedrock Protocol Server;567;1.19.60;0;3;1785006483848;bedrock-protocol;Creative;1;19318;19318;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "567", + "1.19.60", + "0", + "3", + "1785006483848", + "bedrock-protocol", + "Creative", + "1", + "19318", + "19318", + "0", + "" + ] + }, + { + "mc": "1.19.63", + "protocol": 568, + "pongHex": "1c0011223344556677364d999c357ca99b00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3536383b312e31392e36333b303b333b313738353030363438353434393b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393331393b31393331393b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "364d999c357ca99b", + "motd": "MCPE;Bedrock Protocol Server;568;1.19.63;0;3;1785006485449;bedrock-protocol;Creative;1;19319;19319;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "568", + "1.19.63", + "0", + "3", + "1785006485449", + "bedrock-protocol", + "Creative", + "1", + "19319", + "19319", + "0", + "" + ] + }, + { + "mc": "1.19.70", + "protocol": 575, + "pongHex": "1c0011223344556677c734e7e935952b3800ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3537353b312e31392e37303b303b333b313738353030363438373035353b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332303b31393332303b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "c734e7e935952b38", + "motd": "MCPE;Bedrock Protocol Server;575;1.19.70;0;3;1785006487055;bedrock-protocol;Creative;1;19320;19320;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "575", + "1.19.70", + "0", + "3", + "1785006487055", + "bedrock-protocol", + "Creative", + "1", + "19320", + "19320", + "0", + "" + ] + }, + { + "mc": "1.19.80", + "protocol": 582, + "pongHex": "1c0011223344556677e4a145dc35adf27f00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3538323b312e31392e38303b303b333b313738353030363438383637393b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332313b31393332313b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "e4a145dc35adf27f", + "motd": "MCPE;Bedrock Protocol Server;582;1.19.80;0;3;1785006488679;bedrock-protocol;Creative;1;19321;19321;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "582", + "1.19.80", + "0", + "3", + "1785006488679", + "bedrock-protocol", + "Creative", + "1", + "19321", + "19321", + "0", + "" + ] + }, + { + "mc": "1.20.0", + "protocol": 589, + "pongHex": "1c0011223344556677c0c4de6a35c6b65700ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3538393b312e32302e303b303b333b313738353030363439303330323b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332323b31393332323b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "c0c4de6a35c6b657", + "motd": "MCPE;Bedrock Protocol Server;589;1.20.0;0;3;1785006490302;bedrock-protocol;Creative;1;19322;19322;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "589", + "1.20.0", + "0", + "3", + "1785006490302", + "bedrock-protocol", + "Creative", + "1", + "19322", + "19322", + "0", + "" + ] + }, + { + "mc": "1.20.15", + "protocol": 594, + "pongHex": "1c001122334455667738a3bfda35df6dff00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3539343b312e32302e31353b303b333b313738353030363439313932313b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332333b31393332333b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "38a3bfda35df6dff", + "motd": "MCPE;Bedrock Protocol Server;594;1.20.15;0;3;1785006491921;bedrock-protocol;Creative;1;19323;19323;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "594", + "1.20.15", + "0", + "3", + "1785006491921", + "bedrock-protocol", + "Creative", + "1", + "19323", + "19323", + "0", + "" + ] + }, + { + "mc": "1.20.10", + "protocol": 594, + "pongHex": "1c0011223344556677bb9a7d3535f7a91200ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3539343b312e32302e31303b303b333b313738353030363439333530393b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332343b31393332343b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "bb9a7d3535f7a912", + "motd": "MCPE;Bedrock Protocol Server;594;1.20.10;0;3;1785006493509;bedrock-protocol;Creative;1;19324;19324;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "594", + "1.20.10", + "0", + "3", + "1785006493509", + "bedrock-protocol", + "Creative", + "1", + "19324", + "19324", + "0", + "" + ] + }, + { + "mc": "1.20.30", + "protocol": 618, + "pongHex": "1c001122334455667750cc01133610518700ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3631383b312e32302e33303b303b333b313738353030363439353132353b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332353b31393332353b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "50cc011336105187", + "motd": "MCPE;Bedrock Protocol Server;618;1.20.30;0;3;1785006495125;bedrock-protocol;Creative;1;19325;19325;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "618", + "1.20.30", + "0", + "3", + "1785006495125", + "bedrock-protocol", + "Creative", + "1", + "19325", + "19325", + "0", + "" + ] + }, + { + "mc": "1.20.40", + "protocol": 622, + "pongHex": "1c0011223344556677a393ae0c36291fa200ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3632323b312e32302e34303b303b333b313738353030363439363735313b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332363b31393332363b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "a393ae0c36291fa2", + "motd": "MCPE;Bedrock Protocol Server;622;1.20.40;0;3;1785006496751;bedrock-protocol;Creative;1;19326;19326;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "622", + "1.20.40", + "0", + "3", + "1785006496751", + "bedrock-protocol", + "Creative", + "1", + "19326", + "19326", + "0", + "" + ] + }, + { + "mc": "1.20.50", + "protocol": 630, + "pongHex": "1c001122334455667784aade753641f5dd00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3633303b312e32302e35303b303b333b313738353030363439383337393b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332373b31393332373b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "84aade753641f5dd", + "motd": "MCPE;Bedrock Protocol Server;630;1.20.50;0;3;1785006498379;bedrock-protocol;Creative;1;19327;19327;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "630", + "1.20.50", + "0", + "3", + "1785006498379", + "bedrock-protocol", + "Creative", + "1", + "19327", + "19327", + "0", + "" + ] + }, + { + "mc": "1.20.61", + "protocol": 649, + "pongHex": "1c00112233445566776d025988365acd3200ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3634393b312e32302e36313b303b333b313738353030363530303030373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332383b31393332383b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "6d025988365acd32", + "motd": "MCPE;Bedrock Protocol Server;649;1.20.61;0;3;1785006500007;bedrock-protocol;Creative;1;19328;19328;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "649", + "1.20.61", + "0", + "3", + "1785006500007", + "bedrock-protocol", + "Creative", + "1", + "19328", + "19328", + "0", + "" + ] + }, + { + "mc": "1.20.71", + "protocol": 662, + "pongHex": "1c0011223344556677f97ba7013673ad8600ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3636323b312e32302e37313b303b333b313738353030363530313633373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393332393b31393332393b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "f97ba7013673ad86", + "motd": "MCPE;Bedrock Protocol Server;662;1.20.71;0;3;1785006501637;bedrock-protocol;Creative;1;19329;19329;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "662", + "1.20.71", + "0", + "3", + "1785006501637", + "bedrock-protocol", + "Creative", + "1", + "19329", + "19329", + "0", + "" + ] + }, + { + "mc": "1.20.80", + "protocol": 671, + "pongHex": "1c0011223344556677ed9e5624368c3e7300ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3637313b312e32302e38303b303b333b313738353030363530333234373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333303b31393333303b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "ed9e5624368c3e73", + "motd": "MCPE;Bedrock Protocol Server;671;1.20.80;0;3;1785006503247;bedrock-protocol;Creative;1;19330;19330;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "671", + "1.20.80", + "0", + "3", + "1785006503247", + "bedrock-protocol", + "Creative", + "1", + "19330", + "19330", + "0", + "" + ] + }, + { + "mc": "1.21.0", + "protocol": 685, + "pongHex": "1c00112233445566778b56431236a5135600ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3638353b312e32312e303b303b333b313738353030363530343837343b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333313b31393333313b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "8b56431236a51356", + "motd": "MCPE;Bedrock Protocol Server;685;1.21.0;0;3;1785006504874;bedrock-protocol;Creative;1;19331;19331;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "685", + "1.21.0", + "0", + "3", + "1785006504874", + "bedrock-protocol", + "Creative", + "1", + "19331", + "19331", + "0", + "" + ] + }, + { + "mc": "1.21.2", + "protocol": 686, + "pongHex": "1c0011223344556677ab8d56fb36bda8a400ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3638363b312e32312e323b303b333b313738353030363530363438353b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333323b31393333323b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "ab8d56fb36bda8a4", + "motd": "MCPE;Bedrock Protocol Server;686;1.21.2;0;3;1785006506485;bedrock-protocol;Creative;1;19332;19332;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "686", + "1.21.2", + "0", + "3", + "1785006506485", + "bedrock-protocol", + "Creative", + "1", + "19332", + "19332", + "0", + "" + ] + }, + { + "mc": "1.21.20", + "protocol": 712, + "pongHex": "1c001122334455667796a7789336d66d4600ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3731323b312e32312e32303b303b333b313738353030363530383130393b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333333b31393333333b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "96a7789336d66d46", + "motd": "MCPE;Bedrock Protocol Server;712;1.21.20;0;3;1785006508109;bedrock-protocol;Creative;1;19333;19333;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "712", + "1.21.20", + "0", + "3", + "1785006508109", + "bedrock-protocol", + "Creative", + "1", + "19333", + "19333", + "0", + "" + ] + }, + { + "mc": "1.21.30", + "protocol": 729, + "pongHex": "1c001122334455667799d4765a36ef3efa00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3732393b312e32312e33303b303b333b313738353030363530393733353b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333343b31393333343b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "99d4765a36ef3efa", + "motd": "MCPE;Bedrock Protocol Server;729;1.21.30;0;3;1785006509735;bedrock-protocol;Creative;1;19334;19334;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "729", + "1.21.30", + "0", + "3", + "1785006509735", + "bedrock-protocol", + "Creative", + "1", + "19334", + "19334", + "0", + "" + ] + }, + { + "mc": "1.21.42", + "protocol": 748, + "pongHex": "1c0011223344556677a3372ff33708170700ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3734383b312e32312e34323b303b333b313738353030363531313336333b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333353b31393333353b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "a3372ff337081707", + "motd": "MCPE;Bedrock Protocol Server;748;1.21.42;0;3;1785006511363;bedrock-protocol;Creative;1;19335;19335;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "748", + "1.21.42", + "0", + "3", + "1785006511363", + "bedrock-protocol", + "Creative", + "1", + "19335", + "19335", + "0", + "" + ] + }, + { + "mc": "1.21.50", + "protocol": 766, + "pongHex": "1c0011223344556677889e3e983720dd4800ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3736363b312e32312e35303b303b333b313738353030363531323938373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333363b31393333363b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "889e3e983720dd48", + "motd": "MCPE;Bedrock Protocol Server;766;1.21.50;0;3;1785006512987;bedrock-protocol;Creative;1;19336;19336;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "766", + "1.21.50", + "0", + "3", + "1785006512987", + "bedrock-protocol", + "Creative", + "1", + "19336", + "19336", + "0", + "" + ] + }, + { + "mc": "1.21.60", + "protocol": 776, + "pongHex": "1c0011223344556677376812d43739e43f00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3737363b312e32312e36303b303b333b313738353030363531343632373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333373b31393333373b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "376812d43739e43f", + "motd": "MCPE;Bedrock Protocol Server;776;1.21.60;0;3;1785006514627;bedrock-protocol;Creative;1;19337;19337;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "776", + "1.21.60", + "0", + "3", + "1785006514627", + "bedrock-protocol", + "Creative", + "1", + "19337", + "19337", + "0", + "" + ] + }, + { + "mc": "1.21.70", + "protocol": 786, + "pongHex": "1c00112233445566773f207be33752d6c700ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3738363b312e32312e37303b303b333b313738353030363531363236323b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333383b31393333383b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "3f207be33752d6c7", + "motd": "MCPE;Bedrock Protocol Server;786;1.21.70;0;3;1785006516262;bedrock-protocol;Creative;1;19338;19338;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "786", + "1.21.70", + "0", + "3", + "1785006516262", + "bedrock-protocol", + "Creative", + "1", + "19338", + "19338", + "0", + "" + ] + }, + { + "mc": "1.21.80", + "protocol": 800, + "pongHex": "1c001122334455667731d7803f376bcaf400ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3830303b312e32312e38303b303b333b313738353030363531373839373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393333393b31393333393b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "31d7803f376bcaf4", + "motd": "MCPE;Bedrock Protocol Server;800;1.21.80;0;3;1785006517897;bedrock-protocol;Creative;1;19339;19339;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "800", + "1.21.80", + "0", + "3", + "1785006517897", + "bedrock-protocol", + "Creative", + "1", + "19339", + "19339", + "0", + "" + ] + }, + { + "mc": "1.21.90", + "protocol": 818, + "pongHex": "1c00112233445566773c043d1237848c9300ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3831383b312e32312e39303b303b333b313738353030363531393532303b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334303b31393334303b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "3c043d1237848c93", + "motd": "MCPE;Bedrock Protocol Server;818;1.21.90;0;3;1785006519520;bedrock-protocol;Creative;1;19340;19340;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "818", + "1.21.90", + "0", + "3", + "1785006519520", + "bedrock-protocol", + "Creative", + "1", + "19340", + "19340", + "0", + "" + ] + }, + { + "mc": "1.21.93", + "protocol": 819, + "pongHex": "1c0011223344556677a3040fa9379d3b6d00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3831393b312e32312e39333b303b333b313738353030363532313133373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334313b31393334313b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "a3040fa9379d3b6d", + "motd": "MCPE;Bedrock Protocol Server;819;1.21.93;0;3;1785006521137;bedrock-protocol;Creative;1;19341;19341;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "819", + "1.21.93", + "0", + "3", + "1785006521137", + "bedrock-protocol", + "Creative", + "1", + "19341", + "19341", + "0", + "" + ] + }, + { + "mc": "1.21.100", + "protocol": 827, + "pongHex": "1c0011223344556677729cb24037b62a2200ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3832373b312e32312e3130303b303b333b313738353030363532323737313b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334323b31393334323b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "729cb24037b62a22", + "motd": "MCPE;Bedrock Protocol Server;827;1.21.100;0;3;1785006522771;bedrock-protocol;Creative;1;19342;19342;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "827", + "1.21.100", + "0", + "3", + "1785006522771", + "bedrock-protocol", + "Creative", + "1", + "19342", + "19342", + "0", + "" + ] + }, + { + "mc": "1.21.111", + "protocol": 844, + "pongHex": "1c0011223344556677188f9d6c37cf1f6100ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3834343b312e32312e3131313b303b333b313738353030363532343430373b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334333b31393334333b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "188f9d6c37cf1f61", + "motd": "MCPE;Bedrock Protocol Server;844;1.21.111;0;3;1785006524407;bedrock-protocol;Creative;1;19343;19343;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "844", + "1.21.111", + "0", + "3", + "1785006524407", + "bedrock-protocol", + "Creative", + "1", + "19343", + "19343", + "0", + "" + ] + }, + { + "mc": "1.21.120", + "protocol": 859, + "pongHex": "1c0011223344556677a6233aa837e7d3e900ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3835393b312e32312e3132303b303b333b313738353030363532363032363b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334343b31393334343b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "a6233aa837e7d3e9", + "motd": "MCPE;Bedrock Protocol Server;859;1.21.120;0;3;1785006526026;bedrock-protocol;Creative;1;19344;19344;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "859", + "1.21.120", + "0", + "3", + "1785006526026", + "bedrock-protocol", + "Creative", + "1", + "19344", + "19344", + "0", + "" + ] + }, + { + "mc": "1.21.124", + "protocol": 860, + "pongHex": "1c00112233445566778abac1963800536400ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3836303b312e32312e3132343b303b333b313738353030363532373633323b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334353b31393334353b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "8abac19638005364", + "motd": "MCPE;Bedrock Protocol Server;860;1.21.124;0;3;1785006527632;bedrock-protocol;Creative;1;19345;19345;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "860", + "1.21.124", + "0", + "3", + "1785006527632", + "bedrock-protocol", + "Creative", + "1", + "19345", + "19345", + "0", + "" + ] + }, + { + "mc": "1.21.130", + "protocol": 898, + "pongHex": "1c0011223344556677dfe917253818f68a00ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b3839383b312e32312e3133303b303b333b313738353030363532393234363b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334363b31393334363b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "dfe917253818f68a", + "motd": "MCPE;Bedrock Protocol Server;898;1.21.130;0;3;1785006529246;bedrock-protocol;Creative;1;19346;19346;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "898", + "1.21.130", + "0", + "3", + "1785006529246", + "bedrock-protocol", + "Creative", + "1", + "19346", + "19346", + "0", + "" + ] + }, + { + "mc": "1.26.0", + "protocol": 924, + "pongHex": "1c0011223344556677f704ab7b38317aa100ffff00fefefefefdfdfdfd1234567800644d4350453b426564726f636b2050726f746f636f6c205365727665723b3932343b312e32362e303b303b333b313738353030363533303835333b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334373b31393334373b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "f704ab7b38317aa1", + "motd": "MCPE;Bedrock Protocol Server;924;1.26.0;0;3;1785006530853;bedrock-protocol;Creative;1;19347;19347;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "924", + "1.26.0", + "0", + "3", + "1785006530853", + "bedrock-protocol", + "Creative", + "1", + "19347", + "19347", + "0", + "" + ] + }, + { + "mc": "1.26.10", + "protocol": 944, + "pongHex": "1c00112233445566779819b8c6384a368500ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3934343b312e32362e31303b303b333b313738353030363533323437343b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334383b31393334383b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "9819b8c6384a3685", + "motd": "MCPE;Bedrock Protocol Server;944;1.26.10;0;3;1785006532474;bedrock-protocol;Creative;1;19348;19348;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "944", + "1.26.10", + "0", + "3", + "1785006532474", + "bedrock-protocol", + "Creative", + "1", + "19348", + "19348", + "0", + "" + ] + }, + { + "mc": "1.26.20", + "protocol": 975, + "pongHex": "1c0011223344556677612290963862e21a00ffff00fefefefefdfdfdfd1234567800654d4350453b426564726f636b2050726f746f636f6c205365727665723b3937353b312e32362e32303b303b333b313738353030363533343039313b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393334393b31393334393b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "612290963862e21a", + "motd": "MCPE;Bedrock Protocol Server;975;1.26.20;0;3;1785006534091;bedrock-protocol;Creative;1;19349;19349;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "975", + "1.26.20", + "0", + "3", + "1785006534091", + "bedrock-protocol", + "Creative", + "1", + "19349", + "19349", + "0", + "" + ] + }, + { + "mc": "1.26.30", + "protocol": 1001, + "pongHex": "1c00112233445566778ef59e96387bbaaf00ffff00fefefefefdfdfdfd1234567800664d4350453b426564726f636b2050726f746f636f6c205365727665723b313030313b312e32362e33303b303b333b313738353030363533353731393b626564726f636b2d70726f746f636f6c3b43726561746976653b313b31393335303b31393335303b303b", + "echoedPingTime": "0011223344556677", + "serverGUID": "8ef59e96387bbaaf", + "motd": "MCPE;Bedrock Protocol Server;1001;1.26.30;0;3;1785006535719;bedrock-protocol;Creative;1;19350;19350;0;", + "fields": [ + "MCPE", + "Bedrock Protocol Server", + "1001", + "1.26.30", + "0", + "3", + "1785006535719", + "bedrock-protocol", + "Creative", + "1", + "19350", + "19350", + "0", + "" + ] + } + ] +} diff --git a/internal/corpus/data/known_failures.json b/internal/corpus/data/known_failures.json new file mode 100644 index 0000000..feff214 --- /dev/null +++ b/internal/corpus/data/known_failures.json @@ -0,0 +1,55 @@ +{ + "_comment": [ + "XFAIL registry: compatibility invariants that phantom does NOT currently satisfy.", + "These are open protocol bugs tracked in TODO.md, not harness defects.", + "", + "A registered case that fails is reported as XFAIL and does not fail the build.", + "A registered case that PASSES fails the build with an XPASS error - that forces", + "whoever fixes the bug to delete its entry here in the same change. The registry", + "is therefore always an accurate, self-maintaining list of open protocol bugs.", + "", + "To retire an entry: fix the bug, delete the entry, watch the test go green." + ], + "failures": [ + { + "id": "t0/trailing-fields-preserved", + "reason": "PongData is a fixed 12-field struct, so any field beyond Port6 is dropped on rebuild. Every real modern server sends 13.", + "todo": "TODO.md > Protocol correctness > 'Pong rewrite silently drops fields beyond the 12 known ones'" + }, + { + "id": "t0/packet-id-validated", + "reason": "ReadUnconnectedPing discards the packet ID without checking it, so a 0x01 ping parses as a pong.", + "todo": "TODO.md > Bugs > 'Short/truncated packets parse silently into garbage'" + }, + { + "id": "t0/magic-validated", + "reason": "The 16-byte RakNet magic is read but never compared against the expected constant.", + "todo": "TODO.md > Protocol correctness > 'No validation of magic bytes or packet length before parsing'" + }, + { + "id": "t0/short-read-rejected", + "reason": "bytes.Buffer.Read partial-fills without returning an error, so a truncated pong yields a zero-padded struct instead of a parse failure.", + "todo": "TODO.md > Bugs > 'Short/truncated packets parse silently into garbage'" + }, + { + "id": "t0/binary-guid-rewritten", + "reason": "rewriteUnconnectedPong rewrites only the ServerID string field; the binary ServerGUID at bytes 9-16 passes through untouched.", + "todo": "TODO.md > Protocol correctness > 'Offline pong sends a zero binary ServerGUID; rewrite only touches the string ID'" + }, + { + "id": "t1/offline-pong-echoes-ping-time", + "reason": "The canned OfflinePong carries an all-zero ping time and the offline reply path never stamps the client's ping time into it.", + "todo": "TODO.md > Protocol correctness > 'Offline pong doesn't echo the client's ping time'" + }, + { + "id": "t1/offline-pong-nonzero-guid", + "reason": "The canned OfflinePong advertises binary ServerGUID = 0, so every phantom whose upstream is down claims GUID 0.", + "todo": "TODO.md > Protocol correctness > 'Offline pong sends a zero binary ServerGUID'" + }, + { + "id": "t1/offline-pong-answers-0x02", + "reason": "The server-offline reply path matches only UnconnectedPingID (0x01), so a client that pings with 0x02 gets nothing while upstream is down.", + "todo": "TODO.md > Protocol correctness > '0x02 (Unconnected Ping Open Connections) not recognized'" + } + ] +} diff --git a/internal/corpus/data/synthetic.json b/internal/corpus/data/synthetic.json new file mode 100644 index 0000000..46a0300 --- /dev/null +++ b/internal/corpus/data/synthetic.json @@ -0,0 +1,161 @@ +{ + "_comment": [ + "HAND-AUTHORED fixtures. Unlike captured.json (which is real bedrock-protocol", + "wire bytes but a constant 13-field shape across every version), this file is", + "where actual pong SHAPE diversity lives: historical field counts, hypothetical", + "future field counts, hostile content, and malformed frames.", + "", + "Entries with 'motd' get a well-formed RakNet frame built around them by the Go", + "loader. Entries with 'rawHex' are used verbatim, for testing malformed frames.", + "", + "'wantParseError' means proto.ReadUnconnectedPing SHOULD reject the input.", + "Where phantom currently accepts garbage instead, the case is registered in", + "known_failures.json rather than deleted." + ], + "pingTime": "0011223344556677", + "serverGUID": "a1b2c3d4e5f60718", + "entries": [ + { + "id": "shape/legacy-10-field", + "desc": "Pre-port-advertisement MCPE pong: no Port4/Port6 fields at all.", + "motd": "MCPE;Legacy Server;390;1.14.60;0;10;12345678;Sub;Survival;1;", + "fieldCount": 10, + "tags": ["shape"] + }, + { + "id": "shape/classic-12-field", + "desc": "The shape phantom's PongData struct was written against.", + "motd": "MCPE;Classic Server;422;1.16.201;3;10;12345678;Sub;Survival;1;19132;19133;", + "fieldCount": 12, + "tags": ["shape"] + }, + { + "id": "shape/modern-13-field", + "desc": "Current real-world shape: a 13th field (editor-mode flag) after Port6. phantom's fixed 12-field struct drops it.", + "motd": "MCPE;Modern Server;800;1.21.80;5;20;12345678;Sub;Creative;1;19132;19133;0;", + "fieldCount": 13, + "tags": ["shape", "forwards-compat"] + }, + { + "id": "shape/future-15-field", + "desc": "Forwards-compat guard: Mojang adds two more fields. Must survive the rewrite untouched.", + "motd": "MCPE;Future Server;9999;1.99.0;1;100;12345678;Sub;Creative;1;19132;19133;0;newFieldA;newFieldB;", + "fieldCount": 15, + "tags": ["shape", "forwards-compat"] + }, + { + "id": "shape/no-trailing-semicolon", + "desc": "Server omits the trailing separator. Field values must not shift.", + "motd": "MCPE;No Trailer;800;1.21.80;5;20;12345678;Sub;Creative;1;19132;19133", + "fieldCount": 12, + "tags": ["shape"] + }, + { + "id": "content/empty-motd-fields", + "desc": "Server advertises empty MOTD and SubMOTD. Empty must stay empty, not collapse.", + "motd": "MCPE;;800;1.21.80;0;10;12345678;;Survival;1;19132;19133;0;", + "fieldCount": 13, + "tags": ["content"] + }, + { + "id": "content/section-color-codes", + "desc": "MOTD with Bedrock section-sign colour codes, as phantom's own offline pong uses.", + "motd": "MCPE;§cRed §aGreen §9Blue;800;1.21.80;0;10;12345678;§eSub;Survival;1;19132;19133;0;", + "fieldCount": 13, + "tags": ["content"] + }, + { + "id": "content/utf8-multibyte", + "desc": "Multi-byte UTF-8 in the MOTD. Length prefix is BYTES, not runes - a rune-based length would corrupt this.", + "motd": "MCPE;サーバー 🎮 Ünïcödé;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;0;", + "fieldCount": 13, + "tags": ["content"] + }, + { + "id": "content/semicolon-in-motd", + "desc": "A server puts a raw ';' inside the MOTD. The protocol has no escaping, so every later field shifts by one. phantom must not corrupt or crash.", + "motd": "MCPE;Broken;MOTD;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;0;", + "fieldCount": 14, + "tags": ["content", "hostile"] + }, + { + "id": "content/long-motd", + "desc": "MOTD long enough to push the pong past a typical MTU. Guards the 1472-byte read buffer.", + "motdRepeat": { "prefix": "MCPE;", "unit": "A", "count": 1400, "suffix": ";800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;0;" }, + "tags": ["content", "mtu"] + }, + { + "id": "content/many-fields", + "desc": "30 fields. Absurd but well-formed; must not panic or truncate silently.", + "motdRepeat": { "prefix": "MCPE;Many;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133", "unit": ";x", "count": 18, "suffix": ";" }, + "tags": ["shape", "hostile"] + }, + { + "id": "content/empty-string", + "desc": "Zero-length MOTD string with a valid length prefix of 0. Degenerate: phantom rebuilds it as a lone ';'. That is harmless (clients ignore empty trailing fields), so field preservation is not asserted - only that it neither panics nor errors.", + "motd": "", + "fieldCount": 1, + "noRoundTrip": true, + "tags": ["content", "hostile"] + }, + + { + "id": "malformed/empty-packet", + "desc": "Zero bytes.", + "rawHex": "", + "wantParseError": true, + "tags": ["malformed"] + }, + { + "id": "malformed/id-only", + "desc": "Just the 0x1c packet ID and nothing else.", + "rawHex": "1c", + "wantParseError": true, + "tags": ["malformed"] + }, + { + "id": "malformed/truncated-in-header", + "desc": "Cut off partway through the ping-time field.", + "rawHex": "1c00112233", + "wantParseError": true, + "tags": ["malformed"] + }, + { + "id": "malformed/truncated-after-magic", + "desc": "Valid header and magic, then nothing - no length prefix at all.", + "rawHex": "1c0011223344556677a1b2c3d4e5f6071800ffff00fefefefefdfdfdfd12345678", + "wantParseError": true, + "tags": ["malformed"] + }, + { + "id": "malformed/length-exceeds-body", + "desc": "Declares a 500-byte MOTD but supplies 4 bytes. bytes.Buffer.Read partial-fills without erroring, so this parses into a zero-padded struct instead of failing.", + "rawHex": "1c0011223344556677a1b2c3d4e5f6071800ffff00fefefefefdfdfdfd1234567801f44d435045", + "wantParseError": true, + "tags": ["malformed", "short-read"] + }, + { + "id": "malformed/bad-magic", + "desc": "Otherwise complete, valid pong with the 16-byte RakNet magic zeroed. Reference implementations reject this; phantom reads the magic but never compares it. Built rather than hand-written so the ONLY defect is the magic - a truncated fixture would trip the length check first and prove nothing.", + "motd": "MCPE;Bad Magic;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;", + "magicOverride": "00000000000000000000000000000000", + "wantParseError": true, + "tags": ["malformed", "magic"] + }, + { + "id": "malformed/wrong-packet-id", + "desc": "A complete, well-formed frame whose leading byte is 0x01 (Unconnected PING) instead of 0x1c (PONG). ReadUnconnectedPing discards the first byte without checking it, so a ping parses cleanly as a pong.", + "motd": "MCPE;Wrong ID;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;", + "packetIDOverride": 1, + "wantParseError": true, + "tags": ["malformed", "packet-id"] + }, + { + "id": "malformed/zero-length-prefix", + "desc": "Well-formed frame declaring a 0-length MOTD.", + "rawHex": "1c0011223344556677a1b2c3d4e5f6071800ffff00fefefefefdfdfdfd123456780000", + "wantParseError": false, + "tags": ["malformed", "boundary"] + } + ] +} diff --git a/internal/corpus/gen/capture.js b/internal/corpus/gen/capture.js new file mode 100644 index 0000000..029e556 --- /dev/null +++ b/internal/corpus/gen/capture.js @@ -0,0 +1,195 @@ +#!/usr/bin/env node +// +// Captures real RakNet Unconnected Pong (0x1c) bytes from a bedrock-protocol +// server, one entry per supported Minecraft version, into ../data/captured.json. +// +// Run via `make corpus` from the repo root. This is a DEV-TIME task: CI never +// runs it, it only replays the committed bytes. Regenerating and diffing the +// result is how we detect that a new Minecraft version changed the pong shape. +// +// Usage: node capture.js [--out ] [--only [,...]] + +const dgram = require('dgram') +const fs = require('fs') +const path = require('path') +const bp = require('bedrock-protocol') + +const MAGIC = Buffer.from('00ffff00fefefefefdfdfdfd12345678', 'hex') + +// Fixed, arbitrary values so captures are reproducible and so Go tests can +// assert that the server echoed OUR ping time back (RakNet requires it). +const PING_TIME = '0011223344556677' +const CLIENT_GUID = '8877665544332211' + +const BASE_PORT = 19300 +const BOOT_MS = 1500 +const PING_TIMEOUT_MS = 5000 +const PING_RETRIES = 3 + +function versions () { + // bedrock-protocol keeps its version->protocol table here. + return require('bedrock-protocol/src/options').Versions +} + +function buildPing () { + return Buffer.concat([ + Buffer.from([0x01]), + Buffer.from(PING_TIME, 'hex'), + MAGIC, + Buffer.from(CLIENT_GUID, 'hex') + ]) +} + +// Sends an Unconnected Ping and returns the raw pong datagram. +function pingOnce (port) { + return new Promise((resolve, reject) => { + const sock = dgram.createSocket('udp4') + const timer = setTimeout(() => { + sock.close() + reject(new Error('ping timeout')) + }, PING_TIMEOUT_MS) + + sock.on('error', err => { + clearTimeout(timer) + sock.close() + reject(err) + }) + + sock.on('message', msg => { + clearTimeout(timer) + sock.close() + resolve(msg) + }) + + sock.send(buildPing(), port, '127.0.0.1', err => { + if (err) { + clearTimeout(timer) + sock.close() + reject(err) + } + }) + }) +} + +async function captureVersion (mcVersion, port) { + let server + try { + server = bp.createServer({ + host: '127.0.0.1', + port, + version: mcVersion, + offline: true + }) + } catch (err) { + throw new Error(`createServer failed: ${err.message}`) + } + + try { + await new Promise(r => setTimeout(r, BOOT_MS)) + + let pong + let lastErr + for (let attempt = 0; attempt < PING_RETRIES; attempt++) { + try { + pong = await pingOnce(port) + break + } catch (err) { + lastErr = err + } + } + if (!pong) throw lastErr + + if (pong[0] !== 0x1c) { + throw new Error(`expected pong id 0x1c, got 0x${pong[0].toString(16)}`) + } + if (pong.length < 35) { + throw new Error(`pong too short: ${pong.length} bytes`) + } + + const strLen = pong.readUInt16BE(33) + const motd = pong.slice(35, 35 + strLen).toString('utf8') + + return { + mc: mcVersion, + protocol: versions()[mcVersion], + pongHex: pong.toString('hex'), + // Echoed ping time, as the server sent it back. Should equal PING_TIME. + echoedPingTime: pong.slice(1, 9).toString('hex'), + serverGUID: pong.slice(9, 17).toString('hex'), + motd, + // Note the trailing ';' means the final split element is empty; the real + // field count is this array minus that empty tail. + fields: motd.split(';') + } + } finally { + try { server.close() } catch (_) { /* best effort */ } + } +} + +async function main () { + const args = process.argv.slice(2) + const outIdx = args.indexOf('--out') + const outPath = outIdx >= 0 + ? args[outIdx + 1] + : path.join(__dirname, '..', 'data', 'captured.json') + + const onlyIdx = args.indexOf('--only') + const only = onlyIdx >= 0 ? args[onlyIdx + 1].split(',') : null + + const table = versions() + let list = Object.keys(table) + if (only) list = list.filter(v => only.includes(v)) + + // Ascending by protocol number keeps the committed file stable and readable. + list.sort((a, b) => table[a] - table[b]) + + const entries = [] + const failures = [] + + for (let i = 0; i < list.length; i++) { + const mcVersion = list[i] + const port = BASE_PORT + (i % 150) + process.stderr.write(`[${i + 1}/${list.length}] ${mcVersion} (proto ${table[mcVersion]}) ... `) + try { + const entry = await captureVersion(mcVersion, port) + entries.push(entry) + process.stderr.write(`ok, ${entry.fields.length - 1} fields\n`) + } catch (err) { + failures.push({ mc: mcVersion, error: err.message }) + process.stderr.write(`FAILED: ${err.message}\n`) + } + } + + const pkg = require('./package.json') + const doc = { + _comment: [ + 'GENERATED FILE - do not edit by hand. Regenerate with `make corpus`.', + 'Real RakNet Unconnected Pong bytes captured from a bedrock-protocol server,', + 'one entry per supported Minecraft version.', + '', + 'CAVEAT: these are bedrock-protocol advertisements, not Mojang BDS ones.', + 'The MOTD field COUNT is constant across every version here (bedrock-protocol', + 'uses one serializer); only the protocol number and version string vary.', + 'Real per-version shape diversity lives in synthetic.json.' + ], + generatedBy: `bedrock-protocol@${pkg.dependencies['bedrock-protocol']}`, + pingTime: PING_TIME, + clientGUID: CLIENT_GUID, + entries + } + + fs.mkdirSync(path.dirname(outPath), { recursive: true }) + fs.writeFileSync(outPath, JSON.stringify(doc, null, 2) + '\n') + + process.stderr.write(`\nwrote ${entries.length} entries to ${outPath}\n`) + if (failures.length) { + process.stderr.write(`${failures.length} failures:\n`) + for (const f of failures) process.stderr.write(` ${f.mc}: ${f.error}\n`) + process.exit(1) + } +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/internal/corpus/gen/drift.js b/internal/corpus/gen/drift.js new file mode 100644 index 0000000..c386a3e --- /dev/null +++ b/internal/corpus/gen/drift.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// +// Matrix drift check. +// +// Compares the Minecraft versions bedrock-protocol supports against what this +// repo actually tests, and fails if we have fallen behind. This is what keeps +// "N versions" current without anyone having to remember: when Mojang ships a +// new version and bedrock-protocol adds it, the nightly job goes red. +// +// Usage: node drift.js +// Exit 0 = in sync, exit 1 = drift found. + +const fs = require('fs') +const path = require('path') + +const supported = require('bedrock-protocol/src/options').Versions + +const capturedPath = path.join(__dirname, '..', 'data', 'captured.json') +const matrixPath = path.join(__dirname, '..', '..', '..', 'test', 'compat', 'matrix.json') + +const captured = JSON.parse(fs.readFileSync(capturedPath, 'utf8')) +const matrix = JSON.parse(fs.readFileSync(matrixPath, 'utf8')) + +const capturedVersions = new Set(captured.entries.map(e => e.mc)) +const matrixVersions = new Set(matrix.versions.map(v => v.mc)) +const supportedVersions = Object.keys(supported) + +const missingFromCorpus = supportedVersions.filter(v => !capturedVersions.has(v)) + +// The T1 matrix is deliberately a SAMPLE, so a version missing from it is only +// notable when it is newer than everything we currently sample. +const newestSampled = Math.max(...[...matrixVersions].map(v => supported[v] || 0)) +const newerThanMatrix = supportedVersions.filter( + v => supported[v] > newestSampled && !matrixVersions.has(v) +) + +let drift = false + +if (missingFromCorpus.length) { + drift = true + console.error('T0 corpus is missing versions bedrock-protocol now supports:') + for (const v of missingFromCorpus) { + console.error(` ${v} (protocol ${supported[v]})`) + } + console.error('\nFix: run `make corpus` and commit internal/corpus/data/captured.json.\n') +} + +if (newerThanMatrix.length) { + drift = true + console.error('T1 matrix does not sample any version this new:') + for (const v of newerThanMatrix) { + console.error(` ${v} (protocol ${supported[v]})`) + } + console.error('\nFix: add the newest to test/compat/matrix.json, keeping shards balanced.\n') +} + +if (!drift) { + console.log(`In sync: ${supportedVersions.length} versions supported, ` + + `${capturedVersions.size} in the T0 corpus, ${matrixVersions.size} sampled by T1.`) + process.exit(0) +} + +process.exit(1) diff --git a/internal/corpus/gen/package-lock.json b/internal/corpus/gen/package-lock.json new file mode 100644 index 0000000..6472bac --- /dev/null +++ b/internal/corpus/gen/package-lock.json @@ -0,0 +1,1565 @@ +{ + "name": "phantom-corpus-gen", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "phantom-corpus-gen", + "version": "1.0.0", + "dependencies": { + "bedrock-protocol": "3.57.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz", + "integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@jsprismarine/jsbinaryutils": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@jsprismarine/jsbinaryutils/-/jsbinaryutils-2.1.4.tgz", + "integrity": "sha512-LFMbAJsHEiq5UJwPzrHYZbCkZ/LZEhV76SYXPDjcQLosqVwcf0yyyr+ICxT4j/d4xPH7vDSvWCqOuFRsSv9wYw==", + "license": "ISC" + }, + "node_modules/@xboxreplay/xboxlive-auth": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@xboxreplay/xboxlive-auth/-/xboxlive-auth-5.1.0.tgz", + "integrity": "sha512-UngHHsehZbiTjyyNmo8HvdoUDKMID1U9uVfrpFWUK/2UxPuVTKy5n+CzZQ3S488sW5vOhgh0lHqqynT8ouwgvw==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz", + "integrity": "sha512-iFY7JCgHbepc0b82yLaw4IMortylNb6wG4kL+4R0C3iv6i+RHGHux/yUX5BTiRvSX/shMnngjR1YyNMnXEFh5A==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.0.6.tgz", + "integrity": "sha512-Zfw6bteqM9gQXZ1BIWOgM8xEwMrUGoyL8nW13+O+OOgNX3YhuDN1GDgg1NzdTlmm3j+9sHy7uBZ12r+z9lXnZQ==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.0 || ^1.1.13" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/are-we-there-yet/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/are-we-there-yet/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bedrock-protocol": { + "version": "3.57.0", + "resolved": "https://registry.npmjs.org/bedrock-protocol/-/bedrock-protocol-3.57.0.tgz", + "integrity": "sha512-+4YyRvDD4Wf5g8ZInxjJRoVySDJBQKy0atHBGcQqmrwPJ4DQWrlj3DavQTt78cw3iPZMTQ3ZgKvWMGfnv+jilg==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "jsonwebtoken": "^9.0.0", + "jsp-raknet": "^2.1.3", + "minecraft-data": "^3.0.0", + "minecraft-folder-path": "^1.2.0", + "prismarine-auth": "^3.0.0", + "prismarine-nbt": "^2.0.0", + "prismarine-realms": "^1.6.0", + "protodef": "^1.14.0", + "raknet-native": "^1.0.3", + "uuid-1345": "^1.0.2" + }, + "optionalDependencies": { + "raknet-node": "^0.5.0" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffer-shims": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", + "integrity": "sha512-Zy8ZXMyxIT6RMTeY7OP/bDndfj6bwCan7SS98CEndS6deHwWPpseeHlwarNcBim+etXnF9HBc1non5JgDaJU1g==", + "license": "MIT" + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha512-0yayqDxWQbqk3ojkYqUKqaAQ6AfNKeKWRNA8kR0WXzAsdHpP4BIaOmMAG87JGuO6qcobyW4GjxHd9PmhEd+T9w==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + } + }, + "node_modules/cmake-js": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-6.3.2.tgz", + "integrity": "sha512-7MfiQ/ijzeE2kO+WFB9bv4QP5Dn2yVaAP2acFJr4NIFy2hT4w6O4EpOTLNcohR5IPX7M4wNf/5taIqMj7UA9ug==", + "license": "MIT", + "dependencies": { + "axios": "^0.21.1", + "bluebird": "^3", + "debug": "^4", + "fs-extra": "^5.0.0", + "is-iojs": "^1.0.1", + "lodash": "^4", + "memory-stream": "0", + "npmlog": "^1.2.0", + "rc": "^1.2.7", + "semver": "^5.0.3", + "splitargs": "0", + "tar": "^4", + "unzipper": "^0.8.13", + "url-join": "0", + "which": "^1.0.9", + "yargs": "^3.6.0" + }, + "bin": { + "cmake-js": "bin/cmake-js" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cmake-js/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-5.0.0.tgz", + "integrity": "sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "node_modules/fs-minipass": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", + "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", + "license": "ISC", + "dependencies": { + "minipass": "^2.6.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/gauge": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-1.2.7.tgz", + "integrity": "sha512-fVbU2wRE91yDvKUnrIaQlHKAWKY5e08PmztCrwuH5YVQ+Z/p3d0ny2T48o6uvAAXHIUnfaQdHkmxYbQft1eHVA==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "ansi": "^0.3.0", + "has-unicode": "^2.0.0", + "lodash.pad": "^4.1.0", + "lodash.padend": "^4.1.0", + "lodash.padstart": "^4.1.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha512-xgs2NH9AE66ucSq4cNG1nhSFghr5l6tdL15Pk+jl46bmmBapgoaY/AacXyaDznAqmGL99TiLSQgO/XazFSKYeQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "license": "MIT", + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-iojs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-iojs/-/is-iojs-1.1.0.tgz", + "integrity": "sha512-tLn1j3wYSL6DkvEI+V/j0pKohpa5jk+ER74v6S4SgCXnjS0WA+DoZbwZBrrhgwksMvtuwndyGeG5F8YMsoBzSA==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsp-raknet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jsp-raknet/-/jsp-raknet-2.2.0.tgz", + "integrity": "sha512-Ji2wZmI84abcgzSnnbdtMSW02yZ/Ui3JI3N69BsyTcWHWsbeWMRHKpzN72cCQM8W9pvTIfLYml9sS9xaC2MJSA==", + "license": "ISC", + "dependencies": { + "@jsprismarine/jsbinaryutils": "2.1.4", + "debug": "^4.3.1", + "typescript": "^4.2.2" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha512-YiGkH6EnGrDGqLMITnGjXtGmNtjoXw9SVUzcaos8RBi7Ps0VBylkq+vOcY9QE5poLasPCR849ucFUkl0UzUyOw==", + "license": "MIT", + "dependencies": { + "invert-kv": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.pad": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/lodash.pad/-/lodash.pad-4.5.1.tgz", + "integrity": "sha512-mvUHifnLqM+03YNzeTBS1/Gr6JRFjd3rRx88FHWUvamVaT9k2O/kXha3yBSOwB9/DTQrSTLJNHvLBBt2FdX7Mg==", + "license": "MIT" + }, + "node_modules/lodash.padend": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", + "integrity": "sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw==", + "license": "MIT" + }, + "node_modules/lodash.padstart": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz", + "integrity": "sha512-sW73O6S8+Tg66eY56DBk85aQzzUJDtpoXFBgELMd5P/SotAguo+1kYO6RuYgXxA4HJH3LFTFPASX6ET6bjfriw==", + "license": "MIT" + }, + "node_modules/lodash.reduce": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==", + "license": "MIT" + }, + "node_modules/macaddress": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/macaddress/-/macaddress-0.5.4.tgz", + "integrity": "sha512-i8xVWoUjj2woYU8kbpQby86Kq7uF7xl2brtKREXUBWpfgqx1fKXEeYzDiVMVxA/IufC1d3xxwJRHtFCX+9IspA==", + "license": "MIT" + }, + "node_modules/memory-stream": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/memory-stream/-/memory-stream-0.0.3.tgz", + "integrity": "sha512-q0D3m846qY6ZkIt+19ZemU5vH56lpOZZwoJc3AICARKh/menBuayQUjAGPrqtHQQMUYERSdOrej92J9kz7LgYA==", + "license": "MMIT", + "dependencies": { + "readable-stream": "~1.0.26-2" + } + }, + "node_modules/memory-stream/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/memory-stream/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/memory-stream/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/minecraft-data": { + "version": "3.111.0", + "resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.111.0.tgz", + "integrity": "sha512-0kKHqNZL/D1IbH7tJXBJ3z7YSn1/ylnWWR7X2zFwtEV+yr23nimItpGjP3o8UaLYrC+OV1gKLFQoew03XvJyoA==", + "license": "MIT" + }, + "node_modules/minecraft-folder-path": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minecraft-folder-path/-/minecraft-folder-path-1.2.0.tgz", + "integrity": "sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "license": "ISC", + "dependencies": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "node_modules/minizlib": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", + "license": "MIT", + "dependencies": { + "minipass": "^2.9.0" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/npmlog": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-1.2.1.tgz", + "integrity": "sha512-1J5KqSRvESP6XbjPaXt2H6qDzgizLTM7x0y1cXIjP2PpvdCqyNC7TO3cPRKsuYlElbi/DwkzRRdG2zpmE0IktQ==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "ansi": "~0.3.0", + "are-we-there-yet": "~1.0.0", + "gauge": "~1.2.0" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/os-locale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha512-PRT7ZORmwu2MEFt4/fv3Q+mEfN4zetKxufQrkShY2oGvUms9r8otu5HfdyIFHkYXjO7laNsoVGmM2MANfuTA8g==", + "license": "MIT", + "dependencies": { + "lcid": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prismarine-auth": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/prismarine-auth/-/prismarine-auth-3.1.1.tgz", + "integrity": "sha512-NuNrMGZdoigFKsvi1ZZgAEvNYNuE5qe6lo/tw+bqeNbkhpjHC0u1JNxLEujnfqduXI18e19PvUtWNMDl/gH7yw==", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^2.0.2", + "@xboxreplay/xboxlive-auth": "^5.1.0", + "debug": "^4.3.3", + "smart-buffer": "^4.1.0", + "uuid-1345": "^1.0.2" + } + }, + "node_modules/prismarine-nbt": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/prismarine-nbt/-/prismarine-nbt-2.8.0.tgz", + "integrity": "sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==", + "license": "MIT", + "dependencies": { + "protodef": "^1.18.0" + } + }, + "node_modules/prismarine-realms": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/prismarine-realms/-/prismarine-realms-1.6.0.tgz", + "integrity": "sha512-AwemW0vwxG9hQaFtg1twSV7eymB6QtYxGK0jjpxfdA2sdK15kU8jh8uD1o5XF0oxSMU+BbpzZMCmXtXq4QE6bw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.3", + "node-fetch": "^2.6.1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/protodef": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/protodef/-/protodef-1.19.0.tgz", + "integrity": "sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==", + "license": "MIT", + "dependencies": { + "lodash.reduce": "^4.6.0", + "protodef-validator": "^1.3.0", + "readable-stream": "^4.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/protodef-validator": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/protodef-validator/-/protodef-validator-1.4.0.tgz", + "integrity": "sha512-2y2coBolqCEuk5Kc3QwO7ThR+/7TZiOit4FrpAgl+vFMvq8w76nDhh09z08e2NQOdrgPLsN2yzXsvRvtADgUZQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.5.4" + }, + "bin": { + "protodef-validator": "cli.js" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/raknet-native": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/raknet-native/-/raknet-native-1.2.3.tgz", + "integrity": "sha512-iXqQc/2298LJHQAyoYDdreZ9E4z+4d1y8lxp+EKno1r/ooIer83ShayCDsjpjwRHc9RiMdJz6fLbgBf2hLeTzw==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "bindings": "^1.5.0", + "cmake-js": "^6.1.0", + "node-addon-api": "^3.1.0" + } + }, + "node_modules/raknet-node": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/raknet-node/-/raknet-node-0.5.0.tgz", + "integrity": "sha512-xvWsBwUu/UIlsMDN1sm5FX7iCIufuLakoCtlBer4+B4XeWLQFkEiRmwSaon0pKpBc8HKZcyU3S2KjvzYkGi0WA==", + "license": "ISC", + "optional": true, + "dependencies": { + "sha1": "^1.1.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/sha1": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/sha1/-/sha1-1.1.1.tgz", + "integrity": "sha512-dZBS6OrMjtgVkopB1Gmo4RQCDKiZsqcpAQpkV/aaj+FCrCg8r4I4qMkDPQjBgLIxlmu9k4nUbWq6ohXahOneYA==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "charenc": ">= 0.0.1", + "crypt": ">= 0.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/splitargs": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/splitargs/-/splitargs-0.0.7.tgz", + "integrity": "sha512-UUFYD2oWbNwULH6WoVtLUOw8ch586B+HUqcsAjjjeoBQAM1bD4wZRXu01koaxyd8UeYpybWqW4h+lO1Okv40Tg==", + "license": "ISC" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "license": "MIT", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar": { + "version": "4.4.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz", + "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^1.1.4", + "fs-minipass": "^1.2.7", + "minipass": "^2.9.0", + "minizlib": "^1.3.3", + "mkdirp": "^0.5.5", + "safe-buffer": "^5.2.1", + "yallist": "^3.1.1" + }, + "engines": { + "node": ">=4.5" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.8.14.tgz", + "integrity": "sha512-8rFtE7EP5ssOwGpN2dt1Q4njl0N1hUXJ7sSPz0leU2hRdq6+pra57z4YPBlVqm40vcgv6ooKZEAx48fMTv9x4w==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "~1.0.10", + "listenercount": "~1.0.1", + "readable-stream": "~2.1.5", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha512-yN0WQmuCX63LP/TMvAg31nvT6m4vDqJEiiv2CAZqWOGNWutc9DfDk1NPYYmKUFmaVM2UwDowH4u5AHWYP/jxKw==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz", + "integrity": "sha512-NkXT2AER7VKXeXtJNSaWLpWIhmtSE3K2PguaLEeWr4JILghcIKqoLt1A3wHrnpDC5+ekf8gfk1GKWkFXe4odMw==", + "license": "MIT", + "dependencies": { + "buffer-shims": "^1.0.0", + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~0.10.x", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-join": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-0.0.1.tgz", + "integrity": "sha512-H6dnQ/yPAAVzMQRvEvyz01hhfQL5qRWSEt7BX8t9DqnPw9BjMb64fjIRq76Uvf1hkHp+mTZvEVJ5guXOT0Xqaw==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/uuid-1345": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uuid-1345/-/uuid-1345-1.0.2.tgz", + "integrity": "sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==", + "license": "MIT", + "dependencies": { + "macaddress": "^0.5.1" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/window-size": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz", + "integrity": "sha512-2thx4pB0cV3h+Bw7QmMXcEbdmOzv9t0HFplJH/Lz6yu60hXYy5RT8rUu+wlIreVxWsGN20mo+MHeCSfUpQBwPw==", + "license": "MIT", + "bin": { + "window-size": "cli.js" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==", + "license": "MIT", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", + "integrity": "sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz", + "integrity": "sha512-ONJZiimStfZzhKamYvR/xvmgW3uEkAUFSP91y2caTEPhzF6uP2JfPiVZcq66b/YR0C3uitxSV7+T1x8p5bkmMg==", + "license": "MIT", + "dependencies": { + "camelcase": "^2.0.1", + "cliui": "^3.0.3", + "decamelize": "^1.1.1", + "os-locale": "^1.4.0", + "string-width": "^1.0.1", + "window-size": "^0.1.4", + "y18n": "^3.2.0" + } + } + } +} diff --git a/internal/corpus/gen/package.json b/internal/corpus/gen/package.json new file mode 100644 index 0000000..8215149 --- /dev/null +++ b/internal/corpus/gen/package.json @@ -0,0 +1,12 @@ +{ + "name": "phantom-corpus-gen", + "private": true, + "version": "1.0.0", + "description": "Captures real RakNet Unconnected Pong bytes per Minecraft protocol version", + "scripts": { + "capture": "node capture.js" + }, + "dependencies": { + "bedrock-protocol": "3.57.0" + } +} diff --git a/internal/corpus/xfail.go b/internal/corpus/xfail.go new file mode 100644 index 0000000..a361061 --- /dev/null +++ b/internal/corpus/xfail.go @@ -0,0 +1,59 @@ +package corpus + +import "fmt" + +// TB is the subset of *testing.T this package needs. Declaring it structurally +// keeps the "testing" import out of a non-test package. +type TB interface { + Helper() + Errorf(format string, args ...interface{}) + Logf(format string, args ...interface{}) +} + +// Check runs one compatibility assertion under the XFAIL registry. +// +// The four outcomes: +// +// not registered, passes -> silent success +// not registered, fails -> normal test failure +// registered, fails -> XFAIL, logged, build stays green +// registered, passes -> XPASS, build FAILS +// +// The last case is the point of the whole mechanism: fixing a protocol bug +// breaks the build until its entry is deleted from known_failures.json, so the +// registry cannot silently rot into a list of things that were fixed years ago. +// +// Assertions return an error rather than calling t.Errorf directly so that a +// failure can be captured and reinterpreted rather than immediately recorded. +func Check(t TB, id string, assert func() error) { + t.Helper() + + err := assert() + failure, registered := LookupKnownFailure(id) + + switch { + case err == nil && !registered: + // Passing as expected. + + case err != nil && !registered: + t.Errorf("%s: %v", id, err) + + case err != nil && registered: + t.Logf("XFAIL %s: %v\n known bug: %s\n tracked: %s", + id, err, failure.Reason, failure.TODO) + + case err == nil && registered: + t.Errorf( + "XPASS %s: this assertion is registered as a known failure but now PASSES.\n"+ + " Delete its entry from internal/corpus/data/known_failures.json.\n"+ + " Registered reason: %s\n"+ + " Tracked at: %s", + id, failure.Reason, failure.TODO) + } +} + +// Errorf builds an assertion error. Sugar so assertion bodies read as +// `return corpus.Errorf(...)` rather than importing fmt everywhere. +func Errorf(format string, args ...interface{}) error { + return fmt.Errorf(format, args...) +} diff --git a/internal/proto/corpus_test.go b/internal/proto/corpus_test.go new file mode 100644 index 0000000..f6c1a43 --- /dev/null +++ b/internal/proto/corpus_test.go @@ -0,0 +1,243 @@ +package proto_test + +// T0 - white-box unit tests over the cross-version pong corpus. +// +// These replay real captured wire bytes (51 Minecraft versions) and +// hand-authored shape fixtures through the parser and rebuilder, asserting the +// field-preservation contract from test/compat/DESIGN.md section 4. +// +// No network, no subprocess, no Node. Runs everywhere in well under a second. + +import ( + "bytes" + "strings" + "testing" + + "github.com/jhead/phantom/internal/corpus" + "github.com/jhead/phantom/internal/proto" +) + +// fieldsOf splits a MOTD, dropping the empty element produced by the +// conventional trailing semicolon. Comparing field *lists* rather than raw +// strings is deliberate: appending or omitting the trailing ';' is +// semantically meaningless to a client, so it must not count as a difference. +func fieldsOf(motd string) []string { + parts := strings.Split(motd, ";") + if n := len(parts); n > 0 && parts[n-1] == "" { + parts = parts[:n-1] + } + return parts +} + +// roundTrip parses a pong frame and rebuilds it, returning the resulting MOTD. +func roundTrip(frame []byte) (string, error) { + pkt, err := proto.ReadUnconnectedPing(frame) + if err != nil { + return "", err + } + rebuilt := pkt.Build() + return corpus.SplitMOTD(rebuilt.Bytes()) +} + +// assertFieldsPreserved is the core invariant: every MOTD field present on the +// wire must survive parse -> rebuild unchanged, including fields phantom has no +// struct member for. +func assertFieldsPreserved(in, out string) error { + want, got := fieldsOf(in), fieldsOf(out) + + if len(want) != len(got) { + if len(got) < len(want) { + return corpus.Errorf( + "field count dropped from %d to %d; lost trailing fields %q\n in: %q\n out: %q", + len(want), len(got), want[len(got):], in, out) + } + return corpus.Errorf( + "field count grew from %d to %d\n in: %q\n out: %q", + len(want), len(got), in, out) + } + + for i := range want { + if want[i] != got[i] { + return corpus.Errorf("field %d changed: %q -> %q\n in: %q\n out: %q", + i, want[i], got[i], in, out) + } + } + return nil +} + +// TestCapturedCorpusParses asserts every real captured pong parses cleanly and +// that the parser reads the version-identifying fields correctly. This is the +// broad sweep across all supported protocol versions. +func TestCapturedCorpusParses(t *testing.T) { + entries := corpus.Captured() + if len(entries) == 0 { + t.Fatal("captured corpus is empty; run `make corpus`") + } + t.Logf("replaying %d captured versions from %s", len(entries), corpus.GeneratedBy()) + + for _, e := range entries { + t.Run(e.MC, func(t *testing.T) { + pkt, err := proto.ReadUnconnectedPing(e.Frame()) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + want := fieldsOf(e.MOTD) + if pkt.Pong.Edition != want[0] { + t.Errorf("Edition = %q, want %q", pkt.Pong.Edition, want[0]) + } + if pkt.Pong.ProtocolVersion != want[2] { + t.Errorf("ProtocolVersion = %q, want %q", pkt.Pong.ProtocolVersion, want[2]) + } + if pkt.Pong.Version != want[3] { + t.Errorf("Version = %q, want %q", pkt.Pong.Version, want[3]) + } + if !bytes.Equal(pkt.Magic, corpus.Magic) { + t.Errorf("Magic = %x, want %x", pkt.Magic, corpus.Magic) + } + }) + } +} + +// TestCapturedCorpusRoundTrip asserts field preservation across every captured +// version. Every real server sends 13 fields, so this currently XFAILs for all +// of them - that is the headline compatibility bug, measured against real bytes. +func TestCapturedCorpusRoundTrip(t *testing.T) { + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + out, err := roundTrip(e.Frame()) + if err != nil { + t.Fatalf("round-trip failed: %v", err) + } + corpus.Check(t, "t0/trailing-fields-preserved", func() error { + return assertFieldsPreserved(e.MOTD, out) + }) + }) + } +} + +// TestCapturedCorpusEchoesPingTime validates the corpus itself: every real +// server echoed the exact ping time we sent. This is the behaviour phantom's +// offline pong fails to reproduce (see t1/offline-pong-echoes-ping-time), so +// pinning it here documents the requirement with evidence. +func TestCapturedCorpusEchoesPingTime(t *testing.T) { + sent := corpus.CapturedPingTime() + + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + pkt, err := proto.ReadUnconnectedPing(e.Frame()) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if !bytes.Equal(pkt.PingTime, sent) { + t.Errorf("server echoed ping time %x, want %x", pkt.PingTime, sent) + } + }) + } +} + +// TestSyntheticShapes covers pong shapes the captured corpus cannot reach: +// historical field counts, hypothetical future ones, and hostile content. +func TestSyntheticShapes(t *testing.T) { + for _, e := range corpus.Synthetic() { + if e.IsRaw() || e.WantParseError { + continue // rejection cases are covered by TestMalformedFrames + } + + t.Run(e.ID, func(t *testing.T) { + t.Log(e.Desc) + + out, err := roundTrip(e.Frame()) + if err != nil { + t.Fatalf("round-trip failed: %v", err) + } + if e.NoRoundTrip { + return + } + + // Only fixtures carrying more fields than phantom's 12-field struct + // are expected to lose data today. + id := "t0/no-such-failure" + if corpus.RealFieldCount(e.MOTDString()) > 12 { + id = "t0/trailing-fields-preserved" + } + corpus.Check(t, id, func() error { + return assertFieldsPreserved(e.MOTDString(), out) + }) + }) + } +} + +// TestMalformedFrames asserts the parser rejects input it cannot trust. +// Several of these currently parse into zero-padded garbage instead of +// erroring; each is registered against its TODO.md entry. +func TestMalformedFrames(t *testing.T) { + // Which registered bug each malformed fixture demonstrates. Fixtures absent + // from this map are expected to be rejected correctly today - phantom does + // catch outright truncation, just not semantic defects like a bad magic. + xfailByID := map[string]string{ + "malformed/length-exceeds-body": "t0/short-read-rejected", + "malformed/bad-magic": "t0/magic-validated", + "malformed/wrong-packet-id": "t0/packet-id-validated", + } + + for _, e := range corpus.Synthetic() { + if !e.IsRaw() && !e.WantParseError { + continue + } + + t.Run(e.ID, func(t *testing.T) { + t.Log(e.Desc) + + // Must never panic, whatever the input. + var err error + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC on malformed input: %v", r) + } + }() + _, err = proto.ReadUnconnectedPing(e.Frame()) + }() + + if !e.WantParseError { + if err != nil { + t.Errorf("expected clean parse, got error: %v", err) + } + return + } + + id, ok := xfailByID[e.ID] + if !ok { + id = "t0/no-such-failure" + } + corpus.Check(t, id, func() error { + if err == nil { + return corpus.Errorf("parser accepted malformed input that it should reject") + } + return nil + }) + }) + } +} + +// TestKnownFailureRegistryIsWellFormed guards the guard: a typo in an id would +// silently turn an XFAIL into an unreported pass. +func TestKnownFailureRegistryIsWellFormed(t *testing.T) { + seen := map[string]bool{} + for _, f := range corpus.KnownFailures() { + if f.ID == "" { + t.Error("known failure with empty id") + } + if f.Reason == "" { + t.Errorf("known failure %q has no reason", f.ID) + } + if f.TODO == "" { + t.Errorf("known failure %q has no TODO.md reference", f.ID) + } + if seen[f.ID] { + t.Errorf("duplicate known failure id %q", f.ID) + } + seen[f.ID] = true + } +} diff --git a/internal/proto/fuzz_test.go b/internal/proto/fuzz_test.go new file mode 100644 index 0000000..7487e5d --- /dev/null +++ b/internal/proto/fuzz_test.go @@ -0,0 +1,60 @@ +package proto_test + +// T0 - fuzzing. +// +// ReadUnconnectedPing parses untrusted bytes straight off a UDP socket with no +// length or magic validation, which makes it the highest-value fuzz target in +// the codebase. The corpus seeds it with real per-version pongs and every +// hand-authored malformed frame, so the fuzzer starts from realistic structure +// rather than having to discover the RakNet header from scratch. +// +// CI runs a short pass; the nightly workflow runs a long one. + +import ( + "testing" + + "github.com/jhead/phantom/internal/corpus" + "github.com/jhead/phantom/internal/proto" +) + +func FuzzReadUnconnectedPing(f *testing.F) { + for _, e := range corpus.Captured() { + f.Add(e.Frame()) + } + for _, e := range corpus.Synthetic() { + f.Add(e.Frame()) + } + // A few degenerate seeds the corpus does not contain. + f.Add([]byte{}) + f.Add([]byte{corpus.PongID}) + f.Add(make([]byte, 1472)) // all zeros, max MTU + + f.Fuzz(func(t *testing.T, data []byte) { + pkt, err := proto.ReadUnconnectedPing(data) + if err != nil { + return // rejecting input is always acceptable + } + if pkt == nil { + t.Fatal("nil packet returned with nil error") + } + + // Anything the parser accepted must also be rebuildable: phantom calls + // Build() on every pong it forwards, so a parse/build asymmetry here is + // a crash in the proxy's hot path. + out := pkt.Build() + + body := out.Bytes() + if len(body) == 0 { + t.Fatal("Build() produced an empty packet") + } + if body[0] != corpus.PongID { + t.Fatalf("Build() wrote packet id 0x%02x, want 0x%02x", body[0], corpus.PongID) + } + + // The rebuilt packet must itself be parseable. If it is not, phantom + // would be emitting pongs that no client can read. + if _, err := proto.ReadUnconnectedPing(body); err != nil { + t.Fatalf("Build() output is not re-parseable: %v", err) + } + }) +} diff --git a/internal/proxy/rewrite_test.go b/internal/proxy/rewrite_test.go new file mode 100644 index 0000000..b5ffbc0 --- /dev/null +++ b/internal/proxy/rewrite_test.go @@ -0,0 +1,249 @@ +package proxy + +// T0 - white-box unit tests for the pong rewrite. +// +// This file lives in `package proxy` rather than in test/compat because +// rewriteUnconnectedPong is an unexported method; Go offers no way to reach it +// from an external test package. That is the constraint that splits T0 across +// internal/proto and internal/proxy. See test/compat/DESIGN.md section 3. +// +// The rewrite contract (DESIGN.md section 4): phantom replaces the server ID +// and the advertised ports, and must leave every other field exactly as the +// upstream server sent it. + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/jhead/phantom/internal/corpus" +) + +const testBoundPort = 54321 + +// MOTD field indices, in PongData declaration order. +const ( + idxEdition = iota + idxMOTD + idxProtocolVersion + idxVersion + idxPlayers + idxMaxPlayers + idxServerID + idxSubMOTD + idxGameType + idxGameMode // struct calls this NintendoLimited; it is really a numeric game mode + idxPort4 + idxPort6 +) + +// newTestProxy builds the minimal ProxyServer the rewrite path actually touches. +// No sockets are opened - this is a pure-function test of packet surgery. +func newTestProxy(removePorts bool) *ProxyServer { + return &ProxyServer{ + boundPort: testBoundPort, + prefs: ProxyPrefs{RemovePorts: removePorts}, + } +} + +func fields(motd string) []string { + parts := strings.Split(motd, ";") + if n := len(parts); n > 0 && parts[n-1] == "" { + parts = parts[:n-1] + } + return parts +} + +// rewriteFields runs the rewrite and returns the incoming and outgoing MOTD fields. +func rewriteFields(t *testing.T, p *ProxyServer, frame []byte) (in, out []string, outFrame []byte) { + t.Helper() + + inMOTD, err := corpus.SplitMOTD(frame) + if err != nil { + t.Fatalf("test fixture is not a well-formed pong: %v", err) + } + + outFrame = p.rewriteUnconnectedPong(frame) + + outMOTD, err := corpus.SplitMOTD(outFrame) + if err != nil { + t.Fatalf("rewrite produced an unparseable pong: %v", err) + } + return fields(inMOTD), fields(outMOTD), outFrame +} + +// TestRewritePreservesUpstreamFields is invariant 1: everything except the +// server ID and the two port fields must survive the rewrite untouched, across +// every captured protocol version. +func TestRewritePreservesUpstreamFields(t *testing.T) { + rewritten := map[int]bool{idxServerID: true, idxPort4: true, idxPort6: true} + + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + in, out, _ := rewriteFields(t, newTestProxy(false), e.Frame()) + + for i := range in { + if rewritten[i] || i >= len(out) { + continue + } + if in[i] != out[i] { + t.Errorf("field %d must pass through unchanged: %q -> %q", i, in[i], out[i]) + } + } + }) + } +} + +// TestRewriteReplacesServerID is invariant 4a. +func TestRewriteReplacesServerID(t *testing.T) { + want := fmt.Sprintf("%d", serverID) + + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + in, out, _ := rewriteFields(t, newTestProxy(false), e.Frame()) + + if out[idxServerID] != want { + t.Errorf("ServerID = %q, want phantom instance id %q", out[idxServerID], want) + } + if in[idxServerID] == out[idxServerID] { + t.Errorf("ServerID was not rewritten (upstream and output both %q); "+ + "restarting phantom would confuse clients", in[idxServerID]) + } + }) + } +} + +// TestRewriteRewritesPorts is invariant 5. Note the asymmetry: a server that +// advertises no ports keeps advertising none - phantom only rewrites ports that +// were already present. +func TestRewriteRewritesPorts(t *testing.T) { + wantPort := fmt.Sprintf("%d", testBoundPort) + + t.Run("ports-present", func(t *testing.T) { + frame := corpus.BuildPong(corpus.SyntheticPingTime(), corpus.SyntheticGUID(), + "MCPE;Ports;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;") + + _, out, _ := rewriteFields(t, newTestProxy(false), frame) + + if out[idxPort4] != wantPort { + t.Errorf("Port4 = %q, want %q", out[idxPort4], wantPort) + } + if out[idxPort6] != wantPort { + t.Errorf("Port6 = %q, want %q", out[idxPort6], wantPort) + } + }) + + t.Run("remove-ports-flag", func(t *testing.T) { + frame := corpus.BuildPong(corpus.SyntheticPingTime(), corpus.SyntheticGUID(), + "MCPE;Ports;800;1.21.80;0;10;12345678;Sub;Survival;1;19132;19133;") + + _, out, _ := rewriteFields(t, newTestProxy(true), frame) + + if len(out) > idxPort4 && out[idxPort4] != "" { + t.Errorf("Port4 = %q, want empty under -remove_ports", out[idxPort4]) + } + if len(out) > idxPort6 && out[idxPort6] != "" { + t.Errorf("Port6 = %q, want empty under -remove_ports", out[idxPort6]) + } + }) + + t.Run("legacy-pong-without-ports", func(t *testing.T) { + frame := corpus.BuildPong(corpus.SyntheticPingTime(), corpus.SyntheticGUID(), + "MCPE;Legacy;390;1.14.60;0;10;12345678;Sub;Survival;1;") + + _, out, _ := rewriteFields(t, newTestProxy(false), frame) + + if len(out) > idxPort4 && out[idxPort4] != "" { + t.Errorf("Port4 = %q; a server advertising no ports must keep advertising none", + out[idxPort4]) + } + }) +} + +// TestRewritePreservesTrailingFields is invariant 2 - the headline +// forwards-compatibility bug, exercised against real captured bytes. +func TestRewritePreservesTrailingFields(t *testing.T) { + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + in, out, _ := rewriteFields(t, newTestProxy(false), e.Frame()) + + corpus.Check(t, "t0/trailing-fields-preserved", func() error { + if len(out) < len(in) { + return corpus.Errorf( + "rewrite dropped %d trailing field(s) %q (upstream sent %d, phantom emitted %d)", + len(in)-len(out), in[len(out):], len(in), len(out)) + } + return nil + }) + }) + } +} + +// TestRewritePreservesHeader is invariant 3, plus the ping-time half of +// invariant 6: the rewrite must not disturb the RakNet header it passes through. +func TestRewritePreservesHeader(t *testing.T) { + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + frame := e.Frame() + out := newTestProxy(false).rewriteUnconnectedPong(frame) + + if len(out) < 33 { + t.Fatalf("rewritten packet is only %d bytes", len(out)) + } + if !bytes.Equal(out[1:9], frame[1:9]) { + t.Errorf("ping time changed: %x -> %x", frame[1:9], out[1:9]) + } + if !bytes.Equal(out[17:33], corpus.Magic) { + t.Errorf("RakNet magic corrupted: %x", out[17:33]) + } + }) + } +} + +// TestRewriteRewritesBinaryGUID is invariant 7. RakNet identifies a server by +// the 8-byte GUID at bytes 9-16, not by the MOTD string field. phantom rewrites +// only the string, so two instances proxying one upstream broadcast identical +// binary GUIDs. +func TestRewriteRewritesBinaryGUID(t *testing.T) { + for _, e := range corpus.Captured() { + t.Run(e.MC, func(t *testing.T) { + frame := e.Frame() + out := newTestProxy(false).rewriteUnconnectedPong(frame) + + corpus.Check(t, "t0/binary-guid-rewritten", func() error { + if bytes.Equal(out[9:17], frame[9:17]) { + return corpus.Errorf( + "binary ServerGUID passed through unchanged (%x); "+ + "it must carry phantom's per-instance id, like the string ServerID does", + out[9:17]) + } + return nil + }) + }) + } +} + +// TestRewriteSurvivesMalformedInput: the rewrite runs on whatever arrives from +// the network. It must never panic, and on unparseable input it must fall back +// to forwarding the original bytes rather than dropping the packet. +func TestRewriteSurvivesMalformedInput(t *testing.T) { + p := newTestProxy(false) + + for _, e := range corpus.Synthetic() { + t.Run(e.ID, func(t *testing.T) { + frame := e.Frame() + + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC rewriting %q: %v", e.ID, r) + } + }() + + out := p.rewriteUnconnectedPong(frame) + if out == nil { + t.Error("rewrite returned nil; the packet would be silently dropped") + } + }) + } +} diff --git a/test/compat/DESIGN.md b/test/compat/DESIGN.md new file mode 100644 index 0000000..eec41f0 --- /dev/null +++ b/test/compat/DESIGN.md @@ -0,0 +1,312 @@ +# Cross-version compatibility harness — design + +Status: **implemented** — see §11 for what changed during the build and §12 for +what is verified vs. unverified. +Date: 2026-07-25 (rev 3) + +## 1. Problem + +phantom sits between a Bedrock client (console) and a remote Bedrock server. It is +mostly a transparent UDP relay, but it *parses and rewrites* one packet type: the +RakNet Unconnected Pong (`0x1c`), whose payload is a semicolon-delimited MOTD string +whose shape changes as Mojang ships new protocol versions. + +Today we have no way to answer "does phantom still work on MC 1.2x?" without a +console, a real server, and a human. The failure mode is silent and version-gated: +phantom keeps proxying, but a new client either won't list the server or shows +garbage. + +**Goal:** an automated, CI-resident harness that validates phantom against N +Minecraft protocol versions, and fails loudly when a new version breaks an invariant. + +## 2. What actually varies across versions + +Only four things can plausibly break phantom. The matrix targets these and nothing +else — a naive full cross-product of every knob is combinatorial waste. + +| Dim | What varies | Range | phantom code at risk | +|-----|-------------|-------|----------------------| +| **D1** | Pong MOTD field count / semantics | ~40 MC versions; 12 fields historically, 13+ on modern servers | `proto.readPong`/`writePong` (fixed 12-field struct → **extra fields silently dropped**) | +| **D2** | RakNet protocol version in `OpenConnectionRequest1` | 7–11 | pure passthrough — assert it *stays* passthrough | +| **D3** | Negotiated MTU / max datagram size | ≤1464 payload | `maxMTU = 1472` read buffer; truncation risk | +| **D4** | Client ping behavior (`0x01` vs `0x02`, ping-time echo) | 2 packet IDs | offline-pong path only matches `0x01`; pong never echoes ping time | + +**Design consequence:** D1 gets the *broad sweep* across all N versions. D2/D3/D4 are +orthogonal to version and get a small fixed scenario set at one pinned version. Keeps +the matrix linear (N + k) instead of exponential (N × k). + +## 3. Two tiers, two testing philosophies + +The tiers are not just fast/slow — they are **different kinds of test**, and the +boundary is enforced by what each is allowed to import. + +### T0 — White-box unit tests (pure Go, no network) + +Imports `internal/proto` and `internal/proxy` and calls them **directly**. Replays a +committed corpus of **real pong payloads captured per protocol version** through +parse → rewrite → assert bytes. + +- Runtime **< 1s**. No network, no ports, no Node, no subprocess. +- Scales to all ~40 versions for free. This is the backbone of "N versions". +- Full white-box freedom: construct a `ProxyServer` with whatever internal state a + case needs, call unexported functions, assert on struct fields not just bytes. + +**Package placement is forced by Go, not chosen.** `rewriteUnconnectedPong` is an +unexported method on `*ProxyServer`, so its tests must be in-package: + +``` +internal/proto/corpus_test.go # package proto — parse/build round-trip +internal/proto/fuzz_test.go # package proto — FuzzReadUnconnectedPing +internal/proxy/rewrite_test.go # package proxy — rewrite goldens (unexported method) +test/compat/corpus/ # shared data only, no Go +``` + +The **fuzz target** is a natural fit here and cheap: `ReadUnconnectedPing` is a parser +eating untrusted network bytes, seeded from the version corpus. Short run in CI +(`-fuzztime=30s`), longer nightly. + +### T1 — Black-box end-to-end (real binary, real UDP) + +Knows **only** the built `phantom` binary, its CLI flags, and UDP sockets. It gets +**zero imports from `internal/`** — that rule is what keeps it honest. If T1 needs a +Go type from phantom to make an assertion, the assertion belongs in T0. + +Two upstream flavors: +- **Go fake server** (`test/compat/fakeserver`) — a standalone RakNet upstream with + byte-exact control over the pong. Emits malformed, truncated, oversized, zero-field + and 30-field pongs. Used for adversarial cases and D2/D3/D4. +- **`bedrock-protocol` server/client** (Node, pinned) — a *real* stack. Its `ping()` + parses phantom's rewritten pong with a genuine third-party parser, so a malformed + rewrite fails loudly instead of round-tripping through our own buggy parser. Used + for full handshake + login + spawn. + +- Runtime target **< 3 min** wall-clock across all shards. +- Version sampling: oldest supported, latest, latest-of-each-minor ≈ 8–10 versions. + +## 4. Invariant → tier assignment + +Given upstream pong bytes `U` and the bytes `P` phantom emits: + +| # | Invariant | Tier | Status | +|---|-----------|------|--------| +| 1 | `Edition`, `MOTD`, `ProtocolVersion`, `Version`, `Players`, `MaxPlayers`, `SubMOTD`, `GameType` byte-identical `U`→`P` | T0 + T1 | ✅ | +| 2 | **Trailing fields beyond the known 12 preserved** | T0 | ❌ XFAIL (D1) | +| 3 | RakNet magic exactly 16 bytes, unmodified | T0 | ✅ | +| 4a | `ServerID` replaced with phantom's instance ID | T0 | ✅ | +| 4b | `ServerID` stable across pings; **differs between two running instances** | **T1 only** — `serverID` is a package global, unobservable in-process | ✅ | +| 5 | `Port4`/`Port6` = bound port; both empty under `-remove_ports` | T0 (logic) + T1 (flag wiring) | ✅ | +| 6 | `PingTime` echoes the *client's* ping time | T0 (build preserves) + T1 (offline path stamps) | ❌ XFAIL (D4) | +| 7 | Binary `ServerGUID` (bytes 9–16) non-zero and per-instance | T0 + T1 (uniqueness) | ❌ XFAIL | +| 8 | RakNet handshake completes for proto 8/9/10/11 | T1 | ✅ | +| 9 | `bedrock-protocol` client reaches `spawn` through phantom | T1 | ✅ | +| 10 | Payload integrity both directions at 1, 576, 1400, 1464, 1472, 1500 bytes — byte-identical or cleanly dropped, never silently truncated | T1 | ✅ | +| 11 | Two concurrent clients never receive each other's datagrams | T1 | ✅ | +| 12 | Idle client reaped after `-timeout` | T1 (slow) | ✅ | +| 13 | Upstream down → offline pong; upstream back → proxying resumes, no restart | T1 (slow) | ✅ | +| 14 | Client sending `0x02` gets an offline pong | T1 | ❌ XFAIL (D4) | +| 15 | Malformed/truncated/30-field pongs never panic and never produce output the reference parser rejects | T0 (fuzz) + T1 (fake server) | ⚠️ partly | + +Note how cleanly the split falls out: **every T0 invariant is about bytes and pure +functions; every T1-only invariant is about process identity, sockets, or time.** +That's the boundary test — if a proposed case doesn't fit that description, it's in +the wrong tier. + +## 5. Version matrix as data + +`test/compat/matrix.json` is the single source of truth, committed and reviewable: + +```json +[ + { "mc": "1.16.201", "protocol": 422, "raknet": 10, "tier1": true, "shard": 0, "fields": 12 }, + { "mc": "1.21.0", "protocol": 685, "raknet": 11, "tier1": true, "shard": 2, "fields": 13 }, + { "mc": "1.26.30", "protocol": 860, "raknet": 11, "tier1": true, "shard": 3, "fields": 13 } +] +``` + +Two generators keep it honest: +- `make corpus` — starts a `bedrock-protocol` server per entry, captures the raw pong, + writes `corpus/*.bin` + `*.golden.json`. Dev-time/nightly only; **CI never generates + the corpus, it only replays committed bytes.** +- **Nightly matrix-drift job** — diffs `matrix.json` against `bedrock-protocol`'s + supported-version list; opens an issue when Mojang ships a version we don't cover. + This is what makes "N versions" stay current without human vigilance. + +## 6. CI structure + +GitHub Actions matrices, used where they earn their keep: + +**`ci.yml` — every push/PR** + +| Job | Matrix | Why | +|-----|--------|-----| +| `unit` | — | `go vet` (fails today on the unkeyed struct literal), `go test -race ./...` | +| `t0` | `os: [ubuntu, macos, windows]` | Corpus + fuzz. Go-only, no deps, seconds. OS matrix is nearly free and catches endian/path/CRLF surprises. | +| `t1` | `shard: [0,1,2,3]` | Build binary + `npm ci` + run that shard's versions serially | +| `cross-compile` | — | All `make build` targets still link | + +**Why shard T1 rather than one job per version:** each job pays ~40–60s of +checkout/toolchain/`npm ci` overhead for a few seconds of test. Per-version jobs would +spend 90% of runner minutes on setup. Four shards balance that against parallelism and +keep the PR check list readable. + +**The shard matrix also solves port isolation.** phantom hard-binds `:19132` +unconditionally (`reuse.ListenPacket("udp4", ":19132")`), and `SO_REUSEPORT` means two +instances *load-balance each other's packets* rather than failing cleanly — so two +concurrent T1 cases on one machine would silently corrupt each other. Each shard gets +its own runner, hence its own network stack. **Within** a shard, cases run serially. +No network namespaces, no `unshare`, no root. Locally, `go test` runs serially and +preflights `:19132`, **skipping with a clear message** if it's busy — a dev running +Minecraft or phantom shouldn't see mysterious red. + +**`nightly.yml` — non-blocking** +- Full ~40-version T1 sweep (vs. the ~10 sampled per-push) +- Extended fuzzing (`-fuzztime=10m`) +- Matrix-drift check + +## 7. Harness mechanics + +**Subprocess, not in-process.** T1 runs the built binary. `Start()` blocks, `Close()` +nil-panics on early shutdown, and `serverID` is a package global — so invariant 4b is +*impossible* in-process. Bonus: we test the shipped artifact and its flag parsing. + +**Readiness without sleeps.** Never `sleep`. Poll by *behavior*: retry the ping until +a pong arrives or a deadline expires. No log-scraping, no fixed delays. Every timeout +is a named, overridable constant. + +**Always pass `-bind_port` explicitly** — `-bind_port 0` randomizes it and the harness +would have nothing to talk to. + +**Slow cases are tagged.** Idle-reap needs `-timeout 1` plus the 5s sweep ≈ 7s; +offline/online recovery similar. Behind a `slow` build tag: included in CI, excluded +from default `make test`. + +**Known-failing tests.** Invariants 2, 6, 7, 14 fail on `master` today — they are the +open protocol bugs in `TODO.md`. Landing this harness must not turn CI permanently +red. So `known_failures.json` maps test ID → TODO item, XFAIL-marked. CI **fails if an +XFAIL unexpectedly passes**, forcing the marker deleted in the same PR that fixes the +bug. The registry doubles as a live bug-status dashboard. + +## 8. Layout + +``` +internal/proto/corpus_test.go # T0: parse/build round-trip (package proto) +internal/proto/fuzz_test.go # T0: FuzzReadUnconnectedPing (package proto) +internal/proxy/rewrite_test.go # T0: rewrite goldens (package proxy) + +test/compat/ + DESIGN.md # this file + matrix.json # version matrix (source of truth) + known_failures.json # XFAIL registry → TODO.md items + corpus/ # captured pongs + goldens (shared T0 data, no Go) + harness.go # T1: process lifecycle, readiness polling, preflight + e2e_test.go # T1 (build tag: e2e) + slow_test.go # T1 slow cases (build tag: e2e,slow) + fakeserver/ # T1: standalone scriptable RakNet upstream + node/ # pinned bedrock-protocol; ping.js / connect.js / serve.js + +.github/workflows/ci.yml +.github/workflows/nightly.yml +``` + +Node stays *thin*: three single-purpose CLIs that print JSON on stdout. Go owns the +matrix, process lifecycle, and every assertion — `make test` remains the one entry +point and Go contributors never edit JS. + +## 9. Phasing + +| Phase | Deliverable | Value | +|-------|-------------|-------| +| **0** | `ci.yml`: `go vet`, `go test -race`, cross-compile | Closes "No CI" in TODO.md; catches the known `go vet` failure | +| **1** | T0: corpus + goldens + fuzz + `matrix.json` | Broad N-version coverage, ~1s, zero deps | +| **2** | `harness.go` + `fakeserver` + T1 core (1, 4b, 5–7, 10, 15) | Real UDP, real binary, no Node yet | +| **3** | Node `bedrock-protocol` integration (8, 9) + shard matrix | Real third-party client stack validates our rewrite | +| **4** | `nightly.yml`: full sweep, extended fuzz, matrix drift | Stays current without humans | + +Phases 0–1 alone would have caught the dropped-trailing-field bug, need no Node, and +land independently. Recommend starting there. + +## 10. Explicit non-goals + +- **Real BDS testing — dropped.** Considered as a third tier; rejected for now. ~150MB + downloads, EULA, linux/amd64 only, and Mojang has broken old-version CDN URLs before. + Revisit only if T0/T1 prove insufficient at catching real drift. +- Not a performance or load benchmark. +- Not testing real consoles (Xbox/PS), and **not testing LAN broadcast discovery** to + `255.255.255.255`. This is a genuine gap, not a non-issue: broadcast *is* phantom's + real discovery path on console. Covering it needs a `dummy0` interface (Linux, root). + Deferred deliberately. +- Not testing Xbox Live auth — offline mode only. +- Not asserting game-layer packet semantics; phantom is opaque above RakNet and the + harness treats it that way. + +## 11. What changed during implementation + +Eight deviations from rev 2, each with its reason. + +1. **Corpus lives in `internal/corpus/`, not `test/compat/corpus/`.** Both T0 test + packages need it, and `go:embed` cannot reach outside its own package directory. + Embedding removes all relative-path resolution from the tests. + +2. **Corpus is consolidated JSON, not per-version `.bin` + `.golden.json`.** 51 + versions would have meant 102 near-identical files. One `captured.json` shows + exactly which version's bytes changed in a diff. + +3. **No golden output bytes.** Storing phantom's current output as "expected" would + enshrine the very bugs the harness exists to find. T0 asserts the field-level + *invariants* from §4 instead. Strictly better, and it made the XFAIL registry + meaningful. + +4. **The black-box rule is "no *implementation* imports".** T1 may import + `internal/corpus` (fixture data and the XFAIL registry, zero phantom protocol + logic); it may not import `internal/proto`, `internal/proxy` or + `internal/clientmap`. `TestBlackBoxRuleHolds` enforces this with `go list -deps` + rather than trusting anyone to remember. + +5. **Invariant 9 is "reaches `join`", not "reaches `spawn`".** A client only spawns + once the server sends `start_game` and chunk data, which `bedrock-protocol`'s + `createServer` does not do on its own. Writing a mini game server would test + game-layer semantics that are an explicit non-goal, across 8 versions, while + making phantom bugs indistinguishable from gaps in our fake server. Reaching + `join` already proves the full RakNet handshake *and* login completed through + phantom — which is the entire surface phantom actually relays. + +6. **A `node` build tag, separate from `e2e`.** The rest of T1 runs on machines with + no Node toolchain. + +7. **`go 1.12` → `go 1.21` in go.mod.** `go:embed` needs ≥1.16 and native fuzzing + needs ≥1.18. Not optional. `cmd/phantom.go`'s unkeyed struct literal was also + fixed, because Phase 0 CI runs `go vet` and it failed on it. + +8. **The captured corpus has a real limitation, recorded in the file itself.** + `bedrock-protocol` emits a **constant 13-field pong across all 51 versions** — + only the protocol number and version string vary. So per-version capture is a + drift *anchor*, not a source of shape diversity. That diversity is what + `synthetic.json` is for (10-, 12-, 13-, 14- and 15-field shapes, plus hostile + content and malformed frames). Worth knowing before anyone assumes 51 captured + versions means 51 distinct shapes tested. + +## 12. Verification status + +Run locally on darwin/arm64: + +| Tier | Result | +|------|--------| +| T0 corpus + rewrite (`go test ./internal/...`) | **passing**, ~0.3s, 51 versions swept | +| T0 fuzz, 25s | **passing**, 11.4M execs, no crashers | +| T1 e2e + slow (`-tags='e2e slow'`) | **passing**, ~30s | +| T1 `ping` through phantom, all 8 matrix versions | **passing** — a real third-party parser accepts phantom's rewritten pong and sees phantom's port | +| T1 full `connect` session | **not verified locally** — see below | +| CI workflows | **not verified** — never executed; no runner available here | + +**The `connect` session tests have never actually run.** `bedrock-protocol`'s native +RakNet addon crashes on darwin/arm64 (`trace/BPT trap`); it answers pings through a +separate JS path, which is why the ping tier works. The tests are written with a +**control connection**: they first connect straight to the upstream with phantom out +of the path. If that fails, the environment cannot support the test and it *skips*; +only if the direct connection succeeds and the proxied one fails is phantom blamed. +So they are safe to land — they cannot produce a false accusation — but their first +real execution will be on Linux CI, and they should be treated as unproven until a +green run exists. + +Eight invariants currently XFAIL against real captured bytes, all registered in +`internal/corpus/data/known_failures.json` against their `TODO.md` entries. diff --git a/test/compat/e2e_test.go b/test/compat/e2e_test.go new file mode 100644 index 0000000..d240472 --- /dev/null +++ b/test/compat/e2e_test.go @@ -0,0 +1,461 @@ +//go:build e2e + +package compat + +// T1 - black-box end-to-end tests. +// +// Everything here drives a real phantom subprocess over real UDP sockets. No +// knowledge of phantom's internals is used or permitted; TestBlackBoxRuleHolds +// enforces that mechanically. +// +// Run with: go test -tags=e2e ./test/compat/... + +import ( + "bytes" + "fmt" + "os/exec" + "strings" + "testing" + "time" + + "github.com/jhead/phantom/internal/corpus" + "github.com/jhead/phantom/test/compat/fakeserver" +) + +// fieldsOf splits a MOTD, dropping the empty element left by the conventional +// trailing semicolon. +func fieldsOf(motd string) []string { + parts := strings.Split(motd, ";") + if n := len(parts); n > 0 && parts[n-1] == "" { + parts = parts[:n-1] + } + return parts +} + +// motdOf extracts the MOTD from a pong datagram. +func motdOf(t *testing.T, pong []byte) string { + t.Helper() + motd, err := corpus.SplitMOTD(pong) + if err != nil { + t.Fatalf("phantom emitted an unparseable pong (%d bytes): %v", len(pong), err) + } + return motd +} + +// startPair boots a fake upstream and a phantom pointed at it. +func startPair(t *testing.T, up fakeserver.Opts, po Opts) (*fakeserver.Server, *Phantom) { + t.Helper() + RequirePingPort(t) + + srv, err := fakeserver.Start(up) + if err != nil { + t.Fatalf("starting fake upstream: %v", err) + } + t.Cleanup(func() { _ = srv.Close() }) + + po.RemoteServer = srv.Addr() + return srv, Start(t, po) +} + +// TestPongPassesThroughUpstreamFields is invariant 1: everything except the +// server id and the ports must reach the client exactly as the upstream sent it. +func TestPongPassesThroughUpstreamFields(t *testing.T) { + const motd = "MCPE;Passthrough Test;800;1.21.80;7;20;999888777;My Sub;Creative;1;19132;19133;0;" + + _, p := startPair(t, fakeserver.Opts{MOTD: motd}, Opts{}) + + pong, err := p.Ping() + if err != nil { + t.Fatalf("ping: %v", err) + } + + in, out := fieldsOf(motd), fieldsOf(motdOf(t, pong)) + + // Indices 6 (server id), 10 and 11 (ports) are phantom's to rewrite. + rewritten := map[int]bool{6: true, 10: true, 11: true} + for i := range in { + if rewritten[i] || i >= len(out) { + continue + } + if in[i] != out[i] { + t.Errorf("field %d must pass through unchanged: upstream %q, client saw %q", + i, in[i], out[i]) + } + } +} + +// TestTrailingFieldsReachClient is invariant 2 measured end-to-end: a real +// client pinging through phantom never sees the 13th field. +func TestTrailingFieldsReachClient(t *testing.T) { + const motd = "MCPE;Trailing;800;1.21.80;0;10;123;Sub;Creative;1;19132;19133;0;" + + _, p := startPair(t, fakeserver.Opts{MOTD: motd}, Opts{}) + + pong, err := p.Ping() + if err != nil { + t.Fatalf("ping: %v", err) + } + + in, out := fieldsOf(motd), fieldsOf(motdOf(t, pong)) + + corpus.Check(t, "t0/trailing-fields-preserved", func() error { + if len(out) < len(in) { + return corpus.Errorf("client received %d fields, upstream sent %d; lost %q", + len(out), len(in), in[len(out):]) + } + return nil + }) +} + +// TestServerIDIsStableAndPerInstance is invariant 4b, and the reason phantom is +// run as a subprocess: serverID is a package global, so two instances cannot be +// distinguished from inside one process. +func TestServerIDIsStableAndPerInstance(t *testing.T) { + const motd = "MCPE;ID Test;800;1.21.80;0;10;111222333;Sub;Creative;1;19132;19133;0;" + + srv, p1 := startPair(t, fakeserver.Opts{MOTD: motd}, Opts{}) + + idOf := func(p *Phantom) string { + t.Helper() + pong, err := p.Ping() + if err != nil { + t.Fatalf("ping: %v", err) + } + f := fieldsOf(motdOf(t, pong)) + if len(f) < 7 { + t.Fatalf("pong has only %d fields", len(f)) + } + return f[6] + } + + first := idOf(p1) + + t.Run("stable-across-pings", func(t *testing.T) { + for i := 0; i < 3; i++ { + if got := idOf(p1); got != first { + t.Fatalf("server id changed between pings: %q then %q", first, got) + } + } + }) + + t.Run("differs-from-upstream", func(t *testing.T) { + if first == "111222333" { + t.Error("phantom forwarded the upstream's server id unchanged; " + + "restarting phantom would confuse clients") + } + }) + + t.Run("differs-between-instances", func(t *testing.T) { + p2 := Start(t, Opts{RemoteServer: srv.Addr()}) + if second := idOf(p2); second == first { + t.Errorf("two phantom instances advertised the same server id %q; "+ + "clients cannot tell them apart", second) + } + }) +} + +// TestPortRewriting is invariant 5, exercised through the real CLI flags. +func TestPortRewriting(t *testing.T) { + const motd = "MCPE;Ports;800;1.21.80;0;10;123;Sub;Creative;1;19132;19133;0;" + + t.Run("rewritten-to-bind-port", func(t *testing.T) { + _, p := startPair(t, fakeserver.Opts{MOTD: motd}, Opts{}) + + pong, err := p.Ping() + if err != nil { + t.Fatalf("ping: %v", err) + } + f := fieldsOf(motdOf(t, pong)) + want := fmt.Sprintf("%d", p.BindPort) + + if f[10] != want { + t.Errorf("Port4 = %q, want phantom's bind port %q", f[10], want) + } + if f[11] != want { + t.Errorf("Port6 = %q, want phantom's bind port %q", f[11], want) + } + }) + + t.Run("remove_ports-flag", func(t *testing.T) { + _, p := startPair(t, fakeserver.Opts{MOTD: motd}, Opts{RemovePorts: true}) + + pong, err := p.Ping() + if err != nil { + t.Fatalf("ping: %v", err) + } + f := fieldsOf(motdOf(t, pong)) + + if len(f) > 10 && f[10] != "" { + t.Errorf("Port4 = %q, want empty under -remove_ports", f[10]) + } + if len(f) > 11 && f[11] != "" { + t.Errorf("Port6 = %q, want empty under -remove_ports", f[11]) + } + }) +} + +// TestProxiedPongEchoesPingTime is the online half of invariant 6. The upstream +// stamps the client's ping time; phantom must not lose it in the rewrite. +func TestProxiedPongEchoesPingTime(t *testing.T) { + _, p := startPair(t, fakeserver.Opts{}, Opts{}) + + want := []byte{0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04} + pong, err := p.PingWith(corpus.PingID, want) + if err != nil { + t.Fatalf("ping: %v", err) + } + if len(pong) < 9 { + t.Fatalf("pong too short: %d bytes", len(pong)) + } + if !bytes.Equal(pong[1:9], want) { + t.Errorf("pong echoed ping time %x, want %x", pong[1:9], want) + } +} + +// deadUpstream points phantom at a closed loopback port so it detects the +// upstream as offline (ECONNREFUSED arrives promptly on loopback). +func deadUpstream(t *testing.T) *Phantom { + t.Helper() + RequirePingPort(t) + + port := freeUDPPort(t) + return Start(t, Opts{RemoteServer: fmt.Sprintf("127.0.0.1:%d", port)}) +} + +// negativeWait is the budget for confirming a pong will NOT arrive. It is much +// shorter than ReadyTimeout because the caller has already established that +// phantom is up and has detected the upstream as offline - so there is nothing +// left to wait for, and burning the full readiness budget on every negative +// case would dominate the suite's runtime. +const negativeWait = 3 * time.Second + +// waitForOfflinePong pings until phantom answers with its canned offline pong. +func waitForOfflinePong(t *testing.T, p *Phantom, packetID byte, pingTime []byte) []byte { + t.Helper() + return waitForOfflinePongWithin(t, p, packetID, pingTime, ReadyTimeout) +} + +func waitForOfflinePongWithin(t *testing.T, p *Phantom, packetID byte, pingTime []byte, budget time.Duration) []byte { + t.Helper() + + deadline := time.Now().Add(budget) + for time.Now().Before(deadline) { + pong, err := p.PingWith(packetID, pingTime) + if err == nil && strings.Contains(strings.ToLower(motdOf(t, pong)), "offline") { + return pong + } + time.Sleep(pollInterval) + } + return nil +} + +// TestOfflinePong covers the server-offline path: invariants 6, 7 and 14. +func TestOfflinePong(t *testing.T) { + t.Run("is-sent-when-upstream-is-down", func(t *testing.T) { + p := deadUpstream(t) + if waitForOfflinePong(t, p, corpus.PingID, DefaultPingTime()) == nil { + t.Fatalf("phantom never sent an offline pong\n--- output ---\n%s", p.Output()) + } + }) + + t.Run("echoes-ping-time", func(t *testing.T) { + p := deadUpstream(t) + want := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44} + + pong := waitForOfflinePong(t, p, corpus.PingID, want) + if pong == nil { + t.Fatalf("phantom never sent an offline pong\n--- output ---\n%s", p.Output()) + } + + corpus.Check(t, "t1/offline-pong-echoes-ping-time", func() error { + if !bytes.Equal(pong[1:9], want) { + return corpus.Errorf( + "offline pong carries ping time %x, want the client's %x; "+ + "the client uses this to compute displayed latency", + pong[1:9], want) + } + return nil + }) + }) + + t.Run("carries-nonzero-guid", func(t *testing.T) { + p := deadUpstream(t) + + pong := waitForOfflinePong(t, p, corpus.PingID, DefaultPingTime()) + if pong == nil { + t.Fatalf("phantom never sent an offline pong\n--- output ---\n%s", p.Output()) + } + + corpus.Check(t, "t1/offline-pong-nonzero-guid", func() error { + if bytes.Equal(pong[9:17], make([]byte, 8)) { + return corpus.Errorf( + "offline pong advertises binary ServerGUID 0; every phantom whose " + + "upstream is down would claim the same RakNet identity") + } + return nil + }) + }) + + t.Run("answers-0x02-ping", func(t *testing.T) { + p := deadUpstream(t) + + // Establish that the upstream really is detected as down, so a failure + // below is about 0x02 handling and not about timing. + if waitForOfflinePong(t, p, corpus.PingID, DefaultPingTime()) == nil { + t.Fatalf("phantom never sent an offline pong for 0x01\n--- output ---\n%s", p.Output()) + } + + corpus.Check(t, "t1/offline-pong-answers-0x02", func() error { + if waitForOfflinePongWithin(t, p, corpus.PingOpenID, DefaultPingTime(), negativeWait) == nil { + return corpus.Errorf( + "a client pinging with 0x02 (Unconnected Ping Open Connections) " + + "got no offline pong; the offline path matches only 0x01") + } + return nil + }) + }) +} + +// TestPayloadIntegrity is invariant 10: non-ping datagrams must survive the +// round trip through phantom byte for byte, at every size RakNet can produce. +func TestPayloadIntegrity(t *testing.T) { + _, p := startPair(t, fakeserver.Opts{EchoPayloads: true}, Opts{}) + + // RakNet's ceiling is MTU 1492 minus IP (20) and UDP (8) headers = 1464. + // phantom's read buffer is 1472, so 1464 and 1472 are the interesting edges. + for _, size := range []int{1, 576, 1400, 1464, 1472} { + t.Run(fmt.Sprintf("%d-bytes", size), func(t *testing.T) { + c, err := NewClient(p.Addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + + payload := make([]byte, size) + payload[0] = 0x84 // a RakNet data datagram, not an offline ping + for i := 1; i < size; i++ { + payload[i] = byte(i % 251) + } + + if err := c.Send(payload); err != nil { + t.Fatalf("send: %v", err) + } + got, err := c.Recv(PingTimeout) + if err != nil { + t.Fatalf("no echo came back: %v", err) + } + if !bytes.Equal(got, payload) { + t.Errorf("payload corrupted: sent %d bytes, got %d back", len(payload), len(got)) + } + }) + } +} + +// TestOversizedDatagramTruncates documents, rather than condemns, what happens +// past phantom's 1472-byte read buffer. RakNet cannot produce a datagram this +// large (its own ceiling is 1464), so this is unreachable in practice - but +// pinning the behaviour means a future buffer change is a deliberate decision +// rather than an accident. +func TestOversizedDatagramTruncates(t *testing.T) { + srv, p := startPair(t, fakeserver.Opts{EchoPayloads: true}, Opts{}) + + c, err := NewClient(p.Addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + + payload := make([]byte, 1500) + payload[0] = 0x84 + if err := c.Send(payload); err != nil { + t.Fatalf("send: %v", err) + } + + if _, err := c.Recv(PingTimeout); err != nil { + t.Logf("no echo returned for an oversized datagram: %v", err) + } + + if got := srv.LastPayload(); got != nil && len(got) == len(payload) { + t.Errorf("upstream received all %d bytes; phantom's 1472-byte buffer "+ + "was expected to truncate. Buffer size may have changed.", len(got)) + } +} + +// TestConcurrentClientsAreIsolated is invariant 11: two clients sharing one +// phantom must never receive each other's traffic. +func TestConcurrentClientsAreIsolated(t *testing.T) { + _, p := startPair(t, fakeserver.Opts{EchoPayloads: true}, Opts{}) + + const clients = 4 + type conn struct { + c *Client + tag byte + } + + var conns []conn + for i := 0; i < clients; i++ { + c, err := NewClient(p.Addr) + if err != nil { + t.Fatalf("dial %d: %v", i, err) + } + defer c.Close() + conns = append(conns, conn{c: c, tag: byte(0x40 + i)}) + } + + // Each client sends a payload uniquely filled with its own tag. + for _, cn := range conns { + payload := bytes.Repeat([]byte{cn.tag}, 64) + payload[0] = 0x84 + if err := cn.c.Send(payload); err != nil { + t.Fatalf("send: %v", err) + } + } + + for i, cn := range conns { + got, err := cn.c.Recv(PingTimeout) + if err != nil { + t.Fatalf("client %d got no echo: %v", i, err) + } + for j, b := range got[1:] { + if b != cn.tag { + t.Fatalf("client %d received another client's data at byte %d: "+ + "got 0x%02x, want 0x%02x", i, j+1, b, cn.tag) + } + } + } +} + +// TestBlackBoxRuleHolds enforces the tier boundary from DESIGN.md section 3. +// Without this, "T1 is black box" is a convention that decays the first time +// someone reaches for a convenient internal helper. +func TestBlackBoxRuleHolds(t *testing.T) { + forbidden := []string{ + "github.com/jhead/phantom/internal/proto", + "github.com/jhead/phantom/internal/proxy", + "github.com/jhead/phantom/internal/clientmap", + } + + root, err := repoRoot() + if err != nil { + t.Fatalf("locating repo root: %v", err) + } + + cmd := exec.Command("go", "list", "-tags=e2e,slow", "-deps", "./test/compat/...") + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go list: %v\n%s", err, out) + } + + deps := string(out) + for _, pkg := range forbidden { + for _, line := range strings.Split(deps, "\n") { + if strings.TrimSpace(line) == pkg { + t.Errorf("test/compat imports %s.\n"+ + "T1 is the black-box tier: it may know only the phantom binary, its "+ + "CLI flags, and UDP. If an assertion needs phantom's internals, it "+ + "belongs in T0 (internal/proto or internal/proxy).", pkg) + } + } + } +} diff --git a/test/compat/fakeserver/fakeserver.go b/test/compat/fakeserver/fakeserver.go new file mode 100644 index 0000000..0b108d8 --- /dev/null +++ b/test/compat/fakeserver/fakeserver.go @@ -0,0 +1,204 @@ +// Package fakeserver is a scriptable RakNet upstream for the black-box tier. +// +// It exists because a real Minecraft server cannot be made to emit a truncated +// pong, a 30-field MOTD, or a pong from a protocol version that does not exist +// yet. This one can, byte for byte. +// +// It is a test double standing in for the REMOTE server. phantom itself is +// always a real subprocess - see test/compat/harness.go. +package fakeserver + +import ( + "fmt" + "net" + "sync" + "time" + + "github.com/jhead/phantom/internal/corpus" +) + +// Opts configures a fake upstream. +type Opts struct { + // Pong is the exact datagram to answer offline pings with. If nil, one is + // built from MOTD. + Pong []byte + + // MOTD builds a well-formed pong when Pong is nil. + MOTD string + + // EchoPayloads makes the server return any non-ping datagram verbatim, + // which is how payload-integrity and MTU cases are measured. + EchoPayloads bool + + // SilentToPings makes the server ignore pings entirely, simulating an + // upstream that is reachable but not answering. + SilentToPings bool +} + +// Server is a fake RakNet upstream listening on loopback. +type Server struct { + conn *net.UDPConn + opts Opts + + mu sync.Mutex + received [][]byte + pings int + stopped bool + + done chan struct{} + wg sync.WaitGroup +} + +// Start binds a fake upstream to an ephemeral loopback port. +func Start(opts Opts) (*Server, error) { + if opts.Pong == nil { + motd := opts.MOTD + if motd == "" { + motd = "MCPE;Fake Upstream;800;1.21.80;0;10;1234567890;Sub;Survival;1;19132;19133;0;" + } + opts.Pong = corpus.BuildPong( + make([]byte, 8), // ping time; overwritten per-request with the caller's + []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x01, 0x02}, + motd, + ) + } + + addr, err := net.ResolveUDPAddr("udp4", "127.0.0.1:0") + if err != nil { + return nil, err + } + conn, err := net.ListenUDP("udp4", addr) + if err != nil { + return nil, err + } + + s := &Server{conn: conn, opts: opts, done: make(chan struct{})} + s.wg.Add(1) + go s.serve() + return s, nil +} + +// Addr is the host:port phantom should be pointed at with -server. +func (s *Server) Addr() string { return s.conn.LocalAddr().String() } + +func (s *Server) serve() { + defer s.wg.Done() + buf := make([]byte, 4096) + + for { + select { + case <-s.done: + return + default: + } + + _ = s.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, from, err := s.conn.ReadFromUDP(buf) + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + continue + } + return + } + + data := append([]byte(nil), buf[:n]...) + + s.mu.Lock() + s.received = append(s.received, data) + stopped := s.stopped + s.mu.Unlock() + + if stopped || n == 0 { + continue + } + + switch data[0] { + case corpus.PingID, corpus.PingOpenID: + s.mu.Lock() + s.pings++ + s.mu.Unlock() + + if s.opts.SilentToPings { + continue + } + _, _ = s.conn.WriteToUDP(s.pongFor(data), from) + + default: + if s.opts.EchoPayloads { + _, _ = s.conn.WriteToUDP(data, from) + } + } + } +} + +// pongFor returns the configured pong with the caller's ping time stamped into +// it, which is what every real RakNet server does. +func (s *Server) pongFor(ping []byte) []byte { + out := append([]byte(nil), s.opts.Pong...) + if len(ping) >= 9 && len(out) >= 9 { + copy(out[1:9], ping[1:9]) + } + return out +} + +// Stop makes the server go dark without unbinding, simulating an upstream that +// has crashed or stopped responding. Resume brings it back. +func (s *Server) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + s.stopped = true +} + +// Resume undoes Stop. +func (s *Server) Resume() { + s.mu.Lock() + defer s.mu.Unlock() + s.stopped = false +} + +// Pings is the number of offline pings received. +func (s *Server) Pings() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.pings +} + +// Received returns copies of every datagram the server has seen. +func (s *Server) Received() [][]byte { + s.mu.Lock() + defer s.mu.Unlock() + out := make([][]byte, len(s.received)) + copy(out, s.received) + return out +} + +// LastPayload returns the most recent non-ping datagram, or nil. +func (s *Server) LastPayload() []byte { + s.mu.Lock() + defer s.mu.Unlock() + for i := len(s.received) - 1; i >= 0; i-- { + d := s.received[i] + if len(d) > 0 && d[0] != corpus.PingID && d[0] != corpus.PingOpenID { + return d + } + } + return nil +} + +// Close shuts the server down. +func (s *Server) Close() error { + select { + case <-s.done: + return nil + default: + close(s.done) + } + err := s.conn.Close() + s.wg.Wait() + return err +} + +// String aids test failure messages. +func (s *Server) String() string { + return fmt.Sprintf("fakeserver(%s)", s.Addr()) +} diff --git a/test/compat/harness.go b/test/compat/harness.go new file mode 100644 index 0000000..8dee4c8 --- /dev/null +++ b/test/compat/harness.go @@ -0,0 +1,365 @@ +// Package compat is the black-box (T1) compatibility harness. +// +// BLACK-BOX RULE: this package must not import phantom's implementation +// packages (internal/proto, internal/proxy, internal/clientmap). It knows only +// the built binary, its command-line flags, and UDP. Importing internal/corpus +// is permitted - that package is fixture data and the XFAIL registry, with no +// phantom protocol logic in it. TestBlackBoxRuleHolds in e2e_test.go enforces +// this mechanically rather than trusting anyone to remember. +// +// See DESIGN.md. +package compat + +import ( + "bytes" + "encoding/binary" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "github.com/jhead/phantom/internal/corpus" +) + +// Timeouts. Generous by default because CI runners are slow and UDP is lossy; +// override with PHANTOM_TEST_TIMEOUT_SCALE for very slow machines. +var ( + ReadyTimeout = 20 * time.Second + PingTimeout = 2 * time.Second + ShutdownGrace = 5 * time.Second + pollInterval = 150 * time.Millisecond + pingServerPort = 19132 +) + +func init() { + if s := os.Getenv("PHANTOM_TEST_TIMEOUT_SCALE"); s != "" { + if scale, err := strconv.ParseFloat(s, 64); err == nil && scale > 0 { + ReadyTimeout = time.Duration(float64(ReadyTimeout) * scale) + PingTimeout = time.Duration(float64(PingTimeout) * scale) + ShutdownGrace = time.Duration(float64(ShutdownGrace) * scale) + } + } +} + +var ( + buildOnce sync.Once + binPath string + buildErr error +) + +// BuildPhantom compiles the phantom binary once per test run and returns its +// path. Tests drive the real shipped artifact, not an in-process ProxyServer: +// Start() blocks, Close() panics if called before bind, and serverID is a +// package global - so "two instances must advertise different ids" is simply +// not observable in-process. +func BuildPhantom(t *testing.T) string { + t.Helper() + + buildOnce.Do(func() { + dir, err := os.MkdirTemp("", "phantom-compat-*") + if err != nil { + buildErr = err + return + } + out := filepath.Join(dir, "phantom") + + root, err := repoRoot() + if err != nil { + buildErr = err + return + } + + cmd := exec.Command("go", "build", "-o", out, "./cmd") + cmd.Dir = root + if combined, err := cmd.CombinedOutput(); err != nil { + buildErr = fmt.Errorf("building phantom: %v\n%s", err, combined) + return + } + binPath = out + }) + + if buildErr != nil { + t.Fatalf("%v", buildErr) + } + return binPath +} + +// repoRoot walks up from this file's package directory to the module root. +func repoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for i := 0; i < 10; i++ { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", fmt.Errorf("could not locate go.mod above %s", dir) +} + +// RequirePingPort skips the test unless UDP :19132 is free. +// +// phantom binds :19132 unconditionally, and because it sets SO_REUSEPORT a +// second binder does not fail - the kernel load-balances datagrams between +// them, so two concurrent cases would silently steal each other's packets and +// produce baffling flakes. Skipping loudly beats that. In CI each shard runs on +// its own runner, so the port is always free there. +func RequirePingPort(t *testing.T) { + t.Helper() + + addr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: pingServerPort} + conn, err := net.ListenUDP("udp4", addr) + if err != nil { + t.Skipf("UDP :%d is in use (%v) - phantom binds it unconditionally, so this "+ + "test cannot run here. Stop any running phantom or Minecraft instance.", + pingServerPort, err) + } + _ = conn.Close() +} + +// Opts configures a phantom subprocess. +type Opts struct { + RemoteServer string // -server (required) + BindPort int // -bind_port; 0 picks a free one + IdleTimeout time.Duration // -timeout + RemovePorts bool // -remove_ports + Workers int // -workers + Debug bool // -debug +} + +// Phantom is a running phantom subprocess. +type Phantom struct { + BindPort int + Addr string + + cmd *exec.Cmd + out *lockedBuffer + t *testing.T +} + +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// Start launches phantom and blocks until it answers a ping, then registers +// cleanup. Readiness is established by BEHAVIOUR - we ping until we get a pong +// - rather than by scraping logs or sleeping a fixed interval. +func Start(t *testing.T, opts Opts) *Phantom { + t.Helper() + + bin := BuildPhantom(t) + + port := opts.BindPort + if port == 0 { + port = freeUDPPort(t) + } + + // -bind_port is always passed explicitly: phantom's default of 0 makes it + // choose a random port, which the test would have no way to discover. + args := []string{ + "-server", opts.RemoteServer, + "-bind", "127.0.0.1", + "-bind_port", strconv.Itoa(port), + } + if opts.IdleTimeout > 0 { + args = append(args, "-timeout", strconv.Itoa(int(opts.IdleTimeout.Seconds()))) + } + if opts.RemovePorts { + args = append(args, "-remove_ports") + } + if opts.Workers > 0 { + args = append(args, "-workers", strconv.Itoa(opts.Workers)) + } + if opts.Debug { + args = append(args, "-debug") + } + + out := &lockedBuffer{} + cmd := exec.Command(bin, args...) + cmd.Stdout = out + cmd.Stderr = out + + if err := cmd.Start(); err != nil { + t.Fatalf("starting phantom: %v", err) + } + + p := &Phantom{ + BindPort: port, + Addr: fmt.Sprintf("127.0.0.1:%d", port), + cmd: cmd, + out: out, + t: t, + } + t.Cleanup(p.Close) + + if err := p.waitReady(); err != nil { + t.Fatalf("phantom did not become ready: %v\n--- phantom output ---\n%s", err, out.String()) + } + return p +} + +// waitReady pings until phantom proxies a pong back. +func (p *Phantom) waitReady() error { + deadline := time.Now().Add(ReadyTimeout) + var lastErr error + + for time.Now().Before(deadline) { + if p.cmd.ProcessState != nil && p.cmd.ProcessState.Exited() { + return fmt.Errorf("phantom exited early with %v", p.cmd.ProcessState) + } + if _, err := p.Ping(); err == nil { + return nil + } else { + lastErr = err + } + time.Sleep(pollInterval) + } + return fmt.Errorf("no pong within %v (last error: %v)", ReadyTimeout, lastErr) +} + +// Ping sends an Unconnected Ping to phantom and returns the pong. +func (p *Phantom) Ping() ([]byte, error) { + return p.PingWith(corpus.PingID, DefaultPingTime()) +} + +// PingWith sends a ping with a specific packet id and ping time, which is how +// the 0x02 and ping-time-echo invariants are exercised. +func (p *Phantom) PingWith(packetID byte, pingTime []byte) ([]byte, error) { + c, err := NewClient(p.Addr) + if err != nil { + return nil, err + } + defer c.Close() + return c.Ping(packetID, pingTime) +} + +// Output returns everything phantom has logged so far. +func (p *Phantom) Output() string { return p.out.String() } + +// Close terminates phantom, escalating to SIGKILL if it will not exit. +func (p *Phantom) Close() { + if p.cmd.Process == nil { + return + } + _ = p.cmd.Process.Signal(os.Interrupt) + + done := make(chan struct{}) + go func() { + _ = p.cmd.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(ShutdownGrace): + _ = p.cmd.Process.Kill() + <-done + } +} + +// Client is a minimal RakNet-speaking UDP client. +type Client struct { + conn *net.UDPConn +} + +// NewClient dials a UDP socket at addr. +func NewClient(addr string) (*Client, error) { + raddr, err := net.ResolveUDPAddr("udp4", addr) + if err != nil { + return nil, err + } + conn, err := net.DialUDP("udp4", nil, raddr) + if err != nil { + return nil, err + } + return &Client{conn: conn}, nil +} + +// DefaultPingTime is an arbitrary fixed ping time; servers must echo it back. +func DefaultPingTime() []byte { + return []byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77} +} + +// BuildPing assembles an Unconnected Ping datagram. +func BuildPing(packetID byte, pingTime []byte) []byte { + out := make([]byte, 0, 33) + out = append(out, packetID) + out = append(out, pingTime...) + out = append(out, corpus.Magic...) + guid := make([]byte, 8) + binary.BigEndian.PutUint64(guid, 0x8877665544332211) + return append(out, guid...) +} + +// Ping sends an offline ping and waits for a reply. +func (c *Client) Ping(packetID byte, pingTime []byte) ([]byte, error) { + if err := c.Send(BuildPing(packetID, pingTime)); err != nil { + return nil, err + } + return c.Recv(PingTimeout) +} + +// Send writes a datagram. +func (c *Client) Send(data []byte) error { + _, err := c.conn.Write(data) + return err +} + +// Recv waits for a datagram. +func (c *Client) Recv(timeout time.Duration) ([]byte, error) { + if err := c.conn.SetReadDeadline(time.Now().Add(timeout)); err != nil { + return nil, err + } + buf := make([]byte, 4096) + n, err := c.conn.Read(buf) + if err != nil { + return nil, err + } + return buf[:n], nil +} + +// LocalAddr is the client's source address. +func (c *Client) LocalAddr() net.Addr { return c.conn.LocalAddr() } + +// Close releases the socket. +func (c *Client) Close() error { return c.conn.Close() } + +// freeUDPPort asks the kernel for an unused UDP port. +func freeUDPPort(t *testing.T) int { + t.Helper() + + addr, err := net.ResolveUDPAddr("udp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("resolving: %v", err) + } + conn, err := net.ListenUDP("udp4", addr) + if err != nil { + t.Fatalf("finding a free port: %v", err) + } + defer conn.Close() + return conn.LocalAddr().(*net.UDPAddr).Port +} diff --git a/test/compat/matrix.json b/test/compat/matrix.json new file mode 100644 index 0000000..9a1ffc2 --- /dev/null +++ b/test/compat/matrix.json @@ -0,0 +1,31 @@ +{ + "_comment": [ + "T1 version matrix: which Minecraft versions the live end-to-end tier exercises,", + "and how they are distributed across CI shards.", + "", + "This is NOT the full version list. T0 sweeps all 51 supported versions from", + "internal/corpus/data/captured.json in under a second; booting a real client and", + "server stack per version is far too slow for that, so T1 samples: the oldest", + "supported version, the newest, and the newest of each minor line in between.", + "", + "Sharding exists for isolation, not just speed. phantom binds UDP :19132", + "unconditionally and sets SO_REUSEPORT, so two concurrent instances on one host", + "load-balance each other's datagrams instead of failing cleanly. One shard per", + "CI runner gives each its own network stack. Within a shard, cases run serially.", + "", + "Adding a version: add it here with a shard, and keep the shards balanced.", + "The nightly matrix-drift job flags versions bedrock-protocol supports that", + "this file does not." + ], + "shards": 4, + "versions": [ + { "mc": "1.16.201", "protocol": 422, "raknet": 10, "shard": 0, "note": "oldest supported by bedrock-protocol" }, + { "mc": "1.16.220", "protocol": 431, "raknet": 10, "shard": 0 }, + { "mc": "1.17.40", "protocol": 471, "raknet": 10, "shard": 1 }, + { "mc": "1.18.30", "protocol": 503, "raknet": 10, "shard": 1 }, + { "mc": "1.19.80", "protocol": 582, "raknet": 11, "shard": 2 }, + { "mc": "1.20.80", "protocol": 671, "raknet": 11, "shard": 2 }, + { "mc": "1.21.130", "protocol": 898, "raknet": 11, "shard": 3 }, + { "mc": "1.26.30", "protocol": 1001, "raknet": 11, "shard": 3, "note": "newest supported" } + ] +} diff --git a/test/compat/node/connect.js b/test/compat/node/connect.js new file mode 100644 index 0000000..e2c5a03 --- /dev/null +++ b/test/compat/node/connect.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// +// Drives a real bedrock-protocol client through phantom and reports how far the +// session got. Success is defined as reaching 'join', which means the RakNet +// handshake (OpenConnectionRequest 1/2, MTU negotiation) AND the login sequence +// both completed - the entire opaque path phantom relays without parsing, and +// all of which break if it mangles, truncates or reorders datagrams. +// +// Deliberately NOT waiting for 'spawn': a client only spawns once the server +// sends start_game and chunk data, which bedrock-protocol's createServer does +// not do on its own. Implementing a mini game server would test game-layer +// semantics that phantom is explicitly opaque to (see DESIGN.md non-goals), +// while adding a large, version-fragile surface that could not distinguish a +// phantom bug from our own incomplete server. +// +// Usage: node connect.js --host 127.0.0.1 --port --version +// Output: {"ok":true,"joined":true} or {"ok":false,"error":"...","stage":"..."} + +const bp = require('bedrock-protocol') + +function arg (name, fallback) { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : fallback +} + +const host = arg('host', '127.0.0.1') +const port = parseInt(arg('port', '19132'), 10) +const version = arg('version', '1.21.0') +const timeoutMs = parseInt(arg('timeout', '30000'), 10) + +let stage = 'connecting' +let done = false + +function finish (obj, code) { + if (done) return + done = true + process.stdout.write(JSON.stringify(obj) + '\n') + process.exit(code) +} + +const timer = setTimeout( + () => finish({ ok: false, error: 'timed out', stage }, 1), + timeoutMs +) + +let client +try { + client = bp.createClient({ + host, + port, + version, + offline: true, + username: 'CompatBot', + skipPing: true + }) +} catch (err) { + clearTimeout(timer) + finish({ ok: false, error: err.message, stage: 'createClient' }, 1) +} + +// Track progress so a timeout reports HOW FAR the session got - "timed out at +// join" and "timed out at connect" point at very different bugs. +for (const ev of ['connect', 'login', 'status']) { + client.on(ev, () => { stage = ev }) +} + +client.on('join', () => { + stage = 'join' + clearTimeout(timer) + try { client.close() } catch (_) {} + finish({ ok: true, joined: true, stage, version }, 0) +}) + +client.on('error', err => { + clearTimeout(timer) + finish({ ok: false, error: String(err && err.message || err), stage }, 1) +}) + +client.on('kick', reason => { + clearTimeout(timer) + finish({ ok: false, error: 'kicked: ' + JSON.stringify(reason), stage }, 1) +}) diff --git a/test/compat/node/package-lock.json b/test/compat/node/package-lock.json new file mode 100644 index 0000000..4a76429 --- /dev/null +++ b/test/compat/node/package-lock.json @@ -0,0 +1,1565 @@ +{ + "name": "phantom-compat-node", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "phantom-compat-node", + "version": "1.0.0", + "dependencies": { + "bedrock-protocol": "3.57.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz", + "integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@jsprismarine/jsbinaryutils": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@jsprismarine/jsbinaryutils/-/jsbinaryutils-2.1.4.tgz", + "integrity": "sha512-LFMbAJsHEiq5UJwPzrHYZbCkZ/LZEhV76SYXPDjcQLosqVwcf0yyyr+ICxT4j/d4xPH7vDSvWCqOuFRsSv9wYw==", + "license": "ISC" + }, + "node_modules/@xboxreplay/xboxlive-auth": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@xboxreplay/xboxlive-auth/-/xboxlive-auth-5.1.0.tgz", + "integrity": "sha512-UngHHsehZbiTjyyNmo8HvdoUDKMID1U9uVfrpFWUK/2UxPuVTKy5n+CzZQ3S488sW5vOhgh0lHqqynT8ouwgvw==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz", + "integrity": "sha512-iFY7JCgHbepc0b82yLaw4IMortylNb6wG4kL+4R0C3iv6i+RHGHux/yUX5BTiRvSX/shMnngjR1YyNMnXEFh5A==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.0.6.tgz", + "integrity": "sha512-Zfw6bteqM9gQXZ1BIWOgM8xEwMrUGoyL8nW13+O+OOgNX3YhuDN1GDgg1NzdTlmm3j+9sHy7uBZ12r+z9lXnZQ==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.0 || ^1.1.13" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/are-we-there-yet/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/are-we-there-yet/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bedrock-protocol": { + "version": "3.57.0", + "resolved": "https://registry.npmjs.org/bedrock-protocol/-/bedrock-protocol-3.57.0.tgz", + "integrity": "sha512-+4YyRvDD4Wf5g8ZInxjJRoVySDJBQKy0atHBGcQqmrwPJ4DQWrlj3DavQTt78cw3iPZMTQ3ZgKvWMGfnv+jilg==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "jsonwebtoken": "^9.0.0", + "jsp-raknet": "^2.1.3", + "minecraft-data": "^3.0.0", + "minecraft-folder-path": "^1.2.0", + "prismarine-auth": "^3.0.0", + "prismarine-nbt": "^2.0.0", + "prismarine-realms": "^1.6.0", + "protodef": "^1.14.0", + "raknet-native": "^1.0.3", + "uuid-1345": "^1.0.2" + }, + "optionalDependencies": { + "raknet-node": "^0.5.0" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffer-shims": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", + "integrity": "sha512-Zy8ZXMyxIT6RMTeY7OP/bDndfj6bwCan7SS98CEndS6deHwWPpseeHlwarNcBim+etXnF9HBc1non5JgDaJU1g==", + "license": "MIT" + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha512-0yayqDxWQbqk3ojkYqUKqaAQ6AfNKeKWRNA8kR0WXzAsdHpP4BIaOmMAG87JGuO6qcobyW4GjxHd9PmhEd+T9w==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + } + }, + "node_modules/cmake-js": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-6.3.2.tgz", + "integrity": "sha512-7MfiQ/ijzeE2kO+WFB9bv4QP5Dn2yVaAP2acFJr4NIFy2hT4w6O4EpOTLNcohR5IPX7M4wNf/5taIqMj7UA9ug==", + "license": "MIT", + "dependencies": { + "axios": "^0.21.1", + "bluebird": "^3", + "debug": "^4", + "fs-extra": "^5.0.0", + "is-iojs": "^1.0.1", + "lodash": "^4", + "memory-stream": "0", + "npmlog": "^1.2.0", + "rc": "^1.2.7", + "semver": "^5.0.3", + "splitargs": "0", + "tar": "^4", + "unzipper": "^0.8.13", + "url-join": "0", + "which": "^1.0.9", + "yargs": "^3.6.0" + }, + "bin": { + "cmake-js": "bin/cmake-js" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cmake-js/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-5.0.0.tgz", + "integrity": "sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "node_modules/fs-minipass": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", + "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", + "license": "ISC", + "dependencies": { + "minipass": "^2.6.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/gauge": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-1.2.7.tgz", + "integrity": "sha512-fVbU2wRE91yDvKUnrIaQlHKAWKY5e08PmztCrwuH5YVQ+Z/p3d0ny2T48o6uvAAXHIUnfaQdHkmxYbQft1eHVA==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "ansi": "^0.3.0", + "has-unicode": "^2.0.0", + "lodash.pad": "^4.1.0", + "lodash.padend": "^4.1.0", + "lodash.padstart": "^4.1.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha512-xgs2NH9AE66ucSq4cNG1nhSFghr5l6tdL15Pk+jl46bmmBapgoaY/AacXyaDznAqmGL99TiLSQgO/XazFSKYeQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "license": "MIT", + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-iojs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-iojs/-/is-iojs-1.1.0.tgz", + "integrity": "sha512-tLn1j3wYSL6DkvEI+V/j0pKohpa5jk+ER74v6S4SgCXnjS0WA+DoZbwZBrrhgwksMvtuwndyGeG5F8YMsoBzSA==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsp-raknet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jsp-raknet/-/jsp-raknet-2.2.0.tgz", + "integrity": "sha512-Ji2wZmI84abcgzSnnbdtMSW02yZ/Ui3JI3N69BsyTcWHWsbeWMRHKpzN72cCQM8W9pvTIfLYml9sS9xaC2MJSA==", + "license": "ISC", + "dependencies": { + "@jsprismarine/jsbinaryutils": "2.1.4", + "debug": "^4.3.1", + "typescript": "^4.2.2" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha512-YiGkH6EnGrDGqLMITnGjXtGmNtjoXw9SVUzcaos8RBi7Ps0VBylkq+vOcY9QE5poLasPCR849ucFUkl0UzUyOw==", + "license": "MIT", + "dependencies": { + "invert-kv": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.pad": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/lodash.pad/-/lodash.pad-4.5.1.tgz", + "integrity": "sha512-mvUHifnLqM+03YNzeTBS1/Gr6JRFjd3rRx88FHWUvamVaT9k2O/kXha3yBSOwB9/DTQrSTLJNHvLBBt2FdX7Mg==", + "license": "MIT" + }, + "node_modules/lodash.padend": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", + "integrity": "sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw==", + "license": "MIT" + }, + "node_modules/lodash.padstart": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz", + "integrity": "sha512-sW73O6S8+Tg66eY56DBk85aQzzUJDtpoXFBgELMd5P/SotAguo+1kYO6RuYgXxA4HJH3LFTFPASX6ET6bjfriw==", + "license": "MIT" + }, + "node_modules/lodash.reduce": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==", + "license": "MIT" + }, + "node_modules/macaddress": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/macaddress/-/macaddress-0.5.4.tgz", + "integrity": "sha512-i8xVWoUjj2woYU8kbpQby86Kq7uF7xl2brtKREXUBWpfgqx1fKXEeYzDiVMVxA/IufC1d3xxwJRHtFCX+9IspA==", + "license": "MIT" + }, + "node_modules/memory-stream": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/memory-stream/-/memory-stream-0.0.3.tgz", + "integrity": "sha512-q0D3m846qY6ZkIt+19ZemU5vH56lpOZZwoJc3AICARKh/menBuayQUjAGPrqtHQQMUYERSdOrej92J9kz7LgYA==", + "license": "MMIT", + "dependencies": { + "readable-stream": "~1.0.26-2" + } + }, + "node_modules/memory-stream/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/memory-stream/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/memory-stream/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/minecraft-data": { + "version": "3.111.0", + "resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.111.0.tgz", + "integrity": "sha512-0kKHqNZL/D1IbH7tJXBJ3z7YSn1/ylnWWR7X2zFwtEV+yr23nimItpGjP3o8UaLYrC+OV1gKLFQoew03XvJyoA==", + "license": "MIT" + }, + "node_modules/minecraft-folder-path": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minecraft-folder-path/-/minecraft-folder-path-1.2.0.tgz", + "integrity": "sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "license": "ISC", + "dependencies": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "node_modules/minizlib": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", + "license": "MIT", + "dependencies": { + "minipass": "^2.9.0" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/npmlog": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-1.2.1.tgz", + "integrity": "sha512-1J5KqSRvESP6XbjPaXt2H6qDzgizLTM7x0y1cXIjP2PpvdCqyNC7TO3cPRKsuYlElbi/DwkzRRdG2zpmE0IktQ==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "ansi": "~0.3.0", + "are-we-there-yet": "~1.0.0", + "gauge": "~1.2.0" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/os-locale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha512-PRT7ZORmwu2MEFt4/fv3Q+mEfN4zetKxufQrkShY2oGvUms9r8otu5HfdyIFHkYXjO7laNsoVGmM2MANfuTA8g==", + "license": "MIT", + "dependencies": { + "lcid": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prismarine-auth": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/prismarine-auth/-/prismarine-auth-3.1.1.tgz", + "integrity": "sha512-NuNrMGZdoigFKsvi1ZZgAEvNYNuE5qe6lo/tw+bqeNbkhpjHC0u1JNxLEujnfqduXI18e19PvUtWNMDl/gH7yw==", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^2.0.2", + "@xboxreplay/xboxlive-auth": "^5.1.0", + "debug": "^4.3.3", + "smart-buffer": "^4.1.0", + "uuid-1345": "^1.0.2" + } + }, + "node_modules/prismarine-nbt": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/prismarine-nbt/-/prismarine-nbt-2.8.0.tgz", + "integrity": "sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==", + "license": "MIT", + "dependencies": { + "protodef": "^1.18.0" + } + }, + "node_modules/prismarine-realms": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/prismarine-realms/-/prismarine-realms-1.6.0.tgz", + "integrity": "sha512-AwemW0vwxG9hQaFtg1twSV7eymB6QtYxGK0jjpxfdA2sdK15kU8jh8uD1o5XF0oxSMU+BbpzZMCmXtXq4QE6bw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.3", + "node-fetch": "^2.6.1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/protodef": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/protodef/-/protodef-1.19.0.tgz", + "integrity": "sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==", + "license": "MIT", + "dependencies": { + "lodash.reduce": "^4.6.0", + "protodef-validator": "^1.3.0", + "readable-stream": "^4.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/protodef-validator": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/protodef-validator/-/protodef-validator-1.4.0.tgz", + "integrity": "sha512-2y2coBolqCEuk5Kc3QwO7ThR+/7TZiOit4FrpAgl+vFMvq8w76nDhh09z08e2NQOdrgPLsN2yzXsvRvtADgUZQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.5.4" + }, + "bin": { + "protodef-validator": "cli.js" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/raknet-native": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/raknet-native/-/raknet-native-1.2.3.tgz", + "integrity": "sha512-iXqQc/2298LJHQAyoYDdreZ9E4z+4d1y8lxp+EKno1r/ooIer83ShayCDsjpjwRHc9RiMdJz6fLbgBf2hLeTzw==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "bindings": "^1.5.0", + "cmake-js": "^6.1.0", + "node-addon-api": "^3.1.0" + } + }, + "node_modules/raknet-node": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/raknet-node/-/raknet-node-0.5.0.tgz", + "integrity": "sha512-xvWsBwUu/UIlsMDN1sm5FX7iCIufuLakoCtlBer4+B4XeWLQFkEiRmwSaon0pKpBc8HKZcyU3S2KjvzYkGi0WA==", + "license": "ISC", + "optional": true, + "dependencies": { + "sha1": "^1.1.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/sha1": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/sha1/-/sha1-1.1.1.tgz", + "integrity": "sha512-dZBS6OrMjtgVkopB1Gmo4RQCDKiZsqcpAQpkV/aaj+FCrCg8r4I4qMkDPQjBgLIxlmu9k4nUbWq6ohXahOneYA==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "charenc": ">= 0.0.1", + "crypt": ">= 0.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/splitargs": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/splitargs/-/splitargs-0.0.7.tgz", + "integrity": "sha512-UUFYD2oWbNwULH6WoVtLUOw8ch586B+HUqcsAjjjeoBQAM1bD4wZRXu01koaxyd8UeYpybWqW4h+lO1Okv40Tg==", + "license": "ISC" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "license": "MIT", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar": { + "version": "4.4.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz", + "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^1.1.4", + "fs-minipass": "^1.2.7", + "minipass": "^2.9.0", + "minizlib": "^1.3.3", + "mkdirp": "^0.5.5", + "safe-buffer": "^5.2.1", + "yallist": "^3.1.1" + }, + "engines": { + "node": ">=4.5" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.8.14.tgz", + "integrity": "sha512-8rFtE7EP5ssOwGpN2dt1Q4njl0N1hUXJ7sSPz0leU2hRdq6+pra57z4YPBlVqm40vcgv6ooKZEAx48fMTv9x4w==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "~1.0.10", + "listenercount": "~1.0.1", + "readable-stream": "~2.1.5", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha512-yN0WQmuCX63LP/TMvAg31nvT6m4vDqJEiiv2CAZqWOGNWutc9DfDk1NPYYmKUFmaVM2UwDowH4u5AHWYP/jxKw==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz", + "integrity": "sha512-NkXT2AER7VKXeXtJNSaWLpWIhmtSE3K2PguaLEeWr4JILghcIKqoLt1A3wHrnpDC5+ekf8gfk1GKWkFXe4odMw==", + "license": "MIT", + "dependencies": { + "buffer-shims": "^1.0.0", + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~0.10.x", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-join": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-0.0.1.tgz", + "integrity": "sha512-H6dnQ/yPAAVzMQRvEvyz01hhfQL5qRWSEt7BX8t9DqnPw9BjMb64fjIRq76Uvf1hkHp+mTZvEVJ5guXOT0Xqaw==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/uuid-1345": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uuid-1345/-/uuid-1345-1.0.2.tgz", + "integrity": "sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==", + "license": "MIT", + "dependencies": { + "macaddress": "^0.5.1" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/window-size": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz", + "integrity": "sha512-2thx4pB0cV3h+Bw7QmMXcEbdmOzv9t0HFplJH/Lz6yu60hXYy5RT8rUu+wlIreVxWsGN20mo+MHeCSfUpQBwPw==", + "license": "MIT", + "bin": { + "window-size": "cli.js" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==", + "license": "MIT", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", + "integrity": "sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz", + "integrity": "sha512-ONJZiimStfZzhKamYvR/xvmgW3uEkAUFSP91y2caTEPhzF6uP2JfPiVZcq66b/YR0C3uitxSV7+T1x8p5bkmMg==", + "license": "MIT", + "dependencies": { + "camelcase": "^2.0.1", + "cliui": "^3.0.3", + "decamelize": "^1.1.1", + "os-locale": "^1.4.0", + "string-width": "^1.0.1", + "window-size": "^0.1.4", + "y18n": "^3.2.0" + } + } + } +} diff --git a/test/compat/node/package.json b/test/compat/node/package.json new file mode 100644 index 0000000..f7f2a7c --- /dev/null +++ b/test/compat/node/package.json @@ -0,0 +1,9 @@ +{ + "name": "phantom-compat-node", + "private": true, + "version": "1.0.0", + "description": "Thin bedrock-protocol CLIs driven by the Go compatibility harness", + "dependencies": { + "bedrock-protocol": "3.57.0" + } +} diff --git a/test/compat/node/ping.js b/test/compat/node/ping.js new file mode 100644 index 0000000..e7b0c84 --- /dev/null +++ b/test/compat/node/ping.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// +// Pings a host with bedrock-protocol's OWN advertisement parser and prints the +// parsed result as JSON. +// +// This is the point of involving Node at all: phantom's rewritten pong gets +// judged by a genuine third-party client parser rather than by phantom's own +// (buggy) one, so a malformed rewrite fails loudly instead of round-tripping +// through the same mistake twice. +// +// Usage: node ping.js --host 127.0.0.1 --port +// Output: {"ok":true,"advertisement":{...}} or {"ok":false,"error":"..."} + +const bp = require('bedrock-protocol') + +function arg (name, fallback) { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : fallback +} + +const host = arg('host', '127.0.0.1') +const port = parseInt(arg('port', '19132'), 10) +const timeoutMs = parseInt(arg('timeout', '5000'), 10) + +const timer = setTimeout(() => { + process.stdout.write(JSON.stringify({ ok: false, error: 'ping timed out' }) + '\n') + process.exit(1) +}, timeoutMs) + +bp.ping({ host, port }) + .then(res => { + clearTimeout(timer) + process.stdout.write(JSON.stringify({ ok: true, advertisement: res }) + '\n') + process.exit(0) + }) + .catch(err => { + clearTimeout(timer) + process.stdout.write(JSON.stringify({ + ok: false, + error: String(err && err.message || err) + }) + '\n') + process.exit(1) + }) diff --git a/test/compat/node/serve.js b/test/compat/node/serve.js new file mode 100644 index 0000000..2854ee7 --- /dev/null +++ b/test/compat/node/serve.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// +// Boots a real bedrock-protocol server and holds it open until killed. +// +// Prints one line of JSON to stdout when listening: {"ready":true,"port":N} +// The Go harness waits for that line, then points phantom at this port. +// +// Usage: node serve.js --port --version + +const bp = require('bedrock-protocol') + +function arg (name, fallback) { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : fallback +} + +const port = parseInt(arg('port', '19132'), 10) +const version = arg('version', '1.21.0') + +function emit (obj) { + process.stdout.write(JSON.stringify(obj) + '\n') +} + +let server +try { + server = bp.createServer({ + host: '127.0.0.1', + port, + version, + offline: true, + motd: { motd: 'Compat Upstream', levelName: 'compat' } + }) +} catch (err) { + emit({ ready: false, error: err.message }) + process.exit(1) +} + +server.on('error', err => { + emit({ ready: false, error: String(err && err.message || err) }) +}) + +// bedrock-protocol has no single reliable "listening" event across every +// version it supports, so settle briefly and then declare readiness. The Go +// side does not trust this anyway - it polls with a real ping until it gets a +// pong, which is the only readiness signal that actually means anything. +setTimeout(() => emit({ ready: true, port, version }), 1000) + +for (const sig of ['SIGINT', 'SIGTERM']) { + process.on(sig, () => { + try { server.close() } catch (_) {} + process.exit(0) + }) +} diff --git a/test/compat/node_test.go b/test/compat/node_test.go new file mode 100644 index 0000000..2723652 --- /dev/null +++ b/test/compat/node_test.go @@ -0,0 +1,329 @@ +//go:build e2e && node + +package compat + +// T1 - real client stack, driven through phantom. +// +// The fake upstream in fakeserver/ gives byte-exact control but is, by +// construction, only as correct as our own understanding of RakNet. These tests +// close that gap by putting a genuine third-party implementation on both ends: +// bedrock-protocol serves, and bedrock-protocol pings and connects. Its parser +// judges phantom's rewritten pong, so a malformed rewrite fails loudly instead +// of round-tripping through the same misunderstanding twice. +// +// Run with: go test -tags='e2e node' ./test/compat/... +// +// The `node` tag is separate from `e2e` so the rest of T1 runs on machines with +// no Node toolchain. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// Matrix mirrors matrix.json. +type Matrix struct { + Shards int `json:"shards"` + Versions []MatrixVersion `json:"versions"` +} + +// MatrixVersion is one Minecraft version the live tier exercises. +type MatrixVersion struct { + MC string `json:"mc"` + Protocol int `json:"protocol"` + RakNet int `json:"raknet"` + Shard int `json:"shard"` + Note string `json:"note"` +} + +// LoadMatrix reads the committed T1 version matrix, honouring PHANTOM_SHARD so +// CI can split it across runners. +func LoadMatrix(t *testing.T) Matrix { + t.Helper() + + root, err := repoRoot() + if err != nil { + t.Fatalf("locating repo root: %v", err) + } + raw, err := os.ReadFile(filepath.Join(root, "test", "compat", "matrix.json")) + if err != nil { + t.Fatalf("reading matrix.json: %v", err) + } + + var m Matrix + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("parsing matrix.json: %v", err) + } + + if s := os.Getenv("PHANTOM_SHARD"); s != "" { + shard, err := strconv.Atoi(s) + if err != nil { + t.Fatalf("PHANTOM_SHARD=%q is not a number", s) + } + var kept []MatrixVersion + for _, v := range m.Versions { + if v.Shard == shard { + kept = append(kept, v) + } + } + m.Versions = kept + t.Logf("shard %d: %d version(s)", shard, len(kept)) + } + + if len(m.Versions) == 0 { + t.Skip("no versions selected for this shard") + } + return m +} + +// nodeDir is test/compat/node. +func nodeDir(t *testing.T) string { + t.Helper() + root, err := repoRoot() + if err != nil { + t.Fatalf("locating repo root: %v", err) + } + return filepath.Join(root, "test", "compat", "node") +} + +// requireNode skips unless the Node toolchain and installed deps are present. +func requireNode(t *testing.T) { + t.Helper() + + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node is not installed") + } + if _, err := os.Stat(filepath.Join(nodeDir(t), "node_modules")); err != nil { + t.Skipf("test/compat/node/node_modules is missing - run `npm ci` in %s", nodeDir(t)) + } +} + +// bedrockServer is a bedrock-protocol server subprocess. +type bedrockServer struct { + Port int + Addr string + cmd *exec.Cmd + out *lockedBuffer +} + +// startBedrockServer boots a real server for the given version. Readiness is +// confirmed by pinging it until it answers, not by trusting its ready line. +func startBedrockServer(t *testing.T, version string) *bedrockServer { + t.Helper() + + port := freeUDPPort(t) + out := &lockedBuffer{} + + cmd := exec.Command("node", "serve.js", + "--port", strconv.Itoa(port), + "--version", version) + cmd.Dir = nodeDir(t) + cmd.Stdout = out + cmd.Stderr = out + + if err := cmd.Start(); err != nil { + t.Fatalf("starting bedrock server: %v", err) + } + + s := &bedrockServer{ + Port: port, + Addr: fmt.Sprintf("127.0.0.1:%d", port), + cmd: cmd, + out: out, + } + t.Cleanup(s.Close) + + deadline := time.Now().Add(ReadyTimeout) + for time.Now().Before(deadline) { + c, err := NewClient(s.Addr) + if err == nil { + _, perr := c.Ping(0x01, DefaultPingTime()) + _ = c.Close() + if perr == nil { + return s + } + } + time.Sleep(pollInterval) + } + + t.Fatalf("bedrock-protocol server (%s) never answered a ping\n--- output ---\n%s", + version, out.String()) + return nil +} + +func (s *bedrockServer) Close() { + if s.cmd.Process == nil { + return + } + _ = s.cmd.Process.Kill() + _ = s.cmd.Wait() +} + +// pingResult is ping.js's JSON output. +type pingResult struct { + OK bool `json:"ok"` + Error string `json:"error"` + Ad struct { + MOTD string `json:"motd"` + LevelName string `json:"levelName"` + PlayersOnline int `json:"playersOnline"` + PlayersMax int `json:"playersMax"` + Gamemode string `json:"gamemode"` + ServerID string `json:"serverId"` + PortV4 int `json:"portV4"` + PortV6 int `json:"portV6"` + Protocol interface{} `json:"protocol"` + Version string `json:"version"` + } `json:"advertisement"` +} + +// connectResult is connect.js's JSON output. +type connectResult struct { + OK bool `json:"ok"` + Joined bool `json:"joined"` + Stage string `json:"stage"` + Error string `json:"error"` + Version string `json:"version"` +} + +// runNode executes one of the thin Node CLIs and decodes its JSON line. +func runNode(t *testing.T, timeout time.Duration, target interface{}, args ...string) error { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "node", args...) + cmd.Dir = nodeDir(t) + + out, err := cmd.Output() + line := lastJSONLine(string(out)) + if line == "" { + return fmt.Errorf("no JSON from `node %s`: %v", strings.Join(args, " "), err) + } + if jerr := json.Unmarshal([]byte(line), target); jerr != nil { + return fmt.Errorf("undecodable output %q: %v", line, jerr) + } + return nil +} + +// lastJSONLine picks the final JSON object from stdout, ignoring any noise the +// native RakNet addon prints. +func lastJSONLine(s string) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + for i := len(lines) - 1; i >= 0; i-- { + l := strings.TrimSpace(lines[i]) + if strings.HasPrefix(l, "{") && strings.HasSuffix(l, "}") { + return l + } + } + return "" +} + +// TestRealClientPingsThroughPhantom is the core cross-version assertion: for +// every version in the matrix, a real bedrock-protocol client must be able to +// PARSE the pong phantom rewrote, and must see the upstream's identity with +// phantom's port substituted. +func TestRealClientPingsThroughPhantom(t *testing.T) { + requireNode(t) + RequirePingPort(t) + + m := LoadMatrix(t) + + for _, v := range m.Versions { + t.Run(v.MC, func(t *testing.T) { + srv := startBedrockServer(t, v.MC) + p := Start(t, Opts{RemoteServer: srv.Addr}) + + var res pingResult + if err := runNode(t, 30*time.Second, &res, + "ping.js", "--host", "127.0.0.1", "--port", strconv.Itoa(p.BindPort)); err != nil { + t.Fatalf("ping.js: %v", err) + } + + if !res.OK { + t.Fatalf("a real client could not parse phantom's pong: %s", res.Error) + } + + // The advertised version must be the upstream's, untouched. + if res.Ad.Version != v.MC { + t.Errorf("client saw version %q, want the upstream's %q", + res.Ad.Version, v.MC) + } + if got := fmt.Sprintf("%v", res.Ad.Protocol); got != strconv.Itoa(v.Protocol) { + t.Errorf("client saw protocol %q, want %d", got, v.Protocol) + } + + // The advertised port must be phantom's, not the upstream's - that + // substitution is the entire reason phantom rewrites the pong. + if res.Ad.PortV4 != p.BindPort { + t.Errorf("client saw port %d, want phantom's bind port %d "+ + "(upstream is on %d)", res.Ad.PortV4, p.BindPort, srv.Port) + } + if res.Ad.ServerID == "" { + t.Error("client saw an empty server id") + } + }) + } +} + +// TestRealClientSessionThroughPhantom drives a full RakNet handshake and login +// through phantom - the opaque path phantom relays without understanding. +// +// It first opens a CONTROL connection straight to the upstream. That baseline +// is what makes the result trustworthy: if the direct connection fails, the +// client stack cannot run here at all and the test skips; only if direct +// succeeds and the proxied one fails is phantom actually at fault. Without the +// control, a broken RakNet backend would look exactly like a phantom bug. +func TestRealClientSessionThroughPhantom(t *testing.T) { + requireNode(t) + RequirePingPort(t) + + m := LoadMatrix(t) + + for _, v := range m.Versions { + t.Run(v.MC, func(t *testing.T) { + srv := startBedrockServer(t, v.MC) + + // Control: straight to the upstream, phantom not involved. + var direct connectResult + if err := runNode(t, 60*time.Second, &direct, + "connect.js", "--host", "127.0.0.1", + "--port", strconv.Itoa(srv.Port), "--version", v.MC); err != nil { + t.Skipf("control connection could not run (%v); "+ + "the bedrock-protocol client stack is unusable in this "+ + "environment, so a proxied failure would be unattributable", err) + } + if !direct.OK { + t.Skipf("control connection FAILED without phantom in the path "+ + "(stage %q: %s).\nThis is an environment or bedrock-protocol "+ + "problem, not a phantom one - skipping rather than reporting a "+ + "false phantom failure.", direct.Stage, direct.Error) + } + + // The control worked, so anything that fails now is phantom's doing. + p := Start(t, Opts{RemoteServer: srv.Addr}) + + var proxied connectResult + if err := runNode(t, 60*time.Second, &proxied, + "connect.js", "--host", "127.0.0.1", + "--port", strconv.Itoa(p.BindPort), "--version", v.MC); err != nil { + t.Fatalf("connect.js through phantom: %v", err) + } + + if !proxied.OK { + t.Errorf("a real client reached %q directly but only %q through phantom: %s\n"+ + "--- phantom output ---\n%s", + direct.Stage, proxied.Stage, proxied.Error, p.Output()) + } + }) + } +} diff --git a/test/compat/slow_test.go b/test/compat/slow_test.go new file mode 100644 index 0000000..aa4d20b --- /dev/null +++ b/test/compat/slow_test.go @@ -0,0 +1,100 @@ +//go:build e2e && slow + +package compat + +// T1 - slow black-box cases. +// +// These are behind an extra `slow` tag because they wait on phantom's real +// timers: the idle sweep runs every 5s and cannot be shortened from outside the +// process. CI includes them; `make test` does not. +// +// Run with: go test -tags='e2e slow' ./test/compat/... + +import ( + "strings" + "testing" + "time" + + "github.com/jhead/phantom/test/compat/fakeserver" +) + +// TestIdleClientIsReaped is invariant 12. phantom sweeps for idle clients every +// 5 seconds, so with -timeout 1 a silent client should be dropped within ~6s. +func TestIdleClientIsReaped(t *testing.T) { + srv, p := startPair(t, + fakeserver.Opts{EchoPayloads: true}, + Opts{IdleTimeout: time.Second}, + ) + + c, err := NewClient(p.Addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + + // Establish a session. + payload := []byte{0x84, 0x01, 0x02, 0x03} + if err := c.Send(payload); err != nil { + t.Fatalf("send: %v", err) + } + if _, err := c.Recv(PingTimeout); err != nil { + t.Fatalf("session was never established: %v", err) + } + + before := len(srv.Received()) + + // Go quiet for longer than the idle timeout plus one sweep interval. + time.Sleep(8 * time.Second) + + // The connection should have been reaped. Sending again must still work - + // phantom is expected to build a fresh upstream connection, not to wedge. + if err := c.Send(payload); err != nil { + t.Fatalf("send after idle: %v", err) + } + if _, err := c.Recv(PingTimeout); err != nil { + t.Fatalf("phantom stopped proxying after reaping an idle client: %v\n"+ + "--- phantom output ---\n%s", err, p.Output()) + } + + if len(srv.Received()) <= before { + t.Error("upstream saw no new traffic after the idle period") + } +} + +// TestUpstreamRecovery is invariant 13: phantom must notice the upstream coming +// back and resume proxying without a restart. +func TestUpstreamRecovery(t *testing.T) { + const motd = "MCPE;Recovery;800;1.21.80;0;10;123;Sub;Creative;1;19132;19133;0;" + + srv, p := startPair(t, fakeserver.Opts{MOTD: motd}, Opts{}) + + // 1. Healthy: the upstream's MOTD reaches the client. + pong, err := p.Ping() + if err != nil { + t.Fatalf("initial ping: %v", err) + } + if got := motdOf(t, pong); !strings.Contains(got, "Recovery") { + t.Fatalf("expected the upstream MOTD, got %q", got) + } + + // 2. Upstream goes dark: phantom should fall back to its offline pong. + srv.Stop() + if waitForOfflinePong(t, p, 0x01, DefaultPingTime()) == nil { + t.Fatalf("phantom never fell back to an offline pong\n--- output ---\n%s", p.Output()) + } + + // 3. Upstream returns: phantom must resume proxying the real MOTD. + srv.Resume() + + deadline := time.Now().Add(ReadyTimeout) + for time.Now().Before(deadline) { + pong, err := p.Ping() + if err == nil && strings.Contains(motdOf(t, pong), "Recovery") { + return + } + time.Sleep(pollInterval) + } + + t.Fatalf("phantom did not resume proxying after the upstream returned\n"+ + "--- output ---\n%s", p.Output()) +} From 91a6689060d901d3d95a799b0135e683c36bf853 Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:33:14 -0700 Subject: [PATCH 09/18] Fix false OfflinePong after a single UDP read timeout. One flaky i/o timeout was marking the whole proxy offline, so LAN clients got MaxPlayers=0 OfflinePong unless another peer's traffic cleared the flag (#104). Require sustained timeouts, and give Offline pongs placeholder ports so rewrite injects the bind port. Co-authored-by: Cursor --- internal/proto/proto.go | 5 + internal/proxy/offline_detection_test.go | 148 +++++++++++++++++++++++ internal/proxy/proxy.go | 87 ++++++++++--- 3 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 internal/proxy/offline_detection_test.go diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 3fce0f1..4a06e72 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -48,6 +48,11 @@ var OfflinePong = UnconnectedPing{ MaxPlayers: "0", GameType: "Creative", NintendoLimited: "1", + // Placeholder ports so rewriteUnconnectedPong overwrites them with + // phantom's bind port. Empty ports make clients fall back to :19132, + // whose replies come from the data port and fail the join (#104). + Port4: "0", + Port6: "0", }, }.Build() diff --git a/internal/proxy/offline_detection_test.go b/internal/proxy/offline_detection_test.go new file mode 100644 index 0000000..22b7783 --- /dev/null +++ b/internal/proxy/offline_detection_test.go @@ -0,0 +1,148 @@ +package proxy + +import ( + "fmt" + "net" + "syscall" + "testing" + "time" + + "github.com/jhead/phantom/internal/proto" +) + +func TestIsTimeoutError(t *testing.T) { + t.Parallel() + + if !isTimeoutError(timeoutError{}) { + t.Fatal("expected net.Error timeout") + } + if !isTimeoutError(fmt.Errorf("read udp: i/o timeout")) { + t.Fatal("expected legacy timeout string") + } + if isTimeoutError(syscall.ECONNREFUSED) { + t.Fatal("connection refused is not a timeout") + } + if isTimeoutError(nil) { + t.Fatal("nil should not be a timeout") + } +} + +func TestIsConnRefusedError(t *testing.T) { + t.Parallel() + + if !isConnRefusedError(syscall.ECONNREFUSED) { + t.Fatal("expected syscall.ECONNREFUSED") + } + if !isConnRefusedError(fmt.Errorf("write udp: %w", syscall.ECONNREFUSED)) { + t.Fatal("expected wrapped ECONNREFUSED") + } + if !isConnRefusedError(fmt.Errorf("write: connection refused")) { + t.Fatal("expected legacy connection refused string") + } + if isConnRefusedError(timeoutError{}) { + t.Fatal("timeout is not connection refused") + } +} + +func TestSingleTimeoutDoesNotMarkOffline(t *testing.T) { + t.Parallel() + + p := &ProxyServer{} + p.noteUpstreamReadError(fmt.Errorf("read udp: i/o timeout")) + if p.serverOffline { + t.Fatal("a single timeout must not advertise OfflinePong (#104)") + } + if p.offlineTimeouts != 1 { + t.Fatalf("offlineTimeouts = %d, want 1", p.offlineTimeouts) + } +} + +func TestSustainedTimeoutsMarkOffline(t *testing.T) { + t.Parallel() + + p := &ProxyServer{} + for i := 0; i < offlineTimeoutThreshold-1; i++ { + p.noteUpstreamReadError(timeoutError{}) + if p.serverOffline { + t.Fatalf("marked offline after %d timeouts", i+1) + } + } + p.noteUpstreamReadError(timeoutError{}) + if !p.serverOffline { + t.Fatalf("expected offline after %d timeouts", offlineTimeoutThreshold) + } +} + +func TestConnRefusedMarksOfflineImmediately(t *testing.T) { + t.Parallel() + + p := &ProxyServer{} + p.noteUpstreamReadError(syscall.ECONNREFUSED) + if !p.serverOffline { + t.Fatal("expected connection refused to mark offline") + } +} + +func TestReachableClearsTimeoutStreak(t *testing.T) { + t.Parallel() + + p := &ProxyServer{} + p.noteUpstreamReadError(timeoutError{}) + p.noteUpstreamReadError(timeoutError{}) + p.noteUpstreamReachable() + if p.offlineTimeouts != 0 { + t.Fatalf("offlineTimeouts = %d, want 0 after success", p.offlineTimeouts) + } + p.noteUpstreamReadError(timeoutError{}) + if p.serverOffline { + t.Fatal("streak should have reset; one timeout after success must not mark offline") + } +} + +func TestOfflinePongRewriteInjectsBindPort(t *testing.T) { + t.Parallel() + + p := &ProxyServer{boundPort: 54321} + rewritten := p.rewriteUnconnectedPong(proto.OfflinePong.Bytes()) + packet, err := proto.ReadUnconnectedPing(rewritten) + if err != nil { + t.Fatalf("parse offline pong: %v", err) + } + if packet.Pong.Port4 != "54321" { + t.Fatalf("Port4 = %q, want 54321 (clients must not fall back to :19132)", packet.Pong.Port4) + } + if packet.Pong.Port6 != "54321" { + t.Fatalf("Port6 = %q, want 54321", packet.Pong.Port6) + } +} + +func TestReadDeadlineClassifiesAsTimeout(t *testing.T) { + t.Parallel() + + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Millisecond)) + buf := make([]byte, 16) + _, _, err = conn.ReadFrom(buf) + if err == nil { + t.Fatal("expected read deadline error") + } + if !isTimeoutError(err) { + t.Fatalf("expected deadline error to be timeout, got %v", err) + } + if isConnRefusedError(err) { + t.Fatal("deadline error should not be connection refused") + } +} + +type timeoutError struct{} + +func (timeoutError) Error() string { return "i/o timeout" } +func (timeoutError) Timeout() bool { return true } +func (timeoutError) Temporary() bool { return true } + +var _ net.Error = timeoutError{} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a4ce5b8..8955d60 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -1,10 +1,12 @@ package proxy import ( + "errors" "fmt" "math/rand" "net" - "regexp" + "strings" + "syscall" "time" "github.com/jhead/phantom/internal/clientmap" @@ -17,6 +19,11 @@ import ( const maxMTU = 1472 +// offlineTimeoutThreshold is how many consecutive upstream read timeouts are +// required before advertising OfflinePong on LAN discovery. A single flaky +// timeout must not flip the whole proxy "offline" (#104). +const offlineTimeoutThreshold = 3 + var idleCheckInterval = 5 * time.Second type ProxyServer struct { @@ -30,6 +37,7 @@ type ProxyServer struct { prefs ProxyPrefs dead *abool.AtomicBool serverOffline bool + offlineTimeouts int } type ProxyPrefs struct { @@ -44,7 +52,6 @@ type ProxyPrefs struct { var randSource = rand.NewSource(time.Now().UnixNano()) var serverID = randSource.Int63() -var offlineErrorRegex = regexp.MustCompile("(timeout)|(connection refused)") func New(prefs ProxyPrefs) (*ProxyServer, error) { bindPort := prefs.BindPort @@ -78,6 +85,7 @@ func New(prefs ProxyPrefs) (*ProxyServer, error) { prefs, abool.New(), false, + 0, }, nil } @@ -233,6 +241,65 @@ func (proxy *ProxyServer) processDataFromClients(listener net.PacketConn, packet return err } +func isTimeoutError(err error) bool { + if err == nil { + return false + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return true + } + return strings.Contains(err.Error(), "timeout") +} + +func isConnRefusedError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, syscall.ECONNREFUSED) { + return true + } + return strings.Contains(strings.ToLower(err.Error()), "connection refused") +} + +// noteUpstreamReadError updates offline state from a failed server read. +// Connection refused marks offline immediately. Timeouts only do so after +// offlineTimeoutThreshold consecutive failures so one flaky UDP deadline +// does not broadcast OfflinePong to every LAN client (#104). +func (proxy *ProxyServer) noteUpstreamReadError(err error) { + log.Warn().Msgf("%v", err) + + if isConnRefusedError(err) { + proxy.offlineTimeouts = 0 + proxy.markServerOffline() + return + } + if isTimeoutError(err) { + proxy.offlineTimeouts++ + if proxy.offlineTimeouts >= offlineTimeoutThreshold { + proxy.markServerOffline() + } + return + } +} + +func (proxy *ProxyServer) markServerOffline() { + if proxy.serverOffline { + return + } + log.Warn().Msgf("Server seems to be offline :(") + log.Warn().Msgf("We'll keep trying to connect...") + proxy.serverOffline = true +} + +func (proxy *ProxyServer) noteUpstreamReachable() { + proxy.offlineTimeouts = 0 + if proxy.serverOffline { + log.Info().Msgf("Server is back online!") + proxy.serverOffline = false + } +} + // Proxies packets sent by the server to us for a specific Minecraft client back to // that client's UDP connection. func (proxy *ProxyServer) processDataFromServer(remoteConn *net.UDPConn, client net.Addr) { @@ -247,16 +314,7 @@ func (proxy *ProxyServer) processDataFromServer(remoteConn *net.UDPConn, client // Read error if err != nil { - log.Warn().Msgf("%v", err) - - offlineError := offlineErrorRegex.MatchString(err.Error()) - - if offlineError && !proxy.serverOffline { - log.Warn().Msgf("Server seems to be offline :(") - log.Warn().Msgf("We'll keep trying to connect...") - proxy.serverOffline = true - } - + proxy.noteUpstreamReadError(err) break } @@ -265,10 +323,7 @@ func (proxy *ProxyServer) processDataFromServer(remoteConn *net.UDPConn, client continue } - if proxy.serverOffline { - log.Info().Msgf("Server is back online!") - proxy.serverOffline = false - } + proxy.noteUpstreamReachable() // Resize data to byte count from 'read' data := buffer[:read] From 11c7d7a84aad52ea854be19c563d8d7d51f4eefd Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:34:50 -0700 Subject: [PATCH 10/18] Fix LAN discovery stuck after console disconnect. Unconnected pings no longer reuse gameplay DialUDP sessions, which could stop answering after disconnect while console re-pings kept the stale entry alive. Co-authored-by: Cursor --- internal/clientmap/clientmap.go | 7 ++ internal/proxy/discovery_ping_test.go | 132 ++++++++++++++++++++++++++ internal/proxy/proxy.go | 123 ++++++++++++++++++------ 3 files changed, 231 insertions(+), 31 deletions(-) create mode 100644 internal/proxy/discovery_ping_test.go diff --git a/internal/clientmap/clientmap.go b/internal/clientmap/clientmap.go index a6d5fc2..9c87e8a 100644 --- a/internal/clientmap/clientmap.go +++ b/internal/clientmap/clientmap.go @@ -82,6 +82,13 @@ func (cm *ClientMap) idleCleanupLoop() { } } +// Len returns the number of active client connections. +func (cm *ClientMap) Len() int { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + return len(cm.clients) +} + func (cm *ClientMap) Delete(clientAddr net.Addr) { key := clientAddr.String() diff --git a/internal/proxy/discovery_ping_test.go b/internal/proxy/discovery_ping_test.go new file mode 100644 index 0000000..fb8e18a --- /dev/null +++ b/internal/proxy/discovery_ping_test.go @@ -0,0 +1,132 @@ +package proxy + +import ( + "bytes" + "net" + "testing" + "time" + + "github.com/jhead/phantom/internal/proto" + "github.com/stretchr/testify/require" +) + +// bareUnconnectedPing builds a minimal RakNet Unconnected Ping (0x01). +func bareUnconnectedPing(pingTime [8]byte) []byte { + magic := []byte{0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78} + out := make([]byte, 0, 1+8+8+16) + out = append(out, proto.UnconnectedPingID) + out = append(out, pingTime[:]...) + out = append(out, 0, 0, 0, 0, 0, 0, 0, 0) // client GUID + out = append(out, magic...) + return out +} + +func startFakeBedrockPongServer(t *testing.T) *net.UDPAddr { + t.Helper() + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + go func() { + buf := make([]byte, 2048) + for { + n, addr, err := conn.ReadFromUDP(buf) + if err != nil { + return + } + if n < 9 || buf[0] != proto.UnconnectedPingID { + continue + } + pong := proto.UnconnectedPing{ + PingTime: append([]byte(nil), buf[1:9]...), + ID: []byte{1, 2, 3, 4, 5, 6, 7, 8}, + Magic: []byte{0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78}, + Pong: proto.PongData{ + Edition: "MCPE", + MOTD: "test", + ProtocolVersion: "390", + Version: "1.14.60", + Players: "0", + MaxPlayers: "10", + ServerID: "99", + SubMOTD: "", + GameType: "Survival", + NintendoLimited: "1", + Port4: "19132", + Port6: "19132", + }, + }.Build() + _, _ = conn.WriteToUDP(pong.Bytes(), addr) + } + }() + + return conn.LocalAddr().(*net.UDPAddr) +} + +func TestDiscoveryPingDoesNotReuseStaleSession(t *testing.T) { + remoteAddr := startFakeBedrockPongServer(t) + + // Stale "gameplay" peer: accepts datagrams but never replies with a pong. + staleRemote, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + t.Cleanup(func() { _ = staleRemote.Close() }) + + proxyServer, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 0, + RemoteServer: remoteAddr.String(), + IdleTimeout: time.Minute, + NumWorkers: 1, + }) + require.NoError(t, err) + + dataConn, err := net.ListenUDP("udp", proxyServer.bindAddress) + require.NoError(t, err) + proxyServer.server = dataConn + t.Cleanup(func() { + proxyServer.dead.Set() + _ = dataConn.Close() + proxyServer.clientMap.Close() + }) + + clientConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + t.Cleanup(func() { _ = clientConn.Close() }) + clientAddr := clientConn.LocalAddr().(*net.UDPAddr) + + // Plant a stale gameplay session for this console address. Pre-fix code + // would reuse this DialUDP for LAN pings and hang without a pong. + _, err = proxyServer.clientMap.Get(clientAddr, staleRemote.LocalAddr().(*net.UDPAddr), func(*net.UDPConn) {}) + require.NoError(t, err) + require.Equal(t, 1, proxyServer.clientMap.Len()) + + pingTime := [8]byte{9, 8, 7, 6, 5, 4, 3, 2} + require.NoError(t, proxyServer.handleDiscoveryPing(clientAddr, bareUnconnectedPing(pingTime))) + + _ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 2048) + n, _, err := clientConn.ReadFromUDP(buf) + require.NoError(t, err) + require.True(t, n >= 1) + require.Equal(t, proto.UnconnectedPongID, buf[0]) + require.True(t, bytes.Equal(buf[1:9], pingTime[:]), "pong must echo ping time") + + // Discovery must not consume/replace the gameplay session entry. + require.Equal(t, 1, proxyServer.clientMap.Len()) +} + +func TestBuildOfflinePongEchoesPingTime(t *testing.T) { + proxyServer, err := New(ProxyPrefs{ + BindAddress: "127.0.0.1", + BindPort: 19000, + RemoteServer: "127.0.0.1:19001", + IdleTimeout: time.Minute, + NumWorkers: 1, + }) + require.NoError(t, err) + + pingTime := [8]byte{1, 2, 3, 4, 5, 6, 7, 8} + pong := proxyServer.buildOfflinePong(bareUnconnectedPing(pingTime)) + require.Equal(t, proto.UnconnectedPongID, pong[0]) + require.True(t, bytes.Equal(pong[1:9], pingTime[:])) +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a4ce5b8..ac6425b 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -18,6 +18,7 @@ import ( const maxMTU = 1472 var idleCheckInterval = 5 * time.Second +var discoveryPingTimeout = 5 * time.Second type ProxyServer struct { bindAddress *net.UDPAddr @@ -195,6 +196,16 @@ func (proxy *ProxyServer) processDataFromClients(listener net.PacketConn, packet data := packetBuffer[:read] log.Trace().Msgf("client recv: %v", data) + // LAN discovery must not share the per-client DialUDP session used for + // gameplay. After a console disconnects, that socket is often a stale + // RakNet association: the remote ignores further Unconnected Pings, while + // console re-pings keep refreshing SetReadDeadline + idle lastActive, so + // the session never expires and the server vanishes from LAN until restart + // (GitHub #117). + if data[0] == proto.UnconnectedPingID { + return proxy.handleDiscoveryPing(client, data) + } + // Handler triggered when a new client connects and we create a new connetion to the remote server onNewConnection := func(newServerConn *net.UDPConn) { log.Info().Msgf("New connection from client %s -> %s", client.String(), listener.LocalAddr()) @@ -214,23 +225,88 @@ func (proxy *ProxyServer) processDataFromClients(listener net.PacketConn, packet // Wait 5 seconds for the server to respond to whatever we sent, or else timeout _ = serverConn.SetReadDeadline(time.Now().Add(time.Second * 5)) - if packetID := data[0]; packetID == proto.UnconnectedPingID { - log.Info().Msgf("Received LAN ping from client: %s", client.String()) + // Write packet from client to server + _, err = serverConn.Write(data) + return err +} - if proxy.serverOffline { - replyBuffer := proto.OfflinePong - replyBytes := proxy.rewriteUnconnectedPong(replyBuffer.Bytes()) +// handleDiscoveryPing answers a console/LAN Unconnected Ping using a fresh +// probe to the remote server, independent of any gameplay session. +func (proxy *ProxyServer) handleDiscoveryPing(client net.Addr, ping []byte) error { + log.Info().Msgf("Received LAN ping from client: %s", client.String()) - proxy.server.WriteTo(replyBytes, client) - log.Info().Msgf("Sent server offline pong to client: %v", client.String()) + pong, err := proxy.probeRemoteUnconnectedPong(ping) + if err != nil { + proxy.markServerOffline() + if _, werr := proxy.server.WriteTo(proxy.buildOfflinePong(ping), client); werr != nil { + return werr } + log.Info().Msgf("Sent server offline pong to client: %v", client.String()) + return nil + } - // Pass ping through to server even if it's offline + if proxy.serverOffline { + log.Info().Msgf("Server is back online!") + proxy.serverOffline = false } - // Write packet from client to server - _, err = serverConn.Write(data) - return err + pong = proxy.rewriteUnconnectedPong(pong) + if _, err := proxy.server.WriteTo(pong, client); err != nil { + return err + } + log.Info().Msgf("Sent LAN pong to client: %v", client.String()) + return nil +} + +// probeRemoteUnconnectedPong dials a one-shot UDP socket to the remote and +// waits for an Unconnected Pong. A fresh local port avoids stale RakNet state. +func (proxy *ProxyServer) probeRemoteUnconnectedPong(ping []byte) ([]byte, error) { + conn, err := net.DialUDP("udp", nil, proxy.remoteServerAddress) + if err != nil { + return nil, err + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(discoveryPingTimeout)) + if _, err := conn.Write(ping); err != nil { + return nil, err + } + + buf := make([]byte, maxMTU) + for { + n, err := conn.Read(buf) + if err != nil { + return nil, err + } + if n < 1 { + continue + } + if buf[0] != proto.UnconnectedPongID { + continue + } + out := make([]byte, n) + copy(out, buf[:n]) + return out, nil + } +} + +func (proxy *ProxyServer) markServerOffline() { + if proxy.serverOffline { + return + } + log.Warn().Msgf("Server seems to be offline :(") + log.Warn().Msgf("We'll keep trying to connect...") + proxy.serverOffline = true +} + +// buildOfflinePong returns the canned offline advertisement, echoing the +// client's ping time so consoles can match the response to their request. +func (proxy *ProxyServer) buildOfflinePong(ping []byte) []byte { + reply := append([]byte(nil), proto.OfflinePong.Bytes()...) + if len(ping) >= 9 && len(reply) >= 9 { + copy(reply[1:9], ping[1:9]) + } + return proxy.rewriteUnconnectedPong(reply) } // Proxies packets sent by the server to us for a specific Minecraft client back to @@ -247,16 +323,12 @@ func (proxy *ProxyServer) processDataFromServer(remoteConn *net.UDPConn, client // Read error if err != nil { - log.Warn().Msgf("%v", err) - - offlineError := offlineErrorRegex.MatchString(err.Error()) - - if offlineError && !proxy.serverOffline { - log.Warn().Msgf("Server seems to be offline :(") - log.Warn().Msgf("We'll keep trying to connect...") - proxy.serverOffline = true + // Game-session read errors (including the 5s deadline after a + // console disconnect) are not a reliable "server offline" signal. + // LAN discovery uses a dedicated probe (#117). + if !offlineErrorRegex.MatchString(err.Error()) { + log.Warn().Msgf("%v", err) } - break } @@ -265,21 +337,10 @@ func (proxy *ProxyServer) processDataFromServer(remoteConn *net.UDPConn, client continue } - if proxy.serverOffline { - log.Info().Msgf("Server is back online!") - proxy.serverOffline = false - } - // Resize data to byte count from 'read' data := buffer[:read] log.Trace().Msgf("server recv: %v", data) - // Rewrite Unconnected Pong packets - if packetID := data[0]; packetID == proto.UnconnectedPongID { - data = proxy.rewriteUnconnectedPong(data) - log.Info().Msgf("Sent LAN pong to client: %v", client.String()) - } - proxy.server.WriteTo(data, client) } From 2f4b27da85ab95462af1fece6054eefea1909a49 Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:36:49 -0700 Subject: [PATCH 11/18] Fix iSH: linux/386 target and reuseport EINVAL fallback. iSH needs phantom-linux-x86; SO_REUSEPORT fails with invalid argument so fall back to net.ListenPacket. Co-authored-by: Cursor --- Makefile | 9 +++- README.md | 30 +++++++++++ internal/proxy/proxy.go | 66 +++++++++++++++++------ internal/proxy/reuseport_fallback_test.go | 33 ++++++++++++ 4 files changed, 120 insertions(+), 18 deletions(-) create mode 100644 internal/proxy/reuseport_fallback_test.go diff --git a/Makefile b/Makefile index 9343b14..3b9ef0c 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ SHELL=/bin/bash .PHONY: prep -OUT=bin/phantom-windows.exe bin/phantom-windows-32bit.exe bin/phantom-macos bin/phantom-macos-arm8 bin/phantom-linux bin/phantom-linux-arm5 bin/phantom-linux-arm6 bin/phantom-linux-arm7 bin/phantom-linux-arm8 +# linux-x86 = GOARCH=386 (iSH on iOS is x86 userspace; ARM/amd64 builds fail there) +OUT=bin/phantom-windows.exe bin/phantom-windows-32bit.exe bin/phantom-macos bin/phantom-macos-arm8 bin/phantom-linux bin/phantom-linux-x86 bin/phantom-linux-arm5 bin/phantom-linux-arm6 bin/phantom-linux-arm7 bin/phantom-linux-arm8 CMDSRC=phantom.go build: prep ${OUT} @@ -31,6 +32,12 @@ bin/phantom-linux: GOOS=linux GOARCH=amd64 go build -o ../bin/phantom-linux ${CMDSRC} && \ popd +# 32-bit x86 for iSH (iOS) and other linux/386 environments. +bin/phantom-linux-x86: + pushd cmd && \ + CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -o ../bin/phantom-linux-x86 ${CMDSRC} && \ + popd + bin/phantom-linux-arm5: pushd cmd && \ GOOS=linux GOARCH=arm GOARM=5 go build -o ../bin/phantom-linux-arm5 ${CMDSRC} && \ diff --git a/README.md b/README.md index 3b2dcde..365068b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,24 @@ $ chmod u+x ./phantom- Just replace `` with macos, linux, etc. for the correct OS you're using. +**iSH on iOS** + +iSH provides a 32-bit x86 Linux userspace (`uname -m` is typically `i686` or +`x86_64` under emulation of 32-bit userspace — use the x86 build). Download or +build `phantom-linux-x86`, not the ARM or amd64 Linux binaries: + +```bash +chmod u+x ./phantom-linux-x86 +./phantom-linux-x86 -server example.com:19132 +``` + +Build it yourself with: + +```bash +make bin/phantom-linux-x86 +# or: CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -o bin/phantom-linux-x86 ./cmd +``` + ## Usage Open up a command prompt (Windows) or terminal (macOS & Linux) to the location @@ -138,6 +156,7 @@ computer, a VM, or even with a Minecraft hosting service. ## Supported platforms - This tool should work on Windows, macOS, and Linux. +- A `phantom-linux-x86` (386) build is available for iSH on iOS. - ARM builds are available for Raspberry Pi and similar SOCs. - Minecraft for Windows 10, iOS/Android, Xbox One, and PS4 are currently supported. - **Nintendo Switch is not supported.** @@ -148,6 +167,17 @@ your Windows Firewall settings and open up all UDP ports for phantom. ## Troubleshooting +**`syntax error` / `unexpected ")"` when starting phantom in iSH** + +You downloaded the wrong architecture. iSH needs `phantom-linux-x86` (linux/386). +ARM and amd64 Linux binaries look like garbage to the shell and produce syntax +errors. See Installing → iSH on iOS. + +**`listen udp4 :19132: invalid argument` on iSH** + +Older builds required SO_REUSEPORT, which iSH rejects. Current builds fall back +to a normal UDP listen when reuseport is unsupported. + **My server isn't showing up on the list but it's online and phantom is showing connections!** Make sure "Visible to LAN players" is turn **ON** in your server's settings: *(below shows setting OFF)* diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a4ce5b8..4efc6e0 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -1,10 +1,13 @@ package proxy import ( + "errors" "fmt" "math/rand" "net" "regexp" + "strings" + "syscall" "time" "github.com/jhead/phantom/internal/clientmap" @@ -82,29 +85,24 @@ func New(prefs ProxyPrefs) (*ProxyServer, error) { } func (proxy *ProxyServer) Start() error { - // Bind to 19132 on all addresses to receive broadcasted pings - // Sets SO_REUSEADDR et al to support multiple instances of phantom + // Bind to 19132 on all addresses to receive broadcasted pings. + // Prefer reuseport when available; fall back on iSH and similar envs + // that reject SO_REUSEPORT with EINVAL (#94 / #108). log.Info().Msgf("Binding ping server to port 19132") - if pingServer, err := reuse.ListenPacket("udp4", ":19132"); err == nil { - proxy.pingServer = pingServer - - // Start proxying ping packets from the broadcast listener - go proxy.readLoop(proxy.pingServer) - } else { - // Bind failed + pingServer, err := listenPacket("udp4", ":19132") + if err != nil { return err } + proxy.pingServer = pingServer + go proxy.readLoop(proxy.pingServer) // Minecraft automatically broadcasts on port 19133 to the local IPv6 network if proxy.prefs.EnableIPv6 { log.Info().Msgf("Binding IPv6 ping server to port 19133") - if pingServerV6, err := reuse.ListenPacket("udp6", ":19133"); err == nil { + if pingServerV6, err := listenPacket("udp6", ":19133"); err == nil { proxy.pingServerV6 = pingServerV6 - - // Start proxying ping packets from the broadcast listener go proxy.readLoop(proxy.pingServerV6) } else { - // IPv6 Bind failed log.Warn().Msgf("Failed to bind IPv6 ping listener: %v", err) } } @@ -116,12 +114,12 @@ func (proxy *ProxyServer) Start() error { // Bind to specified UDP addr and port to receive data from Minecraft clients log.Info().Msgf("Binding proxy server to: %v", proxy.bindAddress) - if server, err := reuse.ListenPacket(network, proxy.bindAddress.String()); err == nil { - // a safe cast, I promise - proxy.server = server.(*net.UDPConn) - } else { + server, err := listenPacket(network, proxy.bindAddress.String()) + if err != nil { return err } + // a safe cast, I promise + proxy.server = server.(*net.UDPConn) log.Info().Msgf("Proxy server listening!") log.Info().Msgf("Once your console pings phantom, you should see replies below.") @@ -132,6 +130,40 @@ func (proxy *ProxyServer) Start() error { return nil } +// listenPacket prefers SO_REUSEPORT via libp2p/reuseport, but some platforms +// (notably iSH on iOS) return EINVAL for that option. Fall back to the stdlib +// listener only for that unsupported-option case so real bind conflicts still +// surface as errors. +func listenPacket(network, address string) (net.PacketConn, error) { + conn, err := reuse.ListenPacket(network, address) + if err == nil { + return conn, nil + } + if !isReuseportUnsupported(err) { + return nil, err + } + log.Warn().Msgf("reuseport listen %s %s unsupported (%v); falling back to net.ListenPacket", network, address, err) + return net.ListenPacket(network, address) +} + +func isReuseportUnsupported(err error) bool { + if err == nil { + return false + } + if errors.Is(err, syscall.EINVAL) { + return true + } + var opErr *net.OpError + if errors.As(err, &opErr) && opErr.Err != nil { + if errors.Is(opErr.Err, syscall.EINVAL) { + return true + } + err = opErr.Err + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "invalid argument") +} + func (proxy *ProxyServer) Close() { log.Info().Msgf("Stopping proxy server") diff --git a/internal/proxy/reuseport_fallback_test.go b/internal/proxy/reuseport_fallback_test.go new file mode 100644 index 0000000..1b0506b --- /dev/null +++ b/internal/proxy/reuseport_fallback_test.go @@ -0,0 +1,33 @@ +package proxy + +import ( + "errors" + "fmt" + "net" + "syscall" + "testing" +) + +func TestIsReuseportUnsupported(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {name: "nil", err: nil, want: false}, + {name: "EINVAL", err: syscall.EINVAL, want: true}, + {name: "wrapped EINVAL", err: fmt.Errorf("bind: %w", syscall.EINVAL), want: true}, + {name: "OpError EINVAL", err: &net.OpError{Op: "listen", Net: "udp4", Err: syscall.EINVAL}, want: true}, + {name: "string invalid argument", err: errors.New("listen udp4 :19132: invalid argument"), want: true}, + {name: "address in use", err: syscall.EADDRINUSE, want: false}, + {name: "OpError address in use", err: &net.OpError{Op: "listen", Net: "udp4", Err: syscall.EADDRINUSE}, want: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isReuseportUnsupported(tc.err); got != tc.want { + t.Fatalf("isReuseportUnsupported(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} From 654eaf5430d0810e64d32fc82ccc605d30983027 Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:37:57 -0700 Subject: [PATCH 12/18] Document Termux ARM Linux binary selection. Termux runs standard linux/arm* builds; GOOS=android is unnecessary and needs the NDK. Clarify $HOME/noexec and uname -m mapping. Co-authored-by: Cursor --- Makefile | 1 + README.md | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9343b14..916349b 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,7 @@ bin/phantom-linux: GOOS=linux GOARCH=amd64 go build -o ../bin/phantom-linux ${CMDSRC} && \ popd +# Termux on Android uses these linux/arm* targets (not GOOS=android). bin/phantom-linux-arm5: pushd cmd && \ GOOS=linux GOARCH=arm GOARM=5 go build -o ../bin/phantom-linux-arm5 ${CMDSRC} && \ diff --git a/README.md b/README.md index 3b2dcde..6600045 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,29 @@ $ chmod u+x ./phantom- Just replace `` with macos, linux, etc. for the correct OS you're using. +**Termux on Android** + +Termux can run the regular **Linux ARM** builds — you do **not** need a +`GOOS=android` binary (that requires the Android NDK). Pick by `uname -m`: + +| `uname -m` | Download | +|---|---| +| `aarch64` or `arm64` | `phantom-linux-arm8` | +| `armv7l` | `phantom-linux-arm7` | +| older / unsure | `phantom-linux-arm5` (widest compatibility) | + +Do **not** use plain `phantom-linux` (that is amd64). Copy the binary into +Termux home (`$HOME`) — shared storage under `/storage/...` is often mounted +`noexec`, which causes `Permission denied` even after `chmod`. Then: + +```bash +cd $HOME +chmod u+x ./phantom-linux-arm8 # or arm7 / arm5 +./phantom-linux-arm8 -server example.com:19132 +``` + +Run the binary as its own command; do not append `-server` to `cd`. + ## Usage Open up a command prompt (Windows) or terminal (macOS & Linux) to the location @@ -138,7 +161,7 @@ computer, a VM, or even with a Minecraft hosting service. ## Supported platforms - This tool should work on Windows, macOS, and Linux. -- ARM builds are available for Raspberry Pi and similar SOCs. +- ARM builds are available for Raspberry Pi, Termux on Android, and similar SOCs (see Installing). - Minecraft for Windows 10, iOS/Android, Xbox One, and PS4 are currently supported. - **Nintendo Switch is not supported.** @@ -148,6 +171,17 @@ your Windows Firewall settings and open up all UDP ports for phantom. ## Troubleshooting +**`Permission denied` or `cannot execute binary file` in Termux** + +Move the binary into `$HOME` (not `/storage/...`), `chmod u+x`, and use the +ARM Linux build that matches `uname -m` — not `phantom-linux` (amd64) and not +an Android/`GOOS=android` build. See Installing → Termux on Android. + +**`cd: too many arguments`** + +`cd` only changes directory. Run phantom separately, e.g. +`cd ~/phantom && ./phantom-linux-arm8 -server 1.2.3.4:19132`. + **My server isn't showing up on the list but it's online and phantom is showing connections!** Make sure "Visible to LAN players" is turn **ON** in your server's settings: *(below shows setting OFF)* From 386be072bf9c1166e9d564ecb6645acebadb765e Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:46:31 -0700 Subject: [PATCH 13/18] Restore offlineErrorRegex dropped during merge. Co-authored-by: Cursor --- internal/proxy/proxy.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 53dc2da..3f8d373 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -53,6 +53,7 @@ type ProxyPrefs struct { var randSource = rand.NewSource(time.Now().UnixNano()) var serverID = randSource.Int63() +var offlineErrorRegex = regexp.MustCompile("(timeout)|(connection refused)") // isOfflineError reports whether err indicates the remote Bedrock server is // unreachable. Connected UDP sockets surface ICMP port-unreachable as From 7a32535a84fddde77c632de8457bef8870961655 Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:49:46 -0700 Subject: [PATCH 14/18] Finish multi-server merge: nil-safe Close and discovery hub tests. Co-authored-by: Cursor --- internal/proxy/discovery_test.go | 26 ++++++++---------------- internal/proxy/proxy.go | 35 ++++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/internal/proxy/discovery_test.go b/internal/proxy/discovery_test.go index 81445ea..4884845 100644 --- a/internal/proxy/discovery_test.go +++ b/internal/proxy/discovery_test.go @@ -11,22 +11,17 @@ import ( ) func TestHandleUnconnectedPingOfflineReply(t *testing.T) { - remote, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) - if err != nil { - t.Fatal(err) - } - defer remote.Close() - client, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) if err != nil { t.Fatal(err) } defer client.Close() + // Closed port: discovery probe fails quickly and offline pong is advertised. p, err := New(ProxyPrefs{ BindAddress: "127.0.0.1", BindPort: 0, - RemoteServer: remote.LocalAddr().String(), + RemoteServer: "127.0.0.1:1", IdleTimeout: time.Minute, NumWorkers: 1, DisableDiscoveryListener: true, @@ -39,9 +34,11 @@ func TestHandleUnconnectedPingOfflineReply(t *testing.T) { } defer p.Close() - p.serverOffline = true + prev := discoveryPingTimeout + discoveryPingTimeout = 200 * time.Millisecond + defer func() { discoveryPingTimeout = prev }() - ping := []byte{proto.UnconnectedPingID, 0, 0, 0, 0, 0, 0, 0, 0} + ping := []byte{proto.UnconnectedPingID, 1, 2, 3, 4, 5, 6, 7, 8} if err := p.HandleUnconnectedPing(ping, client.LocalAddr()); err != nil { t.Fatalf("HandleUnconnectedPing: %v", err) } @@ -62,15 +59,8 @@ func TestHandleUnconnectedPingOfflineReply(t *testing.T) { if uint16(fromUDP.Port) != p.boundPort { t.Fatalf("pong from port %d, want boundPort %d", fromUDP.Port, p.boundPort) } - - // Ping is still forwarded to the remote even when offline. - _ = remote.SetReadDeadline(time.Now().Add(time.Second)) - rn, _, err := remote.ReadFrom(buf) - if err != nil { - t.Fatalf("remote did not receive forwarded ping: %v", err) - } - if rn < 1 || buf[0] != proto.UnconnectedPingID { - t.Fatalf("remote expected ping, got n=%d id=%v", rn, buf[:rn]) + if !p.serverOffline { + t.Fatal("expected serverOffline after failed discovery probe") } } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index c0d2fb1..43dd47b 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -1,6 +1,7 @@ package proxy import ( + "encoding/binary" "errors" "fmt" "math/rand" @@ -222,16 +223,21 @@ func (proxy *ProxyServer) Close() { // "use of closed network connection" while dead is still unset. proxy.dead.Set() - // Stop UDP listeners - proxy.server.Close() - proxy.pingServer.Close() - + // Stop UDP listeners (ping listeners may be nil when a DiscoveryHub owns them) + if proxy.server != nil { + _ = proxy.server.Close() + } + if proxy.pingServer != nil { + _ = proxy.pingServer.Close() + } if proxy.pingServerV6 != nil { - proxy.pingServerV6.Close() + _ = proxy.pingServerV6.Close() } // Close all connections - proxy.clientMap.Close() + if proxy.clientMap != nil { + proxy.clientMap.Close() + } } @@ -243,6 +249,15 @@ func (proxy *ProxyServer) RemoteServer() string { // HandleUnconnectedPing processes a discovery ping fanned out from a // DiscoveryHub. Uses the dedicated discovery probe path (#117). func (proxy *ProxyServer) HandleUnconnectedPing(data []byte, from net.Addr) error { + if proxy.dead.IsSet() || proxy.server == nil { + return fmt.Errorf("proxy not running") + } + if from == nil { + return fmt.Errorf("nil client address") + } + if len(data) < 1 || data[0] != proto.UnconnectedPingID { + return fmt.Errorf("not an unconnected ping") + } return proxy.handleDiscoveryPing(from, data) } @@ -544,8 +559,12 @@ func (proxy *ProxyServer) rewriteUnconnectedPong(data []byte) []byte { log.Debug().Msgf("Received Unconnected Pong from server: %v", data) if packet, err := proto.ReadUnconnectedPing(data); err == nil { - // Overwrite the server ID with one unique to this phantom instance. - // If we don't do this, the client will get confused if you restart phantom. + // Overwrite the server ID with one unique to this proxy. + // If we don't do this, the client will get confused if you restart phantom, + // and multiple -server backends in one process would look identical. + id := make([]byte, 8) + binary.BigEndian.PutUint64(id, uint64(proxy.serverID)) + packet.ID = id packet.Pong.ServerID = fmt.Sprintf("%d", proxy.serverID) // Always advertise phantom's bind port. Upstream MOTDs (notably Geyser) From 652a62e1af2e7fc3bb859f6f57d31f72dc0b5d76 Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 17:54:50 -0700 Subject: [PATCH 15/18] ci: use a current Go toolchain for the macOS T0 job macos-latest is now macos-26, whose dyld rejects Mach-O binaries with no LC_UUID load command. Go's internal linker only began emitting one in 1.26 (golang/go#69987, golang/go#78012), so pinning go.mod's 1.21 minimum made every macOS test binary abort before any phantom code ran: dyld: missing LC_UUID load command in .../proxy.test signal: abort trap This is a toolchain/OS interaction, not a phantom bug - internal/util, which predates this harness, failed identically. go.mod's directive stays at the true language minimum (embed needs >=1.16, fuzzing >=1.18) and is still exercised by the `unit` job on Linux, where LC_UUID is irrelevant. Only the OS matrix needs a current toolchain. Also records in DESIGN.md that the connect-session tests, previously never executed anywhere, now run and pass on Linux CI: a real bedrock-protocol client completes the full RakNet handshake and login through phantom for every sampled version. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 13 ++++++++++++- test/compat/DESIGN.md | 33 +++++++++++++++++++++------------ 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 262794e..efca27a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ concurrency: cancel-in-progress: true jobs: + # Pins the toolchain to go.mod's declared minimum, so accidental use of a + # newer language feature is caught. Linux only - see the note in `t0`. unit: name: vet + race runs-on: ubuntu-latest @@ -52,7 +54,16 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: go.mod + # A current toolchain, NOT go.mod's minimum, because macos-latest is + # now macos-26 and its dyld refuses binaries without an LC_UUID load + # command. Go's internal linker only started emitting one in 1.26 + # (golang/go#69987, golang/go#78012), so building this repo with Go + # 1.21 on macOS 26 fails before any phantom code runs - including on + # pre-existing tests like internal/util. + # + # go.mod's directive stays at the true LANGUAGE minimum and is still + # exercised by the `unit` job on Linux, where LC_UUID is irrelevant. + go-version: 'stable' cache: true - name: Corpus + rewrite tests diff --git a/test/compat/DESIGN.md b/test/compat/DESIGN.md index eec41f0..105402b 100644 --- a/test/compat/DESIGN.md +++ b/test/compat/DESIGN.md @@ -295,18 +295,27 @@ Run locally on darwin/arm64: | T0 fuzz, 25s | **passing**, 11.4M execs, no crashers | | T1 e2e + slow (`-tags='e2e slow'`) | **passing**, ~30s | | T1 `ping` through phantom, all 8 matrix versions | **passing** — a real third-party parser accepts phantom's rewritten pong and sees phantom's port | -| T1 full `connect` session | **not verified locally** — see below | -| CI workflows | **not verified** — never executed; no runner available here | - -**The `connect` session tests have never actually run.** `bedrock-protocol`'s native -RakNet addon crashes on darwin/arm64 (`trace/BPT trap`); it answers pings through a -separate JS path, which is why the ping tier works. The tests are written with a -**control connection**: they first connect straight to the upstream with phantom out -of the path. If that fails, the environment cannot support the test and it *skips*; -only if the direct connection succeeds and the proxied one fails is phantom blamed. -So they are safe to land — they cannot produce a false accusation — but their first -real execution will be on Linux CI, and they should be treated as unproven until a -green run exists. +| T1 full `connect` session | **passing on Linux CI**, skips on darwin/arm64 — see below | +| CI workflows | **passing** — PR #190, all jobs green | + +**The `connect` session tests do not run on darwin/arm64.** `bedrock-protocol`'s +native RakNet addon crashes there (`trace/BPT trap`); it answers pings through a +separate JS path, which is why the ping tier still works locally. The tests are +written with a **control connection**: they first connect straight to the upstream +with phantom out of the path. If that fails, the environment cannot support the test +and it *skips*; only if the direct connection succeeds and the proxied one fails is +phantom blamed. They therefore cannot produce a false accusation on a broken host. + +On Linux CI they **execute and pass**: a real `bedrock-protocol` client completes the +full RakNet handshake and login sequence through phantom for every sampled version. + +**macOS CI note.** The `t0` job pins a current Go toolchain rather than go.mod's +minimum. `macos-latest` is now macOS 26, whose dyld rejects binaries with no LC_UUID +load command, and Go's internal linker only began emitting one in 1.26 +(golang/go#69987, golang/go#78012). Under Go 1.21 the macOS job fails before any +phantom code runs — `internal/util`, which predates this harness, fails identically. +go.mod's directive remains the true language minimum and is still exercised by the +`unit` job on Linux. Eight invariants currently XFAIL against real captured bytes, all registered in `internal/corpus/data/known_failures.json` against their `TODO.md` entries. From e8cd02adb2ed633b95acc7c253eda442186c41ba Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 18:11:20 -0700 Subject: [PATCH 16/18] docs: rewrite DESIGN.md as a standalone strategy document Removes the implementation-history and verification-status sections and the per-dimension breakdown, and drops the date and revision markers. The result describes the testing strategy as it stands rather than how it came to be, so it does not need updating every time a test changes. Co-Authored-By: Claude Opus 5 --- test/compat/DESIGN.md | 528 +++++++++++++++++++----------------------- 1 file changed, 243 insertions(+), 285 deletions(-) diff --git a/test/compat/DESIGN.md b/test/compat/DESIGN.md index 105402b..05deffc 100644 --- a/test/compat/DESIGN.md +++ b/test/compat/DESIGN.md @@ -1,321 +1,279 @@ -# Cross-version compatibility harness — design +# Cross-version compatibility test strategy -Status: **implemented** — see §11 for what changed during the build and §12 for -what is verified vs. unverified. -Date: 2026-07-25 (rev 3) +phantom sits between a Minecraft Bedrock client and a remote Bedrock server. It is +mostly a transparent UDP relay, but it parses and rewrites one packet type: the +RakNet Unconnected Pong (0x1c), whose payload is a semicolon-delimited MOTD string +whose shape changes as Mojang ships new protocol versions. -## 1. Problem +That makes compatibility failures silent and version-gated. phantom keeps proxying, +but a client on a newer version either does not list the server or displays wrong +information. This document describes the automated tests that catch those failures. -phantom sits between a Bedrock client (console) and a remote Bedrock server. It is -mostly a transparent UDP relay, but it *parses and rewrites* one packet type: the -RakNet Unconnected Pong (`0x1c`), whose payload is a semicolon-delimited MOTD string -whose shape changes as Mojang ships new protocol versions. +## Goals -Today we have no way to answer "does phantom still work on MC 1.2x?" without a -console, a real server, and a human. The failure mode is silent and version-gated: -phantom keeps proxying, but a new client either won't list the server or shows -garbage. +- Validate phantom against every Minecraft protocol version the tooling supports. +- Run unattended in CI on every push, with no external services and no credentials. +- Fail loudly and specifically when a new protocol version breaks an invariant. +- Stay current as new Minecraft versions ship, without relying on anyone to notice. + +## Test tiers -**Goal:** an automated, CI-resident harness that validates phantom against N -Minecraft protocol versions, and fails loudly when a new version breaks an invariant. +There are two tiers. They differ in what they are allowed to know about phantom, not +just in how fast they run. -## 2. What actually varies across versions +### T0: unit tests -Only four things can plausibly break phantom. The matrix targets these and nothing -else — a naive full cross-product of every knob is combinatorial waste. +T0 imports phantom's packages and calls them directly. It replays a corpus of pong +payloads through the parser and the rewriter and asserts the resulting bytes and +fields. -| Dim | What varies | Range | phantom code at risk | -|-----|-------------|-------|----------------------| -| **D1** | Pong MOTD field count / semantics | ~40 MC versions; 12 fields historically, 13+ on modern servers | `proto.readPong`/`writePong` (fixed 12-field struct → **extra fields silently dropped**) | -| **D2** | RakNet protocol version in `OpenConnectionRequest1` | 7–11 | pure passthrough — assert it *stays* passthrough | -| **D3** | Negotiated MTU / max datagram size | ≤1464 payload | `maxMTU = 1472` read buffer; truncation risk | -| **D4** | Client ping behavior (`0x01` vs `0x02`, ping-time echo) | 2 packet IDs | offline-pong path only matches `0x01`; pong never echoes ping time | +- Runs in well under a second, with no network, no ports, no subprocess and no Node. +- Covers all 51 supported protocol versions on every push and on every OS. +- Has full white-box access, so it can construct a ProxyServer with whatever internal + state a case needs and call unexported functions. -**Design consequence:** D1 gets the *broad sweep* across all N versions. D2/D3/D4 are -orthogonal to version and get a small fixed scenario set at one pinned version. Keeps -the matrix linear (N + k) instead of exponential (N × k). +T0 is split across two packages because `rewriteUnconnectedPong` is an unexported +method on `*ProxyServer` and Go cannot reach it from an external test package. Parser +tests live in `internal/proto`, rewrite tests in `internal/proxy`. -## 3. Two tiers, two testing philosophies +T0 also carries a fuzz target. `ReadUnconnectedPing` parses untrusted bytes straight +off a UDP socket, which makes it the highest-value fuzz target in the codebase. It is +seeded from the corpus so the fuzzer starts from realistic structure rather than +discovering the RakNet header from scratch. -The tiers are not just fast/slow — they are **different kinds of test**, and the -boundary is enforced by what each is allowed to import. +### T1: end to end tests -### T0 — White-box unit tests (pure Go, no network) +T1 knows only the built phantom binary, its command line flags, and UDP sockets. It +drives a real subprocess over real sockets. -Imports `internal/proto` and `internal/proxy` and calls them **directly**. Replays a -committed corpus of **real pong payloads captured per protocol version** through -parse → rewrite → assert bytes. +Two kinds of upstream stand behind phantom: -- Runtime **< 1s**. No network, no ports, no Node, no subprocess. -- Scales to all ~40 versions for free. This is the backbone of "N versions". -- Full white-box freedom: construct a `ProxyServer` with whatever internal state a - case needs, call unexported functions, assert on struct fields not just bytes. +- A scriptable fake server in `test/compat/fakeserver`, which gives byte-exact control + over the pong. It can emit malformed, truncated, oversized, zero-field and 30-field + pongs, none of which a real server can be made to produce. +- A real bedrock-protocol server, paired with a real bedrock-protocol client. Its + parser judges phantom's rewritten pong, so a malformed rewrite fails against an + independent implementation rather than round-tripping through phantom's own parser + and hiding the same misunderstanding twice. -**Package placement is forced by Go, not chosen.** `rewriteUnconnectedPong` is an -unexported method on `*ProxyServer`, so its tests must be in-package: +phantom runs as a subprocess rather than in-process for three reasons. `Start()` +blocks, `Close()` panics if called before a successful bind, and `serverID` is a +package global, which makes "two instances must advertise different identities" +unobservable from inside a single process. Running the built binary also exercises +the artifact that actually ships, including its flag parsing. -``` -internal/proto/corpus_test.go # package proto — parse/build round-trip -internal/proto/fuzz_test.go # package proto — FuzzReadUnconnectedPing -internal/proxy/rewrite_test.go # package proxy — rewrite goldens (unexported method) -test/compat/corpus/ # shared data only, no Go -``` +### Tier boundary -The **fuzz target** is a natural fit here and cheap: `ReadUnconnectedPing` is a parser -eating untrusted network bytes, seeded from the version corpus. Short run in CI -(`-fuzztime=30s`), longer nightly. - -### T1 — Black-box end-to-end (real binary, real UDP) - -Knows **only** the built `phantom` binary, its CLI flags, and UDP sockets. It gets -**zero imports from `internal/`** — that rule is what keeps it honest. If T1 needs a -Go type from phantom to make an assertion, the assertion belongs in T0. - -Two upstream flavors: -- **Go fake server** (`test/compat/fakeserver`) — a standalone RakNet upstream with - byte-exact control over the pong. Emits malformed, truncated, oversized, zero-field - and 30-field pongs. Used for adversarial cases and D2/D3/D4. -- **`bedrock-protocol` server/client** (Node, pinned) — a *real* stack. Its `ping()` - parses phantom's rewritten pong with a genuine third-party parser, so a malformed - rewrite fails loudly instead of round-tripping through our own buggy parser. Used - for full handshake + login + spawn. - -- Runtime target **< 3 min** wall-clock across all shards. -- Version sampling: oldest supported, latest, latest-of-each-minor ≈ 8–10 versions. - -## 4. Invariant → tier assignment - -Given upstream pong bytes `U` and the bytes `P` phantom emits: - -| # | Invariant | Tier | Status | -|---|-----------|------|--------| -| 1 | `Edition`, `MOTD`, `ProtocolVersion`, `Version`, `Players`, `MaxPlayers`, `SubMOTD`, `GameType` byte-identical `U`→`P` | T0 + T1 | ✅ | -| 2 | **Trailing fields beyond the known 12 preserved** | T0 | ❌ XFAIL (D1) | -| 3 | RakNet magic exactly 16 bytes, unmodified | T0 | ✅ | -| 4a | `ServerID` replaced with phantom's instance ID | T0 | ✅ | -| 4b | `ServerID` stable across pings; **differs between two running instances** | **T1 only** — `serverID` is a package global, unobservable in-process | ✅ | -| 5 | `Port4`/`Port6` = bound port; both empty under `-remove_ports` | T0 (logic) + T1 (flag wiring) | ✅ | -| 6 | `PingTime` echoes the *client's* ping time | T0 (build preserves) + T1 (offline path stamps) | ❌ XFAIL (D4) | -| 7 | Binary `ServerGUID` (bytes 9–16) non-zero and per-instance | T0 + T1 (uniqueness) | ❌ XFAIL | -| 8 | RakNet handshake completes for proto 8/9/10/11 | T1 | ✅ | -| 9 | `bedrock-protocol` client reaches `spawn` through phantom | T1 | ✅ | -| 10 | Payload integrity both directions at 1, 576, 1400, 1464, 1472, 1500 bytes — byte-identical or cleanly dropped, never silently truncated | T1 | ✅ | -| 11 | Two concurrent clients never receive each other's datagrams | T1 | ✅ | -| 12 | Idle client reaped after `-timeout` | T1 (slow) | ✅ | -| 13 | Upstream down → offline pong; upstream back → proxying resumes, no restart | T1 (slow) | ✅ | -| 14 | Client sending `0x02` gets an offline pong | T1 | ❌ XFAIL (D4) | -| 15 | Malformed/truncated/30-field pongs never panic and never produce output the reference parser rejects | T0 (fuzz) + T1 (fake server) | ⚠️ partly | - -Note how cleanly the split falls out: **every T0 invariant is about bytes and pure -functions; every T1-only invariant is about process identity, sockets, or time.** -That's the boundary test — if a proposed case doesn't fit that description, it's in -the wrong tier. - -## 5. Version matrix as data - -`test/compat/matrix.json` is the single source of truth, committed and reviewable: - -```json -[ - { "mc": "1.16.201", "protocol": 422, "raknet": 10, "tier1": true, "shard": 0, "fields": 12 }, - { "mc": "1.21.0", "protocol": 685, "raknet": 11, "tier1": true, "shard": 2, "fields": 13 }, - { "mc": "1.26.30", "protocol": 860, "raknet": 11, "tier1": true, "shard": 3, "fields": 13 } -] -``` +T1 must not import `internal/proto`, `internal/proxy` or `internal/clientmap`. It may +import `internal/corpus`, which holds fixture data and the known-failure registry and +contains no phantom protocol logic. + +`TestBlackBoxRuleHolds` enforces this with `go list -deps` rather than relying on +convention. If an assertion needs phantom's internals, it belongs in T0. + +A useful rule of thumb follows from the split: T0 invariants are about bytes and pure +functions, T1 invariants are about process identity, sockets and time. + +## Invariants + +Given the pong bytes an upstream server sends and the bytes phantom emits in +response, the following must hold. + +### Field preservation + +1. Edition, MOTD, ProtocolVersion, Version, Players, MaxPlayers, SubMOTD and GameType + pass through byte-identical. Covered by T0 and T1. +2. Fields beyond the twelve phantom models are preserved. Real servers send thirteen. + Covered by T0. +3. The 16-byte RakNet magic is unmodified. Covered by T0. + +### Rewriting + +4. The ServerID field carries phantom's instance identity, is stable across repeated + pings, and differs between two running instances. The multi-instance half is T1 + only, since `serverID` is a package global. +5. Port4 and Port6 carry phantom's bound port, and both are empty under + `-remove_ports`. A server that advertises no ports keeps advertising none. Covered + by T0 for the logic and T1 for the flag wiring. +6. The pong echoes the ping time the client sent. Covered by T0 for the proxied path + and T1 for the offline path. +7. The binary ServerGUID at bytes 9 to 16 is non-zero and per-instance. RakNet + identifies servers by this value, not by the MOTD string field. Covered by T0 and + T1. + +### Session behavior + +8. The RakNet handshake completes through phantom for RakNet protocol versions 8 + through 11. Covered by T1. +9. A real bedrock-protocol client completes the RakNet handshake and the login + sequence through phantom. Covered by T1. +10. Payloads survive the round trip byte-identically at 1, 576, 1400, 1464 and 1472 + bytes, or are dropped cleanly, never truncated silently. Covered by T1. +11. Two concurrent clients never receive each other's datagrams. Covered by T1. +12. An idle client is reaped after the configured timeout, and a later packet from + that client still proxies. Covered by T1. +13. When the upstream stops responding phantom answers with its offline pong, and + when the upstream returns phantom resumes proxying without a restart. Covered by + T1. +14. A client pinging with 0x02 receives an offline pong while the upstream is down, + as it does with 0x01. Covered by T1. + +### Robustness + +15. Truncated, oversized, zero-field and 30-field pongs never panic phantom and never + produce output an independent parser rejects. Covered by the T0 fuzz target and + by T1 against the fake server. + +## Test corpus + +The corpus lives in `internal/corpus/data` and is embedded into the binary, so tests +resolve no paths at runtime. + +### Captured corpus + +`captured.json` holds real pong bytes recorded from a bedrock-protocol server, one +entry per supported Minecraft version. It is regenerated by `make corpus` and +committed, so a change in any version's wire bytes shows up as a reviewable diff. + +Regeneration is a development task. CI replays the committed bytes and never captures +new ones. + +One limit is worth stating plainly. bedrock-protocol uses a single advertisement +serializer, so the field count is constant across every version it emits and only the +protocol number and version string vary. The captured corpus is therefore a drift +anchor and a source of realistic bytes, not a source of shape diversity. + +### Synthetic corpus + +`synthetic.json` supplies the shape diversity the captured corpus cannot: 10, 12, 13, +14 and 15 field pongs, empty fields, section-sign colour codes, multi-byte UTF-8, a +raw semicolon inside the MOTD, an oversized MOTD, and 30 fields. + +It also supplies malformed frames: empty packets, truncation at several offsets, a +length prefix that exceeds the body, a zeroed magic, and a ping packet ID on an +otherwise valid pong. + +Fixtures that test a semantic defect are built as complete, well-formed frames with +exactly one thing wrong. A hand-written truncated fixture would trip the length check +first and prove nothing about the defect it claims to test. + +## Version matrix and sharding + +`test/compat/matrix.json` lists the versions T1 exercises and assigns each to a CI +shard. T0 sweeps every supported version because it is cheap. T1 boots a real client +and server per version, which is not, so it samples the oldest supported version, the +newest, and the newest of each minor line in between. + +Sharding provides isolation as well as parallelism. phantom binds UDP port 19132 +unconditionally, and because it sets SO_REUSEPORT a second binder does not fail. The +kernel load-balances datagrams between the two, so two concurrent cases on one host +silently consume each other's packets. One shard per CI runner gives each its own +network stack. Cases run serially within a shard. -Two generators keep it honest: -- `make corpus` — starts a `bedrock-protocol` server per entry, captures the raw pong, - writes `corpus/*.bin` + `*.golden.json`. Dev-time/nightly only; **CI never generates - the corpus, it only replays committed bytes.** -- **Nightly matrix-drift job** — diffs `matrix.json` against `bedrock-protocol`'s - supported-version list; opens an issue when Mojang ships a version we don't cover. - This is what makes "N versions" stay current without human vigilance. +Locally the suite runs serially and checks port 19132 first, skipping with an +explanatory message if it is busy, so a developer running Minecraft or phantom does +not see confusing failures. -## 6. CI structure +## Known failure registry -GitHub Actions matrices, used where they earn their keep: +`internal/corpus/data/known_failures.json` records invariants phantom does not +satisfy, each with a reason and a reference to the tracking entry in TODO.md. -**`ci.yml` — every push/PR** +The registry has four outcomes: -| Job | Matrix | Why | -|-----|--------|-----| -| `unit` | — | `go vet` (fails today on the unkeyed struct literal), `go test -race ./...` | -| `t0` | `os: [ubuntu, macos, windows]` | Corpus + fuzz. Go-only, no deps, seconds. OS matrix is nearly free and catches endian/path/CRLF surprises. | -| `t1` | `shard: [0,1,2,3]` | Build binary + `npm ci` + run that shard's versions serially | -| `cross-compile` | — | All `make build` targets still link | +- Unregistered and passing: silent success. +- Unregistered and failing: an ordinary test failure. +- Registered and failing: reported as XFAIL, and the build stays green. +- Registered and passing: reported as XPASS, and the build fails. -**Why shard T1 rather than one job per version:** each job pays ~40–60s of -checkout/toolchain/`npm ci` overhead for a few seconds of test. Per-version jobs would -spend 90% of runner minutes on setup. Four shards balance that against parallelism and -keep the PR check list readable. +The last case is the point of the mechanism. Fixing a protocol bug breaks the build +until its entry is deleted, so the registry cannot decay into a list of things that +were fixed long ago. It doubles as an accurate inventory of open protocol bugs. -**The shard matrix also solves port isolation.** phantom hard-binds `:19132` -unconditionally (`reuse.ListenPacket("udp4", ":19132")`), and `SO_REUSEPORT` means two -instances *load-balance each other's packets* rather than failing cleanly — so two -concurrent T1 cases on one machine would silently corrupt each other. Each shard gets -its own runner, hence its own network stack. **Within** a shard, cases run serially. -No network namespaces, no `unshare`, no root. Locally, `go test` runs serially and -preflights `:19132`, **skipping with a clear message** if it's busy — a dev running -Minecraft or phantom shouldn't see mysterious red. +Assertions return an error rather than calling `t.Errorf` directly, so a failure can +be captured and reinterpreted instead of being recorded immediately. -**`nightly.yml` — non-blocking** -- Full ~40-version T1 sweep (vs. the ~10 sampled per-push) -- Extended fuzzing (`-fuzztime=10m`) -- Matrix-drift check +## Harness mechanics -## 7. Harness mechanics +Readiness is established by behavior. The harness pings until it receives a pong, or +until a deadline expires. It does not scrape logs and it does not sleep for fixed +intervals. -**Subprocess, not in-process.** T1 runs the built binary. `Start()` blocks, `Close()` -nil-panics on early shutdown, and `serverID` is a package global — so invariant 4b is -*impossible* in-process. Bonus: we test the shipped artifact and its flag parsing. +`-bind_port` is always passed explicitly, because phantom's default of 0 selects a +random port that the test would have no way to discover. -**Readiness without sleeps.** Never `sleep`. Poll by *behavior*: retry the ping until -a pong arrives or a deadline expires. No log-scraping, no fixed delays. Every timeout -is a named, overridable constant. +Timeouts are named constants and scale with the `PHANTOM_TEST_TIMEOUT_SCALE` +environment variable for slow machines. Negative cases, meaning cases that confirm a +reply will not arrive, use a much shorter budget than readiness cases, since there is +nothing left to wait for once phantom is known to be up. -**Always pass `-bind_port` explicitly** — `-bind_port 0` randomizes it and the harness -would have nothing to talk to. +Cases that wait on phantom's real timers, such as the 5 second idle sweep, sit behind +a `slow` build tag. CI includes them and `make test` does not. -**Slow cases are tagged.** Idle-reap needs `-timeout 1` plus the 5s sweep ≈ 7s; -offline/online recovery similar. Behind a `slow` build tag: included in CI, excluded -from default `make test`. +Tests that need a real client stack sit behind a `node` build tag, so the rest of T1 +runs on machines with no Node toolchain. -**Known-failing tests.** Invariants 2, 6, 7, 14 fail on `master` today — they are the -open protocol bugs in `TODO.md`. Landing this harness must not turn CI permanently -red. So `known_failures.json` maps test ID → TODO item, XFAIL-marked. CI **fails if an -XFAIL unexpectedly passes**, forcing the marker deleted in the same PR that fixes the -bug. The registry doubles as a live bug-status dashboard. +Session tests open a control connection directly to the upstream before testing the +proxied path. If the direct connection fails, the client stack cannot run in that +environment and the test skips. Only a direct success followed by a proxied failure +attributes the problem to phantom. Without that control, an unrelated problem in the +client stack would be indistinguishable from a phantom bug. -## 8. Layout +## Repository layout ``` -internal/proto/corpus_test.go # T0: parse/build round-trip (package proto) -internal/proto/fuzz_test.go # T0: FuzzReadUnconnectedPing (package proto) -internal/proxy/rewrite_test.go # T0: rewrite goldens (package proxy) - -test/compat/ - DESIGN.md # this file - matrix.json # version matrix (source of truth) - known_failures.json # XFAIL registry → TODO.md items - corpus/ # captured pongs + goldens (shared T0 data, no Go) - harness.go # T1: process lifecycle, readiness polling, preflight - e2e_test.go # T1 (build tag: e2e) - slow_test.go # T1 slow cases (build tag: e2e,slow) - fakeserver/ # T1: standalone scriptable RakNet upstream - node/ # pinned bedrock-protocol; ping.js / connect.js / serve.js - -.github/workflows/ci.yml -.github/workflows/nightly.yml +internal/proto/corpus_test.go T0 parser and rebuild tests +internal/proto/fuzz_test.go T0 fuzz target +internal/proxy/rewrite_test.go T0 rewrite tests, in-package +internal/corpus/ fixture loader, embedded data, XFAIL registry +internal/corpus/gen/ corpus capture and version drift tooling + +test/compat/DESIGN.md this document +test/compat/matrix.json T1 version sampling and shard assignment +test/compat/harness.go T1 process lifecycle and readiness polling +test/compat/e2e_test.go T1 cases, build tag e2e +test/compat/slow_test.go T1 timer-bound cases, build tags e2e and slow +test/compat/node_test.go T1 real client stack, build tags e2e and node +test/compat/fakeserver/ scriptable RakNet upstream +test/compat/node/ bedrock-protocol CLIs + +.github/workflows/ci.yml per-push pipeline +.github/workflows/nightly.yml full sweep, extended fuzzing, drift check ``` -Node stays *thin*: three single-purpose CLIs that print JSON on stdout. Go owns the -matrix, process lifecycle, and every assertion — `make test` remains the one entry -point and Go contributors never edit JS. - -## 9. Phasing - -| Phase | Deliverable | Value | -|-------|-------------|-------| -| **0** | `ci.yml`: `go vet`, `go test -race`, cross-compile | Closes "No CI" in TODO.md; catches the known `go vet` failure | -| **1** | T0: corpus + goldens + fuzz + `matrix.json` | Broad N-version coverage, ~1s, zero deps | -| **2** | `harness.go` + `fakeserver` + T1 core (1, 4b, 5–7, 10, 15) | Real UDP, real binary, no Node yet | -| **3** | Node `bedrock-protocol` integration (8, 9) + shard matrix | Real third-party client stack validates our rewrite | -| **4** | `nightly.yml`: full sweep, extended fuzz, matrix drift | Stays current without humans | - -Phases 0–1 alone would have caught the dropped-trailing-field bug, need no Node, and -land independently. Recommend starting there. - -## 10. Explicit non-goals - -- **Real BDS testing — dropped.** Considered as a third tier; rejected for now. ~150MB - downloads, EULA, linux/amd64 only, and Mojang has broken old-version CDN URLs before. - Revisit only if T0/T1 prove insufficient at catching real drift. -- Not a performance or load benchmark. -- Not testing real consoles (Xbox/PS), and **not testing LAN broadcast discovery** to - `255.255.255.255`. This is a genuine gap, not a non-issue: broadcast *is* phantom's - real discovery path on console. Covering it needs a `dummy0` interface (Linux, root). - Deferred deliberately. -- Not testing Xbox Live auth — offline mode only. -- Not asserting game-layer packet semantics; phantom is opaque above RakNet and the - harness treats it that way. - -## 11. What changed during implementation - -Eight deviations from rev 2, each with its reason. - -1. **Corpus lives in `internal/corpus/`, not `test/compat/corpus/`.** Both T0 test - packages need it, and `go:embed` cannot reach outside its own package directory. - Embedding removes all relative-path resolution from the tests. - -2. **Corpus is consolidated JSON, not per-version `.bin` + `.golden.json`.** 51 - versions would have meant 102 near-identical files. One `captured.json` shows - exactly which version's bytes changed in a diff. - -3. **No golden output bytes.** Storing phantom's current output as "expected" would - enshrine the very bugs the harness exists to find. T0 asserts the field-level - *invariants* from §4 instead. Strictly better, and it made the XFAIL registry - meaningful. - -4. **The black-box rule is "no *implementation* imports".** T1 may import - `internal/corpus` (fixture data and the XFAIL registry, zero phantom protocol - logic); it may not import `internal/proto`, `internal/proxy` or - `internal/clientmap`. `TestBlackBoxRuleHolds` enforces this with `go list -deps` - rather than trusting anyone to remember. - -5. **Invariant 9 is "reaches `join`", not "reaches `spawn`".** A client only spawns - once the server sends `start_game` and chunk data, which `bedrock-protocol`'s - `createServer` does not do on its own. Writing a mini game server would test - game-layer semantics that are an explicit non-goal, across 8 versions, while - making phantom bugs indistinguishable from gaps in our fake server. Reaching - `join` already proves the full RakNet handshake *and* login completed through - phantom — which is the entire surface phantom actually relays. - -6. **A `node` build tag, separate from `e2e`.** The rest of T1 runs on machines with - no Node toolchain. - -7. **`go 1.12` → `go 1.21` in go.mod.** `go:embed` needs ≥1.16 and native fuzzing - needs ≥1.18. Not optional. `cmd/phantom.go`'s unkeyed struct literal was also - fixed, because Phase 0 CI runs `go vet` and it failed on it. - -8. **The captured corpus has a real limitation, recorded in the file itself.** - `bedrock-protocol` emits a **constant 13-field pong across all 51 versions** — - only the protocol number and version string vary. So per-version capture is a - drift *anchor*, not a source of shape diversity. That diversity is what - `synthetic.json` is for (10-, 12-, 13-, 14- and 15-field shapes, plus hostile - content and malformed frames). Worth knowing before anyone assumes 51 captured - versions means 51 distinct shapes tested. - -## 12. Verification status - -Run locally on darwin/arm64: - -| Tier | Result | -|------|--------| -| T0 corpus + rewrite (`go test ./internal/...`) | **passing**, ~0.3s, 51 versions swept | -| T0 fuzz, 25s | **passing**, 11.4M execs, no crashers | -| T1 e2e + slow (`-tags='e2e slow'`) | **passing**, ~30s | -| T1 `ping` through phantom, all 8 matrix versions | **passing** — a real third-party parser accepts phantom's rewritten pong and sees phantom's port | -| T1 full `connect` session | **passing on Linux CI**, skips on darwin/arm64 — see below | -| CI workflows | **passing** — PR #190, all jobs green | - -**The `connect` session tests do not run on darwin/arm64.** `bedrock-protocol`'s -native RakNet addon crashes there (`trace/BPT trap`); it answers pings through a -separate JS path, which is why the ping tier still works locally. The tests are -written with a **control connection**: they first connect straight to the upstream -with phantom out of the path. If that fails, the environment cannot support the test -and it *skips*; only if the direct connection succeeds and the proxied one fails is -phantom blamed. They therefore cannot produce a false accusation on a broken host. - -On Linux CI they **execute and pass**: a real `bedrock-protocol` client completes the -full RakNet handshake and login sequence through phantom for every sampled version. - -**macOS CI note.** The `t0` job pins a current Go toolchain rather than go.mod's -minimum. `macos-latest` is now macOS 26, whose dyld rejects binaries with no LC_UUID -load command, and Go's internal linker only began emitting one in 1.26 -(golang/go#69987, golang/go#78012). Under Go 1.21 the macOS job fails before any -phantom code runs — `internal/util`, which predates this harness, fails identically. -go.mod's directive remains the true language minimum and is still exercised by the -`unit` job on Linux. - -Eight invariants currently XFAIL against real captured bytes, all registered in -`internal/corpus/data/known_failures.json` against their `TODO.md` entries. +The Node scripts are deliberately thin. Each is a single-purpose CLI that prints one +line of JSON. Go owns the version matrix, process lifecycle and every assertion, so +`make test` remains the single entry point and Go contributors do not need to edit +JavaScript. + +## Continuous integration + +The per-push pipeline runs four jobs: + +- `unit` runs gofmt, `go vet` and `go test -race`, pinned to the Go version declared + in go.mod so accidental use of a newer language feature is caught. +- `t0` runs the corpus tests across Linux, macOS and Windows, plus a short fuzzing + pass. It uses a current Go toolchain rather than the declared minimum, because + recent macOS releases reject binaries without an LC_UUID load command and Go's + internal linker only began emitting one in 1.26. +- `t1` runs the end to end tier across four shards, one per runner. +- `cross-compile` builds every release target. + +The nightly pipeline runs the full unsampled version sweep, a long fuzzing pass, and a +drift check that compares the versions bedrock-protocol supports against the versions +this repository tests. The drift check is what keeps coverage current without relying +on anyone to notice a new Minecraft release. + +## Out of scope + +- Performance and load testing. +- Real console clients, and LAN broadcast discovery to 255.255.255.255. Broadcast is + phantom's real discovery path on console, so this is a genuine gap. Covering it + needs a dummy network interface and root. +- Xbox Live authentication. All tests run in offline mode. +- Game-layer packet semantics. phantom is opaque above RakNet and the tests treat it + that way. Session tests assert that login completes, not what the server sends + afterwards. +- Testing against Mojang's Bedrock Dedicated Server, which would add large downloads, + an EULA, a single supported architecture, and dependence on version-specific + download URLs that have broken in the past. From 245092ed3ae626fc4bac36b4d07c76dac54da49b Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 18:17:34 -0700 Subject: [PATCH 17/18] Rename test tiers from T0/T1 to unit/e2e The T0 and T1 labels carried no meaning to a reader who had not already read the design document. The tests are unit tests and end-to-end tests, so they are now named that way throughout: known-failure ids, code comments, Makefile targets, the version matrix and the CI job names. CI jobs are now vet, unit, e2e and cross-compile. The job formerly called unit, which runs gofmt/vet/race, is now vet. Also tightens DESIGN.md prose. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 22 +-- Makefile | 4 +- internal/corpus/corpus.go | 2 +- internal/corpus/data/known_failures.json | 16 +- internal/corpus/gen/drift.js | 8 +- internal/proto/corpus_test.go | 20 +- internal/proto/fuzz_test.go | 2 +- internal/proxy/rewrite_test.go | 12 +- test/compat/DESIGN.md | 222 +++++++++++------------ test/compat/e2e_test.go | 16 +- test/compat/harness.go | 2 +- test/compat/matrix.json | 12 +- test/compat/node_test.go | 8 +- test/compat/slow_test.go | 2 +- 14 files changed, 168 insertions(+), 180 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efca27a..0a90fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,8 @@ concurrency: jobs: # Pins the toolchain to go.mod's declared minimum, so accidental use of a - # newer language feature is caught. Linux only - see the note in `t0`. - unit: + # newer language feature is caught. Linux only, see the note on the unit job. + vet: name: vet + race runs-on: ubuntu-latest steps: @@ -40,11 +40,11 @@ jobs: - name: go test -race run: go test -race ./... - # T0: white-box unit tests over the cross-version corpus. Pure Go, no network, - # no Node, ~1s. The OS matrix is nearly free here and catches path, endianness - # and line-ending surprises that only ever show up off Linux. - t0: - name: T0 corpus (${{ matrix.os }}) + # Unit tests over the cross-version corpus. Pure Go, no network, no Node, ~1s. + # The OS matrix is nearly free here and catches path, endianness and + # line-ending surprises that only ever show up off Linux. + unit: + name: unit (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -62,7 +62,7 @@ jobs: # pre-existing tests like internal/util. # # go.mod's directive stays at the true LANGUAGE minimum and is still - # exercised by the `unit` job on Linux, where LC_UUID is irrelevant. + # exercised by the `vet` job on Linux, where LC_UUID is irrelevant. go-version: 'stable' cache: true @@ -73,14 +73,14 @@ jobs: if: matrix.os == 'ubuntu-latest' run: go test ./internal/proto/ -run=Fuzz -fuzz=FuzzReadUnconnectedPing -fuzztime=30s - # T1: black-box end-to-end against a real phantom subprocess. + # End-to-end tests against a real phantom subprocess. # # Sharded for ISOLATION as much as for speed: phantom binds UDP :19132 # unconditionally with SO_REUSEPORT, so two concurrent instances on one host # silently load-balance each other's datagrams. One shard per runner gives # each its own network stack; cases run serially within a shard. - t1: - name: T1 e2e (shard ${{ matrix.shard }}) + e2e: + name: e2e (shard ${{ matrix.shard }}) runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/Makefile b/Makefile index 3cdbc3b..ed1703a 100644 --- a/Makefile +++ b/Makefile @@ -57,11 +57,11 @@ prep: clean: rm -rf bin -# T0 - white-box unit tests over the cross-version corpus. Fast, no deps. +# Unit tests over the cross-version corpus. Fast, no deps. test: go test ./... -# T1 - black-box end-to-end against a real phantom subprocess. +# End-to-end tests against a real phantom subprocess. # Needs `npm ci` in test/compat/node first for the `node` tag to do anything; # without it those cases skip rather than fail. test-e2e: diff --git a/internal/corpus/corpus.go b/internal/corpus/corpus.go index 21e2465..a12b109 100644 --- a/internal/corpus/corpus.go +++ b/internal/corpus/corpus.go @@ -1,7 +1,7 @@ // Package corpus loads the cross-version compatibility test fixtures. // // It contains NO phantom protocol logic - only fixture data and the XFAIL -// registry - which is why both test tiers may import it without the black-box +// registry - which is why both test suites may import it without the black-box // tier learning anything about phantom's implementation. See test/compat/DESIGN.md. package corpus diff --git a/internal/corpus/data/known_failures.json b/internal/corpus/data/known_failures.json index feff214..019f505 100644 --- a/internal/corpus/data/known_failures.json +++ b/internal/corpus/data/known_failures.json @@ -12,42 +12,42 @@ ], "failures": [ { - "id": "t0/trailing-fields-preserved", + "id": "unit/trailing-fields-preserved", "reason": "PongData is a fixed 12-field struct, so any field beyond Port6 is dropped on rebuild. Every real modern server sends 13.", "todo": "TODO.md > Protocol correctness > 'Pong rewrite silently drops fields beyond the 12 known ones'" }, { - "id": "t0/packet-id-validated", + "id": "unit/packet-id-validated", "reason": "ReadUnconnectedPing discards the packet ID without checking it, so a 0x01 ping parses as a pong.", "todo": "TODO.md > Bugs > 'Short/truncated packets parse silently into garbage'" }, { - "id": "t0/magic-validated", + "id": "unit/magic-validated", "reason": "The 16-byte RakNet magic is read but never compared against the expected constant.", "todo": "TODO.md > Protocol correctness > 'No validation of magic bytes or packet length before parsing'" }, { - "id": "t0/short-read-rejected", + "id": "unit/short-read-rejected", "reason": "bytes.Buffer.Read partial-fills without returning an error, so a truncated pong yields a zero-padded struct instead of a parse failure.", "todo": "TODO.md > Bugs > 'Short/truncated packets parse silently into garbage'" }, { - "id": "t0/binary-guid-rewritten", + "id": "unit/binary-guid-rewritten", "reason": "rewriteUnconnectedPong rewrites only the ServerID string field; the binary ServerGUID at bytes 9-16 passes through untouched.", "todo": "TODO.md > Protocol correctness > 'Offline pong sends a zero binary ServerGUID; rewrite only touches the string ID'" }, { - "id": "t1/offline-pong-echoes-ping-time", + "id": "e2e/offline-pong-echoes-ping-time", "reason": "The canned OfflinePong carries an all-zero ping time and the offline reply path never stamps the client's ping time into it.", "todo": "TODO.md > Protocol correctness > 'Offline pong doesn't echo the client's ping time'" }, { - "id": "t1/offline-pong-nonzero-guid", + "id": "e2e/offline-pong-nonzero-guid", "reason": "The canned OfflinePong advertises binary ServerGUID = 0, so every phantom whose upstream is down claims GUID 0.", "todo": "TODO.md > Protocol correctness > 'Offline pong sends a zero binary ServerGUID'" }, { - "id": "t1/offline-pong-answers-0x02", + "id": "e2e/offline-pong-answers-0x02", "reason": "The server-offline reply path matches only UnconnectedPingID (0x01), so a client that pings with 0x02 gets nothing while upstream is down.", "todo": "TODO.md > Protocol correctness > '0x02 (Unconnected Ping Open Connections) not recognized'" } diff --git a/internal/corpus/gen/drift.js b/internal/corpus/gen/drift.js index c386a3e..aed8cec 100644 --- a/internal/corpus/gen/drift.js +++ b/internal/corpus/gen/drift.js @@ -27,7 +27,7 @@ const supportedVersions = Object.keys(supported) const missingFromCorpus = supportedVersions.filter(v => !capturedVersions.has(v)) -// The T1 matrix is deliberately a SAMPLE, so a version missing from it is only +// The e2e matrix is deliberately a SAMPLE, so a version missing from it is only // notable when it is newer than everything we currently sample. const newestSampled = Math.max(...[...matrixVersions].map(v => supported[v] || 0)) const newerThanMatrix = supportedVersions.filter( @@ -38,7 +38,7 @@ let drift = false if (missingFromCorpus.length) { drift = true - console.error('T0 corpus is missing versions bedrock-protocol now supports:') + console.error('unit-test corpus is missing versions bedrock-protocol now supports:') for (const v of missingFromCorpus) { console.error(` ${v} (protocol ${supported[v]})`) } @@ -47,7 +47,7 @@ if (missingFromCorpus.length) { if (newerThanMatrix.length) { drift = true - console.error('T1 matrix does not sample any version this new:') + console.error('e2e matrix does not sample any version this new:') for (const v of newerThanMatrix) { console.error(` ${v} (protocol ${supported[v]})`) } @@ -56,7 +56,7 @@ if (newerThanMatrix.length) { if (!drift) { console.log(`In sync: ${supportedVersions.length} versions supported, ` + - `${capturedVersions.size} in the T0 corpus, ${matrixVersions.size} sampled by T1.`) + `${capturedVersions.size} in the unit-test corpus, ${matrixVersions.size} sampled by e2e.`) process.exit(0) } diff --git a/internal/proto/corpus_test.go b/internal/proto/corpus_test.go index f6c1a43..2b468e2 100644 --- a/internal/proto/corpus_test.go +++ b/internal/proto/corpus_test.go @@ -1,10 +1,10 @@ package proto_test -// T0 - white-box unit tests over the cross-version pong corpus. +// Unit tests over the cross-version pong corpus. // // These replay real captured wire bytes (51 Minecraft versions) and // hand-authored shape fixtures through the parser and rebuilder, asserting the -// field-preservation contract from test/compat/DESIGN.md section 4. +// field-preservation contract in test/compat/DESIGN.md. // // No network, no subprocess, no Node. Runs everywhere in well under a second. @@ -109,7 +109,7 @@ func TestCapturedCorpusRoundTrip(t *testing.T) { if err != nil { t.Fatalf("round-trip failed: %v", err) } - corpus.Check(t, "t0/trailing-fields-preserved", func() error { + corpus.Check(t, "unit/trailing-fields-preserved", func() error { return assertFieldsPreserved(e.MOTD, out) }) }) @@ -118,7 +118,7 @@ func TestCapturedCorpusRoundTrip(t *testing.T) { // TestCapturedCorpusEchoesPingTime validates the corpus itself: every real // server echoed the exact ping time we sent. This is the behaviour phantom's -// offline pong fails to reproduce (see t1/offline-pong-echoes-ping-time), so +// offline pong fails to reproduce (see e2e/offline-pong-echoes-ping-time), so // pinning it here documents the requirement with evidence. func TestCapturedCorpusEchoesPingTime(t *testing.T) { sent := corpus.CapturedPingTime() @@ -157,9 +157,9 @@ func TestSyntheticShapes(t *testing.T) { // Only fixtures carrying more fields than phantom's 12-field struct // are expected to lose data today. - id := "t0/no-such-failure" + id := "unit/no-such-failure" if corpus.RealFieldCount(e.MOTDString()) > 12 { - id = "t0/trailing-fields-preserved" + id = "unit/trailing-fields-preserved" } corpus.Check(t, id, func() error { return assertFieldsPreserved(e.MOTDString(), out) @@ -176,9 +176,9 @@ func TestMalformedFrames(t *testing.T) { // from this map are expected to be rejected correctly today - phantom does // catch outright truncation, just not semantic defects like a bad magic. xfailByID := map[string]string{ - "malformed/length-exceeds-body": "t0/short-read-rejected", - "malformed/bad-magic": "t0/magic-validated", - "malformed/wrong-packet-id": "t0/packet-id-validated", + "malformed/length-exceeds-body": "unit/short-read-rejected", + "malformed/bad-magic": "unit/magic-validated", + "malformed/wrong-packet-id": "unit/packet-id-validated", } for _, e := range corpus.Synthetic() { @@ -209,7 +209,7 @@ func TestMalformedFrames(t *testing.T) { id, ok := xfailByID[e.ID] if !ok { - id = "t0/no-such-failure" + id = "unit/no-such-failure" } corpus.Check(t, id, func() error { if err == nil { diff --git a/internal/proto/fuzz_test.go b/internal/proto/fuzz_test.go index 7487e5d..52c908a 100644 --- a/internal/proto/fuzz_test.go +++ b/internal/proto/fuzz_test.go @@ -1,6 +1,6 @@ package proto_test -// T0 - fuzzing. +// Fuzzing the pong parser. // // ReadUnconnectedPing parses untrusted bytes straight off a UDP socket with no // length or magic validation, which makes it the highest-value fuzz target in diff --git a/internal/proxy/rewrite_test.go b/internal/proxy/rewrite_test.go index b5ffbc0..e964d31 100644 --- a/internal/proxy/rewrite_test.go +++ b/internal/proxy/rewrite_test.go @@ -1,13 +1,13 @@ package proxy -// T0 - white-box unit tests for the pong rewrite. +// Unit tests for the pong rewrite. // // This file lives in `package proxy` rather than in test/compat because // rewriteUnconnectedPong is an unexported method; Go offers no way to reach it -// from an external test package. That is the constraint that splits T0 across -// internal/proto and internal/proxy. See test/compat/DESIGN.md section 3. +// from an external test package. That is what splits the unit tests across +// internal/proto and internal/proxy. See test/compat/DESIGN.md. // -// The rewrite contract (DESIGN.md section 4): phantom replaces the server ID +// The rewrite contract (see DESIGN.md): phantom replaces the server ID // and the advertised ports, and must leave every other field exactly as the // upstream server sent it. @@ -168,7 +168,7 @@ func TestRewritePreservesTrailingFields(t *testing.T) { t.Run(e.MC, func(t *testing.T) { in, out, _ := rewriteFields(t, newTestProxy(false), e.Frame()) - corpus.Check(t, "t0/trailing-fields-preserved", func() error { + corpus.Check(t, "unit/trailing-fields-preserved", func() error { if len(out) < len(in) { return corpus.Errorf( "rewrite dropped %d trailing field(s) %q (upstream sent %d, phantom emitted %d)", @@ -211,7 +211,7 @@ func TestRewriteRewritesBinaryGUID(t *testing.T) { frame := e.Frame() out := newTestProxy(false).rewriteUnconnectedPong(frame) - corpus.Check(t, "t0/binary-guid-rewritten", func() error { + corpus.Check(t, "unit/binary-guid-rewritten", func() error { if bytes.Equal(out[9:17], frame[9:17]) { return corpus.Errorf( "binary ServerGUID passed through unchanged (%x); "+ diff --git a/test/compat/DESIGN.md b/test/compat/DESIGN.md index 05deffc..351a807 100644 --- a/test/compat/DESIGN.md +++ b/test/compat/DESIGN.md @@ -7,122 +7,118 @@ whose shape changes as Mojang ships new protocol versions. That makes compatibility failures silent and version-gated. phantom keeps proxying, but a client on a newer version either does not list the server or displays wrong -information. This document describes the automated tests that catch those failures. +information. ## Goals - Validate phantom against every Minecraft protocol version the tooling supports. - Run unattended in CI on every push, with no external services and no credentials. -- Fail loudly and specifically when a new protocol version breaks an invariant. -- Stay current as new Minecraft versions ship, without relying on anyone to notice. +- Fail loudly and specifically when a protocol version breaks an invariant. +- Stay compatible with every new Minecraft version. -## Test tiers +## Test types -There are two tiers. They differ in what they are allowed to know about phantom, not -just in how fast they run. +Tests are split into two types: unit tests that import phantom's packages, and end to +end tests that may use only the compiled binary. -### T0: unit tests +### Unit tests -T0 imports phantom's packages and calls them directly. It replays a corpus of pong -payloads through the parser and the rewriter and asserts the resulting bytes and -fields. +Unit tests replay a corpus of pong payloads through the parser and the rewriter, +asserting on the resulting bytes and fields. -- Runs in well under a second, with no network, no ports, no subprocess and no Node. -- Covers all 51 supported protocol versions on every push and on every OS. -- Has full white-box access, so it can construct a ProxyServer with whatever internal - state a case needs and call unexported functions. +- Run in well under a second, with no network, no ports, no subprocess and no Node. +- Cover all 51 supported protocol versions on every push and on every OS. +- Can construct a ProxyServer with whatever internal state a case needs and call + unexported functions. -T0 is split across two packages because `rewriteUnconnectedPong` is an unexported -method on `*ProxyServer` and Go cannot reach it from an external test package. Parser -tests live in `internal/proto`, rewrite tests in `internal/proxy`. +They live in two packages because `rewriteUnconnectedPong` is an unexported method on +`*ProxyServer` and Go cannot reach it from an external test package. Parser tests are +in `internal/proto`, rewrite tests in `internal/proxy`. -T0 also carries a fuzz target. `ReadUnconnectedPing` parses untrusted bytes straight -off a UDP socket, which makes it the highest-value fuzz target in the codebase. It is -seeded from the corpus so the fuzzer starts from realistic structure rather than -discovering the RakNet header from scratch. +`ReadUnconnectedPing` parses untrusted bytes straight off a UDP socket, so there is +also a fuzz target. It is seeded from the corpus, so the fuzzer starts from realistic +structure rather than discovering the RakNet header from scratch. -### T1: end to end tests +### End to end tests -T1 knows only the built phantom binary, its command line flags, and UDP sockets. It -drives a real subprocess over real sockets. +These run the compiled phantom binary as a subprocess and drive it over real UDP +sockets. Two kinds of upstream stand behind it: -Two kinds of upstream stand behind phantom: - -- A scriptable fake server in `test/compat/fakeserver`, which gives byte-exact control - over the pong. It can emit malformed, truncated, oversized, zero-field and 30-field - pongs, none of which a real server can be made to produce. +- A scriptable fake server in `test/compat/fakeserver`, giving byte-exact control over + the pong. It emits malformed, truncated, oversized, zero-field and 30-field pongs, + none of which a real server can be made to produce. - A real bedrock-protocol server, paired with a real bedrock-protocol client. Its parser judges phantom's rewritten pong, so a malformed rewrite fails against an - independent implementation rather than round-tripping through phantom's own parser + independent implementation instead of round-tripping through phantom's own parser and hiding the same misunderstanding twice. -phantom runs as a subprocess rather than in-process for three reasons. `Start()` -blocks, `Close()` panics if called before a successful bind, and `serverID` is a -package global, which makes "two instances must advertise different identities" -unobservable from inside a single process. Running the built binary also exercises -the artifact that actually ships, including its flag parsing. - -### Tier boundary +Running the binary rather than an in-process ProxyServer is forced by three things. +`Start()` blocks, `Close()` panics if called before a successful bind, and `serverID` +is a package global, which makes "two instances must advertise different identities" +unobservable from inside a single process. It also exercises the artifact that ships, +including its flag parsing. -T1 must not import `internal/proto`, `internal/proxy` or `internal/clientmap`. It may -import `internal/corpus`, which holds fixture data and the known-failure registry and -contains no phantom protocol logic. +### Boundary +End to end tests must not import `internal/proto`, `internal/proxy` or +`internal/clientmap`. They may import `internal/corpus`, which holds fixture data and +the known-failure registry and contains no phantom protocol logic. `TestBlackBoxRuleHolds` enforces this with `go list -deps` rather than relying on -convention. If an assertion needs phantom's internals, it belongs in T0. +convention. -A useful rule of thumb follows from the split: T0 invariants are about bytes and pure -functions, T1 invariants are about process identity, sockets and time. +Unit invariants are about bytes and pure functions. End to end invariants are about +process identity, sockets and time. An assertion that needs phantom's internals +belongs in a unit test. ## Invariants -Given the pong bytes an upstream server sends and the bytes phantom emits in -response, the following must hold. +Given the pong an upstream server sends and the pong phantom emits in response: ### Field preservation 1. Edition, MOTD, ProtocolVersion, Version, Players, MaxPlayers, SubMOTD and GameType - pass through byte-identical. Covered by T0 and T1. + pass through byte-identical. Covered by both types. 2. Fields beyond the twelve phantom models are preserved. Real servers send thirteen. - Covered by T0. -3. The 16-byte RakNet magic is unmodified. Covered by T0. + Covered by unit tests. +3. The 16-byte RakNet magic is unmodified. Covered by unit tests. ### Rewriting 4. The ServerID field carries phantom's instance identity, is stable across repeated - pings, and differs between two running instances. The multi-instance half is T1 - only, since `serverID` is a package global. + pings, and differs between two running instances. The multi-instance half is end to + end only, since `serverID` is a package global. 5. Port4 and Port6 carry phantom's bound port, and both are empty under - `-remove_ports`. A server that advertises no ports keeps advertising none. Covered - by T0 for the logic and T1 for the flag wiring. -6. The pong echoes the ping time the client sent. Covered by T0 for the proxied path - and T1 for the offline path. + `-remove_ports`. A server that advertises no ports keeps advertising none. Unit + tests cover the logic, end to end tests the flag wiring. +6. The pong echoes the ping time the client sent. Unit tests cover the proxied path, + end to end tests the offline path. 7. The binary ServerGUID at bytes 9 to 16 is non-zero and per-instance. RakNet - identifies servers by this value, not by the MOTD string field. Covered by T0 and - T1. + identifies servers by this value, not by the MOTD string field. Covered by both + types. ### Session behavior 8. The RakNet handshake completes through phantom for RakNet protocol versions 8 - through 11. Covered by T1. + through 11. 9. A real bedrock-protocol client completes the RakNet handshake and the login - sequence through phantom. Covered by T1. + sequence through phantom. 10. Payloads survive the round trip byte-identically at 1, 576, 1400, 1464 and 1472 - bytes, or are dropped cleanly, never truncated silently. Covered by T1. -11. Two concurrent clients never receive each other's datagrams. Covered by T1. + bytes, or are dropped cleanly, never truncated silently. +11. Two concurrent clients never receive each other's datagrams. 12. An idle client is reaped after the configured timeout, and a later packet from - that client still proxies. Covered by T1. + that client still proxies. 13. When the upstream stops responding phantom answers with its offline pong, and - when the upstream returns phantom resumes proxying without a restart. Covered by - T1. -14. A client pinging with 0x02 receives an offline pong while the upstream is down, - as it does with 0x01. Covered by T1. + when the upstream returns phantom resumes proxying without a restart. +14. A client pinging with 0x02 receives an offline pong while the upstream is down, as + it does with 0x01. + +All of these are covered by end to end tests. ### Robustness 15. Truncated, oversized, zero-field and 30-field pongs never panic phantom and never - produce output an independent parser rejects. Covered by the T0 fuzz target and - by T1 against the fake server. + produce output an independent parser rejects. Covered by the fuzz target and by + end to end tests against the fake server. ## Test corpus @@ -132,26 +128,22 @@ resolve no paths at runtime. ### Captured corpus `captured.json` holds real pong bytes recorded from a bedrock-protocol server, one -entry per supported Minecraft version. It is regenerated by `make corpus` and -committed, so a change in any version's wire bytes shows up as a reviewable diff. - -Regeneration is a development task. CI replays the committed bytes and never captures -new ones. +entry per supported Minecraft version. `make corpus` regenerates it, and the result is +committed, so a change in any version's wire bytes shows up as a reviewable diff. CI +replays the committed bytes and never captures new ones. -One limit is worth stating plainly. bedrock-protocol uses a single advertisement -serializer, so the field count is constant across every version it emits and only the -protocol number and version string vary. The captured corpus is therefore a drift -anchor and a source of realistic bytes, not a source of shape diversity. +bedrock-protocol uses a single advertisement serializer, so the field count is +constant across every version it emits and only the protocol number and version +string vary. The captured corpus is therefore a drift anchor and a source of realistic +bytes, not a source of shape diversity. ### Synthetic corpus `synthetic.json` supplies the shape diversity the captured corpus cannot: 10, 12, 13, 14 and 15 field pongs, empty fields, section-sign colour codes, multi-byte UTF-8, a -raw semicolon inside the MOTD, an oversized MOTD, and 30 fields. - -It also supplies malformed frames: empty packets, truncation at several offsets, a -length prefix that exceeds the body, a zeroed magic, and a ping packet ID on an -otherwise valid pong. +raw semicolon inside the MOTD, an oversized MOTD, and 30 fields. It also supplies +malformed frames: empty packets, truncation at several offsets, a length prefix that +exceeds the body, a zeroed magic, and a ping packet ID on an otherwise valid pong. Fixtures that test a semantic defect are built as complete, well-formed frames with exactly one thing wrong. A hand-written truncated fixture would trip the length check @@ -159,10 +151,10 @@ first and prove nothing about the defect it claims to test. ## Version matrix and sharding -`test/compat/matrix.json` lists the versions T1 exercises and assigns each to a CI -shard. T0 sweeps every supported version because it is cheap. T1 boots a real client -and server per version, which is not, so it samples the oldest supported version, the -newest, and the newest of each minor line in between. +`test/compat/matrix.json` lists the versions the end to end tests exercise and assigns +each to a CI shard. Unit tests sweep every supported version because it is cheap. End +to end tests boot a real client and server per version, so they sample the oldest +supported version, the newest, and the newest of each minor line in between. Sharding provides isolation as well as parallelism. phantom binds UDP port 19132 unconditionally, and because it sets SO_REUSEPORT a second binder does not fail. The @@ -177,9 +169,8 @@ not see confusing failures. ## Known failure registry `internal/corpus/data/known_failures.json` records invariants phantom does not -satisfy, each with a reason and a reference to the tracking entry in TODO.md. - -The registry has four outcomes: +satisfy, each with a reason and a reference to the tracking entry in TODO.md. Four +outcomes: - Unregistered and passing: silent success. - Unregistered and failing: an ordinary test failure. @@ -195,23 +186,21 @@ be captured and reinterpreted instead of being recorded immediately. ## Harness mechanics -Readiness is established by behavior. The harness pings until it receives a pong, or -until a deadline expires. It does not scrape logs and it does not sleep for fixed -intervals. +Readiness is established by behavior. The harness pings until it receives a pong or a +deadline expires. It does not scrape logs and it does not sleep for fixed intervals. `-bind_port` is always passed explicitly, because phantom's default of 0 selects a random port that the test would have no way to discover. Timeouts are named constants and scale with the `PHANTOM_TEST_TIMEOUT_SCALE` -environment variable for slow machines. Negative cases, meaning cases that confirm a -reply will not arrive, use a much shorter budget than readiness cases, since there is -nothing left to wait for once phantom is known to be up. +environment variable. Cases that confirm a reply will not arrive use a much shorter +budget than readiness cases, since there is nothing left to wait for once phantom is +known to be up. Cases that wait on phantom's real timers, such as the 5 second idle sweep, sit behind -a `slow` build tag. CI includes them and `make test` does not. - -Tests that need a real client stack sit behind a `node` build tag, so the rest of T1 -runs on machines with no Node toolchain. +a `slow` build tag. CI includes them and `make test` does not. Cases that need a real +client stack sit behind a `node` build tag, so the rest of the suite runs on machines +with no Node toolchain. Session tests open a control connection directly to the upstream before testing the proxied path. If the direct connection fails, the client stack cannot run in that @@ -222,18 +211,18 @@ client stack would be indistinguishable from a phantom bug. ## Repository layout ``` -internal/proto/corpus_test.go T0 parser and rebuild tests -internal/proto/fuzz_test.go T0 fuzz target -internal/proxy/rewrite_test.go T0 rewrite tests, in-package +internal/proto/corpus_test.go parser and rebuild tests +internal/proto/fuzz_test.go fuzz target +internal/proxy/rewrite_test.go rewrite tests, in-package internal/corpus/ fixture loader, embedded data, XFAIL registry internal/corpus/gen/ corpus capture and version drift tooling test/compat/DESIGN.md this document -test/compat/matrix.json T1 version sampling and shard assignment -test/compat/harness.go T1 process lifecycle and readiness polling -test/compat/e2e_test.go T1 cases, build tag e2e -test/compat/slow_test.go T1 timer-bound cases, build tags e2e and slow -test/compat/node_test.go T1 real client stack, build tags e2e and node +test/compat/matrix.json version sampling and shard assignment +test/compat/harness.go process lifecycle and readiness polling +test/compat/e2e_test.go end to end cases, build tag e2e +test/compat/slow_test.go timer-bound cases, build tags e2e and slow +test/compat/node_test.go real client stack, build tags e2e and node test/compat/fakeserver/ scriptable RakNet upstream test/compat/node/ bedrock-protocol CLIs @@ -241,28 +230,27 @@ test/compat/node/ bedrock-protocol CLIs .github/workflows/nightly.yml full sweep, extended fuzzing, drift check ``` -The Node scripts are deliberately thin. Each is a single-purpose CLI that prints one -line of JSON. Go owns the version matrix, process lifecycle and every assertion, so -`make test` remains the single entry point and Go contributors do not need to edit -JavaScript. +Each Node script is a single-purpose CLI that prints one line of JSON. Go owns the +version matrix, process lifecycle and every assertion, so `make test` remains the +single entry point and Go contributors do not need to edit JavaScript. ## Continuous integration The per-push pipeline runs four jobs: -- `unit` runs gofmt, `go vet` and `go test -race`, pinned to the Go version declared - in go.mod so accidental use of a newer language feature is caught. -- `t0` runs the corpus tests across Linux, macOS and Windows, plus a short fuzzing +- `vet` runs gofmt, `go vet` and `go test -race`, pinned to the Go version declared in + go.mod so accidental use of a newer language feature is caught. +- `unit` runs the corpus tests across Linux, macOS and Windows, plus a short fuzzing pass. It uses a current Go toolchain rather than the declared minimum, because recent macOS releases reject binaries without an LC_UUID load command and Go's internal linker only began emitting one in 1.26. -- `t1` runs the end to end tier across four shards, one per runner. +- `e2e` runs the end to end tests across four shards, one per runner. - `cross-compile` builds every release target. The nightly pipeline runs the full unsampled version sweep, a long fuzzing pass, and a -drift check that compares the versions bedrock-protocol supports against the versions -this repository tests. The drift check is what keeps coverage current without relying -on anyone to notice a new Minecraft release. +drift check comparing the versions bedrock-protocol supports against the versions this +repository tests. The drift check is what keeps coverage current as Minecraft ships +new releases. ## Out of scope diff --git a/test/compat/e2e_test.go b/test/compat/e2e_test.go index d240472..2cab005 100644 --- a/test/compat/e2e_test.go +++ b/test/compat/e2e_test.go @@ -2,7 +2,7 @@ package compat -// T1 - black-box end-to-end tests. +// End-to-end tests. // // Everything here drives a real phantom subprocess over real UDP sockets. No // knowledge of phantom's internals is used or permitted; TestBlackBoxRuleHolds @@ -98,7 +98,7 @@ func TestTrailingFieldsReachClient(t *testing.T) { in, out := fieldsOf(motd), fieldsOf(motdOf(t, pong)) - corpus.Check(t, "t0/trailing-fields-preserved", func() error { + corpus.Check(t, "unit/trailing-fields-preserved", func() error { if len(out) < len(in) { return corpus.Errorf("client received %d fields, upstream sent %d; lost %q", len(out), len(in), in[len(out):]) @@ -267,7 +267,7 @@ func TestOfflinePong(t *testing.T) { t.Fatalf("phantom never sent an offline pong\n--- output ---\n%s", p.Output()) } - corpus.Check(t, "t1/offline-pong-echoes-ping-time", func() error { + corpus.Check(t, "e2e/offline-pong-echoes-ping-time", func() error { if !bytes.Equal(pong[1:9], want) { return corpus.Errorf( "offline pong carries ping time %x, want the client's %x; "+ @@ -286,7 +286,7 @@ func TestOfflinePong(t *testing.T) { t.Fatalf("phantom never sent an offline pong\n--- output ---\n%s", p.Output()) } - corpus.Check(t, "t1/offline-pong-nonzero-guid", func() error { + corpus.Check(t, "e2e/offline-pong-nonzero-guid", func() error { if bytes.Equal(pong[9:17], make([]byte, 8)) { return corpus.Errorf( "offline pong advertises binary ServerGUID 0; every phantom whose " + @@ -305,7 +305,7 @@ func TestOfflinePong(t *testing.T) { t.Fatalf("phantom never sent an offline pong for 0x01\n--- output ---\n%s", p.Output()) } - corpus.Check(t, "t1/offline-pong-answers-0x02", func() error { + corpus.Check(t, "e2e/offline-pong-answers-0x02", func() error { if waitForOfflinePongWithin(t, p, corpus.PingOpenID, DefaultPingTime(), negativeWait) == nil { return corpus.Errorf( "a client pinging with 0x02 (Unconnected Ping Open Connections) " + @@ -426,7 +426,7 @@ func TestConcurrentClientsAreIsolated(t *testing.T) { } // TestBlackBoxRuleHolds enforces the tier boundary from DESIGN.md section 3. -// Without this, "T1 is black box" is a convention that decays the first time +// Without this, the black-box rule is a convention that decays the first time // someone reaches for a convenient internal helper. func TestBlackBoxRuleHolds(t *testing.T) { forbidden := []string{ @@ -452,9 +452,9 @@ func TestBlackBoxRuleHolds(t *testing.T) { for _, line := range strings.Split(deps, "\n") { if strings.TrimSpace(line) == pkg { t.Errorf("test/compat imports %s.\n"+ - "T1 is the black-box tier: it may know only the phantom binary, its "+ + "the e2e suite may know only the phantom binary, its "+ "CLI flags, and UDP. If an assertion needs phantom's internals, it "+ - "belongs in T0 (internal/proto or internal/proxy).", pkg) + "belongs in a unit test (internal/proto or internal/proxy).", pkg) } } } diff --git a/test/compat/harness.go b/test/compat/harness.go index 8dee4c8..c1bc16b 100644 --- a/test/compat/harness.go +++ b/test/compat/harness.go @@ -1,4 +1,4 @@ -// Package compat is the black-box (T1) compatibility harness. +// Package compat is the end-to-end compatibility harness. // // BLACK-BOX RULE: this package must not import phantom's implementation // packages (internal/proto, internal/proxy, internal/clientmap). It knows only diff --git a/test/compat/matrix.json b/test/compat/matrix.json index 9a1ffc2..adcb8e2 100644 --- a/test/compat/matrix.json +++ b/test/compat/matrix.json @@ -1,12 +1,12 @@ { "_comment": [ - "T1 version matrix: which Minecraft versions the live end-to-end tier exercises,", - "and how they are distributed across CI shards.", + "Which Minecraft versions the end-to-end tests exercise, and how they are", + "distributed across CI shards.", "", - "This is NOT the full version list. T0 sweeps all 51 supported versions from", - "internal/corpus/data/captured.json in under a second; booting a real client and", - "server stack per version is far too slow for that, so T1 samples: the oldest", - "supported version, the newest, and the newest of each minor line in between.", + "This is NOT the full version list. The unit tests sweep all 51 supported", + "versions from internal/corpus/data/captured.json in under a second. Booting a", + "real client and server stack per version is far too slow for that, so the e2e", + "tests sample: the oldest supported version, the newest, and the newest of each minor line in between.", "", "Sharding exists for isolation, not just speed. phantom binds UDP :19132", "unconditionally and sets SO_REUSEPORT, so two concurrent instances on one host", diff --git a/test/compat/node_test.go b/test/compat/node_test.go index 2723652..5cce221 100644 --- a/test/compat/node_test.go +++ b/test/compat/node_test.go @@ -2,7 +2,7 @@ package compat -// T1 - real client stack, driven through phantom. +// End-to-end tests driving a real client stack through phantom. // // The fake upstream in fakeserver/ gives byte-exact control but is, by // construction, only as correct as our own understanding of RakNet. These tests @@ -13,7 +13,7 @@ package compat // // Run with: go test -tags='e2e node' ./test/compat/... // -// The `node` tag is separate from `e2e` so the rest of T1 runs on machines with +// The `node` tag is separate from `e2e` so the rest of the suite runs on machines with // no Node toolchain. import ( @@ -35,7 +35,7 @@ type Matrix struct { Versions []MatrixVersion `json:"versions"` } -// MatrixVersion is one Minecraft version the live tier exercises. +// MatrixVersion is one Minecraft version the e2e suite exercises. type MatrixVersion struct { MC string `json:"mc"` Protocol int `json:"protocol"` @@ -44,7 +44,7 @@ type MatrixVersion struct { Note string `json:"note"` } -// LoadMatrix reads the committed T1 version matrix, honouring PHANTOM_SHARD so +// LoadMatrix reads the committed e2e version matrix, honouring PHANTOM_SHARD so // CI can split it across runners. func LoadMatrix(t *testing.T) Matrix { t.Helper() diff --git a/test/compat/slow_test.go b/test/compat/slow_test.go index aa4d20b..0c7fcbe 100644 --- a/test/compat/slow_test.go +++ b/test/compat/slow_test.go @@ -2,7 +2,7 @@ package compat -// T1 - slow black-box cases. +// Slow end-to-end cases. // // These are behind an extra `slow` tag because they wait on phantom's real // timers: the idle sweep runs every 5s and cannot be shortened from outside the From b65bb70772eb866f6c4c095f426485c557584794 Mon Sep 17 00:00:00 2001 From: Justin Head Date: Sat, 25 Jul 2026 18:24:42 -0700 Subject: [PATCH 18/18] docs: drop the repository layout section The file listing duplicated the tree and went stale on every rename. The Node scripts paragraph it carried moves into the end-to-end section, where it is about design rather than location. Also runs CI on pushes to dev. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 2 +- test/compat/DESIGN.md | 31 +++++-------------------------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a90fac..eeb7a79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [master, main] + branches: [master, main, dev] pull_request: permissions: diff --git a/test/compat/DESIGN.md b/test/compat/DESIGN.md index 351a807..aeb2f39 100644 --- a/test/compat/DESIGN.md +++ b/test/compat/DESIGN.md @@ -58,6 +58,11 @@ is a package global, which makes "two instances must advertise different identit unobservable from inside a single process. It also exercises the artifact that ships, including its flag parsing. +The bedrock-protocol side is driven by Node scripts, each a single-purpose CLI that +prints one line of JSON. Go owns the version matrix, process lifecycle and every +assertion, so `make test` remains the single entry point and Go contributors do not +need to edit JavaScript. + ### Boundary End to end tests must not import `internal/proto`, `internal/proxy` or @@ -208,32 +213,6 @@ environment and the test skips. Only a direct success followed by a proxied fail attributes the problem to phantom. Without that control, an unrelated problem in the client stack would be indistinguishable from a phantom bug. -## Repository layout - -``` -internal/proto/corpus_test.go parser and rebuild tests -internal/proto/fuzz_test.go fuzz target -internal/proxy/rewrite_test.go rewrite tests, in-package -internal/corpus/ fixture loader, embedded data, XFAIL registry -internal/corpus/gen/ corpus capture and version drift tooling - -test/compat/DESIGN.md this document -test/compat/matrix.json version sampling and shard assignment -test/compat/harness.go process lifecycle and readiness polling -test/compat/e2e_test.go end to end cases, build tag e2e -test/compat/slow_test.go timer-bound cases, build tags e2e and slow -test/compat/node_test.go real client stack, build tags e2e and node -test/compat/fakeserver/ scriptable RakNet upstream -test/compat/node/ bedrock-protocol CLIs - -.github/workflows/ci.yml per-push pipeline -.github/workflows/nightly.yml full sweep, extended fuzzing, drift check -``` - -Each Node script is a single-purpose CLI that prints one line of JSON. Go owns the -version matrix, process lifecycle and every assertion, so `make test` remains the -single entry point and Go contributors do not need to edit JavaScript. - ## Continuous integration The per-push pipeline runs four jobs: