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 }