mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
nullcomp's passthrough path returns non-cmp-header data as-is without error, which is correct for old uncompressed saves. However, a corrupt backup slot containing garbage shorter than the minimum save layout (100 bytes) would pass Decompress() and then panic in updateStructWithSaveData() with a slice-bounds error at the name field read (offset 88–100). Add a minSaveSize check after each backup decompresses; skip the slot if the result is too small. Also document the campaign system and the fix in CHANGELOG under Unreleased.
240 lines
7.4 KiB
Go
240 lines
7.4 KiB
Go
package channelserver
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
cfg "erupe-ce/config"
|
||
"erupe-ce/network/mhfpacket"
|
||
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// Backup configuration constants.
|
||
const (
|
||
saveBackupSlots = 3 // number of rotating backup slots per character
|
||
saveBackupInterval = 30 * time.Minute // minimum time between backups
|
||
)
|
||
|
||
// GetCharacterSaveData loads a character's save data from the database and
|
||
// verifies its integrity checksum when one is stored.
|
||
func GetCharacterSaveData(s *Session, charID uint32) (*CharacterSaveData, error) {
|
||
id, savedata, isNew, name, storedHash, err := s.server.charRepo.LoadSaveDataWithHash(charID)
|
||
if err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
s.logger.Error("No savedata found", zap.Uint32("charID", charID))
|
||
return nil, errors.New("no savedata found")
|
||
}
|
||
s.logger.Error("Failed to get savedata", zap.Error(err), zap.Uint32("charID", charID))
|
||
return nil, err
|
||
}
|
||
|
||
saveData := &CharacterSaveData{
|
||
CharID: id,
|
||
compSave: savedata,
|
||
IsNewCharacter: isNew,
|
||
Name: name,
|
||
Mode: s.server.erupeConfig.RealClientMode,
|
||
Pointers: getPointers(s.server.erupeConfig.RealClientMode),
|
||
}
|
||
|
||
if saveData.compSave == nil {
|
||
return saveData, nil
|
||
}
|
||
|
||
err = saveData.Decompress()
|
||
if err != nil {
|
||
s.logger.Error("Failed to decompress savedata", zap.Error(err))
|
||
return nil, err
|
||
}
|
||
|
||
// Verify integrity checksum if one was stored with this save.
|
||
// A nil hash means the character was saved before checksums were introduced,
|
||
// so we skip verification (the next save will compute and store the hash).
|
||
if storedHash != nil {
|
||
computedHash := sha256.Sum256(saveData.decompSave)
|
||
if !bytes.Equal(storedHash, computedHash[:]) {
|
||
s.logger.Error("Savedata integrity check failed: hash mismatch",
|
||
zap.Uint32("charID", charID),
|
||
zap.Binary("stored_hash", storedHash),
|
||
zap.Binary("computed_hash", computedHash[:]),
|
||
)
|
||
return recoverFromBackups(s, saveData, charID)
|
||
}
|
||
}
|
||
|
||
saveData.updateStructWithSaveData()
|
||
|
||
return saveData, nil
|
||
}
|
||
|
||
// recoverFromBackups is called when the primary savedata fails its integrity check.
|
||
// It queries savedata_backups in recency order and returns the first slot whose
|
||
// compressed blob decompresses cleanly. It never writes to the database — the
|
||
// next successful Save() will overwrite the primary with fresh data and a new hash,
|
||
// self-healing the corruption without any extra recovery writes.
|
||
func recoverFromBackups(s *Session, base *CharacterSaveData, charID uint32) (*CharacterSaveData, error) {
|
||
backups, err := s.server.charRepo.LoadBackupsByRecency(charID)
|
||
if err != nil {
|
||
s.logger.Error("Failed to load savedata backups during recovery",
|
||
zap.Uint32("charID", charID),
|
||
zap.Error(err),
|
||
)
|
||
return nil, errors.New("savedata integrity check failed")
|
||
}
|
||
|
||
if len(backups) == 0 {
|
||
s.logger.Error("Savedata corrupted and no backups available",
|
||
zap.Uint32("charID", charID),
|
||
)
|
||
return nil, errors.New("savedata integrity check failed: no backups available")
|
||
}
|
||
|
||
for _, backup := range backups {
|
||
candidate := &CharacterSaveData{
|
||
CharID: base.CharID,
|
||
IsNewCharacter: base.IsNewCharacter,
|
||
Name: base.Name,
|
||
Mode: base.Mode,
|
||
Pointers: base.Pointers,
|
||
compSave: backup.Data,
|
||
}
|
||
|
||
if err := candidate.Decompress(); err != nil {
|
||
s.logger.Warn("Backup slot decompression failed during recovery, trying next",
|
||
zap.Uint32("charID", charID),
|
||
zap.Int("slot", backup.Slot),
|
||
zap.Time("saved_at", backup.SavedAt),
|
||
zap.Error(err),
|
||
)
|
||
continue
|
||
}
|
||
|
||
// nullcomp passes through data without a "cmp" header as-is (legitimate for
|
||
// old uncompressed saves). Guard against garbage data that is too small to
|
||
// contain the minimum save layout (name field at offset 88–100).
|
||
const minSaveSize = saveFieldNameOffset + saveFieldNameLen
|
||
if len(candidate.decompSave) < minSaveSize {
|
||
s.logger.Warn("Backup slot data too small after decompression, skipping",
|
||
zap.Uint32("charID", charID),
|
||
zap.Int("slot", backup.Slot),
|
||
zap.Int("size", len(candidate.decompSave)),
|
||
)
|
||
continue
|
||
}
|
||
|
||
s.logger.Warn("Savedata recovered from backup — primary was corrupt",
|
||
zap.Uint32("charID", charID),
|
||
zap.Int("slot", backup.Slot),
|
||
zap.Time("saved_at", backup.SavedAt),
|
||
)
|
||
candidate.updateStructWithSaveData()
|
||
return candidate, nil
|
||
}
|
||
|
||
s.logger.Error("Savedata corrupted and all backup slots failed decompression",
|
||
zap.Uint32("charID", charID),
|
||
zap.Int("backups_tried", len(backups)),
|
||
)
|
||
return nil, errors.New("savedata integrity check failed: all backup slots exhausted")
|
||
}
|
||
|
||
func (save *CharacterSaveData) Save(s *Session) error {
|
||
if save.decompSave == nil {
|
||
s.logger.Warn("No decompressed save data, skipping save",
|
||
zap.Uint32("charID", save.CharID),
|
||
)
|
||
return errors.New("no decompressed save data")
|
||
}
|
||
|
||
// Capture the previous compressed savedata before it's overwritten by
|
||
// Compress(). This is what gets backed up — the last known-good state.
|
||
prevCompSave := save.compSave
|
||
|
||
if !s.kqfOverride {
|
||
s.kqf = save.KQF
|
||
} else {
|
||
save.KQF = s.kqf
|
||
}
|
||
|
||
save.updateSaveDataWithStruct()
|
||
|
||
if s.server.erupeConfig.RealClientMode >= cfg.G1 {
|
||
err := save.Compress()
|
||
if err != nil {
|
||
s.logger.Error("Failed to compress savedata", zap.Error(err))
|
||
return fmt.Errorf("compress savedata: %w", err)
|
||
}
|
||
} else {
|
||
// Saves were not compressed
|
||
save.compSave = save.decompSave
|
||
}
|
||
|
||
// Compute integrity hash over the decompressed save.
|
||
hash := sha256.Sum256(save.decompSave)
|
||
|
||
// Build the atomic save params — character data, house data, hash, and
|
||
// optionally a backup snapshot, all in one transaction.
|
||
params := SaveAtomicParams{
|
||
CharID: save.CharID,
|
||
CompSave: save.compSave,
|
||
Hash: hash[:],
|
||
HR: save.HR,
|
||
GR: save.GR,
|
||
IsFemale: save.Gender,
|
||
WeaponType: save.WeaponType,
|
||
WeaponID: save.WeaponID,
|
||
HouseTier: save.HouseTier,
|
||
HouseData: save.HouseData,
|
||
BookshelfData: save.BookshelfData,
|
||
GalleryData: save.GalleryData,
|
||
ToreData: save.ToreData,
|
||
GardenData: save.GardenData,
|
||
}
|
||
|
||
// Time-gated rotating backup: include the previous compressed savedata
|
||
// in the transaction if enough time has elapsed since the last backup.
|
||
if len(prevCompSave) > 0 {
|
||
if slot, ok := shouldBackup(s, save.CharID); ok {
|
||
params.BackupSlot = slot
|
||
params.BackupData = prevCompSave
|
||
}
|
||
}
|
||
|
||
if err := s.server.charRepo.SaveCharacterDataAtomic(params); err != nil {
|
||
s.logger.Error("Failed to save character data atomically",
|
||
zap.Error(err), zap.Uint32("charID", save.CharID))
|
||
return fmt.Errorf("atomic save: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// shouldBackup checks whether enough time has elapsed since the last backup
|
||
// and returns the target slot if a backup should be included in the save
|
||
// transaction. Returns (slot, true) if a backup is due, (0, false) otherwise.
|
||
func shouldBackup(s *Session, charID uint32) (int, bool) {
|
||
lastBackup, err := s.server.charRepo.GetLastBackupTime(charID)
|
||
if err != nil {
|
||
s.logger.Warn("Failed to query last backup time, skipping backup",
|
||
zap.Error(err), zap.Uint32("charID", charID))
|
||
return 0, false
|
||
}
|
||
|
||
if time.Since(lastBackup) < saveBackupInterval {
|
||
return 0, false
|
||
}
|
||
|
||
slot := int(lastBackup.Unix()/int64(saveBackupInterval.Seconds())) % saveBackupSlots
|
||
return slot, true
|
||
}
|
||
|
||
func handleMsgMhfSexChanger(s *Session, p mhfpacket.MHFPacket) {
|
||
pkt := p.(*mhfpacket.MsgMhfSexChanger)
|
||
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
|
||
}
|