Files
GaCy-GO/internal/gameserver/dispatch.go
T

196 lines
6.2 KiB
Go

package gameserver
import (
"errors"
"strings"
"fmt"
"math/rand"
"os"
"time"
"google.golang.org/protobuf/proto"
"gac-hkrpg-go/internal/common"
goproto "gac-hkrpg-go/protocol/proto"
)
func statFile(path string) (time.Time, error) {
info, err := os.Stat(path)
if err != nil {
return time.Time{}, err
}
return info.ModTime(), nil
}
func randomToken() uint32 {
return rand.Uint32()
}
func protoMarshal(msg proto.Message) ([]byte, error) {
return proto.Marshal(msg)
}
// ---------------------------------------------------------------------------
// BASE_AVATAR_IDS — 89 avatars (Rust: handlers/avatar.rs)
// ---------------------------------------------------------------------------
var BaseAvatarIDs = common.BaseAvatarIDs
// ---------------------------------------------------------------------------
// Dispatch — Go port of the trait_handler! macro + on_message special cases.
// ---------------------------------------------------------------------------
type handlerFunc func(s *Session, payload []byte) error
// errResponseAlreadySent tells the typed dispatcher that the handler has
// explicitly sent its response early to preserve packet ordering.
var errResponseAlreadySent = errors.New("response already sent")
var (
typedHandlers = map[uint16]handlerFunc{}
dummyResponses = map[uint16]uint16{} // req cmd -> rsp cmd (empty body reply)
rawHandlers = map[uint16]func(s *Session, payload []byte) error{}
)
// handle registers a typed CsReq handler; it mirrors the default Rust macro
// behaviour: run handler with a default ScRsp, then always send the response.
func handle[ReqT any, RspT any](name string, fn func(s *Session, req *ReqT, rsp *RspT) error) {
handleNamed(name, name+"ScRsp", fn)
}
// handleNamed registers a handler whose response message name differs from
// "<name>ScRsp" (e.g. StartQuickCocoonStageRsp).
func handleNamed[ReqT any, RspT any](name, rspName string, fn func(s *Session, req *ReqT, rsp *RspT) error) {
reqID, ok := goproto.CmdIDByName[name+"CsReq"]
if !ok {
Log("[DISPATCH] loaded protocol has no " + name + "CsReq; handler skipped")
return
}
rspID, ok := goproto.CmdIDByName[rspName]
if !ok {
Log("[DISPATCH] loaded protocol has no " + rspName + "; handler skipped")
return
}
typedHandlers[uint16(reqID)] = func(s *Session, payload []byte) error {
req := new(ReqT)
if len(payload) > 0 {
if m, okc := any(req).(proto.Message); okc {
if err := proto.Unmarshal(payload, m); err != nil {
PacketLog(fmt.Sprintf("[ERROR] Failed to decode cmd_id %d (%s): %v payload=%s", reqID, name, err, hexDump(payload, 256)))
return nil
}
}
}
rsp := new(RspT)
if err := fn(s, req, rsp); errors.Is(err, errResponseAlreadySent) {
return nil
} else if err != nil {
return err
}
return s.Send(any(rsp).(proto.Message))
}
_ = rspID
}
// dummy registers a CsReq that only needs an empty ScRsp.
func dummy(name string) {
reqID, ok1 := goproto.CmdIDByName[name+"CsReq"]
rspID, ok2 := goproto.CmdIDByName[name+"ScRsp"]
if !ok1 || !ok2 {
Log("[DISPATCH] loaded protocol has no dummy pair " + name + "; skipped")
return
}
dummyResponses[uint16(reqID)] = uint16(rspID)
}
// rawNamed binds only the request declared by the current command table.
// Old numeric aliases can collide with responses after a protocol update.
func rawNamed(request string, fn func(*Session, []byte) error) {
id, ok := goproto.CmdIDByName[request]
if !ok {
panic("raw handler request missing from protocol: " + request)
}
if _, exists := rawHandlers[id]; exists {
panic("duplicate raw handler: " + request)
}
rawHandlers[id] = fn
}
// dangerFamilies are client modules that Sync(nil) -> NRE -> black screen when
// they receive an empty ScRsp (lesson recorded in the Rust packet.rs / HANDOFF):
// never auto-answer these, drop them silently instead.
var dangerFamilies = []string{
"GridFight", "ChessRogue", "EvolveBuild", "ChimeraDuel",
"Jukebox", "CycleScore", "MusicAlbum", "DrinkMaker",
// Player.log 2026-10-17: these callbacks dereference state absent from
// the automatic empty response. Leave unsupported modules unanswered.
"RogueTourn", "ChenLing",
// Same failure observed in the fresh verification login.
"SocialPlay", "PetMarble",
}
func isDangerCmd(cmdID uint16) bool {
name := goproto.NameByCmdID[cmdID]
for _, fam := range dangerFamilies {
if strings.Contains(name, fam) {
return true
}
}
// This module requires populated rotation state before it can be answered.
if name == "GetMapRotationDataCsReq" || strings.HasPrefix(name, "ChimeraGet") {
return true
}
return false
}
// hexDump returns a bounded hex preview for debugging wire-format issues.
func hexDump(b []byte, max int) string {
if len(b) > max {
b = b[:max]
}
return fmt.Sprintf("%x", b)
}
// DispatchMessage is the Go port of CommandHandler::on_message.
func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
PacketLog(fmt.Sprintf("[PACKET RECV] cmd_id: %d %s (len: %d)", cmdID, goproto.NameByCmdID[cmdID], len(payload)))
Log(fmt.Sprintf("[PACKET] Received cmd_id: %d (%s)", cmdID, goproto.NameByCmdID[cmdID]))
// 1. empty-dummy responses
if rspID, ok := dummyResponses[cmdID]; ok {
_ = s.SendRaw(&NetPacket{CmdType: rspID})
return
}
// 2. raw special-case handlers (challenge tierce etc.)
if h, ok := rawHandlers[cmdID]; ok {
if err := h(s, payload); err != nil {
PacketLog("[ERROR] raw handler " + fmt.Sprint(cmdID) + ": " + err.Error())
}
return
}
// 3. typed handlers
if h, ok := typedHandlers[cmdID]; ok {
if err := h(s, payload); err != nil {
PacketLog("[ERROR] handler cmd " + fmt.Sprint(cmdID) + ": " + err.Error())
}
return
}
// 4. unknown: auto-answer with an empty ScRsp when the Cmd enum defines the
// pair, EXCEPT the danger families which must be dropped silently.
// (Answering nothing made the client's UI modules stall - e.g. the Endgame
// entry waits for GetUnreleasedBlockInfo and the pause menu waits for
// SetClientPaused, leaving the game unclickable.)
if rspID, ok := goproto.RspIDByReqID[uint32(cmdID)]; ok && !isDangerCmd(cmdID) {
_ = s.SendRaw(&NetPacket{CmdType: uint16(rspID)})
return
}
if cmdID != 7159 {
PacketLog(fmt.Sprintf("Unknown command ID: %d (%s)", cmdID, goproto.NameByCmdID[cmdID]))
}
}