mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
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).
This commit is contained in:
142
cmd/protbot/protocol/entrance.go
Normal file
142
cmd/protbot/protocol/entrance.go
Normal file
@@ -0,0 +1,142 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user