package gamedata import ( "database/sql" "encoding/binary" "fmt" "math" "os" "path/filepath" "bd2server/internal/server/wire" _ "modernc.org/sqlite" ) // EquipmentOption identifies the exact server-owned option stored in // EquipBaseInfo. Rank values use Define_EquipRankType: 0=None, 1=C ... 4=S. type EquipmentOption struct { GroupID uint64 ID uint64 Level int Rank [3]int } type characterStatBase struct { GrowthID uint64 Base BaseStats } // CharacterStatDesign is the immutable, in-memory subset of GameData needed // to resolve character level stats. Keeping the complete curve beside the // other loaded design data prevents request handlers from decrypting and // materializing the roughly 180 MiB common database once per character. type CharacterStatDesign struct { characters map[uint64]characterStatBase growthGroups map[uint64]uint64 levelRatios map[[2]uint64]BaseStats } // BaseStats resolves one character and level without filesystem or database // access. CharacterStatDesign is immutable after loading and is therefore // safe for concurrent request handlers. func (d *CharacterStatDesign) BaseStats(charID, level uint64) (BaseStats, error) { if d == nil || charID == 0 || level == 0 { return BaseStats{}, fmt.Errorf("gamedata: invalid character stat input") } character, found := d.characters[charID] if !found { return BaseStats{}, fmt.Errorf("gamedata: character stats %d: %w", charID, sql.ErrNoRows) } group, found := d.growthGroups[character.GrowthID] if !found { return BaseStats{}, fmt.Errorf("gamedata: character %d growth id %d: %w", charID, character.GrowthID, sql.ErrNoRows) } ratio, found := d.levelRatios[[2]uint64{group, level}] if !found { return BaseStats{}, fmt.Errorf("gamedata: character level group=%d level=%d: %w", group, level, sql.ErrNoRows) } return applyCharacterLevel(character.Base, ratio), nil } func applyCharacterLevel(base, ratio BaseStats) BaseStats { return BaseStats{ Health: math.Trunc(base.Health * (1 + ratio.Health)), Attack: math.Trunc(base.Attack * (1 + ratio.Attack)), Magic: math.Trunc(base.Magic * (1 + ratio.Magic)), } } func loadCharacterStatDesign(db *sql.DB) (*CharacterStatDesign, error) { design := &CharacterStatDesign{ characters: make(map[uint64]characterStatBase), growthGroups: make(map[uint64]uint64), levelRatios: make(map[[2]uint64]BaseStats), } rows, err := db.Query("SELECT id, ProtoBuf FROM CharTable") if err != nil { return nil, fmt.Errorf("gamedata: query character stats: %w", err) } for rows.Next() { var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { rows.Close() return nil, err } growthIDs, err := packedInts(proto, 1) if err != nil || len(growthIDs) != 1 { rows.Close() return nil, fmt.Errorf("gamedata: character %d growth id %v: %w", id, growthIDs, err) } health, found, err := fixed64Double(proto, 11) if err != nil || !found { rows.Close() return nil, fmt.Errorf("gamedata: character %d health: %w", id, err) } attack, _, err := fixed64Double(proto, 17) if err != nil { rows.Close() return nil, fmt.Errorf("gamedata: character %d attack: %w", id, err) } magic, _, err := fixed64Double(proto, 14) if err != nil { rows.Close() return nil, fmt.Errorf("gamedata: character %d magic: %w", id, err) } design.characters[id] = characterStatBase{GrowthID: growthIDs[0], Base: BaseStats{Health: health, Attack: attack, Magic: magic}} } if err := rows.Close(); err != nil { return nil, err } if err := rows.Err(); err != nil { return nil, err } rows, err = db.Query("SELECT id, ProtoBuf FROM CharGrowthTable") if err != nil { return nil, fmt.Errorf("gamedata: query character growths: %w", err) } for rows.Next() { var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { rows.Close() return nil, err } groups, err := packedInts(proto, 1) if err != nil || len(groups) != 1 { rows.Close() return nil, fmt.Errorf("gamedata: character growth %d group %v: %w", id, groups, err) } design.growthGroups[id] = groups[0] } if err := rows.Close(); err != nil { return nil, err } if err := rows.Err(); err != nil { return nil, err } rows, err = db.Query("SELECT GroupId, id, ProtoBuf FROM CharLevelTable") if err != nil { return nil, fmt.Errorf("gamedata: query character levels: %w", err) } for rows.Next() { var group, level uint64 var proto []byte if err := rows.Scan(&group, &level, &proto); err != nil { rows.Close() return nil, err } health, _, err := fixed64Double(proto, 6) if err != nil { rows.Close() return nil, fmt.Errorf("gamedata: character level %d/%d health: %w", group, level, err) } attack, _, err := fixed64Double(proto, 12) if err != nil { rows.Close() return nil, fmt.Errorf("gamedata: character level %d/%d attack: %w", group, level, err) } magic, _, err := fixed64Double(proto, 10) if err != nil { rows.Close() return nil, fmt.Errorf("gamedata: character level %d/%d magic: %w", group, level, err) } design.levelRatios[[2]uint64{group, level}] = BaseStats{Health: health, Attack: attack, Magic: magic} } if err := rows.Close(); err != nil { return nil, err } if err := rows.Err(); err != nil { return nil, err } return design, nil } // CharacterBaseStats reads CharTable -> CharGrowthTable -> CharLevelTable and // applies the client's level formula. It returns design stats only; account // systems such as equipment and costumes are aggregated separately. func CharacterBaseStats(root, version string, charID, level int) (BaseStats, error) { if charID <= 0 || level <= 0 { return BaseStats{}, fmt.Errorf("gamedata: invalid character stat input") } db, closeDB, err := openStatDatabase(root, version) if err != nil { return BaseStats{}, err } defer closeDB() var character []byte if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&character); err != nil { return BaseStats{}, fmt.Errorf("gamedata: character stats %d: %w", charID, err) } growthIDs, err := packedInts(character, 1) if err != nil || len(growthIDs) != 1 { return BaseStats{}, fmt.Errorf("gamedata: character %d growth id %v: %w", charID, growthIDs, err) } var growth []byte if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growth); err != nil { return BaseStats{}, err } groups, err := packedInts(growth, 1) if err != nil || len(groups) != 1 { return BaseStats{}, fmt.Errorf("gamedata: character growth group %v: %w", groups, err) } var levelProto []byte if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=?", groups[0], level).Scan(&levelProto); err != nil { return BaseStats{}, fmt.Errorf("gamedata: character level group=%d level=%d: %w", groups[0], level, err) } baseHP, ok, err := fixed64Double(character, 11) if err != nil || !ok { return BaseStats{}, fmt.Errorf("gamedata: character %d health: %w", charID, err) } baseAttack, _, err := fixed64Double(character, 17) if err != nil { return BaseStats{}, err } baseMagic, _, err := fixed64Double(character, 14) if err != nil { return BaseStats{}, err } hpRatio, _, err := fixed64Double(levelProto, 6) if err != nil { return BaseStats{}, err } attackRatio, _, err := fixed64Double(levelProto, 12) if err != nil { return BaseStats{}, err } magicRatio, _, err := fixed64Double(levelProto, 10) if err != nil { return BaseStats{}, err } return applyCharacterLevel( BaseStats{Health: baseHP, Attack: baseAttack, Magic: baseMagic}, BaseStats{Health: hpRatio, Attack: attackRatio, Magic: magicRatio}, ), nil } // EquipmentOptionContribution resolves an EquipOptionInfo through the real // EquipmentOptionTable, including level and three rank breakpoints. func EquipmentOptionContribution(root, version string, option EquipmentOption) (StatContribution, error) { if option.GroupID == 0 || option.ID == 0 || option.Level < 0 { return StatContribution{}, fmt.Errorf("gamedata: invalid equipment option") } db, closeDB, err := openStatDatabase(root, version) if err != nil { return StatContribution{}, err } defer closeDB() var proto []byte if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentOptionTable WHERE GroupId=? AND id=?", option.GroupID, option.ID).Scan(&proto); err != nil { return StatContribution{}, fmt.Errorf("gamedata: equipment option %d/%d: %w", option.GroupID, option.ID, err) } var rule EquipmentStatRule rule.Default, _, err = fixed64Double(proto, 1) if err != nil { return StatContribution{}, err } rule.Growth, _, err = fixed64Double(proto, 4) if err != nil { return StatContribution{}, err } rule.Levels, err = fixed32Floats(proto, 6) if err != nil { return StatContribution{}, err } for i := range rule.Ranks { rule.Ranks[i], err = fixed32Floats(proto, 7+i) if err != nil { return StatContribution{}, err } } return equipmentStatContribution(rule, option, false) } func optionContribution(id uint64, value float64) (StatContribution, error) { percentage := id != 1 && id != 3 && id != 5 digits := 0 if percentage { digits = 4 } factor := math.Pow10(digits) value = math.Trunc(value*factor) / factor contribution := StatContribution{Option: id} switch id { case 1, 2: contribution.Stat = StatHealth case 3, 4: contribution.Stat = StatAttack case 5, 6: contribution.Stat = StatMagic case 7: contribution.Stat = StatDefencePercent case 8: contribution.Stat = StatMagicResistancePercent default: return StatContribution{}, fmt.Errorf("gamedata: unsupported equipment stat option %d", id) } if percentage { contribution.Percent = value } else { contribution.Flat = value } return contribution, nil } func fixed32Floats(proto []byte, number int) ([]float64, error) { var values []float64 err := wire.Walk(proto, func(field wire.Field) error { if field.Number != number { return nil } if field.Type == 5 { values = append(values, float64(math.Float32frombits(binary.LittleEndian.Uint32(field.Value)))) return nil } if field.Type != 2 || len(field.Value)%4 != 0 { return fmt.Errorf("field %d invalid float encoding", number) } for data := field.Value; len(data) != 0; data = data[4:] { values = append(values, float64(math.Float32frombits(binary.LittleEndian.Uint32(data)))) } return nil }) return values, err } func openStatDatabase(root, version string) (*sql.DB, func(), error) { plain, err := ReadQuestDatabase(root, version) if err != nil { return nil, nil, err } dir, err := os.MkdirTemp("", "bd2-stats-") if err != nil { return nil, nil, err } path := filepath.Join(dir, "common.db") if err := os.WriteFile(path, plain, 0o600); err != nil { os.RemoveAll(dir) return nil, nil, err } db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") if err != nil { os.RemoveAll(dir) return nil, nil, err } return db, func() { _ = db.Close(); _ = os.RemoveAll(dir) }, nil }