服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
173 lines
6.4 KiB
Go
173 lines
6.4 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"golang.org/x/text/encoding/simplifiedchinese"
|
|
)
|
|
|
|
// Class0/NOTI 12 is the client's own announcement packet. The layout is proven
|
|
// from both ends and they agree field for field.
|
|
//
|
|
// Writer, official Linux df_game_r via GameWorld_SendNotiPacketMessage:
|
|
//
|
|
// put_header(buf, 0, 12) // 0 selects the NOTI namespace, 12 the id
|
|
// put_byte(msg_type)
|
|
// put_short(0)
|
|
// put_byte(0)
|
|
// put_int(len) + put_binary(bytes, len)
|
|
// finalize(buf, 1)
|
|
//
|
|
// Reader, current 90CN client sub_1D821E0 (registered for NOTI 12 at RVA
|
|
// 0x01992870 by `push 0; push 0x01D821E0; push 0x0C`):
|
|
//
|
|
// read u8 -> message type, rejected when >= 0x40; 0x30/0x31 normalize to 3
|
|
// read u16 -> a reference id. sub_1D821E0 hands it to the map lookup
|
|
// sub_2699A30 against the manager at 0x051B2728 and stores the
|
|
// resolved pointer in the notice struct; the type-37 path reads it
|
|
// back from [esi+0x0C]. df_game_r always writes zero here.
|
|
// read u8 -> rejected when >= 0xFA
|
|
// read string through sub_3457D20 -> read u32 length, reject when >= 0x100,
|
|
// read that many raw bytes, NUL terminate, then widen into a
|
|
// 256-unit buffer
|
|
//
|
|
// The wire therefore carries single-byte text, not UTF-16: the client widens it
|
|
// itself. A Simplified Chinese client decodes those bytes as GBK.
|
|
//
|
|
// The surface and its color are chosen by the client from the message-type
|
|
// byte (sub_232BC80's jump table: type <= 0x3F indexes byte table 0x0272BF1C
|
|
// into dword table 0x0272BF00); the server cannot override either. The types
|
|
// below were each confirmed on screen on 2026-08-21 by sending one labeled
|
|
// notice per surface to a live client.
|
|
const (
|
|
currentNativeNoticeMsgID = uint16(12)
|
|
currentNativeNoticeMaxBodySize = 0xFF
|
|
currentNativeNoticeMaxType = byte(0x40)
|
|
currentNativeNoticeReservedU8 = byte(0)
|
|
// The reader rejects the third field at or above 0xFA. df_game_r always
|
|
// sends zero. sub_2327B30 loads it as a handle: a non-zero value is used
|
|
// directly, zero makes the client resolve one itself, and either way the
|
|
// result only gates a later branch.
|
|
currentNativeNoticeMaxChannel = byte(0xFA)
|
|
currentNativeNoticePrivateABI = "current_exe_sub_1D821E0_noti12_from_df_game_r_sendnotipacketmessage"
|
|
)
|
|
|
|
// currentNativeNoticeType enumerates the message-type byte. df_game_r callers
|
|
// use 0 for a plain system line, 6 for a reward line and 14 for a world
|
|
// broadcast; the byte selects which native surface renders the text.
|
|
type currentNativeNoticeType byte
|
|
|
|
const (
|
|
// 系统 tab, light blue (client ARGB 0xFF9BC8E6). The handle byte makes no
|
|
// visible difference here.
|
|
currentNativeNoticeTypeSystem currentNativeNoticeType = 0
|
|
// 普通 tab. df_game_r uses it for reward lines.
|
|
currentNativeNoticeTypeReward currentNativeNoticeType = 6
|
|
// 喇叭 tab, rendered green with the native D&F badge. This is what
|
|
// df_game_r's own world broadcast uses.
|
|
currentNativeNoticeTypeBroadcast currentNativeNoticeType = 14
|
|
// 系统 tab, magenta (client ARGB 0xFFFF00FF). A hint for one player rather
|
|
// than a broadcast; the default light blue proved too easy to miss.
|
|
currentNativeNoticeTypeSystemMagenta currentNativeNoticeType = 43
|
|
// 系统 tab, cyan (client ARGB 0xFF69F1FF).
|
|
currentNativeNoticeTypeSystemCyan currentNativeNoticeType = 63
|
|
// 系统 tab, magenta, but the client wraps the server text in its own
|
|
// string 0x11653 ("恭喜[%s]成为[疯狂强化之旅]活动中的幸运获奖者。"). Named so
|
|
// the template is not picked by accident for a plain line.
|
|
currentNativeNoticeTypeSystemLuckyTemplate currentNativeNoticeType = 44
|
|
)
|
|
|
|
// buildCurrentNativeNoticeBody encodes the exact plaintext the client reader
|
|
// consumes. The GBK conversion is required by the reader's byte-then-widen
|
|
// path; the length bound is the reader's own limit, enforced here so a long
|
|
// message is reported instead of being silently dropped by the client.
|
|
func buildCurrentNativeNoticeBody(
|
|
noticeType currentNativeNoticeType,
|
|
channel byte,
|
|
referenceID uint16,
|
|
text string,
|
|
) ([]byte, error) {
|
|
if channel >= currentNativeNoticeMaxChannel {
|
|
return nil, fmt.Errorf(
|
|
"native notice channel %d is not below the client bound %d",
|
|
channel,
|
|
currentNativeNoticeMaxChannel,
|
|
)
|
|
}
|
|
if byte(noticeType) >= currentNativeNoticeMaxType {
|
|
return nil, fmt.Errorf(
|
|
"native notice type %d is not below the client bound %d",
|
|
noticeType,
|
|
currentNativeNoticeMaxType,
|
|
)
|
|
}
|
|
if text == "" {
|
|
return nil, fmt.Errorf("native notice text is empty")
|
|
}
|
|
encoded, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(text))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode native notice text as GBK: %w", err)
|
|
}
|
|
if len(encoded) == 0 {
|
|
return nil, fmt.Errorf("native notice text encoded to zero bytes")
|
|
}
|
|
if len(encoded) > currentNativeNoticeMaxBodySize {
|
|
return nil, fmt.Errorf(
|
|
"native notice text is %d GBK bytes, client accepts at most %d",
|
|
len(encoded),
|
|
currentNativeNoticeMaxBodySize,
|
|
)
|
|
}
|
|
var writer packetWriter
|
|
writer.writeByte(byte(noticeType))
|
|
writer.writeUint16(referenceID)
|
|
writer.writeByte(channel)
|
|
writer.writeUint32(uint32(len(encoded)))
|
|
writer.writeBytes(encoded)
|
|
return writer.bytes(), nil
|
|
}
|
|
|
|
// sendCurrentNativeNotice pushes one announcement through the client's own
|
|
// notice surface. A notice is presentation only, so an unencodable text is
|
|
// logged and dropped instead of failing the caller's gameplay result.
|
|
func (s *Service) sendCurrentNativeNotice(
|
|
session *gameSession,
|
|
noticeType currentNativeNoticeType,
|
|
channel byte,
|
|
referenceID uint16,
|
|
text string,
|
|
source string,
|
|
) error {
|
|
if s == nil || session == nil || session.conn == nil {
|
|
return nil
|
|
}
|
|
body, err := buildCurrentNativeNoticeBody(noticeType, channel, referenceID, text)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-native-notice-blocked",
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"notice_type", byte(noticeType),
|
|
"notice_channel", channel,
|
|
"reference_id", referenceID,
|
|
"reason", err)
|
|
return nil
|
|
}
|
|
s.logGameEvent(session, "game-native-notice-send",
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"msg_id", currentNativeNoticeMsgID,
|
|
"classification", 0,
|
|
"notice_type", byte(noticeType),
|
|
"notice_channel", channel,
|
|
"reference_id", referenceID,
|
|
"text_bytes", len(body)-8,
|
|
"body_len", len(body),
|
|
"body_source", currentNativeNoticePrivateABI)
|
|
return s.sendCurrentSceneFixedClass0Packet(
|
|
session,
|
|
currentNativeNoticeMsgID,
|
|
body,
|
|
source,
|
|
)
|
|
}
|