服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1300 lines
60 KiB
Go
1300 lines
60 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
dnfchat "longheng.io/server/internal/modules/dnf/chat"
|
|
dnfguild "longheng.io/server/internal/modules/dnf/guild"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
// The three chat notification bodies, read straight out of DNF.exe. Class-0
|
|
// dispatch is sub_225CA00: it takes the opcode from packet[1..2], looks the
|
|
// handler up in the hash registry cached at 0x51B2070, and calls it as
|
|
// handler(opcode, context) — the body is not passed, the handler pulls it
|
|
// from the global cursor at 0x52F8500. Four primitives touch that cursor:
|
|
// readU8 sub_34E3970, readU16 sub_34E39E0, readU32 sub_34E3A50 and readBytes
|
|
// sub_34E3AC0; sub_3457C80 is readDstr on top of them (readU32 length, then
|
|
// readBytes) and sub_3457D20 wraps it through a TLS scratch buffer:
|
|
//
|
|
// op12 sub_1D821E0 u8 mode | u16 actorKey | u8 flag | dstr msg(max 256)
|
|
// op65 sub_1D691F0 u8 mode | u8 status | dstr name(31) | u8 flag |
|
|
// dstr msg(256) | dstr(31) only when mode == 0x24
|
|
// op118 sub_1D5B370 u8 mode | u8 ch | u8 flag | u8 ch | u16 senderID |
|
|
// dstr name(30) | dstr msg(512)
|
|
//
|
|
// Each has a 装备引用 twin that repeats its body and then appends the item
|
|
// block, `u8 count` followed by count fixed 148-byte entries, read by
|
|
// sub_2717A70. The twins live in a third registry, not the class-0 one: they
|
|
// are registered at 0x272CBE9 as `push context; push handler; push opcode;
|
|
// call sub_1CF2A70; call sub_225C940`, which is why a scan of the class-0
|
|
// registrar sub_2262980 found no trace of them and this file previously
|
|
// recorded them as unregistered.
|
|
//
|
|
// op370 sub_272BF60 op12's body + item block
|
|
// op371 sub_272C140 op65's body + item block
|
|
// op372 sub_2729060 op118's body + item block
|
|
//
|
|
// Note the one-slot shift against the 86JP reference, which numbers these
|
|
// 371/372/373 — the same shift its cmd table has at ITEM_HYPERLINK_MESSAGE
|
|
// (425 there, 424 here). Sending 86JP's 372 with 86JP's body displayed
|
|
// nothing live: the opcode belongs to a different twin here and the body was
|
|
// the wrong shape for it.
|
|
//
|
|
// Which carrier a channel takes follows from what its readers can resolve.
|
|
// op12 names its speaker by a scene actor key, so it only attributes a line
|
|
// to readers who can see that actor: fine for area, party and raid, useless
|
|
// for guild. op118 spells the name out and, past its two gates, renders
|
|
// through sub_27282E0 with the packet's own sender id — the clickable
|
|
// identity. op65 is not a carrier at all: its display branches take only
|
|
// modes 1, 0x23, 0x24, 0xFE and 0xFF, and anything else falls through to
|
|
// sub_1D69E71, which drops the line on every reader but the speaker.
|
|
currentTownChatNotificationMsgID uint16 = 12
|
|
// currentRaidAnnouncementMsgID is the class-0 notification whose handler
|
|
// sub_1D88DE0 draws the "- 公告 -" banner. It is op12's twin: the body is the
|
|
// same u8 + u16 actorKey + u8 channel + dstr text, but sub_1D88DE0 reads the
|
|
// first byte as a flag and only when it is zero formats string 640
|
|
// " - 公告 - \n\n%s" and shows it through sub_2FDD1A0(0x14E, ...).
|
|
// op12 itself ends in the chat log, which is why broadcasting raid chat alone
|
|
// never raised a banner.
|
|
currentRaidAnnouncementMsgID uint16 = 119
|
|
// The 装备引用 twins of op12 and op118. op371, the op65 twin, has no sender
|
|
// here because op65 is not used as a carrier.
|
|
currentTownChatHyperlinkMsgID uint16 = 370
|
|
currentPeerChatHyperlinkNotifyOpcode uint16 = 372
|
|
currentTownChatWhisperMode byte = 1
|
|
currentTownChatOneToOneMode byte = 0x21
|
|
currentTownChatPeerMenuMode byte = 0x24
|
|
currentTownChatPartyMode byte = 2
|
|
currentTownChatAreaMode byte = 3
|
|
// currentTownChatArenaMode is the 决斗场 room channel. Live 2026-09-14 22:37
|
|
// (conn game-000147, channel 191, an arena room): the client sent mode 5 and
|
|
// the router answered the unsupported-mode failure, which the chat window
|
|
// renders as silence -- "决斗场房间内打字发不出去". The same capture shows the
|
|
// 区域 line (mode 3) reaching private_scene, i.e. the speaker alone, because
|
|
// an arena room is a private map and never enters the public town presence
|
|
// registry the area scope resolves through.
|
|
currentTownChatArenaMode byte = 5
|
|
// currentTownChatGuildMode is 6, not the 4 the release snapshot assumed.
|
|
// Every op17 request the live EXE has ever produced for the 公会 channel
|
|
// carries mode 6 (protocol.log 2026-09-07 17:47:45 and 22:00:09); mode 4
|
|
// has never appeared on the wire once. The assumed value sent both guild
|
|
// messages into the unsupported-mode branch, which answers a failure code
|
|
// the chat window renders as silence. Keep 4 as a tolerated alias so a
|
|
// build that does emit it is still routed to the guild scope.
|
|
currentTownChatGuildMode byte = 6
|
|
currentTownChatGuildLegacyMode byte = 4
|
|
currentTownChatMegaphoneMode byte = 11
|
|
// currentTownChatChannelMegaphoneMode is the 频道喇叭 twin of mode 11. The
|
|
// current EXE builds the same request body for both and only swaps the
|
|
// megaphone it spends; the audience is the speaker's own channel.
|
|
currentTownChatChannelMegaphoneMode byte = 13
|
|
currentTownChatRaidMode byte = 55
|
|
currentTownChatRequestPrefixLen = 11
|
|
currentTownChatMaxMessageBytes = 512
|
|
// op12's decoder (sub_1D821E0) reads its message dstr with a 256-byte cap
|
|
// and discards anything longer, so a composed line has to stay under it.
|
|
currentTownChatDecodedMessageLimit = 256
|
|
currentTownChatGenericError byte = 4
|
|
// currentTownChatOneToOneStateMsgID is the class-0 notification the 90CN
|
|
// client reads as its own private-chat window state. Its handler sub_1D691F0
|
|
// reads, in order:
|
|
//
|
|
// u8 mode | u8 status | dstr name(31) | u8 flag | dstr text(256)
|
|
// | dstr extra(31) -- read only when mode == 0x24
|
|
//
|
|
// status is a *rejection reason*, not a window selector. The handler branches
|
|
// on it at 0x1969CC0, before it reaches the chat window manager, and the four
|
|
// values it can render come from dstr resources 0x12A3 "已将对方添加到黑名单" /
|
|
// 0x81F "对方在黑名单中" / 0x820 "无法向GM发送聊天信息" / 0x81E "对方目前处于无法
|
|
// 接收信息状态". Zero is the only value that means the line was delivered
|
|
// normally, so a successful private line always carries it.
|
|
//
|
|
// The trailing extra dstr feeds the last argument of the chat-line renderer
|
|
// sub_23282E0. What the client expects there is not established, so an empty
|
|
// string is sent rather than invented data.
|
|
currentTownChatOneToOneStateMsgID uint16 = 65
|
|
currentTownChatOneToOneAcceptStatus byte = 0
|
|
// Body byte 4 of op65 is not a sender/receiver selector: the client feeds it to
|
|
// sub_22E85A0(flag, &name) as the name registry's selector, where slot 0 is the
|
|
// other party and slot 1 is the player. See currentChatIncomingLineFlag and
|
|
// currentChatOwnLineFlag in town_chat_conversation.go.
|
|
)
|
|
|
|
const (
|
|
currentServerMegaphonePVFPath = "stackable/cash/megaphone_heartbeat.stk"
|
|
currentChannelMegaphonePVFPath = "stackable/cash/megaphone_echo.stk"
|
|
)
|
|
|
|
type currentTownChatRequest struct {
|
|
Mode byte
|
|
TargetUniqueID uint16
|
|
Argument uint32
|
|
Message []byte
|
|
TargetName []byte
|
|
TrailingFlag byte
|
|
HasTrailing bool
|
|
// ItemCount and ItemData carry the 装备引用 payload of an op424 request. The
|
|
// server never decodes an entry: it echoes the captured bytes back to the
|
|
// audience so the client's own item tooltip owns their layout.
|
|
ItemCount byte
|
|
ItemData []byte
|
|
}
|
|
|
|
func (r currentTownChatRequest) hasItemHyperlinks() bool {
|
|
return r.ItemCount > 0 && len(r.ItemData) > 0
|
|
}
|
|
|
|
// parseCurrentTownChatFields reads the fixed prefix and the message that open
|
|
// both op17 and op424, and reports where the caller must continue.
|
|
func parseCurrentTownChatFields(body []byte) (currentTownChatRequest, int, bool) {
|
|
if len(body) < currentTownChatRequestPrefixLen {
|
|
return currentTownChatRequest{}, 0, false
|
|
}
|
|
messageLength := int(int32(binary.LittleEndian.Uint32(body[7:11])))
|
|
if messageLength <= 0 || messageLength > currentTownChatMaxMessageBytes {
|
|
return currentTownChatRequest{}, 0, false
|
|
}
|
|
next := currentTownChatRequestPrefixLen + messageLength
|
|
if next < currentTownChatRequestPrefixLen || next > len(body) {
|
|
return currentTownChatRequest{}, 0, false
|
|
}
|
|
message := append([]byte(nil), body[currentTownChatRequestPrefixLen:next]...)
|
|
if bytes.IndexByte(message, 0) >= 0 {
|
|
return currentTownChatRequest{}, 0, false
|
|
}
|
|
return currentTownChatRequest{
|
|
Mode: body[0],
|
|
TargetUniqueID: binary.LittleEndian.Uint16(body[1:3]),
|
|
Argument: binary.LittleEndian.Uint32(body[3:7]),
|
|
Message: message,
|
|
}, next, true
|
|
}
|
|
|
|
// parseCurrentTownChatPrivateTail reads the target name and the trailing flag a
|
|
// private mode appends after its message.
|
|
func parseCurrentTownChatPrivateTail(request *currentTownChatRequest, body []byte, next, end int) (int, bool) {
|
|
if next < end {
|
|
name, afterName, ok := readCurrentRawDstr(body[:end], next)
|
|
if !ok || len(name) >= 30 || bytes.IndexByte(name, 0) >= 0 {
|
|
return 0, false
|
|
}
|
|
if len(name) == 0 && !isCurrentOneToOneTownChatMode(request.Mode) {
|
|
return 0, false
|
|
}
|
|
request.TargetName = name
|
|
next = afterName
|
|
}
|
|
if next < end {
|
|
if next+1 != end {
|
|
return 0, false
|
|
}
|
|
request.TrailingFlag = body[next]
|
|
request.HasTrailing = true
|
|
next++
|
|
}
|
|
return next, next == end
|
|
}
|
|
|
|
func currentTownChatRequestShapeValid(request currentTownChatRequest) bool {
|
|
if isCurrentOneToOneTownChatMode(request.Mode) {
|
|
// The two 1:1 window modes are the only private modes whose request may
|
|
// name no peer at all. A client whose window was opened by an *incoming*
|
|
// line sends peer id 0 with an empty name (live capture 2026-09-22
|
|
// 20:16:02 in packet_log.txt), while the client that picked its peer from
|
|
// the friend menu sends the peer's character id and name. The peer of an
|
|
// unnamed request comes from the conversation this server remembers; see
|
|
// town_chat_conversation.go. The trailing byte stays mandatory, and every
|
|
// captured request sent 0 there.
|
|
return request.HasTrailing && request.TrailingFlag <= 1
|
|
}
|
|
if isCurrentPrivateTownChatMode(request.Mode) {
|
|
return request.TargetUniqueID != 0 && len(request.TargetName) > 0 &&
|
|
request.HasTrailing && request.TrailingFlag <= 1
|
|
}
|
|
return len(request.TargetName) == 0 && !request.HasTrailing
|
|
}
|
|
|
|
func parseCurrentTownChatRequest(body []byte) (currentTownChatRequest, bool) {
|
|
request, next, ok := parseCurrentTownChatFields(body)
|
|
if !ok {
|
|
return currentTownChatRequest{}, false
|
|
}
|
|
if next < len(body) {
|
|
name, afterName, ok := readCurrentRawDstr(body, next)
|
|
if !ok || len(name) >= 30 || bytes.IndexByte(name, 0) >= 0 {
|
|
return currentTownChatRequest{}, false
|
|
}
|
|
if len(name) == 0 && !isCurrentOneToOneTownChatMode(request.Mode) {
|
|
return currentTownChatRequest{}, false
|
|
}
|
|
request.TargetName = name
|
|
next = afterName
|
|
}
|
|
if next < len(body) {
|
|
if next+1 != len(body) {
|
|
return currentTownChatRequest{}, false
|
|
}
|
|
request.TrailingFlag = body[next]
|
|
request.HasTrailing = true
|
|
next++
|
|
}
|
|
if next != len(body) {
|
|
return currentTownChatRequest{}, false
|
|
}
|
|
return request, currentTownChatRequestShapeValid(request)
|
|
}
|
|
|
|
// parseCurrentTownChatHyperlinkRequest reads op424, the 装备引用 twin of op17:
|
|
// the same fields, then one item count and that many fixed-width entries. The
|
|
// captured 普通-channel request (protocol.log 2026-09-07 22:34:36, one linked
|
|
// 威士重剑) spends 148 bytes on its single entry, so the width is taken from the
|
|
// request itself rather than pinned — this build does not use the 124-byte
|
|
// entry of the 86JP reference (`../86JPPP`,
|
|
// Server/DfoServer/Network/Builders/ChatMessageBuilder.cs HyperlinkItemDataSize).
|
|
func parseCurrentTownChatHyperlinkRequest(body []byte) (currentTownChatRequest, bool) {
|
|
request, next, ok := parseCurrentTownChatFields(body)
|
|
if !ok {
|
|
return currentTownChatRequest{}, false
|
|
}
|
|
if isCurrentPrivateTownChatMode(request.Mode) {
|
|
// The private tail sits between the message and the item payload, so it
|
|
// has to be located before the count byte can be read.
|
|
nameEnd, found := currentTownChatPrivateTailEnd(body, next)
|
|
if !found {
|
|
return currentTownChatRequest{}, false
|
|
}
|
|
if next, ok = parseCurrentTownChatPrivateTail(&request, body, next, nameEnd); !ok {
|
|
return currentTownChatRequest{}, false
|
|
}
|
|
}
|
|
if next >= len(body) {
|
|
return currentTownChatRequest{}, false
|
|
}
|
|
count := body[next]
|
|
next++
|
|
itemBytes := len(body) - next
|
|
if count == 0 || itemBytes <= 0 || itemBytes%int(count) != 0 {
|
|
return currentTownChatRequest{}, false
|
|
}
|
|
request.ItemCount = count
|
|
request.ItemData = append([]byte(nil), body[next:]...)
|
|
return request, currentTownChatRequestShapeValid(request)
|
|
}
|
|
|
|
// currentTownChatPrivateTailEnd finds the end of a private request's name/flag
|
|
// tail by reading the name's own length prefix. What follows is the item count.
|
|
func currentTownChatPrivateTailEnd(body []byte, next int) (int, bool) {
|
|
name, afterName, ok := readCurrentRawDstr(body, next)
|
|
if !ok || len(name) == 0 || len(name) >= 30 {
|
|
return 0, false
|
|
}
|
|
if afterName >= len(body) {
|
|
return 0, false
|
|
}
|
|
return afterName + 1, true
|
|
}
|
|
|
|
func isCurrentPrivateTownChatMode(mode byte) bool {
|
|
switch mode {
|
|
case currentTownChatWhisperMode, currentTownChatOneToOneMode, currentTownChatPeerMenuMode:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// isCurrentOneToOneTownChatMode reports the two private modes that come from the
|
|
// client's own 1:1 / peer-menu chat window rather than from a typed whisper.
|
|
// They are the only modes the client accepts op65 for, and the only ones whose
|
|
// class-0 op17 state packet is withheld (see handleCurrentWhisperChat).
|
|
func isCurrentOneToOneTownChatMode(mode byte) bool {
|
|
return mode == currentTownChatOneToOneMode || mode == currentTownChatPeerMenuMode
|
|
}
|
|
|
|
func (s *Service) handleCurrentTownChat(session *gameSession, body []byte) error {
|
|
request, ok := parseCurrentTownChatRequest(body)
|
|
return s.routeCurrentTownChat(session, request, body, ok)
|
|
}
|
|
|
|
// handleCurrentTownChatHyperlink answers op424. Every channel reaches the same
|
|
// audience as its plain op17 twin; only the notification changes, because the
|
|
// linked items ride along behind the message.
|
|
func (s *Service) handleCurrentTownChatHyperlink(session *gameSession, body []byte) error {
|
|
request, ok := parseCurrentTownChatHyperlinkRequest(body)
|
|
return s.routeCurrentTownChat(session, request, body, ok)
|
|
}
|
|
|
|
func (s *Service) routeCurrentTownChat(session *gameSession, request currentTownChatRequest, body []byte, ok bool) error {
|
|
if !ok || session == nil || session.selectedCharacterID == 0 {
|
|
if session != nil {
|
|
s.logGameEvent(session, "game-town-chat-rejected", "body_len", len(body), "reason", "current_exe_chat_shape_required")
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, 0, int(request.ItemCount),
|
|
"rejected", "current_exe_chat_shape_required", map[string]any{"body_len": len(body)})
|
|
}
|
|
if !ok && session != nil && session.selectedCharacterID != 0 {
|
|
return s.sendGameUpperFailure(session, currentPeerChatStateOpcode, currentTownChatGenericError)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
switch request.Mode {
|
|
case currentTownChatWhisperMode, currentTownChatOneToOneMode, currentTownChatPeerMenuMode:
|
|
return s.handleCurrentWhisperChat(session, request)
|
|
case currentTownChatMegaphoneMode:
|
|
return s.handleCurrentServerMegaphoneChat(session, request)
|
|
case currentTownChatChannelMegaphoneMode:
|
|
return s.handleCurrentChannelMegaphoneChat(session, request)
|
|
case currentTownChatAreaMode, currentTownChatArenaMode, currentTownChatPartyMode, currentTownChatGuildMode,
|
|
currentTownChatGuildLegacyMode, currentTownChatRaidMode:
|
|
return s.handleCurrentScopedTownChat(session, request)
|
|
default:
|
|
s.logGameEvent(session, "game-town-chat-rejected", "mode", request.Mode, "body_len", len(body), "reason", "unsupported_chat_mode")
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, 0, int(request.ItemCount),
|
|
"rejected", "unsupported_chat_mode", map[string]any{"body_len": len(body)})
|
|
return s.sendGameUpperFailure(session, currentPeerChatStateOpcode, currentTownChatGenericError)
|
|
}
|
|
}
|
|
|
|
func (s *Service) handleCurrentScopedTownChat(session *gameSession, request currentTownChatRequest) error {
|
|
recipients, scope, ok := s.currentScopedChatRecipients(session, request.Mode)
|
|
if !ok || len(recipients) == 0 {
|
|
s.logGameEvent(session, "game-town-chat-rejected", "mode", request.Mode, "reason", "chat_scope_unavailable")
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, 0, int(request.ItemCount),
|
|
"rejected", "chat_scope_unavailable", map[string]any{"scope": scope})
|
|
return s.sendGameUpperFailure(session, currentPeerChatStateOpcode, currentTownChatGenericError)
|
|
}
|
|
// Area, party and raid share a scene with their audience, so op12's actor key
|
|
// resolves and the client prints the speaker itself. Guild does not, and gets
|
|
// the notice that spells the speaker out.
|
|
if isCurrentGuildTownChatMode(request.Mode) {
|
|
return s.broadcastCurrentGuildChat(session, request, recipients, scope)
|
|
}
|
|
// The raid leader's raid-channel speech IS the announcement, so its mode is
|
|
// REPLACED rather than accompanied: mode 56 draws the "- 公告 -" banner, and it
|
|
// prints its own chat line and overhead bubble on the way (the sweep saw one
|
|
// line per packet), so sending the ordinary raid line as well would print every
|
|
// message twice. A hyperlink line travels on op370 instead, whose reader never
|
|
// reaches the notice surface, so those keep the plain mode.
|
|
mode := request.Mode
|
|
var announcedKey uint32
|
|
if !request.hasItemHyperlinks() {
|
|
mode, announcedKey = s.currentRaidAnnouncementModeFor(session, request.Mode)
|
|
}
|
|
notification := buildCurrentTownChatNotification(mode, session.selectedCharacterID, request.Message)
|
|
msgID := currentTownChatNotificationMsgID
|
|
if request.hasItemHyperlinks() {
|
|
notification = appendCurrentChatItemHyperlinks(notification, request)
|
|
msgID = currentTownChatHyperlinkMsgID
|
|
}
|
|
delivered := s.sendCurrentChatPacketToRecipients(session, recipients, msgID, notification)
|
|
// 公会聊天 is one of the four PVF-configured 公会贡献度 activities, paid once per
|
|
// local day against a receipt. Only a 公会-channel line that actually reached
|
|
// someone and carried text counts, which is upstream's gate too; a failure here
|
|
// is deferred rather than turned into a send failure, because the line has
|
|
// already been delivered.
|
|
if isCurrentGuildTownChatMode(request.Mode) && delivered > 0 && len(bytes.TrimSpace(request.Message)) > 0 {
|
|
if updates, err := s.applyCurrentGuildContribution(session, dnfguild.ContributionChat); err != nil {
|
|
s.logGameEvent(session, "game-guild-chat-contribution-deferred", "error", err)
|
|
} else if err = s.sendCurrentGuildContributionUpdates(session, updates); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
announced := 0
|
|
if announcedKey != 0 {
|
|
announced = delivered
|
|
s.logGameEvent(session, "game-raid-leader-announcement-broadcast",
|
|
"raid_key", announcedKey,
|
|
"char_id", session.selectedCharacterID,
|
|
"message_len", len(request.Message),
|
|
"mode", mode,
|
|
"recipients", delivered,
|
|
"source", "op17_raid_chat_from_raid_leader")
|
|
}
|
|
s.logGameEvent(session, "game-town-chat-broadcast",
|
|
"mode", mode,
|
|
"message_len", len(request.Message),
|
|
"recipients", delivered,
|
|
"scope", scope,
|
|
"notification", msgID,
|
|
"item_links", request.ItemCount,
|
|
"announcement_recipients", announced)
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, delivered, int(request.ItemCount),
|
|
"ok", "", map[string]any{"scope": scope, "notification": msgID})
|
|
return nil
|
|
}
|
|
|
|
// currentRaidAnnouncementModeFor reports the mode the raid leader's own raid-channel
|
|
// speech has to travel as, plus the raid it belongs to.
|
|
//
|
|
// Only the raid leader (团长) gets the banner; a squad leader or an ordinary member
|
|
// keeps the plain raid line. Returning the caller's own mode together with a zero
|
|
// key means "send it unchanged".
|
|
func (s *Service) currentRaidAnnouncementModeFor(session *gameSession, mode byte) (byte, uint32) {
|
|
if s == nil || session == nil || mode != currentTownChatRaidMode {
|
|
return mode, 0
|
|
}
|
|
sourceID := selectedCharacterID(session)
|
|
state, inRaid := s.runtimeRaidForMember(sourceID)
|
|
if !inRaid || state.LeaderID != sourceID {
|
|
return mode, 0
|
|
}
|
|
return currentRaidAnnouncementMode, state.RaidKey
|
|
}
|
|
|
|
// currentRaidAnnouncementMode is the 90 client's raid announcement banner mode.
|
|
//
|
|
// Settled by the labelled sweep: the client drew the banner for 56 while 52, 53, 54
|
|
// and 57 stayed ordinary chat lines, and a later single-message test rendered the
|
|
// banner as "通知 | 横幅测试" from the raw GBK bytes. The 86 server pins the mechanism
|
|
// as a chat MODE rather than a separate opcode -- ServerS4A21
|
|
// ChatHandler.IsRaidMessageMode(52|53) with CanSendRaidMessage gating 53 on
|
|
// senderId == leaderId, the notification carrying the mode through unchanged -- but
|
|
// its numbers do not map onto this client (86 raid chat is 52, ours is 55).
|
|
//
|
|
// The surface itself sits behind op12's notice interception: sub_1D821E0 hands the
|
|
// entry to sub_CAB4F0/sub_CAB410 before the chat manager sub_272BC80 ever sees it,
|
|
// and sub_272BC80's 64-way switch on the mode folds 45..62 (56 included) into its
|
|
// plain-line default -- so the banner is drawn by that interception, not by the
|
|
// switch.
|
|
const currentRaidAnnouncementMode byte = 56
|
|
|
|
// buildCurrentRaidAnnouncementNotification builds the leader's banner notification.
|
|
//
|
|
// The body is the ordinary op12 notification byte for byte -- the mode is the only
|
|
// thing that selects the banner -- and the text travels exactly as the client sent
|
|
// it. Live confirmation on this client: the banner drew "横幅测试" correctly from the
|
|
// raw GBK bytes, which is the same reader every other chat line already uses
|
|
// (sub_3457D20 -> sub_34D9CA0 -> wide), so re-encoding the text can only corrupt it.
|
|
func buildCurrentRaidAnnouncementNotification(sourceID uint16, message []byte) []byte {
|
|
return buildCurrentTownChatNotification(currentRaidAnnouncementMode, sourceID, message)
|
|
}
|
|
|
|
func isCurrentGuildTownChatMode(mode byte) bool {
|
|
return mode == currentTownChatGuildMode || mode == currentTownChatGuildLegacyMode
|
|
}
|
|
|
|
// currentChatServerGroup is body byte 2 of the op118 notice, and the value gate
|
|
// 0x1D5B4E3 checks. Two probe rounds settled it: with byte(channel.ID) there,
|
|
// clients on channels 508 and 121 both answered local_channel=1 and rejected the
|
|
// line; with 1 there, both accepted it and gate 0x1D5B5B7 then matched the
|
|
// reader's own channel out of byte 4 (121 against 121, 251 against 251). So byte
|
|
// 2 is the server group — the field 86JP names serverGroup in the third slot of
|
|
// its own op12 body (`../86JPPP`,
|
|
// Server/DfoServer/Network/Builders/ChatMessageBuilder.cs BuildChatMessage) —
|
|
// and it equals this instance's protocol.channelServerIndex / regionId.
|
|
const currentChatServerGroup = byte(1)
|
|
|
|
// broadcastCurrentGuildChat delivers a guild line on the notice that names its
|
|
// speaker. One projection per reader: gate 0x1D5B5B7 wants that reader's own
|
|
// channel in byte 4, so a single shared body would be dropped by everyone
|
|
// standing elsewhere.
|
|
func (s *Service) broadcastCurrentGuildChat(
|
|
session *gameSession,
|
|
request currentTownChatRequest,
|
|
recipients []*gameSession,
|
|
scope string,
|
|
) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
loadedID, sourceName, _, found := s.characterForEnter(ctx, session, session.selectedCharacterID)
|
|
if !found || loadedID != session.selectedCharacterID {
|
|
s.logGameEvent(session, "game-town-chat-rejected", "mode", request.Mode,
|
|
"reason", "chat_speaker_name_unavailable")
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, 0, int(request.ItemCount),
|
|
"rejected", "chat_speaker_name_unavailable", map[string]any{"scope": scope})
|
|
return s.sendGameUpperFailure(session, currentPeerChatStateOpcode, currentTownChatGenericError)
|
|
}
|
|
name := rosterNameBytes(sourceName)
|
|
msgID := currentPeerChatNotifyOpcode
|
|
if request.hasItemHyperlinks() {
|
|
msgID = currentPeerChatHyperlinkNotifyOpcode
|
|
}
|
|
delivered := 0
|
|
for _, recipient := range recipients {
|
|
if recipient == nil {
|
|
continue
|
|
}
|
|
body := buildCurrentGuildChatNoticeBody(request.Mode, currentChatServerGroup,
|
|
byte(recipient.channel.ID), session.selectedCharacterID, name, request.Message)
|
|
if request.hasItemHyperlinks() {
|
|
body = appendCurrentChatItemHyperlinks(
|
|
buildCurrentHyperlinkChatNoticeBody(request.Mode, currentChatServerGroup,
|
|
byte(recipient.channel.ID), session.selectedCharacterID, name, request.Message),
|
|
request)
|
|
}
|
|
if err := s.sendGameUpperRawClassCodec(recipient, msgID, body, 0, true); err != nil {
|
|
s.logGameEvent(session, "game-chat-recipient-write-failed",
|
|
"recipient_char_id", recipient.selectedCharacterID,
|
|
"msg_id", msgID, "error", err)
|
|
continue
|
|
}
|
|
delivered++
|
|
}
|
|
s.logGameEvent(session, "game-town-chat-broadcast",
|
|
"mode", request.Mode,
|
|
"message_len", len(request.Message),
|
|
"recipients", delivered,
|
|
"scope", scope,
|
|
"notification", msgID,
|
|
"item_links", request.ItemCount)
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, delivered, int(request.ItemCount),
|
|
"ok", "", map[string]any{"scope": scope, "notification": msgID, "speaker_name": sourceName})
|
|
// 进行公会聊天,可以获得2点公会贡献 (help text 76515), once per local day.
|
|
// Paused on the user's request (2026-09-12): only 签到 pays 公会贡献 for now.
|
|
// Re-enable by uncommenting; the grant itself is asynchronous and never blocks
|
|
// this session goroutine.
|
|
// s.grantCurrentGuildChatMileage(session, "current_guild_chat")
|
|
return nil
|
|
}
|
|
|
|
// buildCurrentGuildChatNoticeBody is buildCurrentPeerChatNoticeBody with the two
|
|
// leading bytes told apart: sub_1D5B370 reads mode, server group, a flag, the
|
|
// reader's channel, the sender id, then the name and the message.
|
|
//
|
|
// The horn builder still writes the channel into both slots. That is wrong by
|
|
// this layout, and it is why horns render on the gate-failure path instead of
|
|
// the ordinary one — but they do render, and moving them would need its own
|
|
// retest, so it is left alone. Cross-channel whisper uses the same builder and
|
|
// is very likely broken by the same byte; it has not been retested either.
|
|
func buildCurrentGuildChatNoticeBody(mode, serverGroup, channelID byte, senderID uint16, senderName, message []byte) []byte {
|
|
var writer packetWriter
|
|
writer.writeByte(mode)
|
|
writer.writeByte(serverGroup)
|
|
writer.writeByte(0)
|
|
writer.writeByte(channelID)
|
|
writer.writeUint16(senderID)
|
|
writer.writeRawDstr(senderName)
|
|
writer.writeRawDstr(message)
|
|
return writer.bytes()
|
|
}
|
|
|
|
// buildCurrentOneToOneChatStateBody builds op65, the notification the client's
|
|
// own 1:1 and peer-menu chat windows are fed by. It carries one chat line and the
|
|
// name of the person who spoke it: the client's two renderer exits differ only in
|
|
// the flag slot, and the name is what the line is attributed to.
|
|
//
|
|
// The trailing dstr exists only because the client's reader consumes it whenever
|
|
// the mode byte is the peer-menu mode (sub_1D691F0, 0x19692DA compares it against
|
|
// 0x24). It is sent empty rather than omitted, so the body keeps its declared
|
|
// shape on the exact mode that asks for it.
|
|
func buildCurrentOneToOneChatStateBody(mode, flag byte, speakerName, peerName, message []byte) []byte {
|
|
var writer packetWriter
|
|
writer.writeByte(mode)
|
|
writer.writeByte(currentTownChatOneToOneAcceptStatus)
|
|
writer.writeRawDstr(speakerName)
|
|
writer.writeByte(flag)
|
|
writer.writeRawDstr(message)
|
|
// The trailing dstr is the window's *peer* -- the person this conversation is
|
|
// with -- and leaving it empty is what made the client open a nameless window
|
|
// titled 除玩家[]外1人 next to the named one the player already had. Live
|
|
// screenshot 2026-09-22 21:5x: the window that actually carried the lines was
|
|
// titled with an EMPTY name while the window the player opened showed the peer.
|
|
if mode == currentTownChatPeerMenuMode {
|
|
writer.writeRawDstr(peerName)
|
|
}
|
|
return writer.bytes()
|
|
}
|
|
|
|
// sendCurrentHyperlinkChatToRecipients fans an op372 line out with one body per
|
|
// reader, which its channel gate requires.
|
|
func (s *Service) sendCurrentHyperlinkChatToRecipients(
|
|
sender *gameSession,
|
|
recipients []*gameSession,
|
|
request currentTownChatRequest,
|
|
senderName []byte,
|
|
) int {
|
|
delivered := 0
|
|
for _, recipient := range recipients {
|
|
if recipient == nil {
|
|
continue
|
|
}
|
|
body := appendCurrentChatItemHyperlinks(
|
|
buildCurrentHyperlinkChatNoticeBody(request.Mode, currentChatServerGroup,
|
|
byte(recipient.channel.ID), sender.selectedCharacterID, senderName, request.Message),
|
|
request)
|
|
if err := s.sendGameUpperRawClassCodec(recipient, currentPeerChatHyperlinkNotifyOpcode, body, 0, true); err != nil {
|
|
s.logGameEvent(sender, "game-chat-recipient-write-failed",
|
|
"recipient_char_id", recipient.selectedCharacterID,
|
|
"msg_id", currentPeerChatHyperlinkNotifyOpcode, "error", err)
|
|
continue
|
|
}
|
|
delivered++
|
|
}
|
|
return delivered
|
|
}
|
|
|
|
// buildCurrentHyperlinkChatNoticeBody is the op372 twin of the body above. Its
|
|
// four leading bytes sit in the same order: sub_2729060 stores them at
|
|
// [ebp-0xCCD], [ebp-0xCCE], [ebp-0xCD0] and [ebp-0xCCF], and gates on
|
|
// [ebp-0xCCE] at 0x2729296 and [ebp-0xCCF] at 0x272936C — byte 2 for the server
|
|
// group and byte 4 for the reader's channel, exactly like sub_1D5B370.
|
|
//
|
|
// A first reading of that prologue paired one `lea` with the wrong `call` and
|
|
// concluded bytes 3 and 4 were swapped here; sending the channel in byte 3 then
|
|
// left byte 4 zero and the line rendered nowhere, which is the same symptom the
|
|
// original order produced for a different reason. Whatever still blocks op372
|
|
// is past both gates — the probes on 0x2729296 and 0x272936C will say where.
|
|
func buildCurrentHyperlinkChatNoticeBody(mode, serverGroup, channelID byte, senderID uint16, senderName, message []byte) []byte {
|
|
var writer packetWriter
|
|
writer.writeByte(mode)
|
|
writer.writeByte(serverGroup)
|
|
writer.writeByte(0)
|
|
writer.writeByte(channelID)
|
|
writer.writeUint16(senderID)
|
|
writer.writeRawDstr(senderName)
|
|
writer.writeRawDstr(message)
|
|
return writer.bytes()
|
|
}
|
|
|
|
// appendCurrentChatItemHyperlinks closes a 装备引用 notification with the count
|
|
// and the request's own entries, in the shape sub_2717A70 reads them: one count
|
|
// byte then that many 148-byte entries. The entries stay opaque on purpose —
|
|
// the server has no reason to reinterpret an item block the client both wrote
|
|
// and will render.
|
|
func appendCurrentChatItemHyperlinks(body []byte, request currentTownChatRequest) []byte {
|
|
out := make([]byte, 0, len(body)+1+len(request.ItemData))
|
|
out = append(out, body...)
|
|
out = append(out, request.ItemCount)
|
|
return append(out, request.ItemData...)
|
|
}
|
|
|
|
// currentPVPRoomChatRecipients resolves the 决斗场 room the speaker is seated in:
|
|
// its seats are the audience the client shows for a room channel. A speaker who
|
|
// is not seated in a live room keeps the historical private-scene audience,
|
|
// which is the speaker alone.
|
|
//
|
|
// session.pvpRoom is read under pvpMu like every other room reader, and the
|
|
// seats are resolved back through currentChatSessionsForCharacterIDs so a stale
|
|
// seat cannot deliver into a replaced session.
|
|
func (s *Service) currentPVPRoomChatRecipients(session *gameSession) ([]*gameSession, string, bool) {
|
|
if s == nil || session == nil {
|
|
return nil, "private_scene", false
|
|
}
|
|
s.pvpMu.Lock()
|
|
room := session.pvpRoom
|
|
var characterIDs []uint16
|
|
if room != nil && s.pvpRooms[room.id] == room {
|
|
characterIDs = make([]uint16, 0, len(room.members))
|
|
for _, member := range room.members {
|
|
if member == nil || member.characterID == 0 {
|
|
continue
|
|
}
|
|
characterIDs = append(characterIDs, member.characterID)
|
|
}
|
|
}
|
|
s.pvpMu.Unlock()
|
|
if len(characterIDs) == 0 {
|
|
return []*gameSession{session}, "private_scene", true
|
|
}
|
|
recipients := s.currentChatSessionsForCharacterIDs(characterIDs)
|
|
included := false
|
|
for _, recipient := range recipients {
|
|
if recipient == session {
|
|
included = true
|
|
break
|
|
}
|
|
}
|
|
if !included {
|
|
recipients = append(recipients, session)
|
|
}
|
|
return recipients, "pvp_room", len(recipients) > 0
|
|
}
|
|
|
|
func (s *Service) currentScopedChatRecipients(session *gameSession, mode byte) ([]*gameSession, string, bool) {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
|
return nil, "", false
|
|
}
|
|
switch mode {
|
|
case currentTownChatArenaMode:
|
|
// The 决斗场 channel only exists inside an arena room, so its audience is
|
|
// that room's seats.
|
|
return s.currentPVPRoomChatRecipients(session)
|
|
case currentTownChatAreaMode:
|
|
// Town actors, movement and private stores are owned by one concrete
|
|
// OnlinePlayerManager scene, not by the transport's channel number. A
|
|
// visible actor can therefore come from another channel (ordinary towns
|
|
// use instance zero), while raid waiting rooms remain isolated by their
|
|
// server-owned InstanceKey. Area chat must use that same visibility
|
|
// boundary or clients can see a robot/player without receiving its chat.
|
|
if s.onlinePlayers == nil {
|
|
return s.currentPVPRoomChatRecipients(session)
|
|
}
|
|
sender, found := s.onlinePlayers.PlayerForCharacter(session.selectedCharacterID)
|
|
if !found || sender.Session != session {
|
|
// A character in its own Seria room is deliberately kept out of the
|
|
// public-town registry — publishCurrentTownPresence retires it and
|
|
// returns early for a private map — so this lookup is empty for the
|
|
// whole stretch between login and walking out into the town. Treating
|
|
// that as "no audience" answered the failure code, which the chat
|
|
// window renders as silence: a player who logged in and typed in the
|
|
// 普通 channel saw nothing at all. There is nobody else in a private
|
|
// scene, so the speaker is the whole audience — except in an arena
|
|
// room, where the other seats are the audience the client shows.
|
|
return s.currentPVPRoomChatRecipients(session)
|
|
}
|
|
players := s.onlinePlayers.GetAreaInstancePlayers(
|
|
sender.ChannelID, sender.TownID, sender.AreaID, sender.InstanceKey)
|
|
recipients := make([]*gameSession, 0, len(players))
|
|
for _, player := range players {
|
|
candidate := player.Session
|
|
if candidate == nil || candidate.selectedCharacterID != player.CharacterID {
|
|
continue
|
|
}
|
|
recipients = append(recipients, candidate)
|
|
}
|
|
return recipients, "visible_town_scene", len(recipients) > 0
|
|
case currentTownChatPartyMode:
|
|
identity, bound := s.boundGameSessionCharacterSnapshot(session)
|
|
if !bound {
|
|
return nil, "party", false
|
|
}
|
|
party, found := s.runtimePartyManagerForService().SnapshotByUser(identity.character, identity.generation)
|
|
if !found {
|
|
return nil, "party", false
|
|
}
|
|
ids := make([]uint16, 0, len(party.Members))
|
|
for _, member := range party.Members {
|
|
if online, found := s.onlineGameSessionCharacterSnapshot(member.UserID); found && online.generation == member.SessionGeneration {
|
|
ids = append(ids, member.UserID)
|
|
}
|
|
}
|
|
return s.currentChatSessionsForCharacterIDs(ids), "party", len(ids) > 0
|
|
case currentTownChatRaidMode:
|
|
raidState, found := s.runtimeRaidForMember(session.selectedCharacterID)
|
|
if !found {
|
|
return nil, "raid", false
|
|
}
|
|
ids := make([]uint16, 0, len(raidState.Members))
|
|
for _, member := range raidState.Members {
|
|
ids = append(ids, member.CharID)
|
|
}
|
|
return s.currentChatSessionsForCharacterIDs(ids), "raid", len(ids) > 0
|
|
case currentTownChatGuildMode, currentTownChatGuildLegacyMode:
|
|
ids, found := s.currentGuildChatCharacterIDs(session.selectedCharacterID)
|
|
if !found {
|
|
return nil, "guild", false
|
|
}
|
|
return s.currentChatSessionsForCharacterIDs(ids), "guild", len(ids) > 0
|
|
default:
|
|
return nil, "", false
|
|
}
|
|
}
|
|
|
|
func (s *Service) currentGuildChatCharacterIDs(characterID uint16) ([]uint16, bool) {
|
|
ctx, cancel := s.currentGuildContext()
|
|
defer cancel()
|
|
var ids []uint16
|
|
err := s.readCurrentGuild(ctx, func(_ dnfrepo.Group, registry dnfguild.Registry) error {
|
|
guild, _, found := registry.GuildForCharacter(strconv.Itoa(int(characterID)))
|
|
if !found || guild == nil {
|
|
return dnfguild.ErrNotMember
|
|
}
|
|
ids = make([]uint16, 0, len(guild.Members))
|
|
for memberID := range guild.Members {
|
|
value, err := strconv.ParseUint(strings.TrimSpace(memberID), 10, 16)
|
|
if err == nil && value != 0 {
|
|
ids = append(ids, uint16(value))
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
return ids, err == nil
|
|
}
|
|
|
|
func (s *Service) handleCurrentWhisperChat(session *gameSession, request currentTownChatRequest) error {
|
|
// The two 1:1 window modes have their own path: town_chat_conversation.go owns
|
|
// the conversation registry -- the server-side answer to "who is in this
|
|
// client's chat window", which the client's request cannot carry -- and
|
|
// forwards one line to the peer only. Every other private mode below keeps the
|
|
// historical op17/op118 packets byte for byte.
|
|
if isCurrentOneToOneTownChatMode(request.Mode) {
|
|
return s.handleCurrentOneToOneChatMessage(session, request)
|
|
}
|
|
sourceID := session.selectedCharacterID
|
|
targetID, targetSession, resolved := s.resolveCurrentWhisperTarget(session, request)
|
|
if !resolved || targetID == sourceID {
|
|
s.logGameEvent(session, "game-peer-chat-request-rejected",
|
|
"source_char_id", sourceID,
|
|
"requested_target_id", request.TargetUniqueID,
|
|
"target_name", string(request.TargetName),
|
|
"reason", "target_unresolved_or_offline")
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, 0, int(request.ItemCount),
|
|
"rejected", "target_unresolved_or_offline", map[string]any{
|
|
// 统一成字符串:同一类别里同一个字段出现两种 JSON 类型,会给
|
|
// CSV 导出与按字段筛选埋坑("7" 与 7 不是一回事)。
|
|
"target_character_id": strconv.Itoa(int(request.TargetUniqueID)),
|
|
"target_name": chatRecordText(request.TargetName),
|
|
})
|
|
return s.sendGameUpperFailure(session, currentPeerChatStateOpcode, currentTownChatGenericError)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
loadedSourceID, sourceName, _, sourceFound := s.characterForEnter(ctx, session, sourceID)
|
|
loadedTargetID, targetName, _, targetFound := s.characterForEnter(ctx, targetSession, targetID)
|
|
if !sourceFound || loadedSourceID != sourceID || !targetFound || loadedTargetID != targetID ||
|
|
!currentChatNameMatches(request.TargetName, rosterNameBytes(targetName)) {
|
|
s.logGameEvent(session, "game-peer-chat-request-rejected", "source_char_id", sourceID, "target_char_id", targetID, "reason", "repository_identity_or_target_name_mismatch")
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, 0, int(request.ItemCount),
|
|
"rejected", "repository_identity_or_target_name_mismatch", map[string]any{
|
|
"target_character_id": strconv.Itoa(int(targetID)),
|
|
"target_name": chatRecordText(request.TargetName),
|
|
})
|
|
return s.sendGameUpperFailure(session, currentPeerChatStateOpcode, currentTownChatGenericError)
|
|
}
|
|
// Blacklist gate, the same both-directions rule the 1:1 window path applies. A
|
|
// typed whisper used to be delivered to a character who had blocked the sender
|
|
// (operator report 2026-09-22 23:5x: "悄悄话现在并不能拦截并提示"), because only
|
|
// town_chat_conversation.go carried the gate. The rejection rides the client's own
|
|
// whisper rejected branch, which renders dstr 4763 您在对方的黑名单内 / 4771
|
|
// 已将对方添加到黑名单 as a system line.
|
|
blockedByTarget := s.currentBlacklistHas(ctx, targetID, sourceName)
|
|
blockedBySource := s.currentBlacklistHas(ctx, sourceID, targetName)
|
|
if blockedByTarget || blockedBySource {
|
|
status := currentBlacklistStatusTargetBlockedSender
|
|
if blockedBySource {
|
|
status = currentBlacklistStatusSenderBlockedTarget
|
|
}
|
|
s.logGameEvent(session, "game-peer-chat-blocked",
|
|
"source_char_id", sourceID, "target_char_id", targetID,
|
|
"blocked_by_target", blockedByTarget, "blocked_by_source", blockedBySource,
|
|
"status", status, "mode", request.Mode, "channel", "whisper")
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, 0, int(request.ItemCount),
|
|
"rejected", "blacklist", map[string]any{
|
|
"target_character_id": strconv.Itoa(int(targetID)),
|
|
"target_name": targetName,
|
|
"status": strconv.Itoa(int(status)),
|
|
})
|
|
return s.sendCurrentChatRejectionNotice(session, request.Mode, status, targetName, request.Message)
|
|
}
|
|
if err := s.sendGameUpperSuccess(session, currentPeerChatStateOpcode, nil); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendGameUpperRawClassCodec(session, currentPeerChatStateOpcode, buildCurrentPeerChatStateBody(targetID, request.Mode), 0, true); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendGameUpperRawClassCodec(targetSession, currentPeerChatStateOpcode, buildCurrentPeerChatStateBody(sourceID, request.Mode), 0, true); err != nil {
|
|
return err
|
|
}
|
|
// One projection per reader: sub_1D5B370 wants the server group in body byte
|
|
// 2 and that reader's own channel in byte 4 before it reaches the ordinary
|
|
// private-chat display path. This also supplies the sender echo that the
|
|
// current 90CN client does not create locally.
|
|
//
|
|
// Each side is told its *peer*, not the speaker. A private line is filed
|
|
// under the person on the other end — which is how the op17 state packets
|
|
// address a typed whisper, the sender getting the target's id and the target
|
|
// getting the sender's, and how the op65 state above names each side's peer.
|
|
// Naming the speaker in both copies made
|
|
// the sender's echo describe a conversation with itself, and the client drops
|
|
// that: the whisper reached the target and left the sender's own window
|
|
// empty. 86JP echoes with the peer's name for the same reason (`../86JPPP`,
|
|
// Server/DfoServer/Network/Handlers/ChatHandler.cs SendWhisperAsync).
|
|
//
|
|
// The echo also uses the outgoing twin of the request's mode, so the line
|
|
// reads "发送给 [名字]" instead of repeating the incoming "[名字]:" form.
|
|
// sub_2727B30 switches on the mode at struct+0x18 through the table at
|
|
// 0x27280BC, and modes 1, 7, 0x24, 0x27, 0x28 and 0x29 share one case while
|
|
// every other mode takes the other — that set is the private-chat family,
|
|
// and 7 is the member that pairs with the 1 the client sends for a typed
|
|
// whisper. The prefix strings themselves are encrypted in the EXE
|
|
// (sub_34D9B20 decodes them), so which member carries which wording is the
|
|
// one part of this that a live line still has to confirm.
|
|
// A private line carries no peer id. sub_2727B30 treats modes 1 and 2 alike
|
|
// at 0x2727D4D: it resolves the notice's peer id to a scene actor and puts a
|
|
// speech balloon over that actor's head. That is right for party chat and
|
|
// wrong for a whisper, and it only showed up once the notice started
|
|
// rendering at all. Zero leaves sub_2699A30 with nothing to resolve, so the
|
|
// line stays in the chat window. The private window still knows who it is
|
|
// talking to -- a typed whisper is told by the two op17 state packets above.
|
|
//
|
|
// The cost is that the name in a whisper line is text rather than a
|
|
// clickable identity. Guild keeps its id and stays clickable.
|
|
//
|
|
// op118's display branches do not know mode 0x24 at all — they recognise the
|
|
// megaphone family and the typed whisper — which is why the 1:1 modes left
|
|
// this path for op65 (town_chat_conversation.go). Every other private mode
|
|
// keeps the named notice byte for byte.
|
|
noticeOpcode := currentPeerChatNotifyOpcode
|
|
const privateChatNoBalloonPeer = uint16(0)
|
|
sourceNotice := buildCurrentGuildChatNoticeBody(currentOutgoingWhisperMode(request.Mode), currentChatServerGroup, byte(session.channel.ID), privateChatNoBalloonPeer, rosterNameBytes(targetName), request.Message)
|
|
targetNotice := buildCurrentGuildChatNoticeBody(request.Mode, currentChatServerGroup, byte(targetSession.channel.ID), privateChatNoBalloonPeer, rosterNameBytes(sourceName), request.Message)
|
|
if request.hasItemHyperlinks() {
|
|
noticeOpcode = currentPeerChatHyperlinkNotifyOpcode
|
|
sourceNotice = appendCurrentChatItemHyperlinks(
|
|
buildCurrentHyperlinkChatNoticeBody(currentOutgoingWhisperMode(request.Mode), currentChatServerGroup,
|
|
byte(session.channel.ID), privateChatNoBalloonPeer, rosterNameBytes(targetName), request.Message), request)
|
|
targetNotice = appendCurrentChatItemHyperlinks(
|
|
buildCurrentHyperlinkChatNoticeBody(request.Mode, currentChatServerGroup,
|
|
byte(targetSession.channel.ID), privateChatNoBalloonPeer, rosterNameBytes(sourceName), request.Message), request)
|
|
}
|
|
if err := s.sendGameUpperRawClassCodec(session, noticeOpcode, sourceNotice, 0, true); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendGameUpperRawClassCodec(targetSession, noticeOpcode, targetNotice, 0, true); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-peer-chat-forwarded", "source_char_id", sourceID, "target_char_id", targetID, "target_name", targetName, "message_len", len(request.Message), "scope", "online_cross_channel", "notification", noticeOpcode, "item_links", request.ItemCount)
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, 2, int(request.ItemCount),
|
|
"ok", "", map[string]any{
|
|
"scope": "online_cross_channel",
|
|
"notification": noticeOpcode,
|
|
"target_character_id": strconv.Itoa(int(targetID)),
|
|
"target_name": targetName,
|
|
"target_channel_id": targetSession.channel.ID,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// currentWhisperTargetUnknownID is what the EXE puts in the unique-id slot when
|
|
// the player typed the recipient's name instead of clicking a known peer. The
|
|
// captured request reads `01 FF FF ... dstr 悄悄话 ... dstr 鬼剑士 01`: the id is
|
|
// a placeholder and the name is the real selector. Treating 0xFFFF as a
|
|
// character id looked it up in the session map, missed, and answered the
|
|
// failure code the chat window renders as silence.
|
|
const currentWhisperTargetUnknownID = uint16(0xFFFF)
|
|
|
|
// currentTownChatWhisperSentMode is the outgoing twin of the plain 悄悄话 mode.
|
|
// The other private modes are echoed unchanged: they come from the one-to-one
|
|
// window and the peer-menu, which own their own presentation.
|
|
const currentTownChatWhisperSentMode = byte(7)
|
|
|
|
func currentOutgoingWhisperMode(mode byte) byte {
|
|
if mode == currentTownChatWhisperMode {
|
|
return currentTownChatWhisperSentMode
|
|
}
|
|
return mode
|
|
}
|
|
|
|
func (s *Service) resolveCurrentWhisperTarget(
|
|
session *gameSession,
|
|
request currentTownChatRequest,
|
|
) (uint16, *gameSession, bool) {
|
|
if request.TargetUniqueID != 0 && request.TargetUniqueID != currentWhisperTargetUnknownID {
|
|
if target, online := s.onlineGameSession(request.TargetUniqueID); online {
|
|
return request.TargetUniqueID, target, true
|
|
}
|
|
return 0, nil, false
|
|
}
|
|
if len(request.TargetName) == 0 {
|
|
return 0, nil, false
|
|
}
|
|
if targetID, target, found := s.currentChatSessionByName(session, request.TargetName); found {
|
|
return targetID, target, true
|
|
}
|
|
return 0, nil, false
|
|
}
|
|
|
|
// currentChatSessionByName resolves a chat target by the name a client carried:
|
|
// the typed name of a whisper, or the peer name inside a 1:1 window. The stored
|
|
// roster name is what decides, because that is the name the other side's own
|
|
// window will display.
|
|
func (s *Service) currentChatSessionByName(session *gameSession, name []byte) (uint16, *gameSession, bool) {
|
|
if len(name) == 0 {
|
|
return 0, nil, false
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
for _, candidate := range s.currentOnlineChatSessions() {
|
|
if candidate == nil || candidate == session {
|
|
continue
|
|
}
|
|
charID := candidate.selectedCharacterID
|
|
loadedID, stored, _, found := s.characterForEnter(ctx, candidate, charID)
|
|
if !found || loadedID != charID {
|
|
continue
|
|
}
|
|
if currentChatNameMatches(name, rosterNameBytes(stored)) {
|
|
return charID, candidate, true
|
|
}
|
|
}
|
|
return 0, nil, false
|
|
}
|
|
|
|
func currentChatNameMatches(requested, stored []byte) bool {
|
|
return bytes.Equal(requested, stored) || strings.EqualFold(string(requested), string(stored))
|
|
}
|
|
|
|
// currentMegaphoneKind is one loudspeaker chat mode. The current EXE builds the
|
|
// same request body for every megaphone mode and picks the stackable it spends
|
|
// from the mode byte alone (see the item-id table quoted in dnfchat), so the
|
|
// server side differs only in the item it debits and in who hears the line.
|
|
type currentMegaphoneKind struct {
|
|
itemID uint32
|
|
pvfPath string
|
|
scope string
|
|
rejectedEvent string
|
|
broadcastEvent string
|
|
refreshNote string
|
|
// channelScoped narrows the audience to the speaker's own channel, which is
|
|
// what 频道喇叭 means. The server megaphone stays server-wide.
|
|
channelScoped bool
|
|
consume func(*dnfchat.Owner, context.Context, dnfchat.Command) (dnfchat.Result, error)
|
|
}
|
|
|
|
var (
|
|
currentServerMegaphoneKind = currentMegaphoneKind{
|
|
itemID: dnfchat.ServerMegaphoneItemID,
|
|
pvfPath: currentServerMegaphonePVFPath,
|
|
scope: "all_online_game_sessions",
|
|
rejectedEvent: "game-server-megaphone-rejected",
|
|
broadcastEvent: "game-server-megaphone-broadcast",
|
|
refreshNote: "current_exe_op118_type11_local_sender_debits_item36",
|
|
consume: (*dnfchat.Owner).ConsumeServerMegaphone,
|
|
}
|
|
currentChannelMegaphoneKind = currentMegaphoneKind{
|
|
itemID: dnfchat.ChannelMegaphoneItemID,
|
|
pvfPath: currentChannelMegaphonePVFPath,
|
|
scope: "online_game_sessions_on_speaker_channel",
|
|
rejectedEvent: "game-channel-megaphone-rejected",
|
|
broadcastEvent: "game-channel-megaphone-broadcast",
|
|
refreshNote: "current_exe_op118_type13_local_sender_debits_item37",
|
|
channelScoped: true,
|
|
consume: (*dnfchat.Owner).ConsumeChannelMegaphone,
|
|
}
|
|
)
|
|
|
|
func (s *Service) handleCurrentServerMegaphoneChat(session *gameSession, request currentTownChatRequest) error {
|
|
return s.handleCurrentMegaphoneChat(session, request, currentServerMegaphoneKind)
|
|
}
|
|
|
|
// handleCurrentChannelMegaphoneChat answers 频道喇叭 (mode 13). It is the exact
|
|
// twin of the server megaphone apart from the item it spends (37 instead of 36)
|
|
// and the audience, which is limited to the sessions attached to the speaker's
|
|
// own channel.
|
|
func (s *Service) handleCurrentChannelMegaphoneChat(session *gameSession, request currentTownChatRequest) error {
|
|
return s.handleCurrentMegaphoneChat(session, request, currentChannelMegaphoneKind)
|
|
}
|
|
|
|
func (s *Service) handleCurrentMegaphoneChat(session *gameSession, request currentTownChatRequest, kind currentMegaphoneKind) error {
|
|
if request.Argument > uint32(^uint16(0)>>1) {
|
|
return s.sendGameUpperFailure(session, currentPeerChatStateOpcode, currentTownChatGenericError)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
loadedID, sourceName, _, found := s.characterForEnter(ctx, session, session.selectedCharacterID)
|
|
if !found || loadedID != session.selectedCharacterID {
|
|
return s.sendGameUpperFailure(session, currentPeerChatStateOpcode, currentTownChatGenericError)
|
|
}
|
|
catalog, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
return s.rejectCurrentMegaphone(session, kind, request, fmt.Errorf("load item catalog: %w", err))
|
|
}
|
|
definition, err := catalog.ResolveItem(kind.itemID)
|
|
if err != nil || definition.Kind != dungeonDropItemStackable ||
|
|
cleanDungeonDropPath(definition.PVFPath) != kind.pvfPath ||
|
|
normalizeDungeonDropStackableType(definition.StackableType) != "[waste]" {
|
|
return s.rejectCurrentMegaphone(session, kind, request, fmt.Errorf("item %d PVF identity mismatch: path=%q type=%q err=%v", kind.itemID, definition.PVFPath, definition.StackableType, err))
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok {
|
|
return s.rejectCurrentMegaphone(session, kind, request, dnfchat.ErrOwnerUnavailable)
|
|
}
|
|
owner, err := dnfchat.NewOwner(repositories)
|
|
if err != nil {
|
|
return s.rejectCurrentMegaphone(session, kind, request, err)
|
|
}
|
|
result, err := kind.consume(owner, ctx, dnfchat.Command{
|
|
AccountID: s.accountIDForSession(session),
|
|
CharacterID: strconv.Itoa(int(session.selectedCharacterID)),
|
|
Slot: int16(request.Argument),
|
|
UpdatedAt: s.gameplayNow(),
|
|
})
|
|
if err != nil {
|
|
return s.rejectCurrentMegaphone(session, kind, request, err)
|
|
}
|
|
sourceNameBytes := rosterNameBytes(sourceName)
|
|
notice := buildCurrentPeerChatNoticeBody(request.Mode, byte(session.channel.ID), session.selectedCharacterID, sourceNameBytes, request.Message)
|
|
recipients := s.currentOnlineChatSessions()
|
|
if kind.channelScoped {
|
|
recipients = currentChatSessionsOnChannel(recipients, session.channel.ID)
|
|
}
|
|
// A horn carrying 装备引用 goes out on op372, which needs the gate bytes filled
|
|
// properly and therefore one body per reader. Only a human speaker can raise
|
|
// one, so this stays clear of the robot identity projection below, and the
|
|
// plain horn keeps the body it renders with today untouched.
|
|
var delivered int
|
|
if request.hasItemHyperlinks() && !session.robotSessionBound {
|
|
delivered = s.sendCurrentHyperlinkChatToRecipients(session, recipients, request, sourceNameBytes)
|
|
} else {
|
|
if request.hasItemHyperlinks() {
|
|
s.logGameEvent(session, "game-chat-item-links-dropped",
|
|
"mode", request.Mode, "msg_id", currentPeerChatNotifyOpcode, "item_links", request.ItemCount)
|
|
}
|
|
delivered = s.sendCurrentMegaphoneToRecipients(session, recipients, request.Mode, notice, sourceNameBytes, request.Message)
|
|
}
|
|
s.logGameEvent(session, kind.broadcastEvent,
|
|
"source_char_id", session.selectedCharacterID,
|
|
"material_slot", result.Slot,
|
|
"material_remaining", result.Remaining,
|
|
"material_removed", result.Removed,
|
|
"message_len", len(request.Message),
|
|
"recipients", delivered,
|
|
"scope", kind.scope,
|
|
"channel_id", session.channel.ID,
|
|
"robot_identity_projection", session.robotSessionBound,
|
|
"inventory_refresh", kind.refreshNote)
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, delivered, int(request.ItemCount),
|
|
"ok", "", map[string]any{
|
|
"scope": kind.scope,
|
|
"notification": currentPeerChatNotifyOpcode,
|
|
"material_item_id": kind.itemID,
|
|
"material_slot": result.Slot,
|
|
"material_remaining": result.Remaining,
|
|
"material_removed": result.Removed,
|
|
"robot_identity_projection": session.robotSessionBound,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// currentChatSessionsOnChannel keeps the sessions attached to one channel. The
|
|
// transport channel is the right boundary here, unlike area chat: 频道喇叭 must
|
|
// still reach a listener standing in a private Seria room or reading the auction
|
|
// house, who is deliberately absent from every town-area roster. Being audible
|
|
// and being visible are different questions.
|
|
func currentChatSessionsOnChannel(sessions []*gameSession, channelID int) []*gameSession {
|
|
filtered := make([]*gameSession, 0, len(sessions))
|
|
for _, candidate := range sessions {
|
|
if candidate == nil || candidate.channel.ID != channelID {
|
|
continue
|
|
}
|
|
filtered = append(filtered, candidate)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (s *Service) sendCurrentMegaphoneToRecipients(sender *gameSession, recipients []*gameSession, mode byte, senderBody, senderName, message []byte) int {
|
|
if sender == nil || !sender.robotSessionBound {
|
|
return s.sendCurrentChatPacketToRecipients(sender, recipients, currentPeerChatNotifyOpcode, senderBody)
|
|
}
|
|
|
|
// The current EXE renders megaphone names as interactive player identities.
|
|
// Automatic actors have native sessions, but their generated identity is not
|
|
// backed by the complete peer-menu/social context expected by that click
|
|
// path. Clicking such a retained horn row exits the client before it emits
|
|
// any request. Keep the source robot's native row so its local megaphone
|
|
// mirror is debited, while every other client receives the same horn mode
|
|
// with no interactive identity. The visible name is preserved in the
|
|
// message instead of being supplied as a clickable sender field.
|
|
passiveBody := buildCurrentPassiveRobotMegaphoneNoticeBody(mode, byte(sender.channel.ID), senderName, message)
|
|
delivered := 0
|
|
for _, recipient := range recipients {
|
|
if recipient == nil {
|
|
continue
|
|
}
|
|
body := passiveBody
|
|
if recipient == sender {
|
|
body = senderBody
|
|
}
|
|
if err := s.sendGameUpperRawClassCodec(recipient, currentPeerChatNotifyOpcode, body, 0, true); err != nil {
|
|
s.logGameEvent(sender, "game-chat-recipient-write-failed", "recipient_char_id", recipient.selectedCharacterID, "msg_id", currentPeerChatNotifyOpcode, "error", err)
|
|
continue
|
|
}
|
|
delivered++
|
|
}
|
|
return delivered
|
|
}
|
|
|
|
func buildCurrentPassiveRobotMegaphoneNoticeBody(mode byte, channelID byte, senderName, message []byte) []byte {
|
|
visibleMessage := append([]byte(nil), message...)
|
|
const separator = ": "
|
|
if len(senderName)+len(separator)+len(message) <= currentTownChatMaxMessageBytes {
|
|
visibleMessage = make([]byte, 0, len(senderName)+len(separator)+len(message))
|
|
visibleMessage = append(visibleMessage, senderName...)
|
|
visibleMessage = append(visibleMessage, separator...)
|
|
visibleMessage = append(visibleMessage, message...)
|
|
}
|
|
return buildCurrentPeerChatNoticeBody(mode, channelID, 0, nil, visibleMessage)
|
|
}
|
|
|
|
func (s *Service) rejectCurrentMegaphone(session *gameSession, kind currentMegaphoneKind, request currentTownChatRequest, err error) error {
|
|
s.logGameEvent(session, kind.rejectedEvent, "source_char_id", session.selectedCharacterID, "material_slot", request.Argument, "message_len", len(request.Message), "error", err)
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, 0, int(request.ItemCount),
|
|
"rejected", err.Error(), map[string]any{
|
|
"scope": kind.scope,
|
|
"material_item_id": kind.itemID,
|
|
"material_slot": request.Argument,
|
|
})
|
|
return s.sendGameUpperFailure(session, currentPeerChatStateOpcode, currentTownChatGenericError)
|
|
}
|
|
|
|
func (s *Service) currentOnlineChatSessions() []*gameSession {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
result := make([]*gameSession, 0, len(s.gameSessions))
|
|
for characterID, candidate := range s.gameSessions {
|
|
if candidate != nil && characterID != 0 && candidate.selectedCharacterID == characterID {
|
|
result = append(result, candidate)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Service) currentChatSessionsForCharacterIDs(characterIDs []uint16) []*gameSession {
|
|
seen := make(map[*gameSession]struct{}, len(characterIDs))
|
|
result := make([]*gameSession, 0, len(characterIDs))
|
|
for _, characterID := range characterIDs {
|
|
identity, online := s.onlineGameSessionCharacterSnapshot(characterID)
|
|
if !online {
|
|
continue
|
|
}
|
|
if _, duplicate := seen[identity.session]; duplicate {
|
|
continue
|
|
}
|
|
seen[identity.session] = struct{}{}
|
|
result = append(result, identity.session)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Service) sendCurrentChatPacketToRecipients(sender *gameSession, recipients []*gameSession, msgID uint16, body []byte) int {
|
|
delivered := 0
|
|
for _, recipient := range recipients {
|
|
if recipient == nil {
|
|
continue
|
|
}
|
|
if err := s.sendGameUpperRawClassCodec(recipient, msgID, body, 0, true); err != nil {
|
|
s.logGameEvent(sender, "game-chat-recipient-write-failed", "recipient_char_id", recipient.selectedCharacterID, "msg_id", msgID, "error", err)
|
|
continue
|
|
}
|
|
delivered++
|
|
}
|
|
return delivered
|
|
}
|
|
|
|
func buildCurrentTownChatNotification(mode byte, senderCharacterID uint16, message []byte) []byte {
|
|
body := make([]byte, 1+2+1+4+len(message))
|
|
body[0] = mode
|
|
binary.LittleEndian.PutUint16(body[1:3], currentSceneActorObjectKey(senderCharacterID))
|
|
body[3] = 0
|
|
binary.LittleEndian.PutUint32(body[4:8], uint32(len(message)))
|
|
copy(body[8:], message)
|
|
return body
|
|
}
|
|
|
|
// buildCurrentRaidAnnouncementBody builds the class-0 notification sub_1D88DE0
|
|
// renders as the "- 公告 -" banner.
|
|
//
|
|
// The handler reads u8 flag, u16 actorKey, u8 channel and then the text, so the
|
|
// layout matches op12 byte for byte; only the first byte differs in meaning, and
|
|
// zero is what selects the banner branch. The actor key is the same scene object
|
|
// key op12 carries, or the client cannot resolve the speaker.
|
|
func buildCurrentRaidAnnouncementBody(senderCharacterID uint16, channel byte, message []byte) []byte {
|
|
body := make([]byte, 1+2+1+4+len(message))
|
|
body[0] = 0
|
|
binary.LittleEndian.PutUint16(body[1:3], currentSceneActorObjectKey(senderCharacterID))
|
|
body[3] = channel
|
|
binary.LittleEndian.PutUint32(body[4:8], uint32(len(message)))
|
|
copy(body[8:], message)
|
|
return body
|
|
}
|