DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,427 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user