Files
Erupe/cmd/protbot/protocol/entrance.go
Houmgaor 0e84377e21 feat(protbot): add headless MHF protocol bot as cmd/protbot
Copy MHBridge into the Erupe module as cmd/protbot/ so it can be
built, tested, and maintained alongside the server. The bot
implements the full sign → entrance → channel login flow and
supports lobby entry, chat, session setup, and quest enumeration.

The conn/ package keeps its own Blowfish crypto primitives to avoid
importing erupe-ce/config (which requires a config file at init).
2026-02-20 02:49:23 +01:00

143 lines
3.7 KiB
Go

package protocol
import (
"encoding/binary"
"fmt"
"net"
"erupe-ce/common/byteframe"
"erupe-ce/cmd/protbot/conn"
)
// ServerEntry represents a channel server from the entrance server response.
type ServerEntry struct {
IP string
Port uint16
Name string
}
// DoEntrance connects to the entrance server and retrieves the server list.
// Reference: Erupe server/entranceserver/entrance_server.go and make_resp.go.
func DoEntrance(addr string) ([]ServerEntry, error) {
c, err := conn.DialWithInit(addr)
if err != nil {
return nil, fmt.Errorf("entrance connect: %w", err)
}
defer c.Close()
// Send a minimal packet (the entrance server reads it, checks len > 5 for USR data).
// An empty/short packet triggers only SV2 response.
bf := byteframe.NewByteFrame()
bf.WriteUint8(0)
if err := c.SendPacket(bf.Data()); err != nil {
return nil, fmt.Errorf("entrance send: %w", err)
}
resp, err := c.ReadPacket()
if err != nil {
return nil, fmt.Errorf("entrance recv: %w", err)
}
return parseEntranceResponse(resp)
}
// parseEntranceResponse parses the Bin8-encrypted entrance server response.
// Reference: Erupe server/entranceserver/make_resp.go (makeHeader, makeSv2Resp)
func parseEntranceResponse(data []byte) ([]ServerEntry, error) {
if len(data) < 2 {
return nil, fmt.Errorf("entrance response too short")
}
// First byte is the Bin8 encryption key.
key := data[0]
decrypted := conn.DecryptBin8(data[1:], key)
rbf := byteframe.NewByteFrameFromBytes(decrypted)
// Read response type header: "SV2" or "SVR"
respType := string(rbf.ReadBytes(3))
if respType != "SV2" && respType != "SVR" {
return nil, fmt.Errorf("unexpected entrance response type: %s", respType)
}
entryCount := rbf.ReadUint16()
dataLen := rbf.ReadUint16()
if dataLen == 0 {
return nil, nil
}
expectedSum := rbf.ReadUint32()
serverData := rbf.ReadBytes(uint(dataLen))
actualSum := conn.CalcSum32(serverData)
if expectedSum != actualSum {
return nil, fmt.Errorf("entrance checksum mismatch: expected %08X, got %08X", expectedSum, actualSum)
}
return parseServerEntries(serverData, entryCount)
}
// parseServerEntries parses the server info binary blob.
// Reference: Erupe server/entranceserver/make_resp.go (encodeServerInfo)
func parseServerEntries(data []byte, entryCount uint16) ([]ServerEntry, error) {
bf := byteframe.NewByteFrameFromBytes(data)
var entries []ServerEntry
for i := uint16(0); i < entryCount; i++ {
ipBytes := bf.ReadBytes(4)
ip := net.IP([]byte{
byte(ipBytes[3]), byte(ipBytes[2]),
byte(ipBytes[1]), byte(ipBytes[0]),
})
_ = bf.ReadUint16() // serverIdx | 16
_ = bf.ReadUint16() // 0
channelCount := bf.ReadUint16()
_ = bf.ReadUint8() // Type
_ = bf.ReadUint8() // Season/rotation
// G1+ recommended flag
_ = bf.ReadUint8()
// G51+ (ZZ): skip 1 byte, then read 65-byte padded name
_ = bf.ReadUint8()
nameBytes := bf.ReadBytes(65)
// GG+: AllowedClientFlags
_ = bf.ReadUint32()
// Parse name (null-separated: name + description)
name := ""
for j := 0; j < len(nameBytes); j++ {
if nameBytes[j] == 0 {
break
}
name += string(nameBytes[j])
}
// Read channel entries
for j := uint16(0); j < channelCount; j++ {
port := bf.ReadUint16()
_ = bf.ReadUint16() // channelIdx | 16
_ = bf.ReadUint16() // maxPlayers
_ = bf.ReadUint16() // currentPlayers
_ = bf.ReadBytes(14) // remaining channel fields (7 x uint16)
_ = bf.ReadUint16() // 12345
serverIP := ip.String()
// Convert 127.0.0.1 representation
if binary.LittleEndian.Uint32(ipBytes) == 0x0100007F {
serverIP = "127.0.0.1"
}
entries = append(entries, ServerEntry{
IP: serverIP,
Port: port,
Name: fmt.Sprintf("%s ch%d", name, j+1),
})
}
}
return entries, nil
}