Files
90CNServer/server/internal/services/dnfbridge/blacklist.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

428 lines
18 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"errors"
"sort"
"strconv"
"strings"
"time"
dnfenum "longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// Per-character blacklist.
//
// The current client owns a blacklist of its own and *does* report it: legacy
// `type=118` (ENUM_CMDPACKET_REGISITER_TO_BLACKLIST) and `type=119`
// (ENUM_CMDPACKET_DELETE_TO_BLACKLIST), each with the body
// `u32 nameLength | name` -- captured 2026-09-22 14:35,
// `017600150000005e2563b92f00 04000000 74657374` ("test").
//
// This server used to answer those two with a generic ack and store nothing, and
// it answered `type=120` (REQUEST_BLACKLIST) with a fixed *empty* list. The client
// replaces its list with whatever 120 returns, so every relog wiped the player's
// blacklist -- live report 2026-09-22 22:4x, "拉黑后小退就不在黑名单列表了".
//
// ---------------------------------------------------------------------------
// The type=120 answer layout (reverse engineered 2026-09-22, client RVA 0x1910480)
// ---------------------------------------------------------------------------
// `type=120` is answered on the *same* class-1 opcode, and the body is
//
// u8 flag | u8 count | count * ( u32 nameLength | name | u8 dateA | u8 dateB | u8 dateC )
//
// Evidence:
// - The client's reader for opcode 120 is sub_1910480 (registered from the same
// contiguous 118/119/120 blacklist block at RVA 0x19369D6..0x19369F6). It first
// tests its second argument -- the packet's *first* body byte, consumed before
// the payload -- and returns immediately when that flag is zero, then reads
// `u8 count` and loops `count` times, each iteration reading one DSTR
// (readDstrTLS, max 0x1f) followed by exactly three readU8 calls.
// - The client stores those three bytes into one row record at +0x14, +0x10 and
// +0x0C; the official server's own builder (df_game_r(1), ELF with symbols:
// Inter_Monitor_RequestBlackListResult::dispatch_sig) reads a 0x28-byte entry
// struct and emits them in exactly that order as three put_byte calls after
// `put_int(nameLength)` + `put_str(name)` -- so the wire order is dateA/dateB/dateC
// as stored at +0x14/+0x10/+0x0C.
// - That builder also writes `put_header(class 1, cmd)` then `put_byte(1)` before
// the count, i.e. the same leading flag byte, and aborts when the count exceeds
// 0x0a. The flag byte is consumed as the reader's second argument, which is why
// the 118/119 ack (`u8 flag | u32 nameLength | name`) already works today: the
// name is read from the second body byte on.
//
// The three trailing bytes are the 黑名单日期 column (the panel's other column,
// 在线状态, is resolved locally from the actor lookup, not from the packet). They are
// written as year%100 / month / day, so a 2026-09-22 block sends 0x1A, 0x09, 0x16.
//
// The list is per character (not per account), stored through the existing
// settings repository under `character:<id>:blacklist_v1`, the same mechanism the
// project already uses for per-character counters (`:dungeon_admission_counters_v1`)
// -- no new file, no new table.
var errCurrentBlacklistStoreUnavailable = errors.New("blacklist settings repository unavailable")
const (
currentBlacklistScopeSuffix = ":blacklist_v1"
currentBlacklistValueKey = "names"
currentBlacklistMaxNames = 100
// currentBlacklistValueSeparator splits one stored `name<sep>unix-seconds` line.
// A roster name can never contain a tab, and lines without the separator are the
// date-less form written before the block date was tracked, so old saves keep
// loading unchanged.
currentBlacklistValueSeparator = "\t"
// currentBlacklistListAnswerOk is the leading flag the client's op120 reader
// consumes before the payload; zero makes it return without touching its list.
currentBlacklistListAnswerOk byte = 1
// currentBlacklistListAnswerMaxEntries bounds the payload count, which is a u8 on
// the wire (the store's own cap is currentBlacklistMaxNames).
currentBlacklistListAnswerMaxEntries = 0xff
)
// currentBlacklistRejection* are op65's `status` reason codes. The client's handler
// branches on the *mode* byte before it reaches the chat-window manager, and the two
// reachable tables hold different sentences (client-patch\bin\DNF.exe):
//
// mode 0x24 -- the 1:1 peer-menu window, which is the mode this client actually
// sends for window traffic (live capture 2026-09-22 23:25:
// `...0024 03 00000000 00000002 ...`): the mapper at RVA 0x2DA5950 renders
// status 0x36 -> dstr 4771 已将对方添加到黑名单,无法继续进行。
// status 0x4D -> dstr 2079 对方在黑名单中
// status 0x5A -> dstr 2080 无法向GM发送聊天信息
// otherwise -> dstr 2078 对方目前处于无法接收信息状态
// mode 0x01 (whisper) -- the direct branch at RVA 0x1969CF5 renders
// status 0x36 -> dstr 4771
// status 0x4D -> dstr 4763 您在对方的黑名单内,无法进行该操作。
// status 0x5A -> dstr 6424
//
// Operator decision 2026-09-22 23:4x: keep the peer-menu sentences. Routing the
// peer-blocked notice through the whisper table to reach dstr 4763 was tried and
// rolled back -- the sentence arrives as a detached system line instead of in the
// window, which is not what the window is for.
const (
currentBlacklistStatusTargetBlockedSender byte = 0x4D // 「对方在黑名单中」
currentBlacklistStatusSenderBlockedTarget byte = 0x36 // 「已将对方添加到黑名单,无法继续进行。」
)
// currentBlacklistRecord is one stored block: the blocked character name plus the
// time it was added (unix seconds, zero when the save predates date tracking).
type currentBlacklistRecord struct {
Name string
At int64
}
func currentBlacklistScope(characterID uint16) string {
return "character:" + strconv.Itoa(int(characterID)) + currentBlacklistScopeSuffix
}
// parseCurrentBlacklistValue reads the stored value into records, accepting both
// `name` and `name<tab>unix-seconds` lines so pre-existing saves keep working.
func parseCurrentBlacklistValue(raw string) []currentBlacklistRecord {
records := make([]currentBlacklistRecord, 0, 8)
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
name, stamp := line, ""
if index := strings.Index(line, currentBlacklistValueSeparator); index >= 0 {
name = strings.TrimSpace(line[:index])
stamp = strings.TrimSpace(line[index+len(currentBlacklistValueSeparator):])
}
if name == "" {
continue
}
at, err := strconv.ParseInt(stamp, 10, 64)
if err != nil || at < 0 {
at = 0
}
records = append(records, currentBlacklistRecord{Name: name, At: at})
}
return records
}
// formatCurrentBlacklistValue writes records back in the same line format.
func formatCurrentBlacklistValue(records []currentBlacklistRecord) string {
lines := make([]string, 0, len(records))
for _, record := range records {
if record.At > 0 {
lines = append(lines, record.Name+currentBlacklistValueSeparator+strconv.FormatInt(record.At, 10))
continue
}
lines = append(lines, record.Name)
}
return strings.Join(lines, "\n")
}
func currentBlacklistRecordsSorted(records []currentBlacklistRecord) []currentBlacklistRecord {
sorted := make([]currentBlacklistRecord, len(records))
copy(sorted, records)
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
return sorted
}
func currentBlacklistRecordNames(records []currentBlacklistRecord) []string {
names := make([]string, 0, len(records))
for _, record := range records {
names = append(names, record.Name)
}
return names
}
func currentBlacklistRecordsContain(records []currentBlacklistRecord, name string) bool {
for _, record := range records {
if strings.EqualFold(record.Name, name) {
return true
}
}
return false
}
// loadCurrentBlacklistRecords returns the stored blocks of one character, in stored order.
func (s *Service) loadCurrentBlacklistRecords(ctx context.Context, characterID uint16) []currentBlacklistRecord {
if s == nil || characterID == 0 {
return nil
}
repos, ok := s.repositoryGroup()
if !ok || repos.Settings == nil {
return nil
}
record, found, err := repos.Settings.Load(ctx, currentBlacklistScope(characterID))
if err != nil || !found {
return nil
}
return parseCurrentBlacklistValue(record.Values[currentBlacklistValueKey])
}
// loadCurrentBlacklist returns the stored names of one character, in stored order.
func (s *Service) loadCurrentBlacklist(ctx context.Context, characterID uint16) []string {
return currentBlacklistRecordNames(s.loadCurrentBlacklistRecords(ctx, characterID))
}
func (s *Service) saveCurrentBlacklistRecords(ctx context.Context, characterID uint16, records []currentBlacklistRecord) error {
repos, ok := s.repositoryGroup()
if !ok || repos.Settings == nil {
return errCurrentBlacklistStoreUnavailable
}
sorted := currentBlacklistRecordsSorted(records)
record := dnfrepo.SettingsRecord{
Scope: currentBlacklistScope(characterID),
Values: map[string]string{currentBlacklistValueKey: formatCurrentBlacklistValue(sorted)},
UpdatedAt: s.gameplayNow(),
}
return repos.Settings.Save(ctx, record)
}
// loadCurrentBlacklistRecordsForSession is loadCurrentBlacklistRecords with its own
// short context, for callers that have none at hand (the type=120 answer path).
func (s *Service) loadCurrentBlacklistRecordsForSession(session *gameSession) []currentBlacklistRecord {
if session == nil || session.selectedCharacterID == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
return s.loadCurrentBlacklistRecords(ctx, session.selectedCharacterID)
}
// currentBlacklistHas reports whether `owner` has blocked `name` (case-insensitive,
// like the rest of the chat name matching).
func (s *Service) currentBlacklistHas(ctx context.Context, owner uint16, name string) bool {
name = strings.TrimSpace(name)
if name == "" {
return false
}
for _, blocked := range s.loadCurrentBlacklist(ctx, owner) {
if strings.EqualFold(blocked, name) {
return true
}
}
return false
}
// parseCurrentBlacklistName reads `u32 nameLength | name`; the caps mirror the
// client's own roster-name limit and reject a NUL inside the name.
func parseCurrentBlacklistName(body []byte) (string, bool) {
if len(body) < 4 {
return "", false
}
length := int(binary.LittleEndian.Uint32(body[:4]))
if length <= 0 || length > 30 || len(body) != 4+length {
return "", false
}
raw := body[4:]
if strings.IndexByte(string(raw), 0) >= 0 {
return "", false
}
// The client sends the name in the wire's GB18030 form -- a live 1:1 capture
// carries `c0 b4 c0 b4 c0 b4` for 来来来. Decoding it here keeps the stored name
// equal to the repository's own spelling, so the 1:1 gate's name comparison
// matches and rosterNameBytes re-encodes back to the same bytes for the ack.
if name, err := decodeCharacterName(raw); err == nil {
return name, true
}
return string(raw), true
}
// currentBlacklistCanStore reports whether the register/delete path can actually
// persist for this session: a selected character plus a settings repository. The
// upper/class-1 transport uses it to decide between the storing owner and the
// historic ack-only echo.
func (s *Service) currentBlacklistCanStore(session *gameSession) bool {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return false
}
repos, ok := s.repositoryGroup()
return ok && repos.Settings != nil
}
// handleCurrentBlacklistRegister answers legacy 118, handleCurrentBlacklistDelete
// legacy 119. Both reply with the shape the client already got from this server:
// class 1, `flag(1) | u32 nameLength | name` (9 bytes for a 4-character name,
// captured live), so the visible protocol is unchanged for a successful call.
func (s *Service) handleCurrentBlacklistRegister(session *gameSession, body []byte) error {
return s.mutateCurrentBlacklist(session, body, true, uint16(dnfenum.CmdPacketRegisiterToBlacklist))
}
func (s *Service) handleCurrentBlacklistDelete(session *gameSession, body []byte) error {
return s.mutateCurrentBlacklist(session, body, false, uint16(dnfenum.CmdPacketDeleteToBlacklist))
}
func (s *Service) mutateCurrentBlacklist(session *gameSession, body []byte, add bool, opcode uint16) error {
if session == nil || session.selectedCharacterID == 0 {
return nil
}
name, ok := parseCurrentBlacklistName(body)
if !ok {
s.logGameEvent(session, "game-blacklist-request-rejected",
"char_id", session.selectedCharacterID, "opcode", opcode, "body_len", len(body),
"reason", "malformed_name_argument")
return s.sendGameUpperFailure(session, opcode, currentTownChatGenericError)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
records := s.loadCurrentBlacklistRecords(ctx, session.selectedCharacterID)
next := make([]currentBlacklistRecord, 0, len(records)+1)
changed := false
alreadyBlocked := false
for _, existing := range records {
if strings.EqualFold(existing.Name, name) {
alreadyBlocked = true
if add {
next = append(next, existing) // already blocked: keep, no duplicate
} else {
changed = true
}
continue
}
next = append(next, existing)
}
if add && !alreadyBlocked {
if len(next) >= currentBlacklistMaxNames {
s.logGameEvent(session, "game-blacklist-request-rejected",
"char_id", session.selectedCharacterID, "opcode", opcode,
"reason", "blacklist_full", "limit", currentBlacklistMaxNames)
return s.sendGameUpperFailure(session, opcode, currentTownChatGenericError)
}
next = append(next, currentBlacklistRecord{Name: name, At: s.gameplayNow().Unix()})
changed = true
}
if changed {
if err := s.saveCurrentBlacklistRecords(ctx, session.selectedCharacterID, next); err != nil {
s.logGameEvent(session, "game-blacklist-save-failed",
"char_id", session.selectedCharacterID, "opcode", opcode, "error", err)
return s.sendGameUpperFailure(session, opcode, currentTownChatGenericError)
}
}
s.logGameEvent(session, "game-blacklist-updated",
"char_id", session.selectedCharacterID, "opcode", opcode, "blocked", add,
"name", name, "count", len(next), "changed", changed)
// FIXED 2026-09-22 23:2x: this used to write `u32 len(name)` *and then* a full
// DSTR (which writes its own u32 length), i.e. 13 bytes for a 4-character name
// instead of the 9 the shape calls for. The client consumed the first u32 as the
// name length and copied the second one as the name, so the row it appended
// showed a garbled name with Lv.0. The official server's own builder
// (df_game_r(1) 0x84D0F99: put_byte(1) ; put_int(len) ; put_str(name,len)) proves
// the ack is `flag(1) | u32 nameLength | name` -- exactly the 9-byte shape this
// path was documented as sending, so the redundant length is dropped here.
var writer packetWriter
writer.writeRawDstr(rosterNameBytes(name))
return s.sendGameUpperSuccess(session, opcode, writer.bytes())
}
// currentBlacklistDateFields projects one stored block time onto the answer's three
// trailing bytes: year%100, month, day. A zero timestamp (a save from before the
// date was tracked) yields three zero bytes, which the panel reads as no date.
func currentBlacklistDateFields(at int64) (byte, byte, byte) {
if at <= 0 {
return 0, 0, 0
}
stamp := time.Unix(at, 0)
return byte(stamp.Year() % 100), byte(int(stamp.Month())), byte(stamp.Day())
}
// buildCurrentBlacklistListBody is the type=120 answer:
//
// u8 flag(1) | u8 count | count * (u32 nameLength | name | u8 year | u8 month | u8 day)
//
// An empty list answers with currentRequestBlacklistResponseBody verbatim (`00 00`),
// which is byte for byte what this server sent before the layout was known: flag 0
// makes the client's reader return before it touches its list.
func buildCurrentBlacklistListBody(records []currentBlacklistRecord) []byte {
if len(records) == 0 {
return currentRequestBlacklistResponseBody
}
count := len(records)
if count > currentBlacklistListAnswerMaxEntries {
count = currentBlacklistListAnswerMaxEntries
}
var writer packetWriter
writer.writeByte(currentBlacklistListAnswerOk)
writer.writeByte(byte(count))
for _, record := range records[:count] {
raw := rosterNameBytes(record.Name)
writer.writeUint32(uint32(len(raw)))
writer.writeBytes(raw)
year, month, day := currentBlacklistDateFields(record.At)
writer.writeByte(year)
writer.writeByte(month)
writer.writeByte(day)
}
return writer.bytes()
}
// sendCurrentChatRejectionNotice shows the "it did not go through" sentence to the
// *sender* whose line the blacklist gate refused. The client's op65 handler
// implements the rejected branch for exactly two modes -- the 1:1 peer-menu window
// (0x24, sentences 4771/2079/2080/2078) and a typed whisper (1, sentences
// 4771/4763/6424) -- so every other mode keeps the historical failure ack rather than
// a packet the client would not render.
//
// The trailing "extra" dstr is read only in the peer-menu window, so the whisper body
// stops after the text.
//
// The text dstr is read by the same validated reader as a normal line (its error
// string is dstr 642 「信息长度有误」, reached from op65 at 0x19692B9), so the real
// message text is repeated here -- the rejected branch ignores it, but an empty one
// would be rejected by the reader itself.
func (s *Service) sendCurrentChatRejectionNotice(session *gameSession, mode, status byte, peerName string, text []byte) error {
if session == nil {
return nil
}
if mode != currentTownChatPeerMenuMode && mode != currentTownChatWhisperMode {
return s.sendGameUpperFailure(session, currentPeerChatStateOpcode, currentTownChatGenericError)
}
var writer packetWriter
writer.writeByte(mode)
writer.writeByte(status)
writer.writeRawDstr(rosterNameBytes(peerName))
writer.writeByte(currentChatIncomingLineFlag)
writer.writeRawDstr(text)
if mode == currentTownChatPeerMenuMode {
writer.writeRawDstr(rosterNameBytes(peerName))
}
return s.sendGameUpperRawClassCodec(session, currentTownChatOneToOneStateMsgID, writer.bytes(), 0, true)
}