Files
90CNServer/server/internal/services/dnfbridge/game_select_scene_object_projection.go
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

500 lines
17 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"encoding/hex"
"fmt"
dnfguild "longheng.io/server/internal/modules/dnf/guild"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"strconv"
"strings"
)
func (s *Service) selectedSceneStageCharacter(session *gameSession) (dnfrepo.CharacterRecord, bool, string) {
if session == nil || session.selectedCharacterID == 0 {
return dnfrepo.CharacterRecord{}, false, ""
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
_, charName, character, hasCharacter := s.selectedCharacterForEnter(ctx, session)
return character, hasCharacter, charName
}
func (s *Service) buildCurrentSceneObjectListBodyForSession(ctx context.Context, session *gameSession, charID uint16, charName string, character dnfrepo.CharacterRecord, hasCharacter bool) []byte {
return s.buildCurrentSceneObjectListBodyForSessionInContext(
ctx,
session,
charID,
charName,
character,
hasCharacter,
currentSceneObjectContext,
)
}
func (s *Service) buildCurrentSceneObjectListBodyForSessionInContext(ctx context.Context, session *gameSession, charID uint16, charName string, character dnfrepo.CharacterRecord, hasCharacter bool, ownerChannel byte) []byte {
body, _ := s.buildCurrentSceneObjectListBodyForSessionInContextWithPolicy(
ctx,
session,
charID,
charName,
character,
hasCharacter,
ownerChannel,
false,
)
return body
}
func (s *Service) buildCurrentSceneObjectListBodyForSessionInContextStrict(
ctx context.Context,
session *gameSession,
charID uint16,
charName string,
character dnfrepo.CharacterRecord,
hasCharacter bool,
ownerChannel byte,
) ([]byte, error) {
return s.buildCurrentSceneObjectListBodyForSessionInContextWithPolicy(
ctx,
session,
charID,
charName,
character,
hasCharacter,
ownerChannel,
true,
)
}
func (s *Service) buildCurrentSceneObjectListBodyForSessionInContextWithPolicy(
ctx context.Context,
session *gameSession,
charID uint16,
charName string,
character dnfrepo.CharacterRecord,
hasCharacter bool,
ownerChannel byte,
strict bool,
) ([]byte, error) {
if charID == 0 {
charID, charName, character, hasCharacter = s.selectedCharacterForEnter(ctx, session)
}
if charName == "" {
charName = csharpSelectCharacterName(charID)
}
// Mode0 owns the visible Black Diamond badge. The entitlement remains
// account/session scoped; inject only a transient character copy so no
// presentation bit is persisted into the character table.
if hasCharacter && session != nil && charID == session.selectedCharacterID && s.currentBlackDiamondActive(session) {
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64, 1)
}
character.Stats[currentBlackDiamondNameplateStat] = 1
}
privateStorePermission := false
if hasCharacter {
accountID := strings.TrimSpace(character.AccountID)
if accountID == "" && session != nil && charID == session.selectedCharacterID {
accountID = strings.TrimSpace(s.accountIDForSession(session))
}
entitled, entitlementErr := s.currentPrivateStoreEntitledForAccount(ctx, accountID)
if entitlementErr != nil {
if session != nil {
s.logGameEvent(session, "game-upper-current-scene-object-private-store-load-failed",
"char_id", charID,
"account_id", accountID,
"error", entitlementErr)
}
if strict {
return nil, fmt.Errorf("load selected actor private-store permission: %w", entitlementErr)
}
} else if entitled {
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64, 1)
}
character.Stats[currentPrivateStoreScenePermissionStat] = 1
privateStorePermission = true
}
}
mode0EquipSummarySource := "character_roster_partial_current_exe_typed_tail"
if fullRows, source, found, err := s.loadCurrentSelectedActorMode0AppearanceSummary(ctx, session, charID); err != nil {
if session != nil {
s.logGameEvent(session, "game-upper-current-scene-object-mode0-appearance-load-failed",
"char_id", charID,
"error", err)
}
if strict {
return nil, fmt.Errorf("load selected actor mode0 equipment appearance: %w", err)
}
} else if found {
// Work on the value copy only. The selected character repository remains
// unchanged; this replaces the packet projection with all slots 0..13.
character.Roster.Entry.EquipSummary = fullRows
mode0EquipSummarySource = source
}
// PR #239: inject name tag state from equipment slot 30 into character
// stats so the mode0 tail carries the correct item ID and expiry for
// the client's ReadAndApplyActorNameTagState (sub_2008D80).
if itemID, expireTime, found, err := s.loadCurrentSelectedActorNameTagState(ctx, session, charID); err != nil {
if session != nil {
s.logGameEvent(session, "game-upper-current-scene-object-name-tag-load-failed",
"char_id", charID,
"error", err)
}
if strict {
return nil, fmt.Errorf("load selected actor mode0 name-tag appearance: %w", err)
}
} else if found {
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
character.Stats["name_tag_item_id"] = int64(itemID)
character.Stats["name_tag_expire_time"] = int64(expireTime)
}
objectKey := currentSceneActorObjectKey(charID)
creature, creatureErr := s.currentEquippedCreatureForCharacterWithError(ctx, strconv.Itoa(int(charID)))
if creatureErr != nil {
if session != nil {
s.logGameEvent(session, "game-upper-current-scene-object-creature-load-failed",
"char_id", charID,
"error", creatureErr)
}
if strict {
return nil, fmt.Errorf("load selected actor mode0 creature appearance: %w", creatureErr)
}
creature = currentEquippedCreatureSnapshot{}
}
adventureName := s.currentSceneObjectAdventureOverheadName(ctx, session, charID, character, hasCharacter)
guildName := ""
// Guild membership is registry-owned; these fields only exist in this wire copy.
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64, 2)
}
character.Stats["__scene_guild_id"] = 0
character.Stats["__scene_guild_mark"] = 0
if hasCharacter && charID != 0 {
guildErr := s.readCurrentGuild(ctx, func(_ dnfrepo.Group, registry dnfguild.Registry) error {
if guild, _, found := registry.GuildForCharacter(strconv.Itoa(int(charID))); found && guild != nil {
guildName = strings.TrimSpace(guild.Name)
character.Stats["__scene_guild_id"] = int64(guild.ID)
character.Stats["__scene_guild_mark"] = int64(guild.Mark)
}
return nil
})
if guildErr != nil && session != nil {
s.logGameEvent(session, "game-upper-current-scene-object-guild-load-failed",
"char_id", charID,
"error", guildErr)
}
}
body := buildCurrentSceneObjectListBodyWithCreatureAndNamesInContext(objectKey, character, hasCharacter, charName, creature, adventureName, guildName, ownerChannel)
rows := currentSceneObjectEquipSummary(character, hasCharacter)
rawBytes := 0
for _, row := range rows {
rawBytes += len(row.RawEntry)
}
objectLevel, tailStart, levelOK := currentSceneObjectLevelForLog(body)
mode0EquipRows, mode0V113, mode0TailOK := currentSceneObjectTailSummaryForLog(body)
mode0NoAttachedUIResource, mode0NoAttachedUIResourceOK := currentSceneObjectTailNoAttachedUIResourceForLog(body)
mode0ActorStateEventArg, mode0ActorStateEventArgOK := currentSceneObjectTailActorStateEventArgForLog(body)
mode0VisualState, mode0VisualStateOK := currentSceneObjectTailVisualStateForLog(body)
mode0RawSource, mode0RawHeadHex, mode0RawWord2, mode0RawWord5, mode0RawDword0F, mode0RawDword19, mode0Raw25, mode0Raw26, mode0Raw2C, mode0Raw43, mode0RawOK := currentSceneObjectRawStateForLog(body)
headLen := len(body)
if headLen > 96 {
headLen = 96
}
if session != nil {
s.logPacketEvent("game-upper-current-scene-object-list-built",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"char_id", charID,
"object_key", objectKey,
"owner_channel", ownerChannel,
"adventure_overhead_name_set", adventureName != "",
"guild_name_set", guildName != "",
"guild_id", character.Stats["__scene_guild_id"],
"guild_mark", character.Stats["__scene_guild_mark"],
"body_len", len(body),
"object_level", objectLevel,
"object_level_ok", levelOK,
"object_tail_start", tailStart,
"body_head_hex", hex.EncodeToString(body[:headLen]),
"equip_rows", len(rows),
"equip_raw_source_bytes", rawBytes,
"mode0_equip_summary_source", mode0EquipSummarySource,
"mode0_equip_summary_rows", mode0EquipRows,
"mode0_v113", mode0V113,
"mode0_tail_ok", mode0TailOK,
"mode0_sub2008d80_no_attached_ui_resource", mode0NoAttachedUIResource,
"mode0_sub2008d80_no_attached_ui_resource_ok", mode0NoAttachedUIResourceOK,
"mode0_actor_state_event_arg", mode0ActorStateEventArg,
"mode0_actor_state_event_arg_ok", mode0ActorStateEventArgOK,
"mode0_sub20042f0_visual_state", mode0VisualState,
"mode0_sub20042f0_normal_town", mode0VisualStateOK && mode0VisualState == currentSceneNormalTownVisualState,
"mode0_raw_state_source", mode0RawSource,
"mode0_raw_state_ok", mode0RawOK,
"mode0_raw_state_head_hex", mode0RawHeadHex,
"mode0_raw_word2", mode0RawWord2,
"mode0_raw_word5", mode0RawWord5,
"mode0_raw_dword0f", mode0RawDword0F,
"mode0_raw_dword19", mode0RawDword19,
"mode0_raw25", mode0Raw25,
"mode0_raw26_dword", mode0Raw26,
"mode0_raw2c", mode0Raw2C,
"mode0_raw43_dword", mode0Raw43,
"mode0_creature_item_id", creature.itemID,
"mode0_creature_serial_or_handle", creature.serialOrHandle,
"mode0_creature_level", creature.level,
"mode0_creature_town_display", creature.townDisplay,
"mode0_creature_source", creature.source,
"mode0_private_store_permission", privateStorePermission,
"type44_raw_block", "empty_until_current_variant_traced")
}
return body, nil
}
// currentSceneObjectAdventureOverheadName projects only the selected local
// actor's persisted adventure-group name. Peer actor packets must never reuse
// the viewer's account label; unavailable account data leaves this optional
// display field empty without blocking town-object creation.
func (s *Service) currentSceneObjectAdventureOverheadName(
ctx context.Context,
session *gameSession,
charID uint16,
character dnfrepo.CharacterRecord,
hasCharacter bool,
) string {
if s == nil || session == nil || !hasCharacter || charID == 0 ||
charID != session.selectedCharacterID {
return ""
}
accountID := strings.TrimSpace(s.accountIDForSession(session))
if accountID == "" || accountID != strings.TrimSpace(character.AccountID) {
return ""
}
name, _, _, err := s.currentRepresentAccountIdentity(ctx, session)
if err != nil || strings.TrimSpace(name) == "" {
return ""
}
return name
}
func currentSceneObjectLevelForLog(body []byte) (byte, int, bool) {
const nameLenOffset = 5 + 0x47 + 2
if len(body) < nameLenOffset+4 {
return 0, 0, false
}
nameLen := int(binary.LittleEndian.Uint32(body[nameLenOffset : nameLenOffset+4]))
tailStart := nameLenOffset + 4 + nameLen
if nameLen < 0 || tailStart+2 >= len(body) {
return 0, tailStart, false
}
return body[tailStart+2], tailStart, true
}
func currentSceneObjectRawStateForLog(body []byte) (string, string, uint16, uint16, uint32, uint32, byte, uint32, byte, uint32, bool) {
const rawOffset = 5
const rawLen = 0x47
if len(body) < rawOffset+rawLen {
return "", "", 0, 0, 0, 0, 0, 0, 0, 0, false
}
raw := body[rawOffset : rawOffset+rawLen]
source := "current_exe_structured_raw"
if bytesAllZero(raw) {
source = "current_exe_zero_initialized_raw"
}
headLen := 16
if len(raw) < headLen {
headLen = len(raw)
}
return source,
hex.EncodeToString(raw[:headLen]),
binary.LittleEndian.Uint16(raw[0x02:0x04]),
binary.LittleEndian.Uint16(raw[0x05:0x07]),
binary.LittleEndian.Uint32(raw[0x0f:0x13]),
binary.LittleEndian.Uint32(raw[0x19:0x1d]),
raw[0x25],
binary.LittleEndian.Uint32(raw[0x26:0x2a]),
raw[0x2c],
binary.LittleEndian.Uint32(raw[0x43:0x47]),
true
}
func bytesAllZero(raw []byte) bool {
for _, value := range raw {
if value != 0 {
return false
}
}
return true
}
func currentSceneObjectTailSummaryForLog(body []byte) (int, uint32, bool) {
_, tailStart, ok := currentSceneObjectLevelForLog(body)
if !ok || tailStart >= len(body) {
return 0, 0, false
}
tail := body[tailStart:]
if len(tail) <= 6 {
return 0, 0, false
}
equipEnd, ok := currentSceneObjectEquipSummaryEnd(tail, 6)
if !ok || equipEnd+4 > len(tail) {
return int(tail[6]), 0, false
}
return int(tail[6]), binary.LittleEndian.Uint32(tail[equipEnd : equipEnd+4]), true
}
// currentSceneObjectAppearanceSlotItemID reads one typed mode0 appearance row.
// Existing-actor redraws deliberately use this same complete table; it is not
// valid to synthesize a one-row mode0 packet for a skin/avatar change.
func currentSceneObjectAppearanceSlotItemID(body []byte, wantSlot byte) (uint32, bool) {
_, tailStart, ok := currentSceneObjectLevelForLog(body)
if !ok || tailStart >= len(body) {
return 0, false
}
tail := body[tailStart:]
if len(tail) <= 6 {
return 0, false
}
count := int(tail[6])
pos := 7
for row := 0; row < count; row++ {
if pos+currentActorMode0AppearanceRowBytes > len(tail) {
return 0, false
}
if tail[pos] == wantSlot {
return binary.LittleEndian.Uint32(tail[pos+1 : pos+5]), true
}
pos += currentActorMode0AppearanceRowBytes
}
return 0, false
}
func currentSceneObjectTailNoAttachedUIResourceForLog(body []byte) (bool, bool) {
_, tailStart, ok := currentSceneObjectLevelForLog(body)
if !ok || tailStart >= len(body) {
return false, false
}
tail := body[tailStart:]
if len(tail) <= 6 {
return false, false
}
equipEnd, ok := currentSceneObjectEquipSummaryEnd(tail, 6)
if !ok {
return false, false
}
rawOffset := equipEnd + 8
if rawOffset+8 > len(tail) {
return false, false
}
return binary.LittleEndian.Uint32(tail[rawOffset:rawOffset+4]) == currentSceneNoAttachedUIResourceID &&
binary.LittleEndian.Uint32(tail[rawOffset+4:rawOffset+8]) == currentSceneNoAttachedUIResourceID, true
}
func currentSceneObjectTailActorStateEventArgForLog(body []byte) (uint32, bool) {
offset, ok := currentSceneObjectTailActorStateEventArgOffset(body)
if !ok {
return 0, false
}
return binary.LittleEndian.Uint32(body[offset : offset+4]), true
}
func currentSceneObjectTailPrivateStorePermissionForLog(body []byte) (byte, bool) {
offset, ok := currentSceneObjectTailPrivateStorePermissionOffset(body)
if !ok {
return 0, false
}
return body[offset], true
}
func currentSceneObjectTailPrivateStorePermissionOffset(body []byte) (int, bool) {
_, tailStart, ok := currentSceneObjectLevelForLog(body)
if !ok || tailStart >= len(body) {
return 0, false
}
tail := body[tailStart:]
equipEnd, ok := currentSceneObjectEquipSummaryEnd(tail, 6)
if !ok {
return 0, false
}
// v113 + v114/v115/v104/v97 + name-tag item/expiry + sub_2003A20
// + sub_2003AD0 lead directly to the one-byte permission field.
pos := equipEnd + 4 + 4 + 8 + 1 + 4
if pos >= len(tail) {
return 0, false
}
return tailStart + pos, true
}
func currentSceneObjectTailActorStateEventArgOffset(body []byte) (int, bool) {
_, tailStart, ok := currentSceneObjectLevelForLog(body)
if !ok || tailStart >= len(body) {
return 0, false
}
tail := body[tailStart:]
if len(tail) <= 6 {
return 0, false
}
equipEnd, ok := currentSceneObjectEquipSummaryEnd(tail, 6)
if !ok {
return 0, false
}
// After the variable equipment summary, consume the current EXE's typed
// fields up to v121: v113, four flags, raw8, resource flags, creature,
// three single-byte states, v120, organization DSTR, and state u32.
pos := equipEnd + 4 + 4 + 8 + 1 + 4 + 1
if pos+4 > len(tail) {
return 0, false
}
pos += 4 // creature template
if pos+4 > len(tail) {
return 0, false
}
creatureNameLen := int(binary.LittleEndian.Uint32(tail[pos : pos+4]))
pos += 4 + creatureNameLen + 1
if creatureNameLen < 0 || pos+1+1+4+4 > len(tail) {
return 0, false
}
pos += 1 + 1 + 4 // sub_2003B80, sub_2003C90, v120
if pos+4 > len(tail) {
return 0, false
}
organizationNameLen := int(binary.LittleEndian.Uint32(tail[pos : pos+4]))
pos += 4 + organizationNameLen + 4 // DSTR plus sub_2003D40 state value
if organizationNameLen < 0 || pos+4 > len(tail) {
return 0, false
}
return tailStart + pos, true
}
func suppressCurrentSceneObjectActorStateEvent(body []byte) bool {
offset, ok := currentSceneObjectTailActorStateEventArgOffset(body)
if !ok {
return false
}
binary.LittleEndian.PutUint32(body[offset:offset+4], 0)
return true
}
func currentSceneObjectTailVisualStateForLog(body []byte) (byte, bool) {
_, tailStart, ok := currentSceneObjectLevelForLog(body)
if !ok || tailStart >= len(body) {
return 0, false
}
tail := body[tailStart:]
// In the current typed mode0 writer, sub_20042F0's u8 is followed by the
// fixed 19-byte finalizer state (u32/u8/u16/u8/u16/u8/u16/u8/u8/u32).
const finalizerBytesAfterVisualState = 19
if len(tail) <= finalizerBytesAfterVisualState {
return 0, false
}
return tail[len(tail)-1-finalizerBytesAfterVisualState], true
}