Files
90CNServer/server/internal/services/dnfbridge/title_book.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

618 lines
24 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 dnfbridge
import (
"context"
"encoding/binary"
"errors"
"fmt"
"sort"
"strings"
dnfachievement "longheng.io/server/internal/modules/dnf/achievement"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnftitlebook "longheng.io/server/internal/modules/dnf/titlebook"
)
// Title book uses inventory list type 100 with slot encoding:
// slot = category * 1000 + bookIndex
const (
currentTitleBookListType byte = dnftitlebook.ListType
currentTitleBookSlotBase int16 = dnftitlebook.SlotBase
currentTitleBookMsgID uint16 = 0x0166
currentTitleBookPutMsgID uint16 = uint16(dnfenum.CmdPacketTitleBookPut)
currentTitleBookGetMsgID uint16 = uint16(dnfenum.CmdPacketTitleBookGet)
currentTitleBookMaxPerCategory = dnftitlebook.MaxPerCategory
// currentTitleBookEquipmentItemSpace 是客户端 op413 里表示「目标=装备栏」的物品空间号。
// 实测 2026-09-18:右键「使用」发 space=3(+目标槽 13),「拿出来」发 space=0(+期望背包格)。
currentTitleBookEquipmentItemSpace int32 = 3
)
// currentTitleBookEntry is one title in the title book. The current EXE reads
// the 22-byte fixed projection followed by u32 raw_len and raw_len bytes.
type currentTitleBookEntry struct {
SlotIndex uint16
ItemID int32
Value int32
Attr byte
Durability uint16
SealFlag byte
EnchantIndex int32
EnchantUpgrade byte
AmplifyType byte
AmplifyValue uint16
}
func currentTitleBookSlotKey(category int32, index int32) string {
slot := int16(category)*currentTitleBookSlotBase + int16(index)
return fmt.Sprintf("%d:%d", currentTitleBookListType, slot)
}
func currentTitleBookCategoryFromSlot(slot int16) int32 {
return int32(slot / currentTitleBookSlotBase)
}
func currentTitleBookIndexFromSlot(slot int16) int32 {
return int32(slot % currentTitleBookSlotBase)
}
// handleCurrentTitleBookPut processes CMD 0x019C (412): put a title from
// inventory/equipment into the title book.
func (s *Service) handleCurrentTitleBookPut(session *gameSession, body []byte) error {
if session == nil || session.selectedCharacterID == 0 {
return nil
}
if len(body) < 20 {
s.logGameEvent(session, "game-title-book-put-blocked", "body_len", len(body), "reason", "body_too_short")
return nil
}
itemSpace := int32(binary.LittleEndian.Uint32(body[0:4]))
slot := int16(int32(binary.LittleEndian.Uint32(body[4:8])))
itemID := int32(binary.LittleEndian.Uint32(body[8:12]))
category := int32(binary.LittleEndian.Uint32(body[12:16]))
bookIndex := int32(binary.LittleEndian.Uint32(body[16:20]))
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
repos, ok := s.repositoryGroup()
if !ok {
return nil
}
owner, err := dnftitlebook.NewOwner(repos)
if err != nil {
return nil
}
result, err := owner.Put(ctx, dnftitlebook.PutCommand{
SelectedCharacterID: session.selectedCharacterID,
ItemSpace: itemSpace,
SourceSlot: slot,
ItemID: itemID,
Category: category,
BookIndex: bookIndex,
})
switch {
case errors.Is(err, dnftitlebook.ErrInventoryNotFound):
s.logGameEvent(session, "game-title-book-put-blocked",
"char_id", session.selectedCharacterID,
"reason", "inventory_not_found")
return s.sendTitleBookFailure(session, currentTitleBookPutMsgID, 1, itemSpace, category)
case errors.Is(err, dnftitlebook.ErrSourceNotFound):
s.logGameEvent(session, "game-title-book-put-blocked",
"char_id", session.selectedCharacterID,
"source_list", byte(itemSpace),
"source_slot", slot,
"item_id", itemID,
"reason", "source_not_found")
return s.sendTitleBookFailure(session, currentTitleBookPutMsgID, 2, itemSpace, category)
case errors.Is(err, dnftitlebook.ErrCategoryFull):
s.logGameEvent(session, "game-title-book-put-blocked",
"char_id", session.selectedCharacterID,
"category", category,
"reason", "category_full")
return s.sendTitleBookFailure(session, currentTitleBookPutMsgID, 3, itemSpace, category)
case errors.Is(err, dnftitlebook.ErrCategoryInvalid):
return s.sendTitleBookFailure(session, currentTitleBookPutMsgID, 4, itemSpace, category)
case err != nil:
s.logGameEvent(session, "game-title-book-put-failed",
"char_id", session.selectedCharacterID,
"reason", err)
return nil
}
s.logGameEvent(session, "game-title-book-put-success",
"char_id", result.CharacterID,
"item_id", result.ItemID,
"category", result.Category,
"book_index", result.BookIndex,
"source_list", result.SourceList,
"source_slot", result.SourceSlot,
"target_slot", result.TargetSlot)
if err := s.sendTitleBookPutAck(
session,
result.ItemSpace,
result.SourceSlot,
result.Category,
result.BookIndex,
); err != nil {
return err
}
var emptyEntry currentItemListEntry
emptyEntry.patchCore(result.SourceSlot, 0xFFFFFFFF, 0)
updateBody := buildCurrentItemUpdateBody(result.SourceList, []currentItemListEntry{emptyEntry})
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), updateBody, 0); err != nil {
return err
}
return s.sendCurrentTitleBookList(session, ctx, repos, result.CharacterID, result.Category)
}
// handleCurrentTitleBookGet processes CMD 0x019D (413): take a title from
// the title book back to inventory.
func (s *Service) handleCurrentTitleBookGet(session *gameSession, body []byte) error {
if session == nil || session.selectedCharacterID == 0 {
return nil
}
if len(body) < 20 {
s.logGameEvent(session, "game-title-book-get-blocked", "body_len", len(body), "reason", "body_too_short")
return nil
}
itemSpace := int32(binary.LittleEndian.Uint32(body[0:4]))
slot := int16(int32(binary.LittleEndian.Uint32(body[4:8])))
itemID := int32(binary.LittleEndian.Uint32(body[8:12]))
category := int32(binary.LittleEndian.Uint32(body[12:16]))
bookIndex := int32(binary.LittleEndian.Uint32(body[16:20]))
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
repos, ok := s.repositoryGroup()
if !ok {
return nil
}
owner, err := dnftitlebook.NewOwner(repos)
if err != nil {
return nil
}
cmd := dnftitlebook.GetCommand{
SelectedCharacterID: session.selectedCharacterID,
ItemSpace: itemSpace,
SourceSlot: slot,
PreferredSlot: slot,
ItemID: itemID,
Category: category,
BookIndex: bookIndex,
}
// 【2026-09-18 修复:按客户端给的目标空间落位】客户端在 op413 里明确给出目标物品
// 空间并**按回包里的 (space, slot) 摆放物品**(实测两条请求:space=3 是右键「使用」
// → 穿身上;space=0 是「拿出来」→ 回背包)。早期实现忽略了这个字段、一律穿上,
// 于是「拿出来」时玩家看到背包里一个副本,小退重登却变成自动穿戴(服务端真实状态)。
if itemSpace == currentTitleBookEquipmentItemSpace {
result, equipErr := owner.Equip(ctx, cmd)
if equipErr != nil {
return s.handleCurrentTitleBookGetError(session, equipErr, itemSpace, category, bookIndex)
}
return s.sendCurrentTitleBookEquipped(session, ctx, repos, result)
}
result, getErr := owner.Get(ctx, cmd)
if getErr != nil {
return s.handleCurrentTitleBookGetError(session, getErr, itemSpace, category, bookIndex)
}
return s.sendCurrentTitleBookTakenOut(session, ctx, repos, result)
}
// handleCurrentTitleBookGetError 把 owner 的错误映射成客户端认识的失败码。
func (s *Service) handleCurrentTitleBookGetError(session *gameSession, err error, itemSpace, category, bookIndex int32) error {
switch {
case errors.Is(err, dnftitlebook.ErrInventoryNotFound):
return s.sendTitleBookFailure(session, currentTitleBookGetMsgID, 1, itemSpace, category)
case errors.Is(err, dnftitlebook.ErrSourceNotFound):
s.logGameEvent(session, "game-title-book-get-blocked",
"char_id", session.selectedCharacterID,
"category", category,
"book_index", bookIndex,
"reason", "not_found_in_book")
return s.sendTitleBookFailure(session, currentTitleBookGetMsgID, 2, itemSpace, category)
case errors.Is(err, dnftitlebook.ErrTargetFull):
s.logGameEvent(session, "game-title-book-get-blocked",
"char_id", session.selectedCharacterID,
"reason", "inventory_full")
return s.sendTitleBookFailure(session, currentTitleBookGetMsgID, 3, itemSpace, category)
case errors.Is(err, dnftitlebook.ErrCategoryInvalid):
return s.sendTitleBookFailure(session, currentTitleBookGetMsgID, 4, itemSpace, category)
case err != nil:
s.logGameEvent(session, "game-title-book-get-failed",
"char_id", session.selectedCharacterID,
"reason", err)
}
return nil
}
// sendCurrentTitleBookEquipped 处理 space=3(右键「使用」)的成功回包。
//
// 【2026-09-18 方案A(闪退修正版)】称号簿右键使用 = 直接装备:
// - 服务端把称号写进 list-3 称号槽并落库(owner.Equip),登录时随装备回放,
// 所以小退重登后称号还在——修掉「穿戴栏的称号小退就消失」;
// - 不再发「物品回背包」的 op14 —— 那正是玩家看到的「背包里多一个」;
// - 换下来的旧称号要回背包,这里补一行 op14(list0 单行,客户端已验证的形态)
// 让客户端立刻看到;
// - **不再发 sendSelectedCurrentEquipmentSlotItemUpdates**:那是 22 行的 op14 list-3
// 全量刷新,客户端必崩(2026-09-18 23:41:17 客户端 90CN_trace:dispatch
// opcode=14 packet_len=2637 之后立刻 0xC0000005 eip=0x03B25D03,该地址位于
// __report_gsfailure 内,WER 同址报 0xC0000409 即栈缓冲溢出;而 list-3 历史上
// 只发过单行、共 88 次,从未出事)。最可能是客户端按行数在栈上开临时区被压爆,
// 但机制未完全坐实——不管机制如何,官方在这条路径上根本不发列表包,对齐官方最稳。
// 官方参考服务端 CTitleBook::getTitle(0x086425C4) 在装备分支末尾只调
// CUser::send_equip(0x865DD14),而 send_equip 对槽位 13 直接 return,
// 即官方这里根本不发任何物品列表:客户端拿 413 回包里的 (space=3, slot=13)
// 自己就穿上了。
func (s *Service) sendCurrentTitleBookEquipped(session *gameSession, ctx context.Context, repos dnfrepo.Group, result dnftitlebook.EquipResult) error {
s.logGameEvent(session, "game-title-book-get-success",
"char_id", result.CharacterID,
"item_id", result.ItemID,
"category", result.Category,
"book_index", result.BookIndex,
"equipment_slot", result.EquipmentSlot,
"replaced", result.Replaced,
"return_slot", result.ReturnSlot,
"action", "moved_from_book_to_equipment")
if err := s.sendTitleBookGetAck(
session,
currentTitleBookEquipmentItemSpace,
int16(result.EquipmentSlot),
result.Category,
result.BookIndex,
); err != nil {
return err
}
if result.Replaced {
if err := s.sendCurrentTitleBookInventoryRow(session, result.ReturnSlot, result.ReturnItem); err != nil {
return err
}
}
// 称号已穿上,这一页要用「移除后」的列表(owner.Equip 已把簿内条目删除)。
return s.sendCurrentTitleBookList(session, ctx, repos, result.CharacterID, result.Category)
}
// sendCurrentTitleBookTakenOut 处理 space=0 等背包目标(「拿出来」)的成功回包。
// 背包里必须有这一行,客户端才会立刻看到;ack 回**真实落点**——空间号统一报背包
// (标题只会落到背包或装备栏;万一客户端给出别的空间号,也不能把背包槽号报成仓库/
// 装扮的空间,否则客户端会按错误的容器摆放物品)。
func (s *Service) sendCurrentTitleBookTakenOut(session *gameSession, ctx context.Context, repos dnfrepo.Group, result dnftitlebook.GetResult) error {
s.logGameEvent(session, "game-title-book-get-success",
"char_id", result.CharacterID,
"item_id", result.ItemID,
"category", result.Category,
"book_index", result.BookIndex,
"target_slot", result.TargetSlot,
"requested_space", result.ItemSpace,
"action", "moved_from_book_to_inventory")
if err := s.sendTitleBookGetAck(
session,
int32(dnfrepo.MainInventoryListType),
result.TargetSlot,
result.Category,
result.BookIndex,
); err != nil {
return err
}
if err := s.sendCurrentTitleBookInventoryRow(session, result.TargetSlot, result.TargetStack); err != nil {
return err
}
return s.sendCurrentTitleBookList(session, ctx, repos, result.CharacterID, result.Category)
}
// sendCurrentTitleBookInventoryRow 发一行 list-0 单行 op14(客户端已验证的形态:
// 历史上 list-0 单行更新发过上千次,多行也一直是同一种 119 字节行)。
func (s *Service) sendCurrentTitleBookInventoryRow(session *gameSession, slot int16, stack dnfrepo.ItemStack) error {
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
updateBody := buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, []currentItemListEntry{entry})
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), updateBody, 0)
}
// sendCurrentTitleBookList sends NOTI 0x0166 for one category.
func (s *Service) sendCurrentTitleBookList(session *gameSession, ctx context.Context, repos dnfrepo.Group, characterID string, category int32) error {
inventory, found, err := repos.Inventory.Load(ctx, characterID)
if err != nil || !found {
return nil
}
entries := currentTitleBookEntriesForCategory(inventory, category)
body := buildCurrentTitleBookListBody(session.selectedCharacterID, category, entries)
return s.sendGameUpperRawClass(session, currentTitleBookMsgID, body, 0)
}
// sendAllCurrentTitleBookLists sends NOTI 0x0166 for all categories that have entries.
func (s *Service) sendAllCurrentTitleBookLists(session *gameSession, ctx context.Context, repos dnfrepo.Group, characterID string) error {
inventory, found, err := repos.Inventory.Load(ctx, characterID)
if err != nil || !found {
return nil
}
categories := currentTitleBookCategories(inventory)
for _, category := range categories {
entries := currentTitleBookEntriesForCategory(inventory, category)
body := buildCurrentTitleBookListBody(session.selectedCharacterID, category, entries)
if err := s.sendGameUpperRawClass(session, currentTitleBookMsgID, body, 0); err != nil {
return err
}
}
return nil
}
func currentTitleBookCategories(inventory dnfrepo.InventoryRecord) []int32 {
categorySet := make(map[int32]struct{})
for key, stack := range inventory.Slots {
if stack.ItemID <= 0 || dnfachievement.IsLegacyInventoryProgress(key, stack) {
continue
}
listType, slot, ok := parseSceneInventorySlotKey(key)
if !ok || listType != currentTitleBookListType {
continue
}
categorySet[currentTitleBookCategoryFromSlot(slot)] = struct{}{}
}
categories := make([]int32, 0, len(categorySet))
for c := range categorySet {
categories = append(categories, c)
}
sort.Slice(categories, func(i, j int) bool { return categories[i] < categories[j] })
return categories
}
func currentTitleBookEntriesForCategory(inventory dnfrepo.InventoryRecord, category int32) []currentTitleBookEntry {
entries := make([]currentTitleBookEntry, 0, 8)
for key, stack := range inventory.Slots {
if stack.ItemID <= 0 || dnfachievement.IsLegacyInventoryProgress(key, stack) {
continue
}
listType, slot, ok := parseSceneInventorySlotKey(key)
if !ok || listType != currentTitleBookListType {
continue
}
if currentTitleBookCategoryFromSlot(slot) != category {
continue
}
entries = append(entries, currentTitleBookEntry{
SlotIndex: uint16(currentTitleBookIndexFromSlot(slot)),
ItemID: int32(stack.ItemID),
Value: int32(sceneInventoryStackAmount(stack)),
Attr: sceneInventoryExtraByte(stack.Extra, "packed_flag_byte", "packed_flag", "packed", "ext_data0", "ext0"),
Durability: sceneInventoryExtraUint16(stack.Extra, "value_b_word", "durability", "max_durability"),
// 封装标记:0 = 已解封,1 = 封装。必须和背包行同源(currentItemListBindFlag),
// 否则同一件称号在背包显示解封、在簿里显示封装(2026-09-18 玩家报障)。
// 官方 CTitleBook::_putItemData 写的也是物品自己的 Inven_Item+0 字节,
// 详见 scene_inventory.go currentRequestOverseerRowsFromInventory 的注释。
SealFlag: currentItemListBindFlag(stack.Bind, stack.Extra),
EnchantIndex: int32(sceneInventoryExtraUint32(stack.Extra, "value_a", "instance_value", "item_uid", "serial", "count_or_instance")),
EnchantUpgrade: sceneInventoryExtraByte(stack.Extra, "unused_byte_after_value_a", "unused"),
AmplifyType: sceneInventoryExtraByte(stack.Extra, "value_c"),
AmplifyValue: sceneInventoryExtraUint16(stack.Extra, "value_d"),
})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].SlotIndex < entries[j].SlotIndex })
return entries
}
// buildCurrentTitleBookListBody builds NOTI 0x0166 body.
// Format: u8 info_type + u16 owner_id + i32 category + i32 count + count * 22B entries.
func buildCurrentTitleBookListBody(ownerID uint16, category int32, entries []currentTitleBookEntry) []byte {
var w packetWriter
w.writeByte(0) // info_type: 0 = self
w.writeUint16(ownerID)
w.writeInt32(int(category))
w.writeInt32(len(entries))
for _, e := range entries {
w.writeUint16(e.SlotIndex)
w.writeInt32(int(e.ItemID))
w.writeInt32(int(e.Value))
w.writeByte(e.Attr)
w.writeUint16(e.Durability)
w.writeByte(e.SealFlag)
w.writeInt32(int(e.EnchantIndex))
w.writeByte(e.EnchantUpgrade)
w.writeByte(e.AmplifyType)
w.writeUint16(e.AmplifyValue)
w.writeUint32(0)
}
return w.bytes()
}
func (s *Service) sendTitleBookPutAck(session *gameSession, itemSpace int32, slot int16, category, bookIndex int32) error {
var w packetWriter
w.writeInt32(int(itemSpace))
w.writeInt32(int(slot))
w.writeInt32(int(category))
w.writeInt32(int(bookIndex))
return s.sendGameUpperSuccess(session, currentTitleBookPutMsgID, w.bytes())
}
func (s *Service) sendTitleBookGetAck(session *gameSession, itemSpace int32, slot int16, category, bookIndex int32) error {
var w packetWriter
w.writeInt32(int(itemSpace))
w.writeInt32(int(slot))
w.writeInt32(int(category))
w.writeInt32(int(bookIndex))
return s.sendGameUpperSuccess(session, currentTitleBookGetMsgID, w.bytes())
}
func (s *Service) sendTitleBookFailure(session *gameSession, msgID uint16, errorCode byte, itemSpace int32, category int32) error {
var w packetWriter
w.writeByte(0) // failure
w.writeByte(errorCode)
w.writeInt32(int(itemSpace))
w.writeInt32(int(category))
return s.sendGameUpperRaw(session, msgID, w.bytes())
}
// isCurrentTitleBookRequest returns true if the opcode is a title book operation
// that should be handled by the dnfbridge title book handler instead of the
// aligned command system.
func isCurrentTitleBookRequest(opcode uint16) bool {
return opcode == uint16(dnfenum.CmdPacketTitleBookPut) || opcode == uint16(dnfenum.CmdPacketTitleBookGet)
}
// --- Phase 2: Achievement progress (CMD 0x01A1 / 417) ---
const (
currentAchievementTriggerMsgID uint16 = uint16(dnfenum.CmdPacketAchievementTrigger)
)
// resolveAchievementDefinition joins the titlebook.etc placement mapping with
// the same quest PVF catalog used by normal quest logic.
func (s *Service) resolveAchievementDefinition(ctx context.Context, questID int32) (dnfachievement.Definition, error) {
mapping := s.currentTitleBookMapping()
if mapping == nil {
return dnfachievement.Definition{}, dnfachievement.ErrDefinitionNotFound
}
entry, ok := mapping[questID]
if !ok {
return dnfachievement.Definition{}, fmt.Errorf("%w: quest=%d", dnfachievement.ErrDefinitionNotFound, questID)
}
catalog, err := s.loadQuestCatalog(ctx)
if err != nil {
return dnfachievement.Definition{}, err
}
questDefinition, ok := catalog.Find(int64(questID))
if !ok {
return dnfachievement.Definition{}, fmt.Errorf("%w: quest=%d pvf", dnfachievement.ErrDefinitionNotFound, questID)
}
targets := currentAchievementTargets(questDefinition)
return dnfachievement.Definition{
QuestID: questID,
Target1: targets[0],
Target2: targets[1],
Target3: targets[2],
Reward: dnfachievement.TitleReward{
ItemID: entry.titleItemID,
Category: entry.category,
BookIndex: entry.bookIndex,
},
}, nil
}
type titleBookMappingEntry struct {
titleItemID int32
category int32
bookIndex int32
rewardCount int32
}
// currentTitleBookMapping returns the cached quest_id → title mapping from titlebook.etc.
func (s *Service) currentTitleBookMapping() map[int32]titleBookMappingEntry {
s.titleBookMappingMu.Lock()
defer s.titleBookMappingMu.Unlock()
if s.titleBookMappingCache != nil {
return s.titleBookMappingCache
}
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
return nil
}
text, err := archive.ReadText("etc/titlebook.etc")
if err != nil {
s.logPacketEvent("game-titlebook-etc-read-failed", "error", err)
return nil
}
mapping := parseTitleBookEtc(text)
s.titleBookMappingCache = mapping
s.logPacketEvent("game-titlebook-mapping-loaded", "entries", len(mapping))
return mapping
}
// parseTitleBookEtc parses the current PVF's repeated
// [title collection info] sections. Each section starts with category name and
// max slots, then uses: book_index, marker, quest_id, reward_count, title_id.
// marker -1 denotes an empty slot and therefore has no following triple.
func parseTitleBookEtc(text string) map[int32]titleBookMappingEntry {
mapping := make(map[int32]titleBookMappingEntry, 256)
document, err := dnfpvf.Parse("etc/titlebook.etc", text)
if err != nil {
return mapping
}
category := int32(0)
for _, section := range document.Sections {
if !strings.EqualFold(strings.TrimSpace(section.Name), "title collection info") {
continue
}
if section.Start < 0 || section.End > len(document.Tokens) || section.Start >= section.End {
category++
continue
}
tokens := document.Tokens[section.Start:section.End]
cursor := 2 // category name + max slots
for cursor+1 < len(tokens) {
if tokens[cursor].Kind != dnfpvf.TokenInt || tokens[cursor+1].Kind != dnfpvf.TokenInt {
cursor++
continue
}
bookIndex := tokens[cursor].Int
marker := tokens[cursor+1].Int
cursor += 2
if marker == -1 {
continue
}
if cursor+2 >= len(tokens) ||
tokens[cursor].Kind != dnfpvf.TokenInt ||
tokens[cursor+1].Kind != dnfpvf.TokenInt ||
tokens[cursor+2].Kind != dnfpvf.TokenInt {
break
}
questID := tokens[cursor].Int
rewardCount := tokens[cursor+1].Int
titleItemID := tokens[cursor+2].Int
cursor += 3
if questID <= 0 || questID > int64(^uint32(0)>>1) ||
bookIndex < 0 || bookIndex >= int64(currentTitleBookMaxPerCategory) ||
titleItemID <= 0 || titleItemID > int64(^uint32(0)>>1) {
continue
}
mapping[int32(questID)] = titleBookMappingEntry{
titleItemID: int32(titleItemID),
category: category,
bookIndex: int32(bookIndex),
rewardCount: int32(rewardCount),
}
}
category++
}
return mapping
}
func currentAchievementTargets(definition dnfquest.Definition) [3]uint16 {
var targets [3]uint16
for index, value := range definition.CheckCount {
if index >= len(targets) {
break
}
targets[index] = currentAchievementTargetValue(value)
}
if targets[0] != 0 || targets[1] != 0 || targets[2] != 0 {
return targets
}
if strings.EqualFold(strings.TrimSpace(definition.Type), "[seeking]") ||
strings.EqualFold(strings.TrimSpace(definition.Type), "seeking") {
for index := 0; index < len(targets) && index*2+1 < len(definition.IntData); index++ {
targets[index] = currentAchievementTargetValue(definition.IntData[index*2+1])
}
if targets[0] != 0 || targets[1] != 0 || targets[2] != 0 {
return targets
}
}
targets[0] = 1
return targets
}
func currentAchievementTargetValue(value int64) uint16 {
if value <= 0 {
return 0
}
if value > int64(^uint16(0)) {
return ^uint16(0)
}
return uint16(value)
}