package channelserver import ( "crypto/rand" "encoding/binary" "erupe-ce/common/byteframe" "erupe-ce/common/mhfcourse" "erupe-ce/common/mhfmon" ps "erupe-ce/common/pascalstring" "erupe-ce/common/stringsupport" cfg "erupe-ce/config" "erupe-ce/network/mhfpacket" "fmt" "io" "strings" "time" "go.uber.org/zap" ) func handleMsgHead(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysExtendThreshold(s *Session, p mhfpacket.MHFPacket) { // No data aside from header, no resp required. } func handleMsgSysEnd(s *Session, p mhfpacket.MHFPacket) { // No data aside from header, no resp required. } func handleMsgSysNop(s *Session, p mhfpacket.MHFPacket) { // No data aside from header, no resp required. } func handleMsgSysAck(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysTerminalLog(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgSysTerminalLog) for i := range pkt.Entries { s.server.logger.Info("SysTerminalLog", zap.Uint8("Type1", pkt.Entries[i].Type1), zap.Uint8("Type2", pkt.Entries[i].Type2), zap.Int16("Unk0", pkt.Entries[i].Unk0), zap.Int32("Unk1", pkt.Entries[i].Unk1), zap.Int32("Unk2", pkt.Entries[i].Unk2), zap.Int32("Unk3", pkt.Entries[i].Unk3), zap.Int32s("Unk4", pkt.Entries[i].Unk4), ) } resp := byteframe.NewByteFrame() resp.WriteUint32(pkt.LogID + 1) // LogID to use for requests after this. doAckSimpleSucceed(s, pkt.AckHandle, resp.Data()) } func handleMsgSysLogin(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgSysLogin) if !s.server.erupeConfig.DebugOptions.DisableTokenCheck { if err := s.server.sessionRepo.ValidateLoginToken(pkt.LoginTokenString, pkt.LoginTokenNumber, pkt.CharID0); err != nil { _ = s.rawConn.Close() s.logger.Warn("Invalid login token", zap.Uint32("charID", pkt.CharID0)) return } } s.Lock() s.charID = pkt.CharID0 s.token = pkt.LoginTokenString s.Unlock() userID, err := s.server.charRepo.GetUserID(s.charID) if err != nil { s.logger.Error("Failed to resolve user ID for character", zap.Error(err), zap.Uint32("charID", s.charID)) doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4)) return } s.userID = userID bf := byteframe.NewByteFrame() bf.WriteUint32(uint32(TimeAdjusted().Unix())) // Unix timestamp err = s.server.sessionRepo.UpdatePlayerCount(s.server.ID, len(s.server.sessions)) if err != nil { s.logger.Error("Failed to update current players", zap.Error(err)) doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4)) return } err = s.server.sessionRepo.BindSession(s.token, s.server.ID, s.charID) if err != nil { s.logger.Error("Failed to update sign session", zap.Error(err)) doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4)) return } if err = s.server.charRepo.UpdateLastLogin(s.charID, TimeAdjusted().Unix()); err != nil { s.logger.Error("Failed to update last login", zap.Error(err)) doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4)) return } err = s.server.userRepo.SetLastCharacter(s.userID, s.charID) if err != nil { s.logger.Error("Failed to update last character", zap.Error(err)) doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4)) return } doAckSimpleSucceed(s, pkt.AckHandle, bf.Data()) updateRights(s) s.server.BroadcastMHF(&mhfpacket.MsgSysInsertUser{CharID: s.charID}, s) } func handleMsgSysLogout(s *Session, p mhfpacket.MHFPacket) { logoutPlayer(s) } // saveAllCharacterData saves all character data to the database with proper error handling. // This function ensures data persistence even if the client disconnects unexpectedly. // It handles: // - Main savedata blob (compressed) // - User binary data (house, gallery, etc.) // - Plate data (transmog appearance, storage, equipment sets) // - Playtime updates // - RP updates // - Name corruption prevention func saveAllCharacterData(s *Session, rpToAdd int) error { saveStart := time.Now() // Get current savedata from database characterSaveData, err := GetCharacterSaveData(s, s.charID) if err != nil { s.logger.Error("Failed to retrieve character save data", zap.Error(err), zap.Uint32("charID", s.charID), zap.String("name", s.Name), ) return err } if characterSaveData == nil { s.logger.Warn("Character save data is nil, skipping save", zap.Uint32("charID", s.charID), zap.String("name", s.Name), ) return nil } // Force name to match to prevent corruption detection issues // This handles SJIS/UTF-8 encoding differences across game versions if characterSaveData.Name != s.Name { s.logger.Debug("Correcting name mismatch before save", zap.String("savedata_name", characterSaveData.Name), zap.String("session_name", s.Name), zap.Uint32("charID", s.charID), ) characterSaveData.Name = s.Name characterSaveData.updateSaveDataWithStruct() } // Update playtime from session if !s.playtimeTime.IsZero() { sessionPlaytime := uint32(time.Since(s.playtimeTime).Seconds()) s.playtime += sessionPlaytime s.logger.Debug("Updated playtime", zap.Uint32("session_playtime_seconds", sessionPlaytime), zap.Uint32("total_playtime", s.playtime), zap.Uint32("charID", s.charID), ) } characterSaveData.Playtime = s.playtime // Update RP if any gained during session if rpToAdd > 0 { characterSaveData.RP += uint16(rpToAdd) if characterSaveData.RP >= s.server.erupeConfig.GameplayOptions.MaximumRP { characterSaveData.RP = s.server.erupeConfig.GameplayOptions.MaximumRP s.logger.Debug("RP capped at maximum", zap.Uint16("max_rp", s.server.erupeConfig.GameplayOptions.MaximumRP), zap.Uint32("charID", s.charID), ) } s.logger.Debug("Added RP", zap.Int("rp_gained", rpToAdd), zap.Uint16("new_rp", characterSaveData.RP), zap.Uint32("charID", s.charID), ) } // Save to database (main savedata + user_binary) characterSaveData.Save(s) // Save auxiliary data types // Note: Plate data saves immediately when client sends save packets, // so this is primarily a safety net for monitoring and consistency if err := savePlateDataToDatabase(s); err != nil { s.logger.Error("Failed to save plate data during logout", zap.Error(err), zap.Uint32("charID", s.charID), ) // Don't return error - continue with logout even if plate save fails } saveDuration := time.Since(saveStart) s.logger.Info("Saved character data successfully", zap.Uint32("charID", s.charID), zap.String("name", s.Name), zap.Duration("duration", saveDuration), zap.Int("rp_added", rpToAdd), zap.Uint32("playtime", s.playtime), ) return nil } func logoutPlayer(s *Session) { logoutStart := time.Now() // Log logout initiation with session details sessionDuration := time.Duration(0) if s.sessionStart > 0 { sessionDuration = time.Since(time.Unix(s.sessionStart, 0)) } s.logger.Info("Player logout initiated", zap.Uint32("charID", s.charID), zap.String("name", s.Name), zap.Duration("session_duration", sessionDuration), ) // Calculate session metrics FIRST (before cleanup) var timePlayed int var sessionTime int var rpGained int if s.charID != 0 { if val, err := s.server.charRepo.ReadInt(s.charID, "time_played"); err != nil { s.logger.Error("Failed to read time_played, RP accrual may be inaccurate", zap.Error(err)) } else { timePlayed = val } sessionTime = int(TimeAdjusted().Unix()) - int(s.sessionStart) timePlayed += sessionTime if mhfcourse.CourseExists(30, s.courses) { rpGained = timePlayed / rpAccrualCafe timePlayed = timePlayed % rpAccrualCafe if _, err := s.server.charRepo.AdjustInt(s.charID, "cafe_time", sessionTime); err != nil { s.logger.Error("Failed to update cafe time", zap.Error(err)) } } else { rpGained = timePlayed / rpAccrualNormal timePlayed = timePlayed % rpAccrualNormal } s.logger.Debug("Session metrics calculated", zap.Uint32("charID", s.charID), zap.Int("session_time_seconds", sessionTime), zap.Int("rp_gained", rpGained), zap.Int("time_played_remainder", timePlayed), ) // Save all character data ONCE with all updates // This is the safety net that ensures data persistence even if client // didn't send save packets before disconnecting if err := saveAllCharacterData(s, rpGained); err != nil { s.logger.Error("Failed to save character data during logout", zap.Error(err), zap.Uint32("charID", s.charID), zap.String("name", s.Name), ) // Continue with logout even if save fails } // Update time_played and guild treasure hunt if err := s.server.charRepo.UpdateTimePlayed(s.charID, timePlayed); err != nil { s.logger.Error("Failed to update time played", zap.Error(err)) } if err := s.server.guildRepo.ClearTreasureHunt(s.charID); err != nil { s.logger.Error("Failed to clear treasure hunt", zap.Error(err)) } } // NOW do cleanup (after save is complete) s.server.Lock() delete(s.server.sessions, s.rawConn) _ = s.rawConn.Close() s.server.Unlock() // Stage cleanup — snapshot sessions first under server mutex, then iterate stages s.server.Lock() sessionSnapshot := make([]*Session, 0, len(s.server.sessions)) for _, sess := range s.server.sessions { sessionSnapshot = append(sessionSnapshot, sess) } s.server.Unlock() s.server.stages.Range(func(_ string, stage *Stage) bool { stage.Lock() // Tell sessions registered to disconnecting player's quest to unregister if stage.host != nil && stage.host.charID == s.charID { for _, sess := range sessionSnapshot { for rSlot := range stage.reservedClientSlots { if sess.charID == rSlot && sess.stage != nil && sess.stage.id[3:5] != "Qs" { sess.QueueSendMHFNonBlocking(&mhfpacket.MsgSysStageDestruct{}) } } } } for session := range stage.clients { if session.charID == s.charID { delete(stage.clients, session) } } stage.Unlock() return true }) // Update sign sessions and server player count if s.server.db != nil { if err := s.server.sessionRepo.ClearSession(s.token); err != nil { s.logger.Error("Failed to clear sign session", zap.Error(err)) } if err := s.server.sessionRepo.UpdatePlayerCount(s.server.ID, len(s.server.sessions)); err != nil { s.logger.Error("Failed to update player count", zap.Error(err)) } } if s.stage == nil { logoutDuration := time.Since(logoutStart) s.logger.Info("Player logout completed", zap.Uint32("charID", s.charID), zap.String("name", s.Name), zap.Duration("logout_duration", logoutDuration), ) return } // Broadcast user deletion and final cleanup s.server.BroadcastMHF(&mhfpacket.MsgSysDeleteUser{ CharID: s.charID, }, s) s.server.stages.Range(func(_ string, stage *Stage) bool { stage.Lock() delete(stage.reservedClientSlots, s.charID) stage.Unlock() return true }) removeSessionFromSemaphore(s) removeSessionFromStage(s) logoutDuration := time.Since(logoutStart) s.logger.Info("Player logout completed", zap.Uint32("charID", s.charID), zap.String("name", s.Name), zap.Duration("logout_duration", logoutDuration), zap.Int("rp_gained", rpGained), ) } func handleMsgSysSetStatus(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysPing(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgSysPing) doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00}) } func handleMsgSysTime(s *Session, p mhfpacket.MHFPacket) { resp := &mhfpacket.MsgSysTime{ GetRemoteTime: false, Timestamp: uint32(TimeAdjusted().Unix()), // JP timezone } s.QueueSendMHF(resp) s.notifyRavi() } func handleMsgSysIssueLogkey(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgSysIssueLogkey) // Make a random log key for this session. logKey := make([]byte, 16) _, err := rand.Read(logKey) if err != nil { s.logger.Error("Failed to generate log key", zap.Error(err)) doAckBufFail(s, pkt.AckHandle, make([]byte, 4)) return } // TODO(Andoryuuta): In the official client, the log key index is off by one, // cutting off the last byte in _most uses_. Find and document these accordingly. s.Lock() s.logKey = logKey s.Unlock() // Issue it. resp := byteframe.NewByteFrame() resp.WriteBytes(logKey) doAckBufSucceed(s, pkt.AckHandle, resp.Data()) } const localhostAddrLE = uint32(0x0100007F) // 127.0.0.1 in little-endian // Kill log binary layout constants const ( killLogHeaderSize = 32 // bytes before monster kill count array killLogMonsterCount = 176 // monster table entries ) // RP accrual rate constants (seconds per RP point) const ( rpAccrualNormal = 1800 // 30 min per RP without cafe rpAccrualCafe = 900 // 15 min per RP with cafe course ) func handleMsgSysRecordLog(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgSysRecordLog) if s.server.erupeConfig.RealClientMode == cfg.ZZ { bf := byteframe.NewByteFrameFromBytes(pkt.Data) _, _ = bf.Seek(killLogHeaderSize, 0) var val uint8 for i := 0; i < killLogMonsterCount; i++ { val = bf.ReadUint8() if val > 0 && mhfmon.Monsters[i].Large { if err := s.server.guildRepo.InsertKillLog(s.charID, i, val, TimeAdjusted()); err != nil { s.logger.Error("Failed to insert kill log", zap.Error(err)) } } } } // remove a client returning to town from reserved slots to make sure the stage is hidden from board delete(s.stage.reservedClientSlots, s.charID) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) } func handleMsgSysEcho(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysLockGlobalSema(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgSysLockGlobalSema) sgid := s.server.Registry.FindChannelForStage(pkt.UserIDString) bf := byteframe.NewByteFrame() if len(sgid) > 0 && sgid != s.server.GlobalID { bf.WriteUint8(0) bf.WriteUint8(0) ps.Uint16(bf, sgid, false) } else { bf.WriteUint8(2) bf.WriteUint8(0) ps.Uint16(bf, pkt.ServerChannelIDString, false) } doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } func handleMsgSysUnlockGlobalSema(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgSysUnlockGlobalSema) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) } func handleMsgSysUpdateRight(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysAuthQuery(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysAuthTerminal(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysRightsReload(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgSysRightsReload) updateRights(s) doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00}) } func handleMsgMhfTransitMessage(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfTransitMessage) local := strings.Split(s.rawConn.RemoteAddr().String(), ":")[0] == "127.0.0.1" var maxResults, port, count uint16 var cid uint32 var term, ip string bf := byteframe.NewByteFrameFromBytes(pkt.MessageData) switch pkt.SearchType { case 1: maxResults = 1 cid = bf.ReadUint32() case 2: bf.ReadUint16() // term length maxResults = bf.ReadUint16() bf.ReadUint8() // Unk term = stringsupport.SJISToUTF8Lossy(bf.ReadNullTerminatedBytes()) case 3: _ip := bf.ReadBytes(4) ip = fmt.Sprintf("%d.%d.%d.%d", _ip[3], _ip[2], _ip[1], _ip[0]) port = bf.ReadUint16() bf.ReadUint16() // term length maxResults = bf.ReadUint16() bf.ReadUint8() term = string(bf.ReadNullTerminatedBytes()) } resp := byteframe.NewByteFrame() resp.WriteUint16(0) switch pkt.SearchType { case 1, 2, 3: // usersearchidx, usersearchname, lobbysearchname predicate := func(snap SessionSnapshot) bool { switch pkt.SearchType { case 1: return snap.CharID == cid case 2: return strings.Contains(snap.Name, term) case 3: return snap.ServerIP.String() == ip && snap.ServerPort == port && snap.StageID == term } return false } snapshots := s.server.Registry.SearchSessions(predicate, int(maxResults)) count = uint16(len(snapshots)) for _, snap := range snapshots { if !local { resp.WriteUint32(binary.LittleEndian.Uint32(snap.ServerIP)) } else { resp.WriteUint32(localhostAddrLE) } resp.WriteUint16(snap.ServerPort) resp.WriteUint32(snap.CharID) sjisStageID := stringsupport.UTF8ToSJIS(snap.StageID) sjisName := stringsupport.UTF8ToSJIS(snap.Name) resp.WriteUint8(uint8(len(sjisStageID) + 1)) resp.WriteUint8(uint8(len(sjisName) + 1)) resp.WriteUint16(uint16(len(snap.UserBinary3))) // TODO: This case might be <=G2 if s.server.erupeConfig.RealClientMode <= cfg.G1 { resp.WriteBytes(make([]byte, 8)) } else { resp.WriteBytes(make([]byte, 40)) } resp.WriteBytes(make([]byte, 8)) resp.WriteNullTerminatedBytes(sjisStageID) resp.WriteNullTerminatedBytes(sjisName) resp.WriteBytes(snap.UserBinary3) } case 4: // lobbysearch type FindPartyParams struct { StagePrefix string RankRestriction int16 Targets []int16 Unk0 []int16 Unk1 []int16 QuestID []int16 } findPartyParams := FindPartyParams{ StagePrefix: "sl2Ls210", } numParams := bf.ReadUint8() maxResults = bf.ReadUint16() for i := uint8(0); i < numParams; i++ { switch bf.ReadUint8() { case 0: values := bf.ReadUint8() for i := uint8(0); i < values; i++ { if s.server.erupeConfig.RealClientMode >= cfg.Z1 { findPartyParams.RankRestriction = bf.ReadInt16() } else { findPartyParams.RankRestriction = int16(bf.ReadInt8()) } } case 1: values := bf.ReadUint8() for i := uint8(0); i < values; i++ { if s.server.erupeConfig.RealClientMode >= cfg.Z1 { findPartyParams.Targets = append(findPartyParams.Targets, bf.ReadInt16()) } else { findPartyParams.Targets = append(findPartyParams.Targets, int16(bf.ReadInt8())) } } case 2: values := bf.ReadUint8() for i := uint8(0); i < values; i++ { var value int16 if s.server.erupeConfig.RealClientMode >= cfg.Z1 { value = bf.ReadInt16() } else { value = int16(bf.ReadInt8()) } switch value { case 0: // Public Bar findPartyParams.StagePrefix = "sl2Ls210" case 1: // Tokotoko Partnya findPartyParams.StagePrefix = "sl2Ls463" case 2: // Hunting Prowess Match findPartyParams.StagePrefix = "sl2Ls286" case 3: // Volpakkun Together findPartyParams.StagePrefix = "sl2Ls465" case 5: // Quick Party // Unk } } case 3: // Unknown values := bf.ReadUint8() for i := uint8(0); i < values; i++ { if s.server.erupeConfig.RealClientMode >= cfg.Z1 { findPartyParams.Unk0 = append(findPartyParams.Unk0, bf.ReadInt16()) } else { findPartyParams.Unk0 = append(findPartyParams.Unk0, int16(bf.ReadInt8())) } } case 4: // Looking for n or already have n values := bf.ReadUint8() for i := uint8(0); i < values; i++ { if s.server.erupeConfig.RealClientMode >= cfg.Z1 { findPartyParams.Unk1 = append(findPartyParams.Unk1, bf.ReadInt16()) } else { findPartyParams.Unk1 = append(findPartyParams.Unk1, int16(bf.ReadInt8())) } } case 5: values := bf.ReadUint8() for i := uint8(0); i < values; i++ { if s.server.erupeConfig.RealClientMode >= cfg.Z1 { findPartyParams.QuestID = append(findPartyParams.QuestID, bf.ReadInt16()) } else { findPartyParams.QuestID = append(findPartyParams.QuestID, int16(bf.ReadInt8())) } } } } allStages := s.server.Registry.SearchStages(findPartyParams.StagePrefix, int(maxResults)) // Post-fetch filtering on snapshots (rank restriction, targets) type filteredStage struct { StageSnapshot stageData []int16 } var stageResults []filteredStage for _, snap := range allStages { sb3 := byteframe.NewByteFrameFromBytes(snap.RawBinData3) _, _ = sb3.Seek(4, 0) stageDataParams := 7 if s.server.erupeConfig.RealClientMode <= cfg.G10 { stageDataParams = 4 } else if s.server.erupeConfig.RealClientMode <= cfg.Z1 { stageDataParams = 6 } var stageData []int16 for i := 0; i < stageDataParams; i++ { if s.server.erupeConfig.RealClientMode >= cfg.Z1 { stageData = append(stageData, sb3.ReadInt16()) } else { stageData = append(stageData, int16(sb3.ReadInt8())) } } if findPartyParams.RankRestriction >= 0 { if stageData[0] > findPartyParams.RankRestriction { continue } } if len(findPartyParams.Targets) > 0 { var hasTarget bool for _, target := range findPartyParams.Targets { if target == stageData[1] { hasTarget = true break } } if !hasTarget { continue } } stageResults = append(stageResults, filteredStage{ StageSnapshot: snap, stageData: stageData, }) } count = uint16(len(stageResults)) for _, sr := range stageResults { if !local { resp.WriteUint32(binary.LittleEndian.Uint32(sr.ServerIP)) } else { resp.WriteUint32(localhostAddrLE) } resp.WriteUint16(sr.ServerPort) resp.WriteUint16(0) // Static? resp.WriteUint16(0) // Unk, [0 1 2] resp.WriteUint16(uint16(sr.ClientCount)) resp.WriteUint16(sr.MaxPlayers) // TODO: Retail returned the number of clients in quests, not workshop/my series resp.WriteUint16(uint16(sr.Reserved)) resp.WriteUint8(0) // Static? resp.WriteUint8(uint8(sr.MaxPlayers)) resp.WriteUint8(1) // Static? resp.WriteUint8(uint8(len(sr.StageID) + 1)) resp.WriteUint8(uint8(len(sr.RawBinData0))) resp.WriteUint8(uint8(len(sr.RawBinData1))) for i := range sr.stageData { if s.server.erupeConfig.RealClientMode >= cfg.Z1 { resp.WriteInt16(sr.stageData[i]) } else { resp.WriteInt8(int8(sr.stageData[i])) } } resp.WriteUint8(0) // Unk resp.WriteUint8(0) // Unk resp.WriteNullTerminatedBytes([]byte(sr.StageID)) resp.WriteBytes(sr.RawBinData0) resp.WriteBytes(sr.RawBinData1) } } _, _ = resp.Seek(0, io.SeekStart) resp.WriteUint16(count) doAckBufSucceed(s, pkt.AckHandle, resp.Data()) } func handleMsgCaExchangeItem(s *Session, p mhfpacket.MHFPacket) {} func handleMsgMhfServerCommand(s *Session, p mhfpacket.MHFPacket) {} func handleMsgMhfAnnounce(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfAnnounce) s.server.BroadcastRaviente(pkt.IPAddress, pkt.Port, pkt.StageID, pkt.Data.ReadUint8()) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) } func handleMsgMhfSetLoginwindow(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysTransBinary(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysCollectBinary(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysGetState(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysSerialize(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysEnumlobby(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysEnumuser(s *Session, p mhfpacket.MHFPacket) {} func handleMsgSysInfokyserver(s *Session, p mhfpacket.MHFPacket) {} func handleMsgMhfGetCaUniqueID(s *Session, p mhfpacket.MHFPacket) {}