Files
Erupe/server/channelserver/sys_channel_server.go
Houmgaor 5c2fde5cfd feat(rengoku): validate and log Hunting Road config on startup
Port ECD encryption/decryption from ReFrontier (C#) and FrontierTextHandler
(Python) into common/decryption. The cipher uses a 32-bit LCG key stream with
an 8-round Feistel-like nibble transformation and CFB chaining; all six key
sets are supported, key 4 being the default for all MHF files.

On startup, loadRengokuBinary now decrypts (ECD) and decompresses (JKR) the
binary to validate pointer bounds and entry counts, then logs a structured
summary (floor counts, spawn table counts, unique monster IDs). Failures are
non-fatal — the encrypted blob is still cached and served to clients unchanged,
preserving existing behaviour. Closes #173.
2026-03-19 23:59:34 +01:00

503 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package channelserver
import (
"encoding/binary"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"sync"
"time"
"erupe-ce/common/byteframe"
"erupe-ce/common/decryption"
cfg "erupe-ce/config"
"erupe-ce/network"
"erupe-ce/network/binpacket"
"erupe-ce/network/mhfpacket"
"erupe-ce/server/discordbot"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
)
// Config struct allows configuring the server.
type Config struct {
ID uint16
Logger *zap.Logger
DB *sqlx.DB
DiscordBot *discordbot.DiscordBot
ErupeConfig *cfg.Config
Name string
Enable bool
}
// Server is a MHF channel server.
//
// Lock ordering (acquire in this order to avoid deadlocks):
// 1. Server.Mutex protects sessions map
// 2. Stage.RWMutex protects per-stage state (clients, objects)
// 3. Server.semaphoreLock protects semaphore map
//
// Note: Server.stages is a StageMap (sync.Map-backed), so it requires no
// external lock for reads or writes.
//
// Self-contained stores (userBinary, minidata, questCache) manage their
// own locks internally and may be acquired at any point.
type Server struct {
sync.Mutex
Registry ChannelRegistry
ID uint16
GlobalID string
IP string
Port uint16
logger *zap.Logger
db *sqlx.DB
charRepo CharacterRepo
guildRepo GuildRepo
userRepo UserRepo
gachaRepo GachaRepo
houseRepo HouseRepo
festaRepo FestaRepo
towerRepo TowerRepo
rengokuRepo RengokuRepo
mailRepo MailRepo
stampRepo StampRepo
distRepo DistributionRepo
sessionRepo SessionRepo
eventRepo EventRepo
achievementRepo AchievementRepo
shopRepo ShopRepo
cafeRepo CafeRepo
goocooRepo GoocooRepo
divaRepo DivaRepo
miscRepo MiscRepo
scenarioRepo ScenarioRepo
mercenaryRepo MercenaryRepo
mailService *MailService
guildService *GuildService
achievementService *AchievementService
gachaService *GachaService
towerService *TowerService
festaService *FestaService
erupeConfig *cfg.Config
acceptConns chan net.Conn
deleteConns chan net.Conn
sessions map[net.Conn]*Session
listener net.Listener // Listener that is created when Server.Start is called.
isShuttingDown bool
done chan struct{} // Closed on Shutdown to wake background goroutines.
stages StageMap
// Used to map different languages
i18n i18n
userBinary *UserBinaryStore
minidata *MinidataStore
// Per-character save locks prevent concurrent save operations for the
// same character from racing and defeating corruption detection.
charSaveLocks CharacterLocks
// Semaphore
semaphoreLock sync.RWMutex
semaphore map[string]*Semaphore
semaphoreIndex uint32
// Discord chat integration
discordBot *discordbot.DiscordBot
name string
raviente *Raviente
questCache *QuestCache
rengokuBin []byte // Cached rengoku_data.bin (ECD-encrypted, served to clients as-is)
handlerTable map[network.PacketID]handlerFunc
}
// NewServer creates a new Server type.
func NewServer(config *Config) *Server {
s := &Server{
ID: config.ID,
logger: config.Logger,
db: config.DB,
erupeConfig: config.ErupeConfig,
acceptConns: make(chan net.Conn),
deleteConns: make(chan net.Conn),
done: make(chan struct{}),
sessions: make(map[net.Conn]*Session),
userBinary: NewUserBinaryStore(),
minidata: NewMinidataStore(),
semaphore: make(map[string]*Semaphore),
semaphoreIndex: 7,
discordBot: config.DiscordBot,
name: config.Name,
raviente: &Raviente{
id: 1,
register: make([]uint32, 30),
state: make([]uint32, 30),
support: make([]uint32, 30),
},
questCache: NewQuestCache(config.ErupeConfig.QuestCacheExpiry),
handlerTable: buildHandlerTable(),
}
s.charRepo = NewCharacterRepository(config.DB)
s.guildRepo = NewGuildRepository(config.DB)
s.userRepo = NewUserRepository(config.DB)
s.gachaRepo = NewGachaRepository(config.DB)
s.houseRepo = NewHouseRepository(config.DB)
s.festaRepo = NewFestaRepository(config.DB)
s.towerRepo = NewTowerRepository(config.DB)
s.rengokuRepo = NewRengokuRepository(config.DB)
s.mailRepo = NewMailRepository(config.DB)
s.stampRepo = NewStampRepository(config.DB)
s.distRepo = NewDistributionRepository(config.DB)
s.sessionRepo = NewSessionRepository(config.DB)
s.eventRepo = NewEventRepository(config.DB)
s.achievementRepo = NewAchievementRepository(config.DB)
s.shopRepo = NewShopRepository(config.DB)
s.cafeRepo = NewCafeRepository(config.DB)
s.goocooRepo = NewGoocooRepository(config.DB)
s.divaRepo = NewDivaRepository(config.DB)
s.miscRepo = NewMiscRepository(config.DB)
s.scenarioRepo = NewScenarioRepository(config.DB)
s.mercenaryRepo = NewMercenaryRepository(config.DB)
s.mailService = NewMailService(s.mailRepo, s.guildRepo, s.logger)
s.guildService = NewGuildService(s.guildRepo, s.mailService, s.charRepo, s.logger)
s.achievementService = NewAchievementService(s.achievementRepo, s.logger)
s.gachaService = NewGachaService(s.gachaRepo, s.userRepo, s.charRepo, s.logger, config.ErupeConfig.GameplayOptions.MaximumNP)
s.towerService = NewTowerService(s.towerRepo, s.logger)
s.festaService = NewFestaService(s.festaRepo, s.logger)
// Mezeporta
s.stages.Store("sl1Ns200p0a0u0", NewStage("sl1Ns200p0a0u0"))
// Rasta bar stage
s.stages.Store("sl1Ns211p0a0u0", NewStage("sl1Ns211p0a0u0"))
// Pallone Carvan
s.stages.Store("sl1Ns260p0a0u0", NewStage("sl1Ns260p0a0u0"))
// Pallone Guest House 1st Floor
s.stages.Store("sl1Ns262p0a0u0", NewStage("sl1Ns262p0a0u0"))
// Pallone Guest House 2nd Floor
s.stages.Store("sl1Ns263p0a0u0", NewStage("sl1Ns263p0a0u0"))
// Diva fountain / prayer fountain.
s.stages.Store("sl2Ns379p0a0u0", NewStage("sl2Ns379p0a0u0"))
// MezFes
s.stages.Store("sl1Ns462p0a0u0", NewStage("sl1Ns462p0a0u0"))
s.rengokuBin = loadRengokuBinary(config.ErupeConfig.BinPath, s.logger)
s.i18n = getLangStrings(s)
return s
}
// Start starts the server in a new goroutine.
func (s *Server) Start() error {
l, err := net.Listen("tcp", fmt.Sprintf(":%d", s.Port))
if err != nil {
return err
}
s.listener = l
initCommands(s.erupeConfig.Commands, s.logger)
go s.acceptClients()
go s.manageSessions()
go s.invalidateSessions()
// Start the discord bot for chat integration.
if s.erupeConfig.Discord.Enabled && s.discordBot != nil {
s.discordBot.AddHandler(s.onDiscordMessage)
s.discordBot.AddHandler(s.onInteraction)
}
return nil
}
// Shutdown tries to shut down the server gracefully. Safe to call multiple times.
func (s *Server) Shutdown() {
s.Lock()
alreadyShutDown := s.isShuttingDown
s.isShuttingDown = true
s.Unlock()
if alreadyShutDown {
return
}
close(s.done)
if s.listener != nil {
_ = s.listener.Close()
}
}
func (s *Server) acceptClients() {
for {
conn, err := s.listener.Accept()
if err != nil {
s.Lock()
shutdown := s.isShuttingDown
s.Unlock()
if shutdown || errors.Is(err, net.ErrClosed) {
break
} else {
s.logger.Warn("Error accepting client", zap.Error(err))
continue
}
}
select {
case s.acceptConns <- conn:
case <-s.done:
_ = conn.Close()
return
}
}
}
func (s *Server) manageSessions() {
for {
select {
case <-s.done:
return
case newConn := <-s.acceptConns:
session := NewSession(s, newConn)
s.Lock()
s.sessions[newConn] = session
s.Unlock()
session.Start()
case delConn := <-s.deleteConns:
s.Lock()
delete(s.sessions, delConn)
s.Unlock()
}
}
}
func (s *Server) getObjectId() uint16 {
ids := make(map[uint16]struct{})
for _, sess := range s.sessions {
ids[sess.objectID] = struct{}{}
}
for i := uint16(1); i < 100; i++ {
if _, ok := ids[i]; !ok {
return i
}
}
s.logger.Warn("object ids overflowed", zap.Int("sessions", len(s.sessions)))
return 0
}
func (s *Server) invalidateSessions() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case <-ticker.C:
}
s.Lock()
var timedOut []*Session
for _, sess := range s.sessions {
if time.Since(sess.lastPacket) > time.Second*time.Duration(30) {
timedOut = append(timedOut, sess)
}
}
s.Unlock()
for _, sess := range timedOut {
s.logger.Info("session timeout", zap.String("Name", sess.Name))
logoutPlayer(sess)
}
}
}
// BroadcastMHF queues a MHFPacket to be sent to all sessions.
func (s *Server) BroadcastMHF(pkt mhfpacket.MHFPacket, ignoredSession *Session) {
// Broadcast the data.
s.Lock()
defer s.Unlock()
for _, session := range s.sessions {
if session == ignoredSession {
continue
}
// Make the header
bf := byteframe.NewByteFrame()
bf.WriteUint16(uint16(pkt.Opcode()))
// Build the packet onto the byteframe.
_ = pkt.Build(bf, session.clientContext)
// Enqueue in a non-blocking way that drops the packet if the connections send buffer channel is full.
session.QueueSendNonBlocking(bf.Data())
}
}
// WorldcastMHF broadcasts a packet to all sessions across all channel servers.
func (s *Server) WorldcastMHF(pkt mhfpacket.MHFPacket, ignoredSession *Session, ignoredChannel *Server) {
s.Registry.Worldcast(pkt, ignoredSession, ignoredChannel)
}
// BroadcastChatMessage broadcasts a simple chat message to all the sessions.
func (s *Server) BroadcastChatMessage(message string) {
bf := byteframe.NewByteFrame()
bf.SetLE()
msgBinChat := &binpacket.MsgBinChat{
Unk0: 0,
Type: 5,
Flags: chatFlagServer,
Message: message,
SenderName: s.name,
}
_ = msgBinChat.Build(bf)
s.BroadcastMHF(&mhfpacket.MsgSysCastedBinary{
MessageType: BinaryMessageTypeChat,
RawDataPayload: bf.Data(),
}, nil)
}
// DiscordChannelSend sends a chat message to the configured Discord channel.
func (s *Server) DiscordChannelSend(charName string, content string) {
if s.erupeConfig.Discord.Enabled && s.discordBot != nil {
message := fmt.Sprintf("**%s**: %s", charName, content)
_ = s.discordBot.RealtimeChannelSend(message)
}
}
// DiscordScreenShotSend sends a screenshot link to the configured Discord channel.
func (s *Server) DiscordScreenShotSend(charName string, title string, description string, articleToken string) {
if s.erupeConfig.Discord.Enabled && s.discordBot != nil {
imageUrl := fmt.Sprintf("%s:%d/api/ss/bbs/%s", s.erupeConfig.Screenshots.Host, s.erupeConfig.Screenshots.Port, articleToken)
message := fmt.Sprintf("**%s**: %s - %s %s", charName, title, description, imageUrl)
_ = s.discordBot.RealtimeChannelSend(message)
}
}
// FindSessionByCharID looks up a session by character ID across all channels.
func (s *Server) FindSessionByCharID(charID uint32) *Session {
return s.Registry.FindSessionByCharID(charID)
}
// DisconnectUser disconnects all sessions belonging to the given user ID.
func (s *Server) DisconnectUser(uid uint32) {
cids, err := s.charRepo.GetCharIDsByUserID(uid)
if err != nil {
s.logger.Error("Failed to query characters for disconnect", zap.Error(err))
}
s.Registry.DisconnectUser(cids)
}
// FindObjectByChar finds a stage object owned by the given character ID.
func (s *Server) FindObjectByChar(charID uint32) *Object {
var found *Object
s.stages.Range(func(_ string, stage *Stage) bool {
stage.RLock()
for _, obj := range stage.objects {
if obj.ownerCharID == charID {
found = obj
stage.RUnlock()
return false // stop iteration
}
}
stage.RUnlock()
return true
})
return found
}
// HasSemaphore checks if the given session is hosting any semaphore.
func (s *Server) HasSemaphore(ses *Session) bool {
for _, semaphore := range s.semaphore {
if semaphore.host == ses {
return true
}
}
return false
}
// Server ID arithmetic constants
const (
serverIDHighMask = uint16(0xFF00)
serverIDBase = 0x1000 // first server ID offset
serverIDStride = 0x100 // spacing between server IDs
)
// Season returns the current in-game season (0-2) based on server ID and time.
func (s *Server) Season() uint8 {
sid := int64(((s.ID & serverIDHighMask) - serverIDBase) / serverIDStride)
return uint8(((TimeAdjusted().Unix() / secsPerDay) + sid) % 3)
}
// loadRengokuBinary reads, validates, and caches rengoku_data.bin from binPath.
// The file is served to clients as-is (ECD-encrypted); decryption and parsing
// are performed only for structural validation and startup logging.
// Returns the raw encrypted bytes on success, or nil if the file is
// missing or structurally invalid.
func loadRengokuBinary(binPath string, logger *zap.Logger) []byte {
path := filepath.Join(binPath, "rengoku_data.bin")
data, err := os.ReadFile(path)
if err != nil {
logger.Warn("rengoku_data.bin not found, Hunting Road will be unavailable",
zap.String("path", path), zap.Error(err))
return nil
}
if len(data) < 4 {
logger.Warn("rengoku_data.bin too small, ignoring",
zap.Int("bytes", len(data)))
return nil
}
if magic := binary.LittleEndian.Uint32(data[:4]); magic != decryption.ECDMagic {
logger.Warn("rengoku_data.bin has invalid ECD magic, ignoring",
zap.String("expected", fmt.Sprintf("0x%08x", decryption.ECDMagic)),
zap.String("got", fmt.Sprintf("0x%08x", magic)))
return nil
}
// Decrypt and decompress to validate the internal structure and emit a
// human-readable summary at startup. Failures here are non-fatal: the
// encrypted blob is still served to clients unchanged.
if plain, decErr := decryption.DecodeECD(data); decErr != nil {
logger.Warn("rengoku_data.bin ECD decryption failed — serving anyway",
zap.Error(decErr))
} else {
raw := decryption.UnpackSimple(plain)
if info, parseErr := parseRengokuBinary(raw); parseErr != nil {
logger.Warn("rengoku_data.bin structural validation failed",
zap.Error(parseErr))
} else {
logger.Info("Hunting Road config",
zap.Int("multi_floors", info.MultiFloors),
zap.Int("multi_spawn_tables", info.MultiSpawnTables),
zap.Int("solo_floors", info.SoloFloors),
zap.Int("solo_spawn_tables", info.SoloSpawnTables),
zap.Int("unique_monsters", info.UniqueMonsters),
)
}
}
logger.Info("Loaded rengoku_data.bin", zap.Int("bytes", len(data)))
return data
}