Files
90CNServer/server/internal/services/dnfbridge/online_event_spend_time_protocol.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

425 lines
20 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 (
"fmt"
"math"
"time"
"longheng.io/server/internal/modules/dnf/onlineevent"
"longheng.io/server/internal/modules/dnf/packageitem"
)
const (
// The legacy command enum names both values from the opposite packet
// direction. Keep current-EXE notification semantics local to this owner.
currentSpendTimeEventInfoMsgID uint16 = 108
currentSpendTimeProgressMsgID uint16 = 1206
currentSpendTimeEventID uint32 = 2347
// currentSpendTimeActivityClosed=true 时不写「累计在线得好礼」(2347) 的
// 基础目录行,使该活动从客户端活动列表消失。注意:**不要**改成关闭整个
// 活动目录(让 currentSpendTimeRuntimeCatalogIfAvailable 返回 nil),
// 那会让团本日期校验失效,超时空之战报「使用日期不准确」。
currentSpendTimeActivityClosed = true
currentSpendTimeSumEventID uint32 = 2348
currentJoustEventID uint32 = 2365
currentJonathanEventID uint32 = 2395
currentLukeRaidEventID uint32 = 282
currentLukeRaidOpenEventID uint16 = 2417
currentFiendWarEventID uint32 = 2449
// currentAntonRaidEventID completes the family. The client's op1206 reader
// (file 0xA713E0) dispatches 181 / 282 / 2449 onto the mode-1 / -2 / -3 window
// elements -- `cmp eax,0xB5` is the Anton arm, and the 0x991 arm jumps into
// the very same setter call -- so omitting 181 leaves Anton's weekly counters
// with no writer at all.
currentAntonRaidEventID uint32 = 181
currentGuildHongbaoEventID uint16 = 2378
currentGuildWarEventID uint16 = 2400
currentDecisionEventID uint16 = 222
currentMileageShopEventID uint16 = 266
currentSpendTimeDescriptorParamCount = 12
currentSpendTimeMaxRewardItems = 10
currentSpendTimeProgressRawSize = 0x20
currentRaidScheduleInitialUsedCount = uint32(0)
currentRaidScheduleInitialDayMask = uint32(0)
currentSpendTimeUnusedParam = math.MaxUint32
currentSpendTimeBaseCatalogValue = 1
currentSpendTimeEventInfoCodec = "current_op108_spend_time_and_all_day_joust_catalog_zlib"
currentSpendTimeEventRoute = "spend time event/0/0"
)
// buildCurrentSpendTimeEventInfoBody matches the current NoPack key-9 op108
// body consumed by sub_20457F0:
//
// u16 base_catalog_count,
// base_catalog_count * { u16 event_id, u32 value, dstr title,
// dstr description, u8 has_extended, ... },
// u8 active_count, active_count * { u16 event_id, 12 * u32 param }
//
// sub_2043290 reads a retired list only when the already-installed base table
// contains special key 9. The seven native activity-calendar rows are part of
// this same final base catalog because the current client replaces the catalog
// whenever it receives op108. They remain base-only records, though: the
// verified calendar envelope terminates with reward_count=0, and calendar IDs
// are not consumed by the twelve-parameter active-state handlers. Synthetic
// calendar active rows make the native date grid open with no entries. Only
// the six live event families below belong in op108's active-state map.
//
// Event 2037 is the native Seria-luck HUD gate. sub_1FC6210 only makes the
// BOOSTER_GAGE control visible after it finds that event in the active table;
// a later class-0/op412 update alone is therefore intentionally invisible.
// Its first active parameter carries the durable stage ceiling shared with the
// package-item state machine. The control itself retains ownership of the
// display-unit maximum and animation.
//
// The base row is required even though event 2347 owns a fixed client UI.
// sub_2045130 inserts it into the global event table; without that row,
// sub_2045E30 never places 2347 in the active-event map and sub_F3EDF0 hides
// the HUD entry. Event 2347 also requires its exact extended action route.
// sub_2036D50 maps "spend time event" to action 0x6AD, and sub_2042550 marks
// the row clickable and stores its two route arguments. Without that route the
// compact HUD entry is visible, but clicking it cannot open the fixed
// four-stage ItemReward.xui panel.
//
// Event 2347 owns params 0..N-1 as reward item IDs, param 10 as N, and param
// 11 as the total duration of all four stages. Current sub_F3FDA0 treats -1,
// not zero, as the unused reward-slot sentinel.
//
// Event 2395 is the PVF [open visible event] gate on NPC 476 (Red-tail
// Jonathan) in Central Park. The NPC's ambient voice is map-owned and can play
// while the actor remains hidden; installing this schedule-free base/active
// row makes the native town loader instantiate the NPC after the Magic Rift
// access quest has made that town content reachable.
//
// Events 282 and 2449 are the current EXE's Luke and Fiend War ESC/team-raid
// entrance gates. The client can preconnect their authoritative type-32/type-42
// channels while still hiding the cards when these events are absent from the
// installed activity table. Both use the same schedule-free base/active grammar
// as event 2365; op1206 below supplies their separate schedule state. The
// following op1303 projection supplies each current PVF account daily limit;
// this activity-table owner remains separate from raid creation and settlement.
func buildCurrentSpendTimeEventInfoBody(
rewardItemIDs []uint32,
totalStageSeconds uint32,
) ([]byte, error) {
return buildCurrentSpendTimeEventInfoBodyWithSum(rewardItemIDs, nil, totalStageSeconds)
}
func buildCurrentSpendTimeEventInfoBodyWithSum(
rewardItemIDs []uint32,
sumRewardItemIDs []uint32,
totalStageSeconds uint32,
) ([]byte, error) {
return buildCurrentOnlineEventInfoBody(rewardItemIDs, sumRewardItemIDs, totalStageSeconds, false)
}
func buildCurrentOnlineEventInfoBody(rewardItemIDs, sumRewardItemIDs []uint32, totalStageSeconds uint32, nativePCRoom bool) ([]byte, error) {
return buildCurrentOnlineEventInfoBodyAt(rewardItemIDs, sumRewardItemIDs, totalStageSeconds, nativePCRoom, nil, time.Time{})
}
func buildCurrentOnlineEventInfoBodyAt(rewardItemIDs, sumRewardItemIDs []uint32, totalStageSeconds uint32, nativePCRoom bool, labor *onlineevent.LaborDayCatalog, now time.Time) ([]byte, error) {
laborActive := labor != nil && labor.Policy.ActiveAt(now)
laborBase := laborActive || (labor == nil && len(sumRewardItemIDs) > 0)
if len(rewardItemIDs) == 0 || len(rewardItemIDs) > currentSpendTimeMaxRewardItems {
return nil, fmt.Errorf("current spend-time reward count=%d is outside 1..%d", len(rewardItemIDs), currentSpendTimeMaxRewardItems)
}
if totalStageSeconds == 0 {
return nil, fmt.Errorf("current spend-time total stage seconds is zero")
}
params := [currentSpendTimeDescriptorParamCount]uint32{}
for index := 0; index < currentSpendTimeMaxRewardItems; index++ {
params[index] = currentSpendTimeUnusedParam
}
for index, itemID := range rewardItemIDs {
if itemID == 0 {
return nil, fmt.Errorf("current spend-time reward[%d] item id is zero", index)
}
params[index] = itemID
}
params[10] = uint32(len(rewardItemIDs))
params[11] = totalStageSeconds
var writer packetWriter
calendarRecords := currentActivityCalendarRecords()
// Event 9 is the native activity-table anchor. Event 181 is the Anton team
// tab; the client gates that tab with the completed q12829 bit from op356.
// Both rows must be present in the final post-op356 catalog before op1206
// and op1303 are replayed.
baseRows := len(calendarRecords) + 15
if laborBase {
baseRows++
}
writer.writeUint16(uint16(baseRows)) // event 9 + calendar + event rows
writer.writeUint16(9)
writer.writeUint32(0)
writer.writeRawDstr(nil)
writer.writeRawDstr(nil)
writer.writeByte(0)
for _, record := range calendarRecords {
if err := writeCurrentActivityCalendarRecord(&writer, record); err != nil {
return nil, err
}
}
// The guild window checks the native activity directory for 2378 before
// enabling its hongbao panel. Keep the gate in the base catalog; it has no
// active-state payload or synthetic reward data.
writer.writeUint16(currentGuildHongbaoEventID)
writer.writeUint32(currentSpendTimeBaseCatalogValue)
writer.writeRawDstr(nil)
writer.writeRawDstr(nil)
writer.writeByte(0)
if nativePCRoom {
// 0x2FDEADA and 0x1C08FC0 query active event 228. The model reads
// rewards and its complete gold-frame UI directly from PCRoomTimePoint.etc.
writer.writeUint16(currentPCRoomTimePointEventID)
writer.writeUint32(currentSpendTimeBaseCatalogValue)
writer.writeRawDstr(nil)
writer.writeRawDstr(nil)
writer.writeByte(0)
} else {
// 「累计在线得好礼」(event 2347) 的目录行与 active 行照常写入。
//
// 历史:曾尝试"删掉这一行"来关闭该活动,实测无效——该 HUD 在客户端 PVF
// `event/chn_event/chn_attendanceevent.evt` 里有定义,缺目录行时客户端
// 回落到本地定义,HUD 依然显示;而删掉 active 行/进度包会让「领取」失去
// 数据来源,变成"看得到但领不了"。所以这里保持完整下发。
writer.writeUint16(uint16(currentSpendTimeEventID))
writer.writeUint32(currentSpendTimeBaseCatalogValue)
writer.writeRawDstr(nil) // generic title; the fixed event UI owns visible text
writer.writeRawDstr(nil) // generic description
writer.writeByte(1) // has_extended: installs the native click action below
writer.writeByte(0) // generic category flags
writer.writeByte(0) // generic presentation flags
writer.writeRawDstr(nil) // optional title override
writer.writeRawDstr(nil) // optional subtitle override
writer.writeRawDstr(nil) // optional description override
writer.writeUint32(0) // optional start timestamp
writer.writeUint32(0) // optional end timestamp
writer.writeAsciiDstr(currentSpendTimeEventRoute)
writer.writeRawDstr(nil) // optional status text
writer.writeByte(0) // optional presentation tail
}
if laborBase {
writer.writeUint16(uint16(currentSpendTimeSumEventID))
writer.writeUint32(currentSpendTimeBaseCatalogValue)
writer.writeRawDstr(nil)
writer.writeRawDstr(nil)
writer.writeByte(0)
}
// 骑士马战(joust,事件 2365):基础目录行 + 下面的 active 行成对存在,
// 缺任一条客户端活动列表都会丢掉入口。
writer.writeUint16(uint16(currentJoustEventID))
writer.writeUint32(currentSpendTimeBaseCatalogValue)
writer.writeRawDstr(nil) // 可见名称与交互由原生 NPC/PVF 提供
writer.writeRawDstr(nil)
writer.writeByte(0) // schedule-free NPC visibility row
writer.writeUint16(uint16(currentJonathanEventID))
writer.writeUint32(currentSpendTimeBaseCatalogValue)
writer.writeRawDstr(nil) // native NPC/PVF owns visible name and interaction
writer.writeRawDstr(nil)
writer.writeByte(0) // schedule-free NPC visibility row
writer.writeUint16(uint16(currentLukeRaidEventID))
writer.writeUint32(currentSpendTimeBaseCatalogValue)
writer.writeRawDstr(nil) // native Luke entrance UI owns visible text
writer.writeRawDstr(nil) // native Luke entrance UI owns visible description
writer.writeByte(0) // schedule state arrives through op1206 below
writer.writeUint16(uint16(currentFiendWarEventID))
writer.writeUint32(currentSpendTimeBaseCatalogValue)
writer.writeRawDstr(nil) // native Fiend War entrance UI owns visible text
writer.writeRawDstr(nil) // native Fiend War entrance UI owns visible description
writer.writeByte(0) // schedule state arrives through op1206 below
writer.writeUint16(currentSeriaLuckEventID)
writer.writeUint32(currentSpendTimeBaseCatalogValue)
writer.writeRawDstr(nil) // native Seria-luck control owns its visible text
writer.writeRawDstr(nil)
writer.writeByte(0) // no schedule/action extension
// sub_17B1B70 accepts active event 2417 for the native open Luke entrance.
// Keep real quest/item records and the raid entry-cost transaction intact.
// 全职LV预约得好礼(活动 222,currentDecisionEventID)已按运营要求关闭:
// 从日历 Envelope 里摘掉它,客户端活动窗口不再显示该条目。
// 它是"命运/决策"类日历格子之一,摘除只影响这一格(baseRows 由
// len(calendarRecords) 动态计算,会自动同步)。
for _, eventID := range []uint16{currentLukeRaidOpenEventID, currentMonthlyAttendanceEventID, currentWeeklyAttendanceEventID} {
// EventListWindow.evt owns the native rows and popup routes.
record := activityCalendarRecord{eventID: eventID, field0: currentSpendTimeBaseCatalogValue}
if err := writeCurrentActivityCalendarRecord(&writer, record); err != nil {
return nil, err
}
}
// E8E2A0 requires event 2400 before consulting PVF [agitwar day] and
// [war limit time]. Its absence forced the waiting page on every day.
writer.writeUint16(currentGuildWarEventID)
writer.writeUint32(currentSpendTimeBaseCatalogValue)
writer.writeRawDstr(nil)
writer.writeRawDstr(nil)
writer.writeByte(0)
// Current EXE 0x2045237 recognizes base event 266; 0x204574C passes
// its presence to 0x16AB7B0, which retains the mileage category (4).
// No active row is needed for visibility: event 266's active parameters
// belong to a separate controller and are not a wallet balance.
if err := writeCurrentActivityCalendarRecord(&writer, activityCalendarRecord{
eventID: currentMileageShopEventID, field0: currentSpendTimeBaseCatalogValue,
}); err != nil {
return nil, err
}
// The ruins gate is a native NPC with an [open visible event] condition.
// Its full frame/glow assembly comes from PVF ANI/ALS and the existing NPK.
// Only one weekday belongs in the base directory; no active parameters or
// second op108 send are needed for this visibility predicate.
if err := writeCurrentActivityCalendarRecord(&writer, activityCalendarRecord{
eventID: currentMysteriousRuinsPortalEventID(now), field0: currentSpendTimeBaseCatalogValue,
}); err != nil {
return nil, err
}
// 原值 10 = 泡点 + 骑士马战(joust) + 乔纳森 + 卢克 + 恶魔之战 + 命运 +
// 卢克开放 + 月签到 + LVUP + 周签到。
// active 行计数必须与下面实际写入的行数严格一致,否则客户端解析会错位。
//
// 本部署不写泡点行(nativePCRoom=false),实际写入:
// 骑士马战 + 乔纳森 + 卢克 + 恶魔之战 + 命运 + 月签到 + 卢克开放
// + 公会战 + 周签到 = 9
activeRows := 9
if nativePCRoom {
activeRows++
}
if laborActive {
activeRows++
}
writer.writeByte(byte(activeRows))
// 累计在线得好礼(event 2347)active 行已移除:
// 它的 HUD 只在该事件出现在 active 表里时可见(不把 2347 放进
// active-event map 时,客户端会隐藏该 HUD 条目)。结算/历史实现保留为
// 死代码,便于日后按需恢复。
if nativePCRoom {
writer.writeUint16(currentPCRoomTimePointEventID)
for range params {
writer.writeUint32(0)
}
}
// IDA: 0x2043BCB routes 2348 to owner 0x41A / 0xF3F220.
// 0xF40D10 binds exactly three item controls. Sending a fourth item would
// overrun that native control array. param[10] is the item count.
if laborActive {
writer.writeUint16(uint16(currentSpendTimeSumEventID))
for i := 0; i < 12; i++ {
value := uint32(currentSpendTimeUnusedParam)
if i < 3 {
value = uint32(labor.Login[i].ItemID)
}
if i == 10 {
value = 3
}
if i == 11 {
value = 0
}
writer.writeUint32(value)
}
}
// 骑士马战(joust)active 行:与上面的基础目录行成对。
writer.writeUint16(uint16(currentJoustEventID))
for index := 0; index < currentSpendTimeDescriptorParamCount; index++ {
writer.writeUint32(0)
}
writer.writeUint16(uint16(currentJonathanEventID))
for index := 0; index < currentSpendTimeDescriptorParamCount; index++ {
writer.writeUint32(0)
}
writer.writeUint16(uint16(currentLukeRaidEventID))
for index := 0; index < currentSpendTimeDescriptorParamCount; index++ {
writer.writeUint32(0)
}
writer.writeUint16(uint16(currentFiendWarEventID))
for index := 0; index < currentSpendTimeDescriptorParamCount; index++ {
writer.writeUint32(0)
}
writer.writeUint16(currentSeriaLuckEventID)
writer.writeUint32(uint32(packageitem.SeriaLuckValueMax))
for index := 1; index < currentSpendTimeDescriptorParamCount; index++ {
writer.writeUint32(0)
}
writer.writeUint16(currentMonthlyAttendanceEventID)
// sub_2043290 -> sub_1829EE0 stores param[0] as required clear count.
writer.writeUint32(onlineevent.MonthlyRequiredClears)
for index := 1; index < currentSpendTimeDescriptorParamCount; index++ {
writer.writeUint32(0)
}
writer.writeUint16(currentLukeRaidOpenEventID)
for index := 0; index < currentSpendTimeDescriptorParamCount; index++ {
writer.writeUint32(0)
}
writer.writeUint16(currentGuildWarEventID)
for index := 0; index < currentSpendTimeDescriptorParamCount; index++ {
writer.writeUint32(0)
}
writer.writeUint16(currentWeeklyAttendanceEventID)
for index := 0; index < currentSpendTimeDescriptorParamCount; index++ {
writer.writeUint32(0)
}
return writer.bytes(), nil
}
// currentRaidScheduleCounters is the live state one raid-schedule record carries.
//
// The three u32s are NOT free-form. The client hands raw[0]/raw[1]/raw[2] to
// sub_C39140 (file 0xC39140) in that order, and that setter -- the ONLY writer of
// the per-mode window element's counters in the whole EXE -- stores them as:
//
// raw[0] -> element+0x30 account WEEKLY used (rendered as 账号周入场次数)
// raw[1] -> element+0x08 character WEEKLY used (rendered as 角色周入场次数)
// raw[2] -> element+0x04 seven-day mask, and the 7 cells at +0xc..+0x24
//
// Both weekly readouts print (limit - used), which is why byte 3 of the op1303
// row can only ever move the limit and the probe of 2026-09-30 saw 43/43 and
// 3/3: those were used == 0, not "the weekly counter is limit-only".
type currentRaidScheduleCounters struct {
AccountWeeklyUsed uint32
CharacterWeeklyUsed uint32
DayMask uint32
}
// buildCurrentRaidScheduleProgressBody matches the current narrow op1206
// RaidScheduleEvent records for the Anton, Luke and Fiend War entrance events.
// raw[0]/raw[1] are the account and character weekly USED counts and raw[2] is
// the seven-day mask; a player who has not entered this week is therefore all
// zero, but a player who has must see their own number or the entry window's
// 角色周入场次数 shows limit/limit while the start command refuses on the same
// counter (raid_admission_counter.go charges exactly these two policies).
func buildCurrentRaidScheduleProgressBody(
eventID uint32,
counters currentRaidScheduleCounters,
) ([]byte, error) {
switch eventID {
case currentAntonRaidEventID, currentLukeRaidEventID, currentFiendWarEventID:
default:
return nil, fmt.Errorf("current raid schedule event id=%d is unsupported", eventID)
}
var writer packetWriter
writer.writeUint32(eventID)
writer.writeByte(0) // narrow 0x20-byte record
writer.writeUint32(counters.AccountWeeklyUsed)
writer.writeUint32(counters.CharacterWeeklyUsed)
writer.writeUint32(counters.DayMask)
writer.writeBytes(make([]byte, currentSpendTimeProgressRawSize-12))
return writer.bytes(), nil
}
// buildCurrentSpendTimeProgressBody matches current NoPack op1206 dispatch:
//
// u32 event_id, u8 wide_flag, raw[wide_flag ? 0xA0 : 0x20]
//
// Event 2347 uses the narrow record. Its first two u32 values are authoritative
// elapsed seconds and the number of automatically completed reward stages.
func buildCurrentSpendTimeProgressBody(elapsedSeconds uint64, completedStages uint32) ([]byte, error) {
if elapsedSeconds > math.MaxUint32 {
return nil, fmt.Errorf("current spend-time elapsed seconds=%d overflows u32", elapsedSeconds)
}
var writer packetWriter
writer.writeUint32(currentSpendTimeEventID)
writer.writeByte(0) // narrow 0x20-byte record
writer.writeUint32(uint32(elapsedSeconds))
writer.writeUint32(completedStages)
writer.writeBytes(make([]byte, currentSpendTimeProgressRawSize-8))
return writer.bytes(), nil
}