mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-27 10:03:06 +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:
74
cmd/protbot/scenario/chat.go
Normal file
74
cmd/protbot/scenario/chat.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"erupe-ce/common/byteframe"
|
||||
"erupe-ce/common/stringsupport"
|
||||
|
||||
"erupe-ce/cmd/protbot/protocol"
|
||||
)
|
||||
|
||||
// ChatMessage holds a parsed incoming chat message.
|
||||
type ChatMessage struct {
|
||||
ChatType uint8
|
||||
SenderName string
|
||||
Message string
|
||||
}
|
||||
|
||||
// SendChat sends a chat message via MSG_SYS_CAST_BINARY with a MsgBinChat payload.
|
||||
// broadcastType controls delivery scope: 0x03 = stage, 0x06 = world.
|
||||
func SendChat(ch *protocol.ChannelConn, broadcastType, chatType uint8, message, senderName string) error {
|
||||
payload := protocol.BuildChatPayload(chatType, message, senderName)
|
||||
pkt := protocol.BuildCastBinaryPacket(broadcastType, 1, payload)
|
||||
fmt.Printf("[chat] Sending chat (type=%d, broadcast=%d): %s\n", chatType, broadcastType, message)
|
||||
return ch.SendPacket(pkt)
|
||||
}
|
||||
|
||||
// ChatCallback is invoked when a chat message is received.
|
||||
type ChatCallback func(msg ChatMessage)
|
||||
|
||||
// ListenChat registers a handler on MSG_SYS_CASTED_BINARY that parses chat
|
||||
// messages (messageType=1) and invokes the callback.
|
||||
func ListenChat(ch *protocol.ChannelConn, cb ChatCallback) {
|
||||
ch.OnPacket(protocol.MSG_SYS_CASTED_BINARY, func(opcode uint16, data []byte) {
|
||||
// MSG_SYS_CASTED_BINARY layout from server:
|
||||
// uint32 unk
|
||||
// uint8 broadcastType
|
||||
// uint8 messageType
|
||||
// uint16 dataSize
|
||||
// []byte payload
|
||||
if len(data) < 8 {
|
||||
return
|
||||
}
|
||||
messageType := data[5]
|
||||
if messageType != 1 { // Only handle chat messages.
|
||||
return
|
||||
}
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
_ = bf.ReadUint32() // unk
|
||||
_ = bf.ReadUint8() // broadcastType
|
||||
_ = bf.ReadUint8() // messageType
|
||||
dataSize := bf.ReadUint16()
|
||||
if dataSize == 0 {
|
||||
return
|
||||
}
|
||||
payload := bf.ReadBytes(uint(dataSize))
|
||||
|
||||
// Parse MsgBinChat inner payload.
|
||||
pbf := byteframe.NewByteFrameFromBytes(payload)
|
||||
_ = pbf.ReadUint8() // unk0
|
||||
chatType := pbf.ReadUint8()
|
||||
_ = pbf.ReadUint16() // flags
|
||||
_ = pbf.ReadUint16() // senderNameLen
|
||||
_ = pbf.ReadUint16() // messageLen
|
||||
msg := stringsupport.SJISToUTF8(pbf.ReadNullTerminatedBytes())
|
||||
sender := stringsupport.SJISToUTF8(pbf.ReadNullTerminatedBytes())
|
||||
|
||||
cb(ChatMessage{
|
||||
ChatType: chatType,
|
||||
SenderName: sender,
|
||||
Message: msg,
|
||||
})
|
||||
})
|
||||
}
|
||||
82
cmd/protbot/scenario/login.go
Normal file
82
cmd/protbot/scenario/login.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Package scenario provides high-level MHF protocol flows.
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"erupe-ce/cmd/protbot/protocol"
|
||||
)
|
||||
|
||||
// LoginResult holds the outcome of a full login flow.
|
||||
type LoginResult struct {
|
||||
Sign *protocol.SignResult
|
||||
Servers []protocol.ServerEntry
|
||||
Channel *protocol.ChannelConn
|
||||
}
|
||||
|
||||
// Login performs the full sign → entrance → channel login flow.
|
||||
func Login(signAddr, username, password string) (*LoginResult, error) {
|
||||
// Step 1: Sign server authentication.
|
||||
fmt.Printf("[sign] Connecting to %s...\n", signAddr)
|
||||
sign, err := protocol.DoSign(signAddr, username, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign: %w", err)
|
||||
}
|
||||
fmt.Printf("[sign] OK — tokenID=%d, %d character(s), entrance=%s\n",
|
||||
sign.TokenID, len(sign.CharIDs), sign.EntranceAddr)
|
||||
|
||||
if len(sign.CharIDs) == 0 {
|
||||
return nil, fmt.Errorf("no characters on account")
|
||||
}
|
||||
|
||||
// Step 2: Entrance server — get server/channel list.
|
||||
fmt.Printf("[entrance] Connecting to %s...\n", sign.EntranceAddr)
|
||||
servers, err := protocol.DoEntrance(sign.EntranceAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entrance: %w", err)
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
return nil, fmt.Errorf("no channels available")
|
||||
}
|
||||
for i, s := range servers {
|
||||
fmt.Printf("[entrance] [%d] %s — %s:%d\n", i, s.Name, s.IP, s.Port)
|
||||
}
|
||||
|
||||
// Step 3: Connect to the first channel server.
|
||||
first := servers[0]
|
||||
channelAddr := fmt.Sprintf("%s:%d", first.IP, first.Port)
|
||||
fmt.Printf("[channel] Connecting to %s...\n", channelAddr)
|
||||
ch, err := protocol.ConnectChannel(channelAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("channel connect: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Send MSG_SYS_LOGIN.
|
||||
charID := sign.CharIDs[0]
|
||||
ack := ch.NextAckHandle()
|
||||
loginPkt := protocol.BuildLoginPacket(ack, charID, sign.TokenID, sign.TokenString)
|
||||
fmt.Printf("[channel] Sending MSG_SYS_LOGIN (charID=%d, ackHandle=%d)...\n", charID, ack)
|
||||
if err := ch.SendPacket(loginPkt); err != nil {
|
||||
ch.Close()
|
||||
return nil, fmt.Errorf("channel send login: %w", err)
|
||||
}
|
||||
|
||||
resp, err := ch.WaitForAck(ack, 10*time.Second)
|
||||
if err != nil {
|
||||
ch.Close()
|
||||
return nil, fmt.Errorf("channel login ack: %w", err)
|
||||
}
|
||||
if resp.ErrorCode != 0 {
|
||||
ch.Close()
|
||||
return nil, fmt.Errorf("channel login failed: error code %d", resp.ErrorCode)
|
||||
}
|
||||
fmt.Printf("[channel] Login ACK received (error=%d, %d bytes data)\n",
|
||||
resp.ErrorCode, len(resp.Data))
|
||||
|
||||
return &LoginResult{
|
||||
Sign: sign,
|
||||
Servers: servers,
|
||||
Channel: ch,
|
||||
}, nil
|
||||
}
|
||||
17
cmd/protbot/scenario/logout.go
Normal file
17
cmd/protbot/scenario/logout.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"erupe-ce/cmd/protbot/protocol"
|
||||
)
|
||||
|
||||
// Logout sends MSG_SYS_LOGOUT and closes the channel connection.
|
||||
func Logout(ch *protocol.ChannelConn) error {
|
||||
fmt.Println("[logout] Sending MSG_SYS_LOGOUT...")
|
||||
if err := ch.SendPacket(protocol.BuildLogoutPacket()); err != nil {
|
||||
ch.Close()
|
||||
return fmt.Errorf("logout send: %w", err)
|
||||
}
|
||||
return ch.Close()
|
||||
}
|
||||
31
cmd/protbot/scenario/quest.go
Normal file
31
cmd/protbot/scenario/quest.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"erupe-ce/cmd/protbot/protocol"
|
||||
)
|
||||
|
||||
// EnumerateQuests sends MSG_MHF_ENUMERATE_QUEST and returns the raw quest list data.
|
||||
func EnumerateQuests(ch *protocol.ChannelConn, world uint8, counter uint16) ([]byte, error) {
|
||||
ack := ch.NextAckHandle()
|
||||
pkt := protocol.BuildEnumerateQuestPacket(ack, world, counter, 0)
|
||||
fmt.Printf("[quest] Sending MSG_MHF_ENUMERATE_QUEST (world=%d, counter=%d, ackHandle=%d)...\n",
|
||||
world, counter, ack)
|
||||
if err := ch.SendPacket(pkt); err != nil {
|
||||
return nil, fmt.Errorf("enumerate quest send: %w", err)
|
||||
}
|
||||
|
||||
resp, err := ch.WaitForAck(ack, 15*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enumerate quest ack: %w", err)
|
||||
}
|
||||
if resp.ErrorCode != 0 {
|
||||
return nil, fmt.Errorf("enumerate quest failed: error code %d", resp.ErrorCode)
|
||||
}
|
||||
fmt.Printf("[quest] ENUMERATE_QUEST ACK (error=%d, %d bytes data)\n",
|
||||
resp.ErrorCode, len(resp.Data))
|
||||
|
||||
return resp.Data, nil
|
||||
}
|
||||
50
cmd/protbot/scenario/session.go
Normal file
50
cmd/protbot/scenario/session.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"erupe-ce/cmd/protbot/protocol"
|
||||
)
|
||||
|
||||
// SetupSession performs the post-login session setup: ISSUE_LOGKEY, RIGHTS_RELOAD, LOADDATA.
|
||||
// Returns the loaddata response blob for inspection.
|
||||
func SetupSession(ch *protocol.ChannelConn, charID uint32) ([]byte, error) {
|
||||
// Step 1: Issue logkey.
|
||||
ack := ch.NextAckHandle()
|
||||
fmt.Printf("[session] Sending MSG_SYS_ISSUE_LOGKEY (ackHandle=%d)...\n", ack)
|
||||
if err := ch.SendPacket(protocol.BuildIssueLogkeyPacket(ack)); err != nil {
|
||||
return nil, fmt.Errorf("issue logkey send: %w", err)
|
||||
}
|
||||
resp, err := ch.WaitForAck(ack, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue logkey ack: %w", err)
|
||||
}
|
||||
fmt.Printf("[session] ISSUE_LOGKEY ACK (error=%d, %d bytes)\n", resp.ErrorCode, len(resp.Data))
|
||||
|
||||
// Step 2: Rights reload.
|
||||
ack = ch.NextAckHandle()
|
||||
fmt.Printf("[session] Sending MSG_SYS_RIGHTS_RELOAD (ackHandle=%d)...\n", ack)
|
||||
if err := ch.SendPacket(protocol.BuildRightsReloadPacket(ack)); err != nil {
|
||||
return nil, fmt.Errorf("rights reload send: %w", err)
|
||||
}
|
||||
resp, err = ch.WaitForAck(ack, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rights reload ack: %w", err)
|
||||
}
|
||||
fmt.Printf("[session] RIGHTS_RELOAD ACK (error=%d, %d bytes)\n", resp.ErrorCode, len(resp.Data))
|
||||
|
||||
// Step 3: Load save data.
|
||||
ack = ch.NextAckHandle()
|
||||
fmt.Printf("[session] Sending MSG_MHF_LOADDATA (ackHandle=%d)...\n", ack)
|
||||
if err := ch.SendPacket(protocol.BuildLoaddataPacket(ack)); err != nil {
|
||||
return nil, fmt.Errorf("loaddata send: %w", err)
|
||||
}
|
||||
resp, err = ch.WaitForAck(ack, 30*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loaddata ack: %w", err)
|
||||
}
|
||||
fmt.Printf("[session] LOADDATA ACK (error=%d, %d bytes)\n", resp.ErrorCode, len(resp.Data))
|
||||
|
||||
return resp.Data, nil
|
||||
}
|
||||
111
cmd/protbot/scenario/stage.go
Normal file
111
cmd/protbot/scenario/stage.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"erupe-ce/common/byteframe"
|
||||
|
||||
"erupe-ce/cmd/protbot/protocol"
|
||||
)
|
||||
|
||||
// StageInfo holds a parsed stage entry from MSG_SYS_ENUMERATE_STAGE response.
|
||||
type StageInfo struct {
|
||||
ID string
|
||||
Reserved uint16
|
||||
Clients uint16
|
||||
Displayed uint16
|
||||
MaxPlayers uint16
|
||||
Flags uint8
|
||||
}
|
||||
|
||||
// EnterLobby enumerates available lobby stages and enters the first one.
|
||||
func EnterLobby(ch *protocol.ChannelConn) error {
|
||||
// Step 1: Enumerate stages with "sl1Ns" prefix (main lobby stages).
|
||||
ack := ch.NextAckHandle()
|
||||
enumPkt := protocol.BuildEnumerateStagePacket(ack, "sl1Ns")
|
||||
fmt.Printf("[stage] Sending MSG_SYS_ENUMERATE_STAGE (prefix=\"sl1Ns\", ackHandle=%d)...\n", ack)
|
||||
if err := ch.SendPacket(enumPkt); err != nil {
|
||||
return fmt.Errorf("enumerate stage send: %w", err)
|
||||
}
|
||||
|
||||
resp, err := ch.WaitForAck(ack, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enumerate stage ack: %w", err)
|
||||
}
|
||||
if resp.ErrorCode != 0 {
|
||||
return fmt.Errorf("enumerate stage failed: error code %d", resp.ErrorCode)
|
||||
}
|
||||
|
||||
stages := parseEnumerateStageResponse(resp.Data)
|
||||
fmt.Printf("[stage] Found %d stage(s)\n", len(stages))
|
||||
for i, s := range stages {
|
||||
fmt.Printf("[stage] [%d] %s — %d/%d players, flags=0x%02X\n",
|
||||
i, s.ID, s.Clients, s.MaxPlayers, s.Flags)
|
||||
}
|
||||
|
||||
// Step 2: Enter the default lobby stage.
|
||||
// Even if no stages were enumerated, use the default stage ID.
|
||||
stageID := "sl1Ns200p0a0u0"
|
||||
if len(stages) > 0 {
|
||||
stageID = stages[0].ID
|
||||
}
|
||||
|
||||
ack = ch.NextAckHandle()
|
||||
enterPkt := protocol.BuildEnterStagePacket(ack, stageID)
|
||||
fmt.Printf("[stage] Sending MSG_SYS_ENTER_STAGE (stageID=%q, ackHandle=%d)...\n", stageID, ack)
|
||||
if err := ch.SendPacket(enterPkt); err != nil {
|
||||
return fmt.Errorf("enter stage send: %w", err)
|
||||
}
|
||||
|
||||
resp, err = ch.WaitForAck(ack, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enter stage ack: %w", err)
|
||||
}
|
||||
if resp.ErrorCode != 0 {
|
||||
return fmt.Errorf("enter stage failed: error code %d", resp.ErrorCode)
|
||||
}
|
||||
fmt.Printf("[stage] Enter stage ACK received (error=%d)\n", resp.ErrorCode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseEnumerateStageResponse parses the ACK data from MSG_SYS_ENUMERATE_STAGE.
|
||||
// Reference: Erupe server/channelserver/handlers_stage.go (handleMsgSysEnumerateStage)
|
||||
func parseEnumerateStageResponse(data []byte) []StageInfo {
|
||||
if len(data) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
count := bf.ReadUint16()
|
||||
|
||||
var stages []StageInfo
|
||||
for i := uint16(0); i < count; i++ {
|
||||
s := StageInfo{}
|
||||
s.Reserved = bf.ReadUint16()
|
||||
s.Clients = bf.ReadUint16()
|
||||
s.Displayed = bf.ReadUint16()
|
||||
s.MaxPlayers = bf.ReadUint16()
|
||||
s.Flags = bf.ReadUint8()
|
||||
|
||||
// Stage ID is a pascal string with uint8 length prefix.
|
||||
strLen := bf.ReadUint8()
|
||||
if strLen > 0 {
|
||||
idBytes := bf.ReadBytes(uint(strLen))
|
||||
// Remove null terminator if present.
|
||||
if len(idBytes) > 0 && idBytes[len(idBytes)-1] == 0 {
|
||||
idBytes = idBytes[:len(idBytes)-1]
|
||||
}
|
||||
s.ID = string(idBytes)
|
||||
}
|
||||
|
||||
stages = append(stages, s)
|
||||
}
|
||||
|
||||
// After stages: uint32 timestamp, uint32 max clan members (we ignore these).
|
||||
_ = binary.BigEndian // suppress unused import if needed
|
||||
|
||||
return stages
|
||||
}
|
||||
Reference in New Issue
Block a user