Files
Erupe/server/channelserver/handlers_quest.go
Houmgaor 9b0f735335 chore(merge): merge develop into main for 9.4.0 cycle
Brings 53 develop commits (i18n, Diva, campaign, guild invites, save
transfer, return/rookie guilds, hunting tournament, JSON quest/scenario
loaders, Ghidra-derived user binary parsing, and misc fixes) onto main
now that 9.3.2 has been tagged and released.

Resolves two overlap zones:

1. Migration number collision. Main shipped 0010_fix_zero_rasta_id and
   0011_fix_stale_boost_time in 9.3.2; develop had independently
   numbered 0010_campaign..0015_tournament. The migration runner keys
   applied versions by integer, so coexisting files with the same
   numeric prefix would silently skip each other. Develop's files have
   been renumbered to 0016..0021, leaving main's 0010/0011 intact. A
   schema_version rename script is required on any server that had
   already applied the old develop numbers (only frontier.mogapedia.fr
   at the time of this merge).

2. CHANGELOG.md. Develop's in-progress feature entries move into
   [Unreleased] with updated migration references; the [9.3.2] section
   is preserved verbatim.

main.go version string bumped to 9.4.0-dev to mark the new cycle.

Full test suite (go test -race ./...) passes.
2026-04-06 19:06:09 +02:00

765 lines
27 KiB
Go

package channelserver
import (
"encoding/binary"
"erupe-ce/common/byteframe"
"erupe-ce/common/decryption"
ps "erupe-ce/common/pascalstring"
cfg "erupe-ce/config"
"erupe-ce/network/mhfpacket"
"fmt"
"io"
"math"
"os"
"path/filepath"
"time"
"go.uber.org/zap"
)
type tuneValue struct {
ID uint16
Value uint16
}
func findSubSliceIndices(data []byte, sub []byte) []int {
var indices []int
lenSub := len(sub)
for i := 0; i < len(data); i++ {
if i+lenSub > len(data) {
break
}
if equal(data[i:i+lenSub], sub) {
indices = append(indices, i)
}
}
return indices
}
func equal(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i, v := range a {
if v != b[i] {
return false
}
}
return true
}
// BackportQuest converts a quest binary to an older format.
func BackportQuest(data []byte, mode cfg.Mode) []byte {
wp := binary.LittleEndian.Uint32(data[0:4]) + questRewardTableBase
rp := wp + 4
for i := uint32(0); i < 6; i++ {
if i != 0 {
wp += 4
rp += 8
}
copy(data[wp:wp+4], data[rp:rp+4])
}
fillLength := questBackportFillZZ
if mode <= cfg.S6 {
fillLength = questBackportFillS6
} else if mode <= cfg.F5 {
fillLength = questBackportFillF5
} else if mode <= cfg.G101 {
fillLength = questBackportFillG101
}
copy(data[wp:wp+fillLength], data[rp:rp+fillLength])
if mode <= cfg.G91 {
patterns := [][]byte{
{0x0A, 0x00, 0x01, 0x33, 0xD7, 0x00}, // 10% Armor Sphere -> Stone
{0x06, 0x00, 0x02, 0x33, 0xD8, 0x00}, // 6% Armor Sphere+ -> Iron Ore
{0x0A, 0x00, 0x03, 0x33, 0xD7, 0x00}, // 10% Adv Armor Sphere -> Stone
{0x06, 0x00, 0x04, 0x33, 0xDB, 0x00}, // 6% Hard Armor Sphere -> Dragonite Ore
{0x0A, 0x00, 0x05, 0x33, 0xD9, 0x00}, // 10% Heaven Armor Sphere -> Earth Crystal
{0x06, 0x00, 0x06, 0x33, 0xDB, 0x00}, // 6% True Armor Sphere -> Dragonite Ore
}
for i := range patterns {
j := findSubSliceIndices(data, patterns[i][0:4])
for k := range j {
copy(data[j[k]+2:j[k]+4], patterns[i][4:6])
}
}
}
if mode <= cfg.S6 {
binary.LittleEndian.PutUint32(data[16:20], binary.LittleEndian.Uint32(data[8:12]))
}
return data
}
func handleMsgSysGetFile(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgSysGetFile)
if pkt.IsScenario {
if s.server.erupeConfig.DebugOptions.QuestTools {
s.logger.Debug(
"Scenario",
zap.Uint8("CategoryID", pkt.ScenarioIdentifer.CategoryID),
zap.Uint32("MainID", pkt.ScenarioIdentifer.MainID),
zap.Uint8("ChapterID", pkt.ScenarioIdentifer.ChapterID),
zap.Uint8("Flags", pkt.ScenarioIdentifer.Flags),
)
}
filename := fmt.Sprintf("%d_0_0_0_S%d_T%d_C%d", pkt.ScenarioIdentifer.CategoryID, pkt.ScenarioIdentifer.MainID, pkt.ScenarioIdentifer.Flags, pkt.ScenarioIdentifer.ChapterID)
data, err := loadScenarioBinary(s, filename)
if err != nil {
s.logger.Error("Failed to open scenario file", zap.String("binPath", s.server.erupeConfig.BinPath), zap.String("filename", filename), zap.Error(err))
doAckBufFail(s, pkt.AckHandle, nil)
return
}
doAckBufSucceed(s, pkt.AckHandle, data)
} else {
if s.server.erupeConfig.DebugOptions.QuestTools {
s.logger.Debug(
"Quest",
zap.String("Filename", pkt.Filename),
)
}
if s.server.erupeConfig.GameplayOptions.SeasonOverride {
pkt.Filename = seasonConversion(s, pkt.Filename)
}
data, err := loadQuestBinary(s, pkt.Filename)
if err != nil {
s.logger.Error("Failed to open quest file", zap.String("binPath", s.server.erupeConfig.BinPath), zap.String("filename", pkt.Filename), zap.Error(err))
doAckBufFail(s, pkt.AckHandle, nil)
return
}
if s.server.erupeConfig.RealClientMode <= cfg.Z1 && s.server.erupeConfig.DebugOptions.AutoQuestBackport {
data = BackportQuest(decryption.UnpackSimple(data), s.server.erupeConfig.RealClientMode)
}
doAckBufSucceed(s, pkt.AckHandle, data)
}
}
func questFileExists(s *Session, filename string) bool {
base := filepath.Join(s.server.erupeConfig.BinPath, "quests", filename)
if _, err := os.Stat(base + ".bin"); err == nil {
return true
}
_, err := os.Stat(base + ".json")
return err == nil
}
// loadQuestBinary loads a quest file by name, trying .bin first then .json.
// For .json files it compiles the JSON to the MHF binary wire format.
func loadQuestBinary(s *Session, filename string) ([]byte, error) {
base := filepath.Join(s.server.erupeConfig.BinPath, "quests", filename)
if data, err := os.ReadFile(base + ".bin"); err == nil {
return data, nil
}
jsonData, err := os.ReadFile(base + ".json")
if err != nil {
return nil, err
}
compiled, err := CompileQuestJSON(jsonData)
if err != nil {
return nil, fmt.Errorf("compile quest JSON %s: %w", filename, err)
}
return compiled, nil
}
// loadScenarioBinary loads a scenario file by name, trying .bin first then .json.
// For .json files it compiles the JSON to the MHF binary wire format.
func loadScenarioBinary(s *Session, filename string) ([]byte, error) {
base := filepath.Join(s.server.erupeConfig.BinPath, "scenarios", filename)
if data, err := os.ReadFile(base + ".bin"); err == nil {
return data, nil
}
jsonData, err := os.ReadFile(base + ".json")
if err != nil {
return nil, err
}
compiled, err := CompileScenarioJSON(jsonData)
if err != nil {
return nil, fmt.Errorf("compile scenario JSON %s: %w", filename, err)
}
return compiled, nil
}
func seasonConversion(s *Session, questFile string) string {
// Try the seasonal override file (e.g., 00001d2 for season 2)
filename := fmt.Sprintf("%s%d", questFile[:6], s.server.Season())
if questFileExists(s, filename) {
return filename
}
// Try the originally requested file as-is
if questFileExists(s, questFile) {
return questFile
}
// Try constructing a day/night base file (e.g., 00001d0 or 00001n0).
// Quest filenames are formatted as [5-digit ID][d/n][season]: e.g., "00001d0".
var currentTime, oppositeTime string
if TimeGameAbsolute() > 2880 {
currentTime = "d"
oppositeTime = "n"
} else {
currentTime = "n"
oppositeTime = "d"
}
// Try current time-of-day base variant
dayNightFile := fmt.Sprintf("%s%s%d", questFile[:5], currentTime, 0)
if questFileExists(s, dayNightFile) {
return dayNightFile
}
// Try opposite time-of-day base variant as last resort
oppositeFile := fmt.Sprintf("%s%s%d", questFile[:5], oppositeTime, 0)
if questFileExists(s, oppositeFile) {
s.logger.Warn("Quest file not found for current time, using opposite variant",
zap.String("requested", questFile),
zap.String("using", oppositeFile),
)
return oppositeFile
}
// No valid file found. Return the original request so handleMsgSysGetFile
// sends doAckBufFail, which triggers the client's error dialog
// (snj_questd_matching_fail → SetDialogData) instead of a softlock.
s.logger.Warn("No quest file variant found for any season or time-of-day",
zap.String("requested", questFile),
)
return questFile
}
func handleMsgMhfLoadFavoriteQuest(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfLoadFavoriteQuest)
loadCharacterData(s, pkt.AckHandle, "savefavoritequest",
[]byte{0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
}
func handleMsgMhfSaveFavoriteQuest(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfSaveFavoriteQuest)
saveCharacterData(s, pkt.AckHandle, "savefavoritequest", pkt.Data, 65536)
}
func loadQuestFile(s *Session, questId int) []byte {
if cached, ok := s.server.questCache.Get(questId); ok {
return cached
}
base := filepath.Join(s.server.erupeConfig.BinPath, fmt.Sprintf("quests/%05dd0", questId))
var decrypted []byte
if data, err := os.ReadFile(base + ".bin"); err == nil {
decrypted = decryption.UnpackSimple(data)
} else if jsonData, err := os.ReadFile(base + ".json"); err == nil {
compiled, err := CompileQuestJSON(jsonData)
if err != nil {
s.logger.Error("loadQuestFile: failed to compile quest JSON",
zap.Int("questId", questId), zap.Error(err))
return nil
}
decrypted = compiled
} else {
return nil
}
if s.server.erupeConfig.RealClientMode <= cfg.Z1 && s.server.erupeConfig.DebugOptions.AutoQuestBackport {
decrypted = BackportQuest(decrypted, s.server.erupeConfig.RealClientMode)
}
fileBytes := byteframe.NewByteFrameFromBytes(decrypted)
fileBytes.SetLE()
_, _ = fileBytes.Seek(int64(fileBytes.ReadUint32()), 0)
bodyLength := questBodyLenZZ
if s.server.erupeConfig.RealClientMode <= cfg.S6 {
bodyLength = questBodyLenS6
} else if s.server.erupeConfig.RealClientMode <= cfg.F5 {
bodyLength = questBodyLenF5
} else if s.server.erupeConfig.RealClientMode <= cfg.G101 {
bodyLength = questBodyLenG101
} else if s.server.erupeConfig.RealClientMode <= cfg.Z1 {
bodyLength = questBodyLenZ1
}
// The n bytes directly following the data pointer must go directly into the event's body, after the header and before the string pointers.
questBody := byteframe.NewByteFrameFromBytes(fileBytes.ReadBytes(uint(bodyLength)))
questBody.SetLE()
// Find the master quest string pointer
_, _ = questBody.Seek(questStringPointerOff, 0)
_, _ = fileBytes.Seek(int64(questBody.ReadUint32()), 0)
_, _ = questBody.Seek(questStringPointerOff, 0)
// Overwrite it
questBody.WriteUint32(uint32(bodyLength))
_, _ = questBody.Seek(0, 2)
// Rewrite the quest strings and their pointers
var tempString []byte
newStrings := byteframe.NewByteFrame()
tempPointer := bodyLength + questStringTablePadding
for i := 0; i < questStringCount; i++ {
questBody.WriteUint32(uint32(tempPointer))
temp := int64(fileBytes.Index())
_, _ = fileBytes.Seek(int64(fileBytes.ReadUint32()), 0)
tempString = fileBytes.ReadNullTerminatedBytes()
_, _ = fileBytes.Seek(temp+4, 0)
tempPointer += len(tempString) + 1
newStrings.WriteNullTerminatedBytes(tempString)
}
questBody.WriteBytes(newStrings.Data())
result := questBody.Data()
s.server.questCache.Put(questId, result)
return result
}
func makeEventQuest(s *Session, eq EventQuest) ([]byte, error) {
data := loadQuestFile(s, eq.QuestID)
if data == nil {
return nil, fmt.Errorf("failed to load quest file (%d)", eq.QuestID)
}
bf := byteframe.NewByteFrame()
bf.WriteUint32(eq.ID)
bf.WriteUint32(0) // Unk
bf.WriteUint8(0) // Unk
switch eq.QuestType {
case QuestTypeRegularRaviente:
bf.WriteUint8(s.server.erupeConfig.GameplayOptions.RegularRavienteMaxPlayers)
case QuestTypeViolentRaviente:
bf.WriteUint8(s.server.erupeConfig.GameplayOptions.ViolentRavienteMaxPlayers)
case QuestTypeBerserkRaviente:
bf.WriteUint8(s.server.erupeConfig.GameplayOptions.BerserkRavienteMaxPlayers)
case QuestTypeExtremeRaviente:
bf.WriteUint8(s.server.erupeConfig.GameplayOptions.ExtremeRavienteMaxPlayers)
case QuestTypeSmallBerserkRavi:
bf.WriteUint8(s.server.erupeConfig.GameplayOptions.SmallBerserkRavienteMaxPlayers)
default:
bf.WriteUint8(eq.MaxPlayers)
}
bf.WriteUint8(eq.QuestType)
if eq.QuestType == QuestTypeSpecialTool {
var stamps, required int
var deadline time.Time
err := s.server.db.QueryRow(`SELECT COUNT(*) FROM campaign_state WHERE campaign_id = (
SELECT campaign_id
FROM campaign_rewards
WHERE item_type = 9
AND item_id = $1
LIMIT 1
) AND character_id = $2`, eq.QuestID, s.charID).Scan(&stamps)
if err != nil {
bf.WriteBool(false)
} else {
err = s.server.db.QueryRow(`SELECT stamps, end_time
FROM campaigns
WHERE id = (
SELECT campaign_id
FROM campaign_rewards
WHERE item_type = 9
AND item_id = $1
LIMIT 1
)`, eq.QuestID).Scan(&required, &deadline)
required = campaignRequiredStamps(required)
if err == nil && stamps >= required && deadline.After(time.Now()) {
bf.WriteBool(true)
} else {
bf.WriteBool(false)
}
}
} else {
bf.WriteBool(true)
}
bf.WriteUint16(0) // Unk
if s.server.erupeConfig.RealClientMode >= cfg.G2 {
bf.WriteUint32(eq.Mark)
}
bf.WriteUint16(0) // Unk
bf.WriteUint16(uint16(len(data)))
bf.WriteBytes(data)
// Time Flag Replacement
// Bitset Structure: b8 UNK, b7 Required Objective, b6 UNK, b5 Night, b4 Day, b3 Cold, b2 Warm, b1 Spring
// if the byte is set to 0 the game choses the quest file corresponding to whatever season the game is on
_, _ = bf.Seek(questFrameTimeFlagOffset, 0)
flagByte := bf.ReadUint8()
_, _ = bf.Seek(questFrameTimeFlagOffset, 0)
if s.server.erupeConfig.GameplayOptions.SeasonOverride {
bf.WriteUint8(flagByte & 0b11100000)
} else {
// Allow for seasons to be specified in database, otherwise use the one in the file.
if eq.Flags < 0 {
bf.WriteUint8(flagByte)
} else {
bf.WriteUint8(uint8(eq.Flags))
}
}
// Bitset Structure Quest Variant 1: b8 UL Fixed, b7 UNK, b6 UNK, b5 UNK, b4 G Rank, b3 HC to UL, b2 Fix HC, b1 Hiden
// Bitset Structure Quest Variant 2: b8 Road, b7 High Conquest, b6 Fixed Difficulty, b5 No Active Feature, b4 Timer, b3 No Cuff, b2 No Halk Pots, b1 Low Conquest
// Bitset Structure Quest Variant 3: b8 No Sigils, b7 UNK, b6 Interception, b5 Zenith, b4 No GP Skills, b3 No Simple Mode?, b2 GSR to GR, b1 No Reward Skills
_, _ = bf.Seek(questFrameVariant3Offset, 0)
questVariant3 := bf.ReadUint8()
questVariant3 &= 0b11011111 // disable Interception flag
_, _ = bf.Seek(questFrameVariant3Offset, 0)
bf.WriteUint8(questVariant3)
_, _ = bf.Seek(0, 2)
ps.Uint8(bf, "", true) // Debug/Notes string for quest
return bf.Data(), nil
}
func handleMsgMhfEnumerateQuest(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfEnumerateQuest)
var totalCount, returnedCount uint16
bf := byteframe.NewByteFrame()
bf.WriteUint16(0)
quests, err := s.server.eventRepo.GetEventQuests()
if err == nil {
currentTime := time.Now()
var updates []EventQuestUpdate
for i, eq := range quests {
// Use the Event Cycling system
if eq.ActiveDays > 0 {
cycleLength := (time.Duration(eq.ActiveDays) + time.Duration(eq.InactiveDays)) * 24 * time.Hour
// Count the number of full cycles elapsed since the last rotation.
extraCycles := int(currentTime.Sub(eq.StartTime) / cycleLength)
if extraCycles > 0 {
// Calculate the rotation time based on start time, active duration, and inactive duration.
rotationTime := eq.StartTime.Add(time.Duration(eq.ActiveDays+eq.InactiveDays) * 24 * time.Hour * time.Duration(extraCycles))
if currentTime.After(rotationTime) {
// Normalize rotationTime to 12PM JST to align with the in-game events update notification.
newRotationTime := time.Date(rotationTime.Year(), rotationTime.Month(), rotationTime.Day(), 12, 0, 0, 0, TimeAdjusted().Location())
updates = append(updates, EventQuestUpdate{ID: eq.ID, StartTime: newRotationTime})
quests[i].StartTime = newRotationTime // Set the new start time so the quest can be used/removed immediately.
eq = quests[i]
}
}
// Check if the quest is currently active
if currentTime.Before(eq.StartTime) || currentTime.After(eq.StartTime.Add(time.Duration(eq.ActiveDays)*24*time.Hour)) {
continue
}
}
data, err := makeEventQuest(s, eq)
if err != nil {
s.logger.Error("Failed to make event quest", zap.Error(err))
continue
} else {
if len(data) > questDataMaxLen || len(data) < questDataMinLen {
s.logger.Error("Invalid quest data length", zap.Int("len", len(data)))
continue
} else {
totalCount++
if totalCount > pkt.Offset && len(bf.Data()) < 60000 {
returnedCount++
bf.WriteBytes(data)
continue
}
}
}
}
if err := s.server.eventRepo.UpdateEventQuestStartTimes(updates); err != nil {
s.logger.Error("Failed to update event quest start times", zap.Error(err))
}
}
tuneValues := []tuneValue{
{ID: 20, Value: 1},
{ID: 26, Value: 1},
{ID: 27, Value: 1},
{ID: 33, Value: 1},
{ID: 40, Value: 1},
{ID: 49, Value: 1},
{ID: 53, Value: 1},
{ID: 59, Value: 1},
{ID: 67, Value: 1},
{ID: 80, Value: 1},
{ID: 94, Value: 1},
{ID: 1001, Value: 100}, // get_hrp_rate
{ID: 1010, Value: 300}, // get_hrp_rate_netcafe
{ID: 1011, Value: 300}, // get_zeny_rate_netcafe
{ID: 1012, Value: 300}, // get_hrp_rate_ncource
{ID: 1013, Value: 300}, // get_zeny_rate_ncource
{ID: 1014, Value: 200}, // get_hrp_rate_premium
{ID: 1015, Value: 200}, // get_zeny_rate_premium
{ID: 1021, Value: 400}, // get_gcp_rate_assist
{ID: 1023, Value: 8}, // unused?
{ID: 1024, Value: 150}, // get_hrp_rate_ptbonus
{ID: 1025, Value: 1}, // isValid_stampcard
{ID: 1026, Value: 999}, // get_grank_cap
{ID: 1027, Value: 100}, // get_exchange_rate_festa
{ID: 1028, Value: 100}, // get_exchange_rate_cafe
{ID: 1030, Value: 8}, // get_gquest_cap
{ID: 1031, Value: 100}, // get_exchange_rate_guild (GCP)
{ID: 1032, Value: 0}, // isValid_partner
{ID: 1044, Value: 200}, // get_rate_tload_time_out
{ID: 1045, Value: 0}, // get_rate_tower_treasure_preset
{ID: 1046, Value: 99}, // get_hunter_life_cap
{ID: 1048, Value: 0}, // get_rate_tower_hint_sec
{ID: 1049, Value: 10}, // get_rate_tower_gem_max
{ID: 1050, Value: 1}, // get_rate_tower_gem_set
{ID: 1051, Value: 200}, // get_pallone_score_rate_premium
{ID: 1052, Value: 200}, // get_trp_rate_premium
{ID: 1063, Value: 50000}, // get_nboost_quest_point_from_hrank
{ID: 1064, Value: 50000}, // get_nboost_quest_point_from_srank
{ID: 1065, Value: 25000}, // get_nboost_quest_point_from_grank
{ID: 1066, Value: 25000}, // get_nboost_quest_point_from_gsrank
{ID: 1067, Value: 90}, // get_lobby_member_upper_for_making_room Lv1?
{ID: 1068, Value: 80}, // get_lobby_member_upper_for_making_room Lv2?
{ID: 1069, Value: 70}, // get_lobby_member_upper_for_making_room Lv3?
{ID: 1072, Value: 300}, // get_rate_premium_ravi_tama
{ID: 1073, Value: 300}, // get_rate_premium_ravi_ax_tama
{ID: 1074, Value: 300}, // get_rate_premium_ravi_g_tama
{ID: 1078, Value: 0}, // isCapped_tenrou_irai
{ID: 1079, Value: 1}, // get_add_tower_level_assist
{ID: 1080, Value: 1}, // get_tune_add_tower_level_w_assist_nboost
// get_tune_secret_book_item
{ID: 1081, Value: 1},
{ID: 1082, Value: 4},
{ID: 1083, Value: 2},
{ID: 1084, Value: 10},
{ID: 1085, Value: 1},
{ID: 1086, Value: 4},
{ID: 1087, Value: 2},
{ID: 1088, Value: 10},
{ID: 1089, Value: 1},
{ID: 1090, Value: 3},
{ID: 1091, Value: 2},
{ID: 1092, Value: 10},
{ID: 1093, Value: 2},
{ID: 1094, Value: 5},
{ID: 1095, Value: 2},
{ID: 1096, Value: 10},
{ID: 1097, Value: 2},
{ID: 1098, Value: 5},
{ID: 1099, Value: 2},
{ID: 1100, Value: 10},
{ID: 1101, Value: 2},
{ID: 1102, Value: 5},
{ID: 1103, Value: 2},
{ID: 1104, Value: 10},
{ID: 1145, Value: 200}, // get_ud_point_rate_premium
{ID: 1146, Value: 0}, // isTower_invisible
{ID: 1147, Value: 0}, // isVenom_playable
{ID: 1149, Value: 20}, // get_ud_break_parts_point
{ID: 1152, Value: 1130}, // unused?
{ID: 1154, Value: 0}, // isDisabled_object_season
{ID: 1158, Value: 1}, // isDelivery_venom_ult_quest
{ID: 1160, Value: 300}, // get_rate_premium_ravi_g_enhance_tama
// unknown
{ID: 1162, Value: 1},
{ID: 1163, Value: 3},
{ID: 1164, Value: 5},
{ID: 1165, Value: 1},
{ID: 1166, Value: 5},
{ID: 1167, Value: 1},
{ID: 1168, Value: 3},
{ID: 1169, Value: 3},
{ID: 1170, Value: 5},
{ID: 1171, Value: 1},
{ID: 1172, Value: 1},
{ID: 1173, Value: 1},
{ID: 1174, Value: 2},
{ID: 1175, Value: 4},
{ID: 1176, Value: 10},
{ID: 1177, Value: 4},
{ID: 1178, Value: 10},
{ID: 1179, Value: 2},
{ID: 1180, Value: 5},
}
tuneValues = append(tuneValues, tuneValue{1020, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.GCPMultiplier)})
tuneValues = append(tuneValues, tuneValue{1029, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.GUrgentRate)})
if s.server.erupeConfig.GameplayOptions.DisableHunterNavi {
tuneValues = append(tuneValues, tuneValue{1037, 1})
}
if s.server.erupeConfig.GameplayOptions.EnableKaijiEvent {
tuneValues = append(tuneValues, tuneValue{1106, 1})
}
if s.server.erupeConfig.GameplayOptions.EnableHiganjimaEvent {
tuneValues = append(tuneValues, tuneValue{1144, 1})
}
if s.server.erupeConfig.GameplayOptions.EnableNierEvent {
tuneValues = append(tuneValues, tuneValue{1153, 1})
}
if s.server.erupeConfig.GameplayOptions.DisableRoad {
tuneValues = append(tuneValues, tuneValue{1155, 1})
}
// get_hrp_rate_from_rank
tuneValues = append(tuneValues, getTuneValueRange(3000, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.HRPMultiplier))...)
tuneValues = append(tuneValues, getTuneValueRange(3338, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.HRPMultiplierNC))...)
// get_srp_rate_from_rank
tuneValues = append(tuneValues, getTuneValueRange(3013, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.SRPMultiplier))...)
tuneValues = append(tuneValues, getTuneValueRange(3351, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.SRPMultiplierNC))...)
// get_grp_rate_from_rank
tuneValues = append(tuneValues, getTuneValueRange(3026, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.GRPMultiplier))...)
tuneValues = append(tuneValues, getTuneValueRange(3364, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.GRPMultiplierNC))...)
// get_gsrp_rate_from_rank
tuneValues = append(tuneValues, getTuneValueRange(3039, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.GSRPMultiplier))...)
tuneValues = append(tuneValues, getTuneValueRange(3377, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.GSRPMultiplierNC))...)
// get_zeny_rate_from_hrank
tuneValues = append(tuneValues, getTuneValueRange(3052, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.ZennyMultiplier))...)
tuneValues = append(tuneValues, getTuneValueRange(3390, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.ZennyMultiplierNC))...)
// get_zeny_rate_from_grank
tuneValues = append(tuneValues, getTuneValueRange(3078, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.GZennyMultiplier))...)
tuneValues = append(tuneValues, getTuneValueRange(3416, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.GZennyMultiplierNC))...)
// get_reward_rate_from_hrank
tuneValues = append(tuneValues, getTuneValueRange(3104, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.MaterialMultiplier))...)
tuneValues = append(tuneValues, getTuneValueRange(3442, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.MaterialMultiplierNC))...)
// get_reward_rate_from_grank
tuneValues = append(tuneValues, getTuneValueRange(3130, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.GMaterialMultiplier))...)
tuneValues = append(tuneValues, getTuneValueRange(3468, multiplierToTuneValue(s.server.erupeConfig.GameplayOptions.GMaterialMultiplierNC))...)
// get_lottery_rate_from_hrank
tuneValues = append(tuneValues, getTuneValueRange(3156, 0)...)
tuneValues = append(tuneValues, getTuneValueRange(3494, 0)...)
// get_lottery_rate_from_grank
tuneValues = append(tuneValues, getTuneValueRange(3182, 0)...)
tuneValues = append(tuneValues, getTuneValueRange(3520, 0)...)
// get_hagi_rate_from_hrank
tuneValues = append(tuneValues, getTuneValueRange(3208, s.server.erupeConfig.GameplayOptions.ExtraCarves)...)
tuneValues = append(tuneValues, getTuneValueRange(3546, s.server.erupeConfig.GameplayOptions.ExtraCarvesNC)...)
// get_hagi_rate_from_grank
tuneValues = append(tuneValues, getTuneValueRange(3234, s.server.erupeConfig.GameplayOptions.GExtraCarves)...)
tuneValues = append(tuneValues, getTuneValueRange(3572, s.server.erupeConfig.GameplayOptions.GExtraCarvesNC)...)
// get_nboost_transcend_rate_from_hrank
tuneValues = append(tuneValues, getTuneValueRange(3286, 200)...)
tuneValues = append(tuneValues, getTuneValueRange(3312, 300)...)
// get_nboost_transcend_rate_from_grank
tuneValues = append(tuneValues, getTuneValueRange(3299, 200)...)
tuneValues = append(tuneValues, getTuneValueRange(3325, 300)...)
tuneLimit := tuneLimitZZ
if s.server.erupeConfig.RealClientMode <= cfg.G1 {
tuneLimit = tuneLimitG1
} else if s.server.erupeConfig.RealClientMode <= cfg.G3 {
tuneLimit = tuneLimitG3
} else if s.server.erupeConfig.RealClientMode <= cfg.GG {
tuneLimit = tuneLimitGG
} else if s.server.erupeConfig.RealClientMode <= cfg.G61 {
tuneLimit = tuneLimitG61
} else if s.server.erupeConfig.RealClientMode <= cfg.G7 {
tuneLimit = tuneLimitG7
} else if s.server.erupeConfig.RealClientMode <= cfg.G81 {
tuneLimit = tuneLimitG81
} else if s.server.erupeConfig.RealClientMode <= cfg.G91 {
tuneLimit = tuneLimitG91
} else if s.server.erupeConfig.RealClientMode <= cfg.G101 {
tuneLimit = tuneLimitG101
} else if s.server.erupeConfig.RealClientMode <= cfg.Z2 {
tuneLimit = tuneLimitZ2
}
if len(tuneValues) > tuneLimit {
tuneValues = tuneValues[:tuneLimit]
}
offset := uint16(time.Now().Unix())
bf.WriteUint16(offset)
bf.WriteUint16(uint16(len(tuneValues)))
for i := range tuneValues {
bf.WriteUint16(tuneValues[i].ID ^ offset)
bf.WriteUint16(offset)
bf.WriteBytes(make([]byte, 4))
bf.WriteUint16(tuneValues[i].Value ^ offset)
}
vsQuestItems := []uint16{1580, 1581, 1582, 1583, 1584, 1585, 1587, 1588, 1589, 1595, 1596, 1597, 1598, 1599, 1600, 1601, 1602, 1603, 1604}
vsQuestBets := []struct {
IsTicket bool
Quantity uint32
}{
{true, 5},
{false, 1000},
{false, 5000},
{false, 10000},
}
bf.WriteUint16(uint16(len(vsQuestItems)))
bf.WriteUint16(0) // Unk array of uint16s
bf.WriteUint16(uint16(len(vsQuestBets)))
bf.WriteUint16(0) // Unk
for i := range vsQuestItems {
bf.WriteUint16(vsQuestItems[i])
}
for i := range vsQuestBets {
bf.WriteBool(vsQuestBets[i].IsTicket)
bf.WriteUint8(9)
bf.WriteUint16(7)
bf.WriteUint32(vsQuestBets[i].Quantity)
}
bf.WriteUint16(totalCount)
bf.WriteUint16(pkt.Offset)
_, _ = bf.Seek(0, io.SeekStart)
bf.WriteUint16(returnedCount)
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
// multiplierToTuneValue converts a float32 config multiplier (e.g. 0.20 for 20%)
// into the uint16 percentage value expected by the client tune table. Uses
// rounding to avoid float32 truncation artifacts such as 0.20*100 → 19.
func multiplierToTuneValue(m float32) uint16 {
return uint16(math.Round(float64(m) * 100))
}
func getTuneValueRange(start uint16, value uint16) []tuneValue {
var tv []tuneValue
for i := uint16(0); i < 13; i++ {
tv = append(tv, tuneValue{start + i, value})
}
return tv
}
func handleMsgMhfGetUdBonusQuestInfo(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfGetUdBonusQuestInfo)
udBonusQuestInfos := []struct {
Unk0 uint8
Unk1 uint8
StartTime uint32 // Unix timestamp (seconds)
EndTime uint32 // Unix timestamp (seconds)
Unk4 uint32
Unk5 uint8
Unk6 uint8
}{} // Blank stub array.
resp := byteframe.NewByteFrame()
resp.WriteUint8(uint8(len(udBonusQuestInfos)))
for _, q := range udBonusQuestInfos {
resp.WriteUint8(q.Unk0)
resp.WriteUint8(q.Unk1)
resp.WriteUint32(q.StartTime)
resp.WriteUint32(q.EndTime)
resp.WriteUint32(q.Unk4)
resp.WriteUint8(q.Unk5)
resp.WriteUint8(q.Unk6)
}
doAckBufSucceed(s, pkt.AckHandle, resp.Data())
}