服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
237 lines
9.5 KiB
Go
237 lines
9.5 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"unicode/utf16"
|
|
)
|
|
|
|
// Class0/op413 is the DLL-private envelope this tree already shares between
|
|
// several one-way projections; the first body byte is the only discriminator.
|
|
// Types 1, 2 and 3 are taken by the combat-power sidecar, the mood projection
|
|
// and the boss-mutation decision respectively, and every consumer in 90CN.dll
|
|
// claims a packet as soon as its type and version match. Type 4 is therefore
|
|
// the first free slot and is what the generic client notice uses: any server
|
|
// module can post one short UTF-16 popup through it without a new opcode and
|
|
// without an unproved native UI call, and 90CN.dll renders it through the
|
|
// owned notice overlay that already exists for the Lua bridge. The current EXE
|
|
// never sees this packet, so no native layout is claimed here.
|
|
//
|
|
// Body layout: type, version, u16 UTF-16 unit count, u32 ARGB text color, then
|
|
// exactly count UTF-16 code units. This is the only notice surface whose color
|
|
// the server controls: the client picks the color for its own NOTI 12 surfaces
|
|
// from the message-type byte.
|
|
const (
|
|
currentClientNoticeMsgID = currentCombatPowerAffixMsgID
|
|
currentClientNoticeType = byte(4)
|
|
currentClientNoticeVersion = byte(1)
|
|
currentClientNoticeHeaderBytes = 8
|
|
currentClientNoticeMaxCharacters = 511
|
|
currentClientNoticePrivateABI = "90cn_private_client_notice_v1_type4_argb"
|
|
// The overlay keeps its own theme color when the server sends zero.
|
|
currentClientNoticeDefaultColor = uint32(0)
|
|
)
|
|
|
|
// Version 2 of the same type-4 envelope appends a style byte plus one reserved
|
|
// byte to the header. The style is how a server module picks the client-side
|
|
// renderer without spending a new opcode:
|
|
//
|
|
// 0 the owned overlay - a layered window 90CN.dll draws itself. Byte for
|
|
// byte what version 1 always did, and the only style that honors the
|
|
// server's ARGB color.
|
|
// 1 the native selector prompt the Lua bridge already uses for the
|
|
// fatigue notice (ShowNativeDungeonSelectFatiguePrompt in 90CN.cpp).
|
|
// 2 the client's own native window at scene UI owner 0x298 - a real
|
|
// in-game window, so it survives exclusive fullscreen and honors "\n"
|
|
// for multiple lines. The client owns its frame, its title ("公告") and
|
|
// its colors, so the server controls the text and nothing else.
|
|
//
|
|
// Why owner 0x298 is the announcement window is recorded outside this tree in
|
|
// the workspace-root knowledge note; the short version is that the client
|
|
// itself has 2818 call sites of the same shape and the second argument is a
|
|
// bare const wchar_t* (proved by the inlined std::wstring::c_str() at
|
|
// 0x0094DDFD). 90CN.dll opens it through the single entry point
|
|
// OpenNativeAnnouncementWindow, which closes an already-open instance first:
|
|
// re-opening an open 0x298 is not a no-op, it takes another branch that adds a
|
|
// tutorial sprite to the window.
|
|
//
|
|
// Compatibility runs both ways on purpose. 90CN.dll still accepts version 1
|
|
// (8-byte header, no style) and treats it as style 0, and an unknown style
|
|
// falls back to the overlay instead of being dropped. So rolling back either
|
|
// side degrades the look rather than losing the message - which matters
|
|
// because the DLL hash is pinned in runtime/assets/client-compatibility.json
|
|
// and the two sides can be updated at different times.
|
|
//
|
|
// Version 2 body layout: type, version, u16 UTF-16 unit count, u32 ARGB text
|
|
// color, u8 style, u8 reserved, then exactly count UTF-16 code units.
|
|
const (
|
|
currentClientNoticeVersionStyled = byte(2)
|
|
currentClientNoticeStyledHeaderBytes = 10
|
|
currentClientNoticeStyleOverlay = byte(0)
|
|
currentClientNoticeStyleNativePrompt = byte(1)
|
|
currentClientNoticeStyleNativeWindow = byte(2)
|
|
currentClientNoticeStyledPrivateABI = "90cn_private_client_notice_v2_type4_argb_style"
|
|
)
|
|
|
|
// buildCurrentClientNoticeBody encodes type, version, the UTF-16 unit count,
|
|
// the ARGB text color and the text itself. The count is in UTF-16 code units
|
|
// because the DLL copies wchar_t units and rejects an embedded NUL, so both
|
|
// bounds are enforced here rather than left to the client.
|
|
func buildCurrentClientNoticeBody(text string, color uint32) ([]byte, error) {
|
|
return encodeCurrentClientNoticeBody(text, color, currentClientNoticeVersion,
|
|
currentClientNoticeStyleOverlay)
|
|
}
|
|
|
|
// buildCurrentClientNoticeStyledBody encodes the version-2 envelope, whose only
|
|
// addition is the style byte that selects the client-side renderer.
|
|
// The style is checked against the three renderers 90CN.dll actually
|
|
// implements rather than passed through: the DLL falls back to the overlay on
|
|
// an unknown style, so a typo here would silently ship the wrong surface
|
|
// instead of failing.
|
|
func buildCurrentClientNoticeStyledBody(
|
|
text string, color uint32, style byte,
|
|
) ([]byte, error) {
|
|
switch style {
|
|
case currentClientNoticeStyleOverlay,
|
|
currentClientNoticeStyleNativePrompt,
|
|
currentClientNoticeStyleNativeWindow:
|
|
default:
|
|
return nil, fmt.Errorf("client notice style %d is not a client renderer", style)
|
|
}
|
|
return encodeCurrentClientNoticeBody(text, color,
|
|
currentClientNoticeVersionStyled, style)
|
|
}
|
|
|
|
// encodeCurrentClientNoticeBody is the one encoder for both versions; the
|
|
// version argument decides the header length so the two callers cannot drift
|
|
// apart. Every bound below is the DLL reader's own bound, enforced here because
|
|
// the client rejects a malformed private body silently.
|
|
func encodeCurrentClientNoticeBody(
|
|
text string, color uint32, version byte, style byte,
|
|
) ([]byte, error) {
|
|
header := currentClientNoticeHeaderBytes
|
|
if version == currentClientNoticeVersionStyled {
|
|
header = currentClientNoticeStyledHeaderBytes
|
|
}
|
|
units := utf16.Encode([]rune(text))
|
|
if len(units) == 0 {
|
|
return nil, fmt.Errorf("client notice text is empty")
|
|
}
|
|
if len(units) > currentClientNoticeMaxCharacters {
|
|
return nil, fmt.Errorf(
|
|
"client notice text is %d UTF-16 units, limit is %d",
|
|
len(units),
|
|
currentClientNoticeMaxCharacters,
|
|
)
|
|
}
|
|
for _, unit := range units {
|
|
if unit == 0 {
|
|
return nil, fmt.Errorf("client notice text contains an embedded NUL")
|
|
}
|
|
}
|
|
body := make([]byte, header+len(units)*2)
|
|
body[0] = currentClientNoticeType
|
|
body[1] = version
|
|
binary.LittleEndian.PutUint16(body[2:4], uint16(len(units)))
|
|
binary.LittleEndian.PutUint32(body[4:8], color)
|
|
if header == currentClientNoticeStyledHeaderBytes {
|
|
body[8] = style
|
|
body[9] = 0
|
|
}
|
|
for index, unit := range units {
|
|
offset := header + index*2
|
|
binary.LittleEndian.PutUint16(body[offset:offset+2], unit)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// sendCurrentClientNotice posts one popup notice to the selected client. A
|
|
// notice is presentation only: an unencodable text is logged and dropped so a
|
|
// caller's gameplay result is never lost to a cosmetic message.
|
|
func (s *Service) sendCurrentClientNotice(
|
|
session *gameSession,
|
|
text string,
|
|
color uint32,
|
|
source string,
|
|
) error {
|
|
if s == nil || session == nil || session.conn == nil {
|
|
return nil
|
|
}
|
|
body, err := buildCurrentClientNoticeBody(text, color)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-client-notice-blocked",
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", err)
|
|
return nil
|
|
}
|
|
return s.writeCurrentClientNotice(session, body, color,
|
|
currentClientNoticeStyleOverlay, currentClientNoticePrivateABI, source)
|
|
}
|
|
|
|
// sendCurrentClientNativeWindowNotice posts the same envelope with style 2, so
|
|
// 90CN.dll opens the client's own native window with this text. Reached through
|
|
// sendNotice's native_window surface; see notice.go for why callers pick a
|
|
// surface rather than a backend.
|
|
func (s *Service) sendCurrentClientNativeWindowNotice(
|
|
session *gameSession,
|
|
text string,
|
|
source string,
|
|
) error {
|
|
if s == nil || session == nil || session.conn == nil {
|
|
return nil
|
|
}
|
|
body, err := buildCurrentClientNoticeStyledBody(text,
|
|
currentClientNoticeDefaultColor, currentClientNoticeStyleNativeWindow)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-client-notice-blocked",
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"style", currentClientNoticeStyleNativeWindow,
|
|
"reason", err)
|
|
return nil
|
|
}
|
|
return s.writeCurrentClientNotice(session, body,
|
|
currentClientNoticeDefaultColor,
|
|
currentClientNoticeStyleNativeWindow,
|
|
currentClientNoticeStyledPrivateABI, source)
|
|
}
|
|
|
|
// writeCurrentClientNotice is the single wire boundary for both notice
|
|
// styles: one log shape, one opcode, one send. The version and the header
|
|
// length are read back off the encoded body rather than passed in, so a future
|
|
// third style cannot report one thing and send another.
|
|
//
|
|
// text is not logged. A notice can carry a player-visible message that a
|
|
// caller composed from account or character state, and the packet log is kept
|
|
// free of it on purpose; character_count is enough to correlate with the DLL's
|
|
// own "client notice private envelope consumed chars=" line.
|
|
func (s *Service) writeCurrentClientNotice(
|
|
session *gameSession,
|
|
body []byte,
|
|
color uint32,
|
|
style byte,
|
|
abi string,
|
|
source string,
|
|
) error {
|
|
header := currentClientNoticeHeaderBytes
|
|
if body[1] == currentClientNoticeVersionStyled {
|
|
header = currentClientNoticeStyledHeaderBytes
|
|
}
|
|
s.logGameEvent(session, "game-client-notice-send",
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"msg_id", currentClientNoticeMsgID,
|
|
"classification", 0,
|
|
"notice_type", currentClientNoticeType,
|
|
"notice_version", body[1],
|
|
"notice_style", style,
|
|
"text_color", color,
|
|
"character_count", (len(body)-header)/2,
|
|
"body_len", len(body),
|
|
"body_source", abi,
|
|
"wire_owner", "90CN.dll",
|
|
"native_exe_protocol", false)
|
|
return s.sendGameUpperRawClass(session, currentClientNoticeMsgID, body, 0)
|
|
}
|