diff --git a/internal/gameserver/dispatch.go b/internal/gameserver/dispatch.go index 5580500..5c1507c 100644 --- a/internal/gameserver/dispatch.go +++ b/internal/gameserver/dispatch.go @@ -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))) diff --git a/internal/gameserver/handlers_challenge_flow.go b/internal/gameserver/handlers_challenge_flow.go index 7a34403..fd2d45d 100644 --- a/internal/gameserver/handlers_challenge_flow.go +++ b/internal/gameserver/handlers_challenge_flow.go @@ -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) } diff --git a/internal/gameserver/handlers_scene.go b/internal/gameserver/handlers_scene.go index 404bd7c..e383b21 100644 --- a/internal/gameserver/handlers_scene.go +++ b/internal/gameserver/handlers_scene.go @@ -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 { diff --git a/persistent b/persistent index 6cda4d7..96b5765 100644 --- a/persistent +++ b/persistent @@ -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",