fix(challenge,scene): challenge id auto-detect, real ScRsp ids, teleport ownership

Round-2 fixes from the 01:44-01:53 client session:

- StartChallenge (MoC/PF/AS normal start) decoded challenge_id=0 -> client
  retried and aborted. detectChallengeID scans top-level varints for the
  first plausible id (challenge_data + known mode ranges) when the strict
  tag-14 decode misses; payload hexdump logged when still 0
- StartChallengeScRsp now carries stage_info (tag 13) with boss node buff
  echo, per the 4.6.51 proto
- GetSwitchHandData replies 8108 (the real ScRsp) - 8104 is
  SwitchHandStartCsReq, we were sending a request id back at the client
- GetSceneMapInfo: strict proto parse failed on some payloads (layout
  drift) which stalled the map UI ('no pillars / navigation loading') -
  now a raw handler with a lenient wire walk fallback + hexdump logging
- teleport destination ('AA teleport landed in AP'): teleport ids repeat
  on every floor and teleports.json was scanned globally in random map
  order. Map clicks now resolve floor-first via scene_identifier, and the
  global id scan never touches teleports.json
- tierce: arena 3014101 -> 3000101 fallback (client has no MapEntrance
  3014101), same as StartChallenge
This commit is contained in:
CAtlas0784 committed 2026-10-07 02:13:45 +07:00
1 parent d364b9616f
commit 876f20d857
4 files changed
+229 -29

No files matched your search

+9 -1
View File
@@ -79,7 +79,7 @@ func handleNamed[ReqT any, RspT any](name, rspName string, fn func(s *Session, r
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))
PacketLog(fmt.Sprintf("[ERROR] Failed to decode cmd_id %d (%s): %v payload=%s", reqID, name, err, hexDump(payload, 256)))
return nil
}
}
@@ -127,6 +127,14 @@ func isDangerCmd(cmdID uint16) bool {
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)))
+88 -7
View File
@@ -567,10 +567,64 @@ func dedupeTruncate4(in []uint32) []uint32 {
return out
}
// looksLikeChallengeID: known challenge-id ranges across endgame modes.
func looksLikeChallengeID(v uint64) bool {
switch {
case v >= 101 && v <= 119, v == 900,
v >= 1001 && v <= 1035,
v >= 2001 && v <= 2025,
v >= 3001 && v <= 3020,
v >= 5000 && v <= 5700, // MoC floors incl. 4.6 phases
v >= 20000 && v <= 20300, // Pure Fiction
v >= 30000 && v <= 30300: // Apocalyptic Shadow
return true
}
cd := getChallengeData()
_, okC := cd.Challenges[uint32(v)]
_, okT := cd.Tierce[uint32(v)]
return okC || okT
}
// detectChallengeID: when the strict decoder misses the challenge id (field
// layout drift between client builds), scan top-level varint fields for the
// first value that looks like a challenge id.
func detectChallengeID(payload []byte) (uint32, int) {
buf := payload
for len(buf) > 0 {
tag, n := readUvarint(buf)
if n == 0 {
break
}
buf = buf[n:]
wireType := uint32(tag & 0x7)
if wireType == 0 {
v, n2 := readUvarint(buf)
if n2 == 0 {
break
}
buf = buf[n2:]
if looksLikeChallengeID(v) {
return uint32(v), int(tag >> 3)
}
} else {
skipWireField(wireType, &buf)
}
}
return 0, 0
}
// handleStartChallenge ports handle_start_challenge: respond cmd 1787 with
// retcode(3) + cur_challenge(5) + lineup_list(6) + scene(10).
// retcode(3) + cur_challenge(5) + lineup_list(6) + scene(10) + stage_info(13).
func handleStartChallenge(s *Session, payload []byte) error {
req := decodeStartChallengeReq(payload)
if req.challengeID == 0 {
if id, field := detectChallengeID(payload); id != 0 {
Log("handle_start_challenge: strict decode missed, detected challenge_id=" + itoa(int(id)) + " at field " + itoa(field))
req.challengeID = id
} else {
PacketLog("[CHALLENGE] challenge_id=0 payload=" + hexDump(payload, 128))
}
}
Log("handle_start_challenge: challenge_id=" + itoa(int(req.challengeID)))
chosenAvatars := req.firstAvatars
@@ -592,12 +646,13 @@ func handleStartChallenge(s *Session, payload []byte) error {
}
}
// fallback to the standard arena when the mapped one is missing from res
if res, err := common.GameRes(); err == nil {
if !mapHasEntry(res, entrance) {
Log("[CHALLENGE] Arena entrance missing in res.json, fallback to 3000101")
entrance = 3000101
}
// the 4.6.51 client has no MapEntrance 3014101 (Rust lesson: Lua
// GotoMapParam nil -> stuck loading) even though res.json has it
if entrance == 3014101 {
entrance = 3000101
} else if res, err := common.GameRes(); err == nil && !mapHasEntry(res, entrance) {
Log("[CHALLENGE] Arena entrance missing in res.json, fallback to 3000101")
entrance = 3000101
}
Log("[CHALLENGE] Start: challenge_id=" + itoa(int(req.challengeID)) + " (mapped " + itoa(int(baseID)) + ")")
@@ -640,6 +695,20 @@ func handleStartChallenge(s *Session, payload []byte) error {
return err
}
// 4.6.51: StartChallengeScRsp.stage_info (tag 13) - boss node info echoes
// the selected buff so the client can set up the challenge UI
stageInfo := &goproto.ChallengeStageInfo{}
if req.buffID != 0 {
stageInfo.BossInfo = &goproto.ChallengeBossInfo{
FirstNode: &goproto.ChallengeBossSingleNodeInfo{BuffId: req.buffID},
SecondNode: &goproto.ChallengeBossSingleNodeInfo{BuffId: req.buffID},
}
}
stageBuf, err := proto.Marshal(stageInfo)
if err != nil {
return err
}
var body []byte
body = append(body, 0x18, 0x00) // tag 3: retcode = 0
body = append(body, 0x2A) // tag 5: cur_challenge
@@ -651,6 +720,9 @@ func handleStartChallenge(s *Session, payload []byte) error {
body = append(body, 0x52) // tag 10: scene
body = appendUvarint(body, uint64(len(sceneBuf)))
body = append(body, sceneBuf...)
body = append(body, 0x6A) // tag 13: stage_info
body = appendUvarint(body, uint64(len(stageBuf)))
body = append(body, stageBuf...)
return s.SendRaw(&NetPacket{CmdType: 1787, Body: body})
}
@@ -731,6 +803,15 @@ func handleStartChallengeTierce(s *Session, payload []byte) error {
}
}
// the 4.6.51 client cannot load arena 3014101 (no MapEntrance) - fall
// back to the standard arena, mirroring handleStartChallenge
if entrance == 3014101 {
entrance = 3000101
} else if res, err := common.GameRes(); err == nil && !mapHasEntry(res, entrance) {
Log("[CHALLENGE] Tierce arena entrance missing in res.json, fallback to 3000101")
entrance = 3000101
}
if player := s.JSON(); player != nil {
setChallengeBattleConfig(player, chosenAvatars, req.challengeID, event, buff)
}
+125 -14
View File
@@ -126,11 +126,24 @@ func init() {
// req.EntryId doubles as a floor id in some map flows
resolvedEntry = findEntryForFloor(res, req.EntryId)
case req.TeleportId != 0:
resolvedEntry = findEntryByTeleport(res, req.TeleportId, currentEntry())
floorID := uint32(0)
if sceneIdent != nil {
floorID = sceneIdent.FloorId
}
if entry := findEntryByTeleportInFloor(res, req.TeleportId, floorID); entry != 0 {
resolvedEntry = entry
} else {
resolvedEntry = findEntryByTeleport(res, req.TeleportId, currentEntry())
}
case sceneIdent != nil && sceneIdent.TeleportNigger != nil:
if t, okc := sceneIdent.TeleportNigger.(*goproto.SceneIdentifier_MDAIDPPKOPO); okc && t.MDAIDPPKOPO != nil && t.MDAIDPPKOPO.PLIDNBMCIJH != 0 {
// map teleport click: teleport id arrives via scene_identifier
resolvedEntry = findEntryByTeleport(res, t.MDAIDPPKOPO.PLIDNBMCIJH, currentEntry())
// map teleport click: teleport id + floor arrive via scene_identifier
tpID := t.MDAIDPPKOPO.PLIDNBMCIJH
if entry := findEntryByTeleportInFloor(res, tpID, sceneIdent.FloorId); entry != 0 {
resolvedEntry = entry
} else {
resolvedEntry = findEntryByTeleport(res, tpID, currentEntry())
}
}
case interactID != 0:
resolvedEntry = findEntryByInteract(res, interactID, currentEntry())
@@ -221,13 +234,33 @@ func init() {
return nil
})
handle("GetSceneMapInfo", func(s *Session, req *goproto.GetSceneMapInfoCsReq, rsp *goproto.GetSceneMapInfoScRsp) error {
// decodeSceneIdentifiersLenient: the 4.6.51 client's cmd-1461 payload can
// fail strict proto parsing (field layout drift between builds) — walk the
// wire format manually and pull floor_id/content_id out of field 2.
// GetSceneMapInfo as a raw handler: the 4.6.51 client's cmd-1461 payload
// can fail strict proto parsing (layout drift), so fall back to a manual
// wire walk instead of dropping the request (the map UI stalls without
// this reply - no pillars, "navigation loading").
rawHandlers[1461] = func(s *Session, payload []byte) error {
res, err := common.GameRes()
if err != nil {
return err
}
rsp := &goproto.GetSceneMapInfoScRsp{}
for _, si := range req.SceneIdentifiers {
var identifiers []*goproto.SceneIdentifier
var strictReq goproto.GetSceneMapInfoCsReq
if err := proto.Unmarshal(payload, &strictReq); err == nil {
identifiers = strictReq.SceneIdentifiers
}
if len(identifiers) == 0 {
identifiers = decodeSceneIdentifiersLenient(payload)
if len(identifiers) > 0 {
PacketLog("[SCENE] GetSceneMapInfo: used lenient decode (strict parse failed)")
}
}
for _, si := range identifiers {
floorID := si.FloorId
mapInfo := &goproto.SceneMapInfo{
FloorId: floorID,
@@ -283,8 +316,9 @@ func init() {
rsp.SceneMapInfo = append(rsp.SceneMapInfo, mapInfo)
}
return nil
})
return s.Send(rsp)
}
handle("SceneEntityMove", func(s *Session, req *goproto.SceneEntityMoveCsReq, rsp *goproto.SceneEntityMoveScRsp) error {
player := s.JSON()
@@ -413,16 +447,35 @@ func mapHasEntry(res *common.JsonConfig, entryID uint32) bool {
return ok
}
func findEntryByTeleport(res *common.JsonConfig, teleportID uint32, fallback uint32) uint32 {
// teleports.json covers floors whose res config has no inline teleports:
// teleport id -> floor -> entry (via the floor name search)
for floorID, m := range common.Teleports() {
if _, ok := m[teleportID]; ok {
if entry := findEntryForFloor(res, floorID); entry != 0 {
return entry
// findEntryByTeleportInFloor resolves a teleport inside a KNOWN floor
// (map-teleport clicks always carry the floor in scene_identifier).
func findEntryByTeleportInFloor(res *common.JsonConfig, teleportID, floorID uint32) uint32 {
if floorID != 0 {
suffix := fmt.Sprintf("_F%d", floorID)
entryIDs := sortedU32Keys2(res.LevelOutputConfigs)
for _, eid := range entryIDs {
for name, cfg := range res.LevelOutputConfigs[eid] {
if len(name) <= len(suffix) || name[len(name)-len(suffix):] != suffix {
continue
}
for _, sc := range cfg.Scenes {
if _, ok := sc.Teleports[teleportID]; ok {
return eid
}
}
}
}
// floor known but teleport not in its res config (teleports.json-only
// floors): the entry owning the floor is still the right destination
return findEntryForFloor(res, floorID)
}
return 0
}
func findEntryByTeleport(res *common.JsonConfig, teleportID uint32, fallback uint32) uint32 {
// NOTE: never scan teleports.json globally by id alone - teleport ids
// (1001, 1002, ...) repeat on every floor, which sent players to a random
// region ("AA teleport landed in AP").
entryIDs := sortedU32Keys2(res.LevelOutputConfigs)
for _, eid := range entryIDs {
names := make([]string, 0, len(res.LevelOutputConfigs[eid]))
@@ -466,6 +519,64 @@ func findEntryByInteract(res *common.JsonConfig, interactID uint32, fallback uin
return fallback
}
// decodeSceneIdentifiersLenient walks the raw wire format of a
// GetSceneMapInfoCsReq payload: field 2 = repeated SceneIdentifier;
// inside each: floor_id = 13 (varint), content_id = 7 (varint).
func decodeSceneIdentifiersLenient(payload []byte) []*goproto.SceneIdentifier {
var out []*goproto.SceneIdentifier
buf := payload
for len(buf) > 0 {
tag, n := readUvarint(buf)
if n == 0 {
break
}
buf = buf[n:]
fieldNumber := uint32(tag >> 3)
wireType := uint32(tag & 0x7)
if fieldNumber == 2 && wireType == 2 {
l, n2 := readUvarint(buf)
if n2 == 0 || int(l) > len(buf[n2:]) {
break
}
sub := buf[n2 : n2+int(l)]
buf = buf[n2+int(l):]
si := &goproto.SceneIdentifier{}
for len(sub) > 0 {
stag, n3 := readUvarint(sub)
if n3 == 0 {
break
}
sfn := uint32(stag >> 3)
swt := uint32(stag & 0x7)
sub2 := sub[n3:]
if swt == 0 && sfn == 13 {
v, n4 := readUvarint(sub2)
if n4 == 0 {
break
}
sub = sub2[n4:]
si.FloorId = uint32(v)
} else if swt == 0 && sfn == 7 {
v, n4 := readUvarint(sub2)
if n4 == 0 {
break
}
sub = sub2[n4:]
si.ContentId = uint32(v)
} else {
skipWireField(swt, &sub)
}
}
out = append(out, si)
} else {
skipWireField(wireType, &buf)
}
}
return out
}
func sortedU32Keys2[V any](m map[uint32]V) []uint32 {
keys := make([]uint32, 0, len(m))
for k := range m {
+7 -7
View File
@@ -6,15 +6,15 @@
"3": 1004
},
"position": {
"x": 780,
"y": -153008,
"z": -621450,
"rot_y": 0
"x": 5379,
"y": 0,
"z": -2300,
"rot_y": 54387
},
"scene": {
"plane_id": 30141,
"floor_id": 30141001,
"entry_id": 3014101
"plane_id": 10000,
"floor_id": 10000000,
"entry_id": 100000304
},
"main_character": "FemaleHarmony",
"march_type": "MarchHunt",