package channelserver import ( "encoding/binary" ps "erupe-ce/common/pascalstring" "erupe-ce/common/byteframe" "erupe-ce/network/mhfpacket" "go.uber.org/zap" ) // Rengoku save blob layout offsets const ( rengokuSkillSlotsStart = 0x1B rengokuSkillSlotsEnd = 0x21 rengokuSkillValuesStart = 0x2E rengokuSkillValuesEnd = 0x3A rengokuPointsStart = 0x3B rengokuPointsEnd = 0x47 rengokuMaxStageMpOffset = 71 rengokuMinPayloadSize = 91 rengokuMaxPayloadSize = 4096 ) // rengokuSkillsZeroed checks if the skill slot IDs (offsets 0x1B-0x20) and // equipped skill values (offsets 0x2E-0x39) are all zero in a rengoku save blob. func rengokuSkillsZeroed(data []byte) bool { if len(data) < rengokuSkillValuesEnd { return true } for _, b := range data[rengokuSkillSlotsStart:rengokuSkillSlotsEnd] { if b != 0 { return false } } for _, b := range data[rengokuSkillValuesStart:rengokuSkillValuesEnd] { if b != 0 { return false } } return true } // rengokuHasPoints checks if any skill point allocation (offsets 0x3B-0x46) is nonzero. func rengokuHasPoints(data []byte) bool { if len(data) < rengokuPointsEnd { return false } for _, b := range data[rengokuPointsStart:rengokuPointsEnd] { if b != 0 { return true } } return false } // rengokuMergeSkills copies skill slot IDs (0x1B-0x20) and equipped skill // values (0x2E-0x39) from existing data into the incoming save payload, // preserving the skills that the client failed to populate due to a race // condition during area transitions (see issue #85). func rengokuMergeSkills(dst, src []byte) { copy(dst[rengokuSkillSlotsStart:rengokuSkillSlotsEnd], src[rengokuSkillSlotsStart:rengokuSkillSlotsEnd]) copy(dst[rengokuSkillValuesStart:rengokuSkillValuesEnd], src[rengokuSkillValuesStart:rengokuSkillValuesEnd]) } func handleMsgMhfSaveRengokuData(s *Session, p mhfpacket.MHFPacket) { // Saved every floor on road, holds values such as floors progressed, points etc. // Can be safely handled by the client. pkt := p.(*mhfpacket.MsgMhfSaveRengokuData) if len(pkt.RawDataPayload) < rengokuMinPayloadSize || len(pkt.RawDataPayload) > rengokuMaxPayloadSize { s.logger.Warn("Rengoku payload size out of range", zap.Int("len", len(pkt.RawDataPayload))) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) return } dumpSaveData(s, pkt.RawDataPayload, "rengoku") saveData := pkt.RawDataPayload // Guard against a client race condition (issue #85): the Sky Corridor init // path triggers a rengoku save BEFORE the load response has been parsed into // the character data area. This produces a save with zeroed skill fields but // preserved point totals. Detect this pattern and merge existing skill data. if len(saveData) >= rengokuPointsEnd && rengokuSkillsZeroed(saveData) && rengokuHasPoints(saveData) { existing, err := s.server.charRepo.LoadColumn(s.charID, "rengokudata") if err == nil { if len(existing) >= rengokuPointsEnd && !rengokuSkillsZeroed(existing) { s.logger.Info("Rengoku save has zeroed skills with invested points, preserving existing skills", zap.Uint32("charID", s.charID)) merged := make([]byte, len(saveData)) copy(merged, saveData) rengokuMergeSkills(merged, existing) saveData = merged } } } // Also reject saves where the sentinel is 0 (no data) if valid data already exists. if len(saveData) >= 4 && binary.BigEndian.Uint32(saveData[:4]) == 0 { existing, err := s.server.charRepo.LoadColumn(s.charID, "rengokudata") if err == nil { if len(existing) >= 4 && binary.BigEndian.Uint32(existing[:4]) != 0 { s.logger.Warn("Refusing to overwrite valid rengoku data with empty sentinel", zap.Uint32("charID", s.charID)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) return } } } err := s.server.charRepo.SaveColumn(s.charID, "rengokudata", saveData) if err != nil { s.logger.Error("Failed to save rengokudata", zap.Error(err)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) return } bf := byteframe.NewByteFrameFromBytes(saveData) _, _ = bf.Seek(rengokuMaxStageMpOffset, 0) maxStageMp := bf.ReadUint32() maxScoreMp := bf.ReadUint32() _, _ = bf.Seek(4, 1) maxStageSp := bf.ReadUint32() maxScoreSp := bf.ReadUint32() if err := s.server.rengokuRepo.UpsertScore(s.charID, maxStageMp, maxScoreMp, maxStageSp, maxScoreSp); err != nil { s.logger.Error("Failed to upsert rengoku score", zap.Error(err)) } doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) } func handleMsgMhfLoadRengokuData(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfLoadRengokuData) data, err := s.server.charRepo.LoadColumn(s.charID, "rengokudata") if err != nil { s.logger.Error("Failed to load rengokudata", zap.Error(err), zap.Uint32("charID", s.charID)) } if len(data) > 0 { doAckBufSucceed(s, pkt.AckHandle, data) } else { resp := byteframe.NewByteFrame() resp.WriteUint32(0) resp.WriteUint32(0) resp.WriteUint16(0) resp.WriteUint32(0) resp.WriteUint16(0) resp.WriteUint16(0) resp.WriteUint32(0) resp.WriteUint32(0) // an extra 4 bytes were missing based on pcaps resp.WriteUint8(3) // Count of next 3 resp.WriteUint16(0) resp.WriteUint16(0) resp.WriteUint16(0) resp.WriteUint32(0) resp.WriteUint32(0) resp.WriteUint32(0) resp.WriteUint8(3) // Count of next 3 resp.WriteUint32(0) resp.WriteUint32(0) resp.WriteUint32(0) resp.WriteUint8(3) // Count of next 3 resp.WriteUint32(0) resp.WriteUint32(0) resp.WriteUint32(0) resp.WriteUint32(0) resp.WriteUint32(0) resp.WriteUint32(0) resp.WriteUint32(0) resp.WriteUint32(0) resp.WriteUint32(0) doAckBufSucceed(s, pkt.AckHandle, resp.Data()) } } func handleMsgMhfGetRengokuBinary(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetRengokuBinary) data := s.server.rengokuBin if data == nil { doAckBufFail(s, pkt.AckHandle, nil) return } doAckBufSucceed(s, pkt.AckHandle, data) } // RengokuScore represents a Rengoku (Hunting Road) ranking score. type RengokuScore struct { Name string `db:"name"` Score uint32 `db:"score"` } func handleMsgMhfEnumerateRengokuRanking(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfEnumerateRengokuRanking) guild, guildErr := s.server.guildRepo.GetByCharID(s.charID) if guildErr != nil { s.logger.Warn("Failed to get guild for rengoku ranking", zap.Error(guildErr)) } var isApplicant bool if guild != nil { var appErr error isApplicant, appErr = s.server.guildRepo.HasApplication(guild.ID, s.charID) if appErr != nil { s.logger.Warn("Failed to check guild application status", zap.Error(appErr)) } } if isApplicant { guild = nil } if pkt.Leaderboard == 2 || pkt.Leaderboard == 3 || pkt.Leaderboard == 6 || pkt.Leaderboard == 7 { if guild == nil { doAckBufSucceed(s, pkt.AckHandle, make([]byte, 11)) return } } var selfExist bool i := uint32(1) bf := byteframe.NewByteFrame() scoreData := byteframe.NewByteFrame() var guildID uint32 if guild != nil { guildID = guild.ID } scores, err := s.server.rengokuRepo.GetRanking(pkt.Leaderboard, guildID) if err != nil { s.logger.Error("Failed to query rengoku ranking", zap.Error(err)) doAckBufSucceed(s, pkt.AckHandle, make([]byte, 11)) return } for _, score := range scores { if score.Name == s.Name { bf.WriteUint32(i) bf.WriteUint32(score.Score) ps.Uint8(bf, s.Name, true) ps.Uint8(bf, "", false) selfExist = true } if i > 100 { i++ continue } scoreData.WriteUint32(i) scoreData.WriteUint32(score.Score) ps.Uint8(scoreData, score.Name, true) ps.Uint8(scoreData, "", false) i++ } if !selfExist { bf.WriteBytes(make([]byte, 10)) } bf.WriteUint8(uint8(i) - 1) bf.WriteBytes(scoreData.Data()) doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } func handleMsgMhfGetRengokuRankingRank(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetRengokuRankingRank) // What is this for? bf := byteframe.NewByteFrame() bf.WriteUint32(0) // Max stage overall MP rank bf.WriteUint32(0) // Max RdP overall MP rank doAckBufSucceed(s, pkt.AckHandle, bf.Data()) }