First real-client session (2026-10-07 01:00) showed the client walking and attacking but UI interactions stalling: ~120 CsReq dropped with no reply. - auto-empty responder: any unknown CsReq with a Cmd-enum ScRsp pair now gets an empty ScRsp, except the danger families (GridFight/ChessRogue/ EvolveBuild/ChimeraDuel/Jukebox/CycleScore) which must stay dropped (client Sync(nil) NREs on empty, per the Rust lessons) - GetUnreleasedBlockInfo: the Endgame entry waits on this - 4.6.51 wire is CsReq 4190 -> ScRsp 4112 retcode tag 14 (the Rust build answered 4130, an id this client never sends) - SetClientPaused acked (client pauses itself when opening menus/map) - explicit handlers: TakeQuestReward, GetFirstTalkNpc, QuitBattle, GetMapRotationData (Amphoreus teleport pillars), GetPamSkinData, GetAllServerPrefsData/UpdateServerPrefsData, GetHeartDialInfo, GetPetData, GetShopList - SendRaw now logs (dummy/raw replies were invisible in gameserver.log) - cmdpairs.go: req->rsp id pairs generated from the Cmd enum (865 pairs), stable across message renames - smoke test extended: 4190->4112 and SetClientPaused ack verified
171 lines
5.4 KiB
Go
171 lines
5.4 KiB
Go
package gameserver
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"time"
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
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 = []uint32{
|
|
8001, 1001,
|
|
1002, 1003, 1004, 1005, 1006, 1008, 1009, 1013, 1101, 1102, 1103, 1104, 1105, 1106, 1107, 1108,
|
|
1109, 1110, 1111, 1112, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212,
|
|
1213, 1214, 1215, 1217, 1301, 1302, 1303, 1304, 1305, 1306, 1307, 1308, 1309, 1312, 1315, 1310,
|
|
1314, 1218, 1221, 1220, 1222, 1223, 1317, 1313, 1225, 1402, 1401, 1404, 1403, 1405, 1407, 1406,
|
|
1409, 1014, 1015, 1408, 1410, 1412, 1413, 1414, 1415, 1321, 1501, 1502, 1503, 1504, 1505, 1506,
|
|
1507, 1508, 1509, 1510, 1511, 1512, 1513,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dispatch — Go port of the trait_handler! macro + on_message special cases.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type handlerFunc func(s *Session, payload []byte) error
|
|
|
|
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] proto 4.6.51 has no " + name + "CsReq; handler skipped")
|
|
return
|
|
}
|
|
rspID, ok := goproto.CmdIDByName[rspName]
|
|
if !ok {
|
|
Log("[DISPATCH] proto 4.6.51 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", reqID, name, err))
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
rsp := new(RspT)
|
|
if err := fn(s, req, rsp); 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] proto 4.6.51 has no dummy pair " + name + "; skipped")
|
|
return
|
|
}
|
|
dummyResponses[uint16(reqID)] = uint16(rspID)
|
|
}
|
|
|
|
// 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",
|
|
}
|
|
|
|
func isDangerCmd(cmdID uint16) bool {
|
|
name := goproto.NameByCmdID[cmdID]
|
|
for _, fam := range dangerFamilies {
|
|
if strings.Contains(name, fam) {
|
|
return true
|
|
}
|
|
}
|
|
// CycleScore cmd ids (4143/4146) and GridFight range carry generic names
|
|
switch cmdID {
|
|
case 4143, 4146, 8541, 5410, 5449, 7142, 3174, 9252, 8175:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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]))
|
|
}
|
|
}
|