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 // "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) { s.stateMu.Lock() defer s.stateMu.Unlock() 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])) } }