fix(gameserver): answer UI-gating cmds the 4.6.51 client stalls on

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
This commit is contained in:
CAtlas0784 committed 2026-10-07 01:19:56 +07:00
1 parent 580717c3d8
commit 91ee027eb7
8 files changed
+1154 -115

No files matched your search

+35 -3
View File
@@ -1,6 +1,8 @@
package gameserver
import (
"strings"
"fmt"
"math/rand"
"os"
@@ -102,6 +104,29 @@ func dummy(name string) {
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)))
@@ -129,9 +154,16 @@ func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
return
}
// 4. unknown — do NOT answer with an empty ScRsp: client modules
// (GridFight/ChessRogue/EvolveBuild/ChimeraDuel/Jukebox) Sync(nil) and NRE
// into a black screen (lesson recorded in the Rust packet.rs)
// 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]))
}
+26
View File
@@ -0,0 +1,26 @@
package gameserver
import (
"testing"
goproto "gac-hkrpg-go/protocol/proto"
)
func TestDispatchMapsRegistered(t *testing.T) {
t.Logf("typedHandlers=%d dummyResponses=%d rawHandlers=%d", len(typedHandlers), len(dummyResponses), len(rawHandlers))
if len(dummyResponses) == 0 {
t.Fatal("dummyResponses is EMPTY - handlers_raw.go init did not run")
}
if len(rawHandlers) == 0 {
t.Fatal("rawHandlers is EMPTY")
}
if _, ok := dummyResponses[17]; !ok {
t.Fatal("GetVideoVersionKeyCsReq (17) not in dummyResponses")
}
if _, ok := dummyResponses[uint16(goproto.CmdIDByName["GetSecretKeyInfoCsReq"])]; !ok {
t.Fatal("GetSecretKeyInfoCsReq not in dummyResponses")
}
if _, ok := rawHandlers[8115]; !ok {
t.Fatal("8115 GetSwitchHandData not in rawHandlers")
}
}
+79
View File
@@ -0,0 +1,79 @@
package gameserver
import (
goproto "gac-hkrpg-go/protocol/proto"
)
// Handlers for cmds the real 4.6.51 client sends during normal play that were
// dropped in the first test session (2026-10-07 01:00) — several of them gate
// UI state (pause menu, Endgame entry, map rotation) on the server's reply.
func init() {
// client pauses itself when opening menus/map — must be acked
handle("SetClientPaused", func(s *Session, req *goproto.SetClientPausedCsReq, rsp *goproto.SetClientPausedScRsp) error {
rsp.Retcode = 0
rsp.Paused = req.Paused
return nil
})
// the Endgame entrance waits for this before opening its UI
// (4.6.51: CsReq 4190 -> ScRsp 4112, retcode = tag 14 — the Rust build
// answered the wrong id 4130 and the client stalled)
rawHandlers[4190] = func(s *Session, payload []byte) error {
return s.SendRaw(&NetPacket{CmdType: 4112, Body: []byte{0x70, 0x00}})
}
handle("TakeQuestReward", func(s *Session, req *goproto.TakeQuestRewardCsReq, rsp *goproto.TakeQuestRewardScRsp) error {
rsp.Retcode = 0
rsp.SuccQuestIdList = req.SuccQuestIdList
return nil
})
handle("GetFirstTalkNpc", func(s *Session, req *goproto.GetFirstTalkNpcCsReq, rsp *goproto.GetFirstTalkNpcScRsp) error {
rsp.Retcode = 0
return nil
})
handle("QuitBattle", func(s *Session, req *goproto.QuitBattleCsReq, rsp *goproto.QuitBattleScRsp) error {
rsp.Retcode = 0
return nil
})
// Amphoreus map rotation (teleport pillars) — empty but valid
handle("GetMapRotationData", func(s *Session, req *goproto.GetMapRotationDataCsReq, rsp *goproto.GetMapRotationDataScRsp) error {
rsp.Retcode = 0
return nil
})
handle("GetPamSkinData", func(s *Session, req *goproto.GetPamSkinDataCsReq, rsp *goproto.GetPamSkinDataScRsp) error {
rsp.Retcode = 0
rsp.CurSkin = 0
rsp.UnlockSkinList = []uint32{0}
return nil
})
handle("GetAllServerPrefsData", func(s *Session, req *goproto.GetAllServerPrefsDataCsReq, rsp *goproto.GetAllServerPrefsDataScRsp) error {
rsp.Retcode = 0
return nil
})
handle("UpdateServerPrefsData", func(s *Session, req *goproto.UpdateServerPrefsDataCsReq, rsp *goproto.UpdateServerPrefsDataScRsp) error {
rsp.Retcode = 0
return nil
})
// simple empty-but-valid data replies (auto-empty also covers these, but
// keeping them explicit documents that they are safe to answer)
handle("GetHeartDialInfo", func(s *Session, req *goproto.GetHeartDialInfoCsReq, rsp *goproto.GetHeartDialInfoScRsp) error {
rsp.Retcode = 0
return nil
})
handle("GetPetData", func(s *Session, req *goproto.GetPetDataCsReq, rsp *goproto.GetPetDataScRsp) error {
rsp.Retcode = 0
return nil
})
handle("GetShopList", func(s *Session, req *goproto.GetShopListCsReq, rsp *goproto.GetShopListScRsp) error {
rsp.Retcode = 0
return nil
})
}
+2
View File
@@ -164,6 +164,8 @@ func (s *Session) SendRaw(pkt *NetPacket) error {
if s.closed.Load() {
return nil
}
PacketLog(fmt.Sprintf("[PACKET SEND RAW] cmd_id: %d %s (len: %d)", pkt.CmdType, goproto.NameByCmdID[pkt.CmdType], len(pkt.Body)))
payload := pkt.Encode()
s.kcpMu.Lock()
+30
View File
@@ -126,6 +126,36 @@ func TestGatewayHandshakeAndLogin(t *testing.T) {
t.Fatalf("expected battle to start for a real monster target")
}
t.Log("SceneCastSkill verified: battle starts for real monster target")
// 6. GetUnreleasedBlockInfo (4190): Endgame entry gates on this reply
if err := client.sendCmd(4190, nil); err != nil {
t.Fatalf("send GetUnreleasedBlockInfo: %v", err)
}
cmdID, body, err = client.recvCmd(3 * time.Second)
if err != nil {
t.Fatalf("no GetUnreleasedBlockInfo response: %v", err)
}
if cmdID != 4112 {
t.Fatalf("unexpected GetUnreleasedBlockInfoScRsp id: got %d want 4112", cmdID)
}
if len(body) != 2 || body[0] != 0x70 || body[1] != 0x00 {
t.Fatalf("unexpected UnreleasedBlockInfo body: %x", body)
}
t.Log("GetUnreleasedBlockInfo verified: 4190 -> 4112 retcode 0")
// 7. SetClientPaused must be acked (pause menu waits for it)
pauseReq, _ := proto.Marshal(&goproto.SetClientPausedCsReq{Paused: true})
if err := client.sendCmd(1466, pauseReq); err != nil {
t.Fatalf("send SetClientPaused: %v", err)
}
cmdID, body, err = client.recvCmd(3 * time.Second)
if err != nil {
t.Fatalf("no SetClientPaused response: %v", err)
}
if cmdID != 1490 { // SetClientPausedScRsp
t.Fatalf("unexpected SetClientPausedScRsp id: got %d want 1490", cmdID)
}
t.Log("SetClientPaused verified: acked")
}
// ---------------------------------------------------------------------------