服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
190 lines
8.0 KiB
Go
190 lines
8.0 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"golang.org/x/text/encoding/simplifiedchinese"
|
|
)
|
|
|
|
const (
|
|
// currentCharacterNameChangeLogMode is the GET_USERINFO mode behind the
|
|
// client's 角色名称变更记录 (character name-change record) window. The
|
|
// request shares the role-selector opcode and carries the native three-byte
|
|
// shape: u16 target character id + u8 mode.
|
|
currentCharacterNameChangeLogMode = byte(5)
|
|
|
|
// currentCharacterNameChangeLogMsgID is the class-0 notification that owns
|
|
// that window.
|
|
//
|
|
// Evidence for the current 90CN client (sha256
|
|
// 8A7AF614769D0B47D0647B77C33BCE725F2F20258651C01FC6C22AC1DB44AA4D): the
|
|
// class-0 registry dump lists `opcode=733 handler=0x015E7D60`, and that
|
|
// handler (a) resolves the window object, (b) empties its row list and
|
|
// zeroes the row count, (c) reads one u8 row count from the packet and
|
|
// returns immediately when it is zero, (d) otherwise reads two fields per
|
|
// row and appends the row. This is the same contract as the 86-generation
|
|
// reference notification (CHARAC_NAME_CHANGE_LOG = 0x02DD = 733).
|
|
currentCharacterNameChangeLogMsgID = uint16(733)
|
|
|
|
// currentCharacterNameChangeLogNameMaxBytes is the client's own per-row name
|
|
// limit. The third-cell reader (0x3457C80) rejects a length of 31 or more
|
|
// (`cmp eax, esi / jae` against maxLen 0x1F) and falls back to its built-in
|
|
// empty string, so a row may carry at most 30 name bytes.
|
|
currentCharacterNameChangeLogNameMaxBytes = 30
|
|
)
|
|
|
|
// buildCurrentCharacterNameChangeLogBody encodes the window's row list.
|
|
//
|
|
// Row layout, re-derived from the opcode-733 handler at 0x015E7D60 together with
|
|
// its third-cell reader 0x3457C80 (2026-09-23):
|
|
//
|
|
// u8 rowCount
|
|
// per row (9 + nameLen bytes):
|
|
// u32 nameLen -> 0x3457C80 compares it against maxLen 0x1F: a length of 31
|
|
// or more, or exactly 0, makes the client fall back to its
|
|
// built-in string and consume no further bytes.
|
|
// u8 name[...] -> nameLen bytes of GBK text read by 0x34E3AC0 and converted
|
|
// to UTF-16 for the 角色名 column.
|
|
// u8 serverId -> 0x2272670(serverId) resolves the 大区名 column.
|
|
// u32 unixTime -> localtime() -> (year + 1900, month + 1, day) 日期 column.
|
|
//
|
|
// The v1 of this file wrote a four-byte "key" where nameLen now sits and shipped
|
|
// the names in a trailing block read only by the standalone RenameLogNames.dll.
|
|
// Disassembling 0x3457C80 settled that question: it is not a table lookup at all
|
|
// but a length-prefixed string reader, so the name belongs in the row itself and
|
|
// no client patch is needed.
|
|
//
|
|
// field order is fixed by the handler's instruction sequence, which reads the
|
|
// third cell first, then the two following fields:
|
|
//
|
|
// 0x015E7E25 call 0x3457D20 ; name (u32 length + bytes, via 0x3457C80)
|
|
// 0x015E7E33 call 0x34E3970 ; serverId (u8)
|
|
// 0x015E7E3F call 0x34E3A50 ; unixTime (u32)
|
|
//
|
|
// The row stride was proven against five rows of a captured window: with a zero
|
|
// name length the server id sits at 5+9k and the timestamp at 6+9k, reproducing
|
|
// 山东6 / 未定名62 / 未定名53 / 奇迹暖暖 with dates 1971.01.28, 1971.04.15 and
|
|
// 2026.09.23 exactly. A five-byte stride (emitted until that revision) shifts
|
|
// every field by four bytes and produced the "correct 大区名 but 1970 date"
|
|
// reports.
|
|
func buildCurrentCharacterNameChangeLogBody(entries []currentCharacterRenameLogEntry, fallbackServerID byte) []byte {
|
|
count := len(entries)
|
|
if count > 0xff {
|
|
count = 0xff
|
|
}
|
|
var writer packetWriter
|
|
writer.writeByte(byte(count))
|
|
for idx := 0; idx < count; idx++ {
|
|
serverID := entries[idx].ServerID
|
|
if !entries[idx].HasServerID {
|
|
serverID = fallbackServerID
|
|
}
|
|
name := currentCharacterNameChangeLogNameBytes(entries[idx].OldName)
|
|
writer.writeUint32(uint32(len(name)))
|
|
writer.writeBytes(name)
|
|
writer.writeByte(serverID)
|
|
writer.writeUint32(uint32(entries[idx].Unix))
|
|
}
|
|
return writer.bytes()
|
|
}
|
|
|
|
// currentCharacterNameChangeLogNameBytes encodes one record's previous name.
|
|
//
|
|
// The client converts these bytes from GBK, so the value is capped at
|
|
// currentCharacterNameChangeLogNameMaxBytes on a character boundary rather than a
|
|
// byte boundary: slicing mid-character would leave a dangling lead byte that the
|
|
// conversion turns into a stray glyph.
|
|
func currentCharacterNameChangeLogNameBytes(name string) []byte {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
encoder := simplifiedchinese.GB18030.NewEncoder()
|
|
var encoded []byte
|
|
for _, value := range name {
|
|
unit, err := encoder.Bytes([]byte(string(value)))
|
|
if err != nil || len(unit) == 0 {
|
|
unit = []byte(string(value))
|
|
}
|
|
if len(encoded)+len(unit) > currentCharacterNameChangeLogNameMaxBytes {
|
|
break
|
|
}
|
|
encoded = append(encoded, unit...)
|
|
}
|
|
return encoded
|
|
}
|
|
|
|
// handleCurrentCharacterNameChangeLogRequest answers the name-change-record
|
|
// window query.
|
|
//
|
|
// This branch exists because the request reuses the role-selector opcode: the
|
|
// name-change window emits GET_USERINFO with mode=5, and the role-selector
|
|
// bootstrap answered every GET_USERINFO with the account's full character
|
|
// roster. The client dispatches an incoming roster to the role-selector reader
|
|
// (sub_200BEA0), which is only valid on the character-select screen, so opening
|
|
// that window while a character was already in a scene fed a 33-row roster into
|
|
// the selector reader and hard-crashed the client (live crash 2026-09-23
|
|
// 00:13:23, immediately after the 4200-byte roster reply).
|
|
//
|
|
// Only the exact mode-5 shape is consumed here; mode 2 (own roster) and mode 3
|
|
// (town character inspection) keep their existing handlers untouched.
|
|
func (s *Service) handleCurrentCharacterNameChangeLogRequest(session *gameSession, body []byte) (bool, error) {
|
|
if len(body) < 3 || body[2] != currentCharacterNameChangeLogMode {
|
|
return false, nil
|
|
}
|
|
targetID := binary.LittleEndian.Uint16(body[:2])
|
|
regionID := s.currentRegionID()
|
|
entries := s.loadCurrentCharacterRenameLog(session, targetID)
|
|
payload := buildCurrentCharacterNameChangeLogBody(entries, regionID)
|
|
s.logGameEvent(session, "game-character-name-change-log-send",
|
|
"target_char_id", targetID,
|
|
"msg_id", currentCharacterNameChangeLogMsgID,
|
|
"entries", len(entries),
|
|
"region_id", regionID,
|
|
"body_len", len(body),
|
|
"payload_len", len(payload))
|
|
return true, s.sendGameUpperRawClass(session, currentCharacterNameChangeLogMsgID, payload, 0)
|
|
}
|
|
|
|
// loadCurrentCharacterRenameLog loads one character's committed renames.
|
|
//
|
|
// Any requester may read them. The window is opened for a *target* character -
|
|
// the mode-5 request carries that character's id - and a player inspecting
|
|
// somebody else has to see that character's record too; until 2026-09-23 this
|
|
// returned nil unless the target belonged to the requesting account, so the
|
|
// window came up empty for every other player. The target still has to exist;
|
|
// an unknown id answers with the empty list, which keeps the window well formed.
|
|
//
|
|
// The history itself stays stored on the owner's account record (see
|
|
// appendCurrentCharacterRenameLog), so serving a foreign character means loading
|
|
// that character's owner and reading the same "<serverID>|<old name>" entries.
|
|
func (s *Service) loadCurrentCharacterRenameLog(session *gameSession, targetID uint16) []currentCharacterRenameLogEntry {
|
|
if s == nil || session == nil || targetID == 0 {
|
|
return nil
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Account == nil || repositories.Character == nil {
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
|
|
characterID := strconv.FormatUint(uint64(targetID), 10)
|
|
character, found, err := repositories.Character.Load(ctx, characterID)
|
|
if err != nil || !found {
|
|
return nil
|
|
}
|
|
ownerID := strings.TrimSpace(character.AccountID)
|
|
if ownerID == "" {
|
|
return nil
|
|
}
|
|
account, found, err := repositories.Account.Load(ctx, ownerID)
|
|
if err != nil || !found {
|
|
return nil
|
|
}
|
|
return currentCharacterRenameLogEntries(account, character.CharacterID)
|
|
}
|