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:
1 parent
d364b9616f
commit
876f20d857
4 files changed
+229
-29
No files matched your search
@@ -79,7 +79,7 @@ func handleNamed[ReqT any, RspT any](name, rspName string, fn func(s *Session, r
|
|||||||
if len(payload) > 0 {
|
if len(payload) > 0 {
|
||||||
if m, okc := any(req).(proto.Message); okc {
|
if m, okc := any(req).(proto.Message); okc {
|
||||||
if err := proto.Unmarshal(payload, m); err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,14 @@ func isDangerCmd(cmdID uint16) bool {
|
|||||||
return false
|
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.
|
// DispatchMessage is the Go port of CommandHandler::on_message.
|
||||||
func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
|
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)))
|
PacketLog(fmt.Sprintf("[PACKET RECV] cmd_id: %d %s (len: %d)", cmdID, goproto.NameByCmdID[cmdID], len(payload)))
|
||||||
|
|||||||
@@ -567,10 +567,64 @@ func dedupeTruncate4(in []uint32) []uint32 {
|
|||||||
return out
|
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
|
// 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 {
|
func handleStartChallenge(s *Session, payload []byte) error {
|
||||||
req := decodeStartChallengeReq(payload)
|
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)))
|
Log("handle_start_challenge: challenge_id=" + itoa(int(req.challengeID)))
|
||||||
|
|
||||||
chosenAvatars := req.firstAvatars
|
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
|
// the 4.6.51 client has no MapEntrance 3014101 (Rust lesson: Lua
|
||||||
if res, err := common.GameRes(); err == nil {
|
// GotoMapParam nil -> stuck loading) even though res.json has it
|
||||||
if !mapHasEntry(res, entrance) {
|
if entrance == 3014101 {
|
||||||
Log("[CHALLENGE] Arena entrance missing in res.json, fallback to 3000101")
|
entrance = 3000101
|
||||||
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)) + ")")
|
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
|
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
|
var body []byte
|
||||||
body = append(body, 0x18, 0x00) // tag 3: retcode = 0
|
body = append(body, 0x18, 0x00) // tag 3: retcode = 0
|
||||||
body = append(body, 0x2A) // tag 5: cur_challenge
|
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 = append(body, 0x52) // tag 10: scene
|
||||||
body = appendUvarint(body, uint64(len(sceneBuf)))
|
body = appendUvarint(body, uint64(len(sceneBuf)))
|
||||||
body = append(body, 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})
|
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 {
|
if player := s.JSON(); player != nil {
|
||||||
setChallengeBattleConfig(player, chosenAvatars, req.challengeID, event, buff)
|
setChallengeBattleConfig(player, chosenAvatars, req.challengeID, event, buff)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,11 +126,24 @@ func init() {
|
|||||||
// req.EntryId doubles as a floor id in some map flows
|
// req.EntryId doubles as a floor id in some map flows
|
||||||
resolvedEntry = findEntryForFloor(res, req.EntryId)
|
resolvedEntry = findEntryForFloor(res, req.EntryId)
|
||||||
case req.TeleportId != 0:
|
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:
|
case sceneIdent != nil && sceneIdent.TeleportNigger != nil:
|
||||||
if t, okc := sceneIdent.TeleportNigger.(*goproto.SceneIdentifier_MDAIDPPKOPO); okc && t.MDAIDPPKOPO != nil && t.MDAIDPPKOPO.PLIDNBMCIJH != 0 {
|
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
|
// map teleport click: teleport id + floor arrive via scene_identifier
|
||||||
resolvedEntry = findEntryByTeleport(res, t.MDAIDPPKOPO.PLIDNBMCIJH, currentEntry())
|
tpID := t.MDAIDPPKOPO.PLIDNBMCIJH
|
||||||
|
if entry := findEntryByTeleportInFloor(res, tpID, sceneIdent.FloorId); entry != 0 {
|
||||||
|
resolvedEntry = entry
|
||||||
|
} else {
|
||||||
|
resolvedEntry = findEntryByTeleport(res, tpID, currentEntry())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case interactID != 0:
|
case interactID != 0:
|
||||||
resolvedEntry = findEntryByInteract(res, interactID, currentEntry())
|
resolvedEntry = findEntryByInteract(res, interactID, currentEntry())
|
||||||
@@ -221,13 +234,33 @@ func init() {
|
|||||||
return nil
|
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()
|
res, err := common.GameRes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
floorID := si.FloorId
|
||||||
mapInfo := &goproto.SceneMapInfo{
|
mapInfo := &goproto.SceneMapInfo{
|
||||||
FloorId: floorID,
|
FloorId: floorID,
|
||||||
@@ -283,8 +316,9 @@ func init() {
|
|||||||
|
|
||||||
rsp.SceneMapInfo = append(rsp.SceneMapInfo, mapInfo)
|
rsp.SceneMapInfo = append(rsp.SceneMapInfo, mapInfo)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
})
|
return s.Send(rsp)
|
||||||
|
}
|
||||||
|
|
||||||
handle("SceneEntityMove", func(s *Session, req *goproto.SceneEntityMoveCsReq, rsp *goproto.SceneEntityMoveScRsp) error {
|
handle("SceneEntityMove", func(s *Session, req *goproto.SceneEntityMoveCsReq, rsp *goproto.SceneEntityMoveScRsp) error {
|
||||||
player := s.JSON()
|
player := s.JSON()
|
||||||
@@ -413,16 +447,35 @@ func mapHasEntry(res *common.JsonConfig, entryID uint32) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func findEntryByTeleport(res *common.JsonConfig, teleportID uint32, fallback uint32) uint32 {
|
// findEntryByTeleportInFloor resolves a teleport inside a KNOWN floor
|
||||||
// teleports.json covers floors whose res config has no inline teleports:
|
// (map-teleport clicks always carry the floor in scene_identifier).
|
||||||
// teleport id -> floor -> entry (via the floor name search)
|
func findEntryByTeleportInFloor(res *common.JsonConfig, teleportID, floorID uint32) uint32 {
|
||||||
for floorID, m := range common.Teleports() {
|
if floorID != 0 {
|
||||||
if _, ok := m[teleportID]; ok {
|
suffix := fmt.Sprintf("_F%d", floorID)
|
||||||
if entry := findEntryForFloor(res, floorID); entry != 0 {
|
entryIDs := sortedU32Keys2(res.LevelOutputConfigs)
|
||||||
return entry
|
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)
|
entryIDs := sortedU32Keys2(res.LevelOutputConfigs)
|
||||||
for _, eid := range entryIDs {
|
for _, eid := range entryIDs {
|
||||||
names := make([]string, 0, len(res.LevelOutputConfigs[eid]))
|
names := make([]string, 0, len(res.LevelOutputConfigs[eid]))
|
||||||
@@ -466,6 +519,64 @@ func findEntryByInteract(res *common.JsonConfig, interactID uint32, fallback uin
|
|||||||
return fallback
|
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 {
|
func sortedU32Keys2[V any](m map[uint32]V) []uint32 {
|
||||||
keys := make([]uint32, 0, len(m))
|
keys := make([]uint32, 0, len(m))
|
||||||
for k := range m {
|
for k := range m {
|
||||||
|
|||||||
+7
-7
@@ -6,15 +6,15 @@
|
|||||||
"3": 1004
|
"3": 1004
|
||||||
},
|
},
|
||||||
"position": {
|
"position": {
|
||||||
"x": 780,
|
"x": 5379,
|
||||||
"y": -153008,
|
"y": 0,
|
||||||
"z": -621450,
|
"z": -2300,
|
||||||
"rot_y": 0
|
"rot_y": 54387
|
||||||
},
|
},
|
||||||
"scene": {
|
"scene": {
|
||||||
"plane_id": 30141,
|
"plane_id": 10000,
|
||||||
"floor_id": 30141001,
|
"floor_id": 10000000,
|
||||||
"entry_id": 3014101
|
"entry_id": 100000304
|
||||||
},
|
},
|
||||||
"main_character": "FemaleHarmony",
|
"main_character": "FemaleHarmony",
|
||||||
"march_type": "MarchHunt",
|
"march_type": "MarchHunt",
|
||||||
|
|||||||
Reference in new issue
Block a user