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:
Houmgaor
2026-02-20 02:49:23 +01:00
parent 754b5a3bff
commit 0e84377e21
19 changed files with 2106 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
package protocol
import (
"encoding/binary"
"fmt"
"sync"
"sync/atomic"
"time"
"erupe-ce/cmd/protbot/conn"
)
// PacketHandler is a callback invoked when a server-pushed packet is received.
type PacketHandler func(opcode uint16, data []byte)
// ChannelConn manages a connection to a channel server.
type ChannelConn struct {
conn *conn.MHFConn
ackCounter uint32
waiters sync.Map // map[uint32]chan *AckResponse
handlers sync.Map // map[uint16]PacketHandler
closed atomic.Bool
}
// OnPacket registers a handler for a specific server-pushed opcode.
// Only one handler per opcode; later registrations replace earlier ones.
func (ch *ChannelConn) OnPacket(opcode uint16, handler PacketHandler) {
ch.handlers.Store(opcode, handler)
}
// AckResponse holds the parsed ACK data from the server.
type AckResponse struct {
AckHandle uint32
IsBufferResponse bool
ErrorCode uint8
Data []byte
}
// ConnectChannel establishes a connection to a channel server.
// Channel servers do NOT use the 8 NULL byte initialization.
func ConnectChannel(addr string) (*ChannelConn, error) {
c, err := conn.DialDirect(addr)
if err != nil {
return nil, fmt.Errorf("channel connect: %w", err)
}
ch := &ChannelConn{
conn: c,
}
go ch.recvLoop()
return ch, nil
}
// NextAckHandle returns the next unique ACK handle for packet requests.
func (ch *ChannelConn) NextAckHandle() uint32 {
return atomic.AddUint32(&ch.ackCounter, 1)
}
// SendPacket encrypts and sends raw packet data (including the 0x00 0x10 terminator
// which is already appended by the Build* functions in packets.go).
func (ch *ChannelConn) SendPacket(data []byte) error {
return ch.conn.SendPacket(data)
}
// WaitForAck waits for an ACK response matching the given handle.
func (ch *ChannelConn) WaitForAck(handle uint32, timeout time.Duration) (*AckResponse, error) {
waitCh := make(chan *AckResponse, 1)
ch.waiters.Store(handle, waitCh)
defer ch.waiters.Delete(handle)
select {
case resp := <-waitCh:
return resp, nil
case <-time.After(timeout):
return nil, fmt.Errorf("ACK timeout for handle %d", handle)
}
}
// Close closes the channel connection.
func (ch *ChannelConn) Close() error {
ch.closed.Store(true)
return ch.conn.Close()
}
// recvLoop continuously reads packets from the channel server and dispatches ACKs.
func (ch *ChannelConn) recvLoop() {
for {
if ch.closed.Load() {
return
}
pkt, err := ch.conn.ReadPacket()
if err != nil {
if ch.closed.Load() {
return
}
fmt.Printf("[channel] read error: %v\n", err)
return
}
if len(pkt) < 2 {
continue
}
// Strip trailing 0x00 0x10 terminator if present for opcode parsing.
// Packets from server: [opcode uint16][fields...][0x00 0x10]
opcode := binary.BigEndian.Uint16(pkt[0:2])
switch opcode {
case MSG_SYS_ACK:
ch.handleAck(pkt[2:])
case MSG_SYS_PING:
ch.handlePing(pkt[2:])
default:
if val, ok := ch.handlers.Load(opcode); ok {
val.(PacketHandler)(opcode, pkt[2:])
} else {
fmt.Printf("[channel] recv opcode 0x%04X (%d bytes)\n", opcode, len(pkt))
}
}
}
}
// handleAck parses an ACK packet and dispatches it to a waiting caller.
// Reference: Erupe network/mhfpacket/msg_sys_ack.go
func (ch *ChannelConn) handleAck(data []byte) {
if len(data) < 8 {
return
}
ackHandle := binary.BigEndian.Uint32(data[0:4])
isBuffer := data[4] > 0
errorCode := data[5]
var ackData []byte
if isBuffer {
payloadSize := binary.BigEndian.Uint16(data[6:8])
offset := uint32(8)
if payloadSize == 0xFFFF {
if len(data) < 12 {
return
}
payloadSize32 := binary.BigEndian.Uint32(data[8:12])
offset = 12
if uint32(len(data)) >= offset+payloadSize32 {
ackData = data[offset : offset+payloadSize32]
}
} else {
if uint32(len(data)) >= offset+uint32(payloadSize) {
ackData = data[offset : offset+uint32(payloadSize)]
}
}
} else {
// Simple ACK: 4 bytes of data after the uint16 field.
if len(data) >= 12 {
ackData = data[8:12]
}
}
resp := &AckResponse{
AckHandle: ackHandle,
IsBufferResponse: isBuffer,
ErrorCode: errorCode,
Data: ackData,
}
if val, ok := ch.waiters.Load(ackHandle); ok {
waitCh := val.(chan *AckResponse)
select {
case waitCh <- resp:
default:
}
} else {
fmt.Printf("[channel] unexpected ACK handle %d (error=%d, buffer=%v, %d bytes)\n",
ackHandle, errorCode, isBuffer, len(ackData))
}
}
// handlePing responds to a server ping to keep the connection alive.
func (ch *ChannelConn) handlePing(data []byte) {
var ackHandle uint32
if len(data) >= 4 {
ackHandle = binary.BigEndian.Uint32(data[0:4])
}
pkt := BuildPingPacket(ackHandle)
if err := ch.conn.SendPacket(pkt); err != nil {
fmt.Printf("[channel] ping response failed: %v\n", err)
}
}