feat(protbot): add boost and gacha inspection scenarios

Adds non-destructive test scenarios for the #187 boost-time fix and
the #175 / gacha-logging changes so regressions in those paths can be
caught without a full game client.

- boost: queries GET_BOOST_TIME_LIMIT, GET_BOOST_RIGHT, and
  GET_KEEP_LOGIN_BOOST_STATUS, flagging a zero boost_limit and all-zero
  login boost entries as the expected DisableBoostTime/DisableLoginBoost
  state.
- gacha: snapshots GET_GACHA_POINT and RECEIVE_GACHA_ITEM (freeze=true,
  so temp storage is not cleared), with an opt-in --roll flag that
  exercises PLAY_NORMAL_GACHA end-to-end. Detects the post-#175
  single-byte validation-failure ACK.
This commit is contained in:
Houmgaor
2026-04-06 17:58:04 +02:00
parent 6fa07ae4ae
commit 3e9f3d1b62
5 changed files with 477 additions and 18 deletions

View File

@@ -0,0 +1,113 @@
package scenario
import (
"fmt"
"time"
"erupe-ce/cmd/protbot/protocol"
"erupe-ce/common/byteframe"
)
// BoostTimeStatus holds the parsed response of MSG_MHF_GET_BOOST_TIME_LIMIT.
// When boost time is disabled server-side, or has not been started yet,
// BoostLimitUnix is 0. Prior to the #187 fix, unset boost_time columns
// wrapped to a large uint32 the client interpreted as permanently active.
type BoostTimeStatus struct {
BoostLimitUnix uint32
}
// BoostRight holds the parsed response of MSG_MHF_GET_BOOST_RIGHT.
// 0 = disabled, 1 = active, 2 = available.
type BoostRight struct {
State uint32
}
// LoginBoostEntry holds a single entry of the 5-entry MSG_MHF_GET_KEEP_LOGIN_BOOST_STATUS response.
type LoginBoostEntry struct {
WeekReq uint8
Active bool
WeekCount uint8
Expiration uint32
}
// LoginBoostStatus holds the full parsed keep login boost status response.
type LoginBoostStatus struct {
Entries []LoginBoostEntry
}
// GetBoostTimeLimit sends MSG_MHF_GET_BOOST_TIME_LIMIT and parses the response.
func GetBoostTimeLimit(ch *protocol.ChannelConn) (*BoostTimeStatus, error) {
ack := ch.NextAckHandle()
pkt := protocol.BuildGetBoostTimeLimitPacket(ack)
fmt.Printf("[boost] Sending GET_BOOST_TIME_LIMIT (ackHandle=%d)...\n", ack)
if err := ch.SendPacket(pkt); err != nil {
return nil, fmt.Errorf("get boost time limit send: %w", err)
}
resp, err := ch.WaitForAck(ack, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("get boost time limit ack: %w", err)
}
if resp.ErrorCode != 0 {
return nil, fmt.Errorf("get boost time limit failed: error code %d", resp.ErrorCode)
}
if len(resp.Data) < 4 {
return nil, fmt.Errorf("get boost time limit response too short: %d bytes", len(resp.Data))
}
bf := byteframe.NewByteFrameFromBytes(resp.Data)
return &BoostTimeStatus{BoostLimitUnix: bf.ReadUint32()}, nil
}
// GetBoostRight sends MSG_MHF_GET_BOOST_RIGHT and parses the response.
func GetBoostRight(ch *protocol.ChannelConn) (*BoostRight, error) {
ack := ch.NextAckHandle()
pkt := protocol.BuildGetBoostRightPacket(ack)
fmt.Printf("[boost] Sending GET_BOOST_RIGHT (ackHandle=%d)...\n", ack)
if err := ch.SendPacket(pkt); err != nil {
return nil, fmt.Errorf("get boost right send: %w", err)
}
resp, err := ch.WaitForAck(ack, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("get boost right ack: %w", err)
}
if resp.ErrorCode != 0 {
return nil, fmt.Errorf("get boost right failed: error code %d", resp.ErrorCode)
}
if len(resp.Data) < 4 {
return nil, fmt.Errorf("get boost right response too short: %d bytes", len(resp.Data))
}
bf := byteframe.NewByteFrameFromBytes(resp.Data)
return &BoostRight{State: bf.ReadUint32()}, nil
}
// GetKeepLoginBoostStatus sends MSG_MHF_GET_KEEP_LOGIN_BOOST_STATUS and parses the response.
// The server returns either 35 bytes (5 entries × 7 bytes) or 35 zero bytes
// when DisableLoginBoost is set.
func GetKeepLoginBoostStatus(ch *protocol.ChannelConn) (*LoginBoostStatus, error) {
ack := ch.NextAckHandle()
pkt := protocol.BuildGetKeepLoginBoostStatusPacket(ack)
fmt.Printf("[boost] Sending GET_KEEP_LOGIN_BOOST_STATUS (ackHandle=%d)...\n", ack)
if err := ch.SendPacket(pkt); err != nil {
return nil, fmt.Errorf("get login boost status send: %w", err)
}
resp, err := ch.WaitForAck(ack, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("get login boost status ack: %w", err)
}
if resp.ErrorCode != 0 {
return nil, fmt.Errorf("get login boost status failed: error code %d", resp.ErrorCode)
}
if len(resp.Data) < 35 {
return nil, fmt.Errorf("login boost status response too short: %d bytes", len(resp.Data))
}
bf := byteframe.NewByteFrameFromBytes(resp.Data)
status := &LoginBoostStatus{}
for i := 0; i < 5; i++ {
status.Entries = append(status.Entries, LoginBoostEntry{
WeekReq: bf.ReadUint8(),
Active: bf.ReadBool(),
WeekCount: bf.ReadUint8(),
Expiration: bf.ReadUint32(),
})
}
return status, nil
}

View File

@@ -0,0 +1,133 @@
package scenario
import (
"fmt"
"time"
"erupe-ce/cmd/protbot/protocol"
"erupe-ce/common/byteframe"
)
// GachaPoints holds the parsed MSG_MHF_GET_GACHA_POINT response.
type GachaPoints struct {
Premium uint32
Trial uint32
Frontier uint32
}
// GachaReward is one item returned by a normal gacha roll.
type GachaReward struct {
ItemType uint8
ItemID uint16
Quantity uint16
Rarity uint8
}
// GachaStoredItem is one item currently sitting in the character's
// gacha_items column (the client's "temp storage" display).
type GachaStoredItem struct {
ItemType uint8
ItemID uint16
Quantity uint16
}
// GetGachaPoint sends MSG_MHF_GET_GACHA_POINT and parses the response.
func GetGachaPoint(ch *protocol.ChannelConn) (*GachaPoints, error) {
ack := ch.NextAckHandle()
pkt := protocol.BuildGetGachaPointPacket(ack)
fmt.Printf("[gacha] Sending GET_GACHA_POINT (ackHandle=%d)...\n", ack)
if err := ch.SendPacket(pkt); err != nil {
return nil, fmt.Errorf("get gacha point send: %w", err)
}
resp, err := ch.WaitForAck(ack, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("get gacha point ack: %w", err)
}
if resp.ErrorCode != 0 {
return nil, fmt.Errorf("get gacha point failed: error code %d", resp.ErrorCode)
}
if len(resp.Data) < 12 {
return nil, fmt.Errorf("gacha point response too short: %d bytes", len(resp.Data))
}
bf := byteframe.NewByteFrameFromBytes(resp.Data)
return &GachaPoints{
Premium: bf.ReadUint32(),
Trial: bf.ReadUint32(),
Frontier: bf.ReadUint32(),
}, nil
}
// PlayNormalGacha sends MSG_MHF_PLAY_NORMAL_GACHA and parses the reward list.
// Response layout: uint8 count, then count * (uint8 type, uint16 id, uint16 qty, uint8 rarity).
// The server returns a single 0x00 byte ACK payload on validation failure
// (e.g. empty reward pool), which this function reports as an error.
func PlayNormalGacha(ch *protocol.ChannelConn, gachaID uint32, rollType, gachaType uint8) ([]GachaReward, error) {
ack := ch.NextAckHandle()
pkt := protocol.BuildPlayNormalGachaPacket(ack, gachaID, rollType, gachaType)
fmt.Printf("[gacha] Sending PLAY_NORMAL_GACHA (gachaID=%d, rollType=%d)...\n", gachaID, rollType)
if err := ch.SendPacket(pkt); err != nil {
return nil, fmt.Errorf("play normal gacha send: %w", err)
}
resp, err := ch.WaitForAck(ack, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("play normal gacha ack: %w", err)
}
if resp.ErrorCode != 0 {
return nil, fmt.Errorf("play normal gacha failed: error code %d", resp.ErrorCode)
}
if len(resp.Data) < 1 {
return nil, fmt.Errorf("play normal gacha response too short: %d bytes", len(resp.Data))
}
bf := byteframe.NewByteFrameFromBytes(resp.Data)
count := bf.ReadUint8()
// A single-byte (count=0) reply from an otherwise-successful ACK means
// the server's validation failed. Surface it so callers can tell the
// difference from a genuinely empty roll.
if count == 0 && len(resp.Data) == 1 {
return nil, fmt.Errorf("play normal gacha: server returned empty reward pool (check gacha config)")
}
rewards := make([]GachaReward, 0, count)
for i := uint8(0); i < count; i++ {
rewards = append(rewards, GachaReward{
ItemType: bf.ReadUint8(),
ItemID: bf.ReadUint16(),
Quantity: bf.ReadUint16(),
Rarity: bf.ReadUint8(),
})
}
return rewards, nil
}
// ReceiveGachaItem sends MSG_MHF_RECEIVE_GACHA_ITEM and parses the pending
// items in the character's gacha_items column. When freeze=true the server
// does not clear the column, allowing non-destructive inspection.
// Response layout: uint8 count + count * (uint8 type, uint16 id, uint16 qty).
func ReceiveGachaItem(ch *protocol.ChannelConn, max uint8, freeze bool) ([]GachaStoredItem, error) {
ack := ch.NextAckHandle()
pkt := protocol.BuildReceiveGachaItemPacket(ack, max, freeze)
fmt.Printf("[gacha] Sending RECEIVE_GACHA_ITEM (max=%d, freeze=%v)...\n", max, freeze)
if err := ch.SendPacket(pkt); err != nil {
return nil, fmt.Errorf("receive gacha item send: %w", err)
}
resp, err := ch.WaitForAck(ack, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("receive gacha item ack: %w", err)
}
if resp.ErrorCode != 0 {
return nil, fmt.Errorf("receive gacha item failed: error code %d", resp.ErrorCode)
}
if len(resp.Data) < 1 {
return nil, fmt.Errorf("receive gacha item response too short: %d bytes", len(resp.Data))
}
bf := byteframe.NewByteFrameFromBytes(resp.Data)
count := bf.ReadUint8()
items := make([]GachaStoredItem, 0, count)
for i := uint8(0); i < count; i++ {
items = append(items, GachaStoredItem{
ItemType: bf.ReadUint8(),
ItemID: bf.ReadUint16(),
Quantity: bf.ReadUint16(),
})
}
return items, nil
}