Files
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

383 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 本文件按当前 EXE/C# 证据构造队伍 S2C body。
package party
import "longheng.io/server/internal/modules/dnf/alignedcmd"
const (
fullPartyMemberPercent = 100
defaultTownUserState = 1
partyPeerEndpointSize = 22
directoryMemberSlots = 6
directoryOrdinaryState = 0xff
directoryEmptyState = 0xff
directoryEmptyUserID = 0xffff
// DirectoryTypeOrdinary selects the first current-client party directory.
DirectoryTypeOrdinary byte = 2
)
// PeerEndpoint is one current-client PARTY_IP_INFO member row. IPv4 is kept
// as four network-order octets while Port is written in network byte order;
// the remaining numeric fields use the packet's ordinary little-endian order.
type PeerEndpoint struct {
UserID uint16
InnerIPv4 [4]byte
OuterIPv4 [4]byte
Port uint16
RoutingKey uint32
NATType byte
PathMTU uint32
UserState byte
}
// DirectoryRecord is the server-owned public party-directory projection used
// by the current client class0/op87 reader.
type DirectoryRecord struct {
PartyID uint16
SelectionID uint32
MemberIDs []uint16
}
// BuildDirectorySnapshot constructs the current NoPack class0/op87 body:
//
// u16 count
// repeat count {
// u16 partyID
// u8 reservedA
// u8 directoryType
// u8 reservedB
// u8 specialState
// u32 selectionID
// repeat 6 { u8 memberState; u16 characterID }
// }
//
// Current EXE sub_1D3F040 subtracts its protected logical base value 2 from
// directoryType before indexing a five-element directory container. Therefore
// this byte is a directory discriminator, not the resident channel ID. This
// builder currently publishes only ordinary-dungeon parties and deliberately
// fixes directoryType to 2 (container index zero). sub_1D3F040 reads but does
// not retain reservedA/reservedB. Ordinary parties must use specialState FF:
// any other value enters the optional complete-display subsystem. Occupied
// slots use neutral state zero; unused slots use the exact FF/FFFF sentinels.
func BuildDirectorySnapshot(records []DirectoryRecord) []byte {
if len(records) > int(^uint16(0)) {
records = records[:int(^uint16(0))]
}
var writer packetWriter
writer.writeUint16(uint16(len(records)))
for _, record := range records {
writer.writeUint16(record.PartyID)
writer.writeByte(0)
writer.writeByte(DirectoryTypeOrdinary)
writer.writeByte(0)
writer.writeByte(directoryOrdinaryState)
writer.writeUint32(record.SelectionID)
for slot := 0; slot < directoryMemberSlots; slot++ {
if slot < len(record.MemberIDs) &&
record.MemberIDs[slot] != 0 &&
record.MemberIDs[slot] != directoryEmptyUserID {
writer.writeByte(0)
writer.writeUint16(record.MemberIDs[slot])
continue
}
writer.writeByte(directoryEmptyState)
writer.writeUint16(directoryEmptyUserID)
}
}
return writer.bytes()
}
// BuildSingleMemberRealtimeInfo 构造 NOTI 0x0099 PARTY_MEMBER_REALTIME_INFO。
//
// 行布局与 86JP 的 PartyRealtimeInfoBuilder 相同,而该 builder 是逐字节照
// df_game_r CParty::get_party_realtime_info @0x0859CBAC 写的:
//
// count:u8, 每有效成员 { uid:u16, hpPercent:u8, isHelpAbuseParty:u8, slotIndex:u8 }
//
// 即 +2 是该成员的 HP 百分比(0~100,满血 100;本包没有 MP 字段,客户端取到
// HP 后自行取 MP),+4 是成员在队伍表里的固定槽位。把 +2 写成 UserState、把
// +4 写死 0 会让一支多人队伍的每一行都声称自己占 0 号槽:客户端按槽位匹配时
// 血条会互相覆盖,而 HP 也永远显示满血。
//
// +4 的取值优先用 member.Slot(op9 下发的就是同一个槽位号)。只有当调用方
// 没有给出可用槽位(越界或与前面的行重复)时才按第一个空槽补齐,规则与
// dnfbridge 侧 appendRuntimePartyMember 的槽位分配完全一致,避免 op153 与
// op9 对同一支队伍给出互相矛盾的行。
func BuildSingleMemberRealtimeInfo(state alignedcmd.PartyState) []byte {
if state.PartyID <= 0 || state.UserID == 0 {
return []byte{0}
}
members := partyMembers(state)
var writer packetWriter
var used [4]bool
writer.writeByte(byte(len(members)))
for _, member := range members {
slot := member.Slot
if slot >= byte(len(used)) || used[slot] {
slot = 0
for candidate := byte(0); candidate < byte(len(used)); candidate++ {
if !used[candidate] {
slot = candidate
break
}
}
}
used[slot] = true
writer.writeUint16(member.UserID)
writer.writeByte(memberPercentByte(member.HPPercent))
writer.writeByte(0)
writer.writeByte(slot)
}
return writer.bytes()
}
// BuildEmptyRealtimeInfo 构造退出队伍后用于清空实时队员 UI 的空列表。
func BuildEmptyRealtimeInfo() []byte {
return []byte{0}
}
// BuildPeerEndpointInfo constructs current-client class0/op11
// PARTY_IP_INFO. The current reader consumes one count byte followed by 22
// bytes per member:
//
// u16 uid, 4B inner IPv4, 4B outer IPv4, u16be port,
// u32 routing key, u8 NAT type, u32 path MTU, u8 connection percentage.
//
// RoutingKey and PathMTU must be the remote connection's values previously
// published by its login/path-report flow; UserState is a connection percent.
func BuildPeerEndpointInfo(endpoints []PeerEndpoint) []byte {
if len(endpoints) > 4 {
endpoints = endpoints[:4]
}
var writer packetWriter
writer.writeByte(byte(len(endpoints)))
for _, endpoint := range endpoints {
writer.writeUint16(endpoint.UserID)
for _, octet := range endpoint.InnerIPv4 {
writer.writeByte(octet)
}
for _, octet := range endpoint.OuterIPv4 {
writer.writeByte(octet)
}
writer.writeByte(byte(endpoint.Port >> 8))
writer.writeByte(byte(endpoint.Port))
writer.writeUint32(endpoint.RoutingKey)
writer.writeByte(endpoint.NATType)
writer.writeUint32(endpoint.PathMTU)
userState := endpoint.UserState
if userState == 0 || userState == defaultTownUserState {
userState = fullPartyMemberPercent
}
writer.writeByte(userState)
}
return writer.bytes()
}
// BuildReserveLeavePartyAck 构造 0x02B3 成功响应:success,u8 flag,u16 targetChar。
func BuildReserveLeavePartyAck(flag byte, targetChar uint16) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeByte(flag)
writer.writeUint16(targetChar)
return writer.bytes()
}
// BuildEntryIntoPartyAck 构造 0x02C1 成功响应:success,u32 target,u32 currentChar。
func BuildEntryIntoPartyAck(targetID uint32, currentCharID uint32) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeUint32(targetID)
writer.writeUint32(currentCharID)
return writer.bytes()
}
// BuildEntryIntoPartyFinishEmptyBody constructs class0/op706 when the bridge
// has no peer-value rows to publish. Current EXE sub_1D56F60 always consumes
// state:u8 and count:u8 before reading count pairs of u32 key/u32 value.
func BuildEntryIntoPartyFinishEmptyBody(state byte) []byte {
return []byte{state, 0}
}
// BuildRequestPeerAck 构造 0x000A 的 class1 最小成功响应。
// 当前 EXE sub_2261E30 先消费 success:u8;success 非零时
// sub_1D11A80 立即返回,不再读取业务字段。
func BuildRequestPeerAck() []byte {
return []byte{1}
}
// BuildRequestPeerSelectionNotice constructs the class0/op7 mode-15 reply
// that completes a town-player click. Current EXE sub_1D82CF0 reads
// target:u16, mode:u8, value0:u32 and party_marker:u16. The marker is not an
// opaque echo: 0xffff clears the selected actor's party-present flag, while
// every other value sets it, before sub_325FA70 opens the interaction menu.
// The trailing value2:u32 from the class1/op10 request is not consumed by
// this mode and therefore must not be appended to the notification.
func BuildRequestPeerSelectionNotice(targetID uint16, value0 uint32, targetHasParty bool) []byte {
var writer packetWriter
writer.writeUint16(targetID)
writer.writeByte(15)
writer.writeUint32(value0)
if targetHasParty {
writer.writeUint16(0)
} else {
writer.writeUint16(^uint16(0))
}
return writer.bytes()
}
// PeerNoticeStats carries the op7 prompt rows the requesting client cannot
// supply, because the mode-10 request tail and the mode-10 reply tail do not
// describe the same fields.
//
// Measured 2026-09-26 on the live server. The request tail (u32 value0,
// u16 value1, u32 value2, shared by modes 0/10/12/15) self-reports the
// requester as (复活币, 抗魔值, 0): char 22 sent value0=10 and value1=2828 while
// its stored 复活币 was 10 and its stored fatigue was 156, and char 1 sent
// value0=430 and value1=5837 while its stored 复活币 was 430 and its stored
// fatigue was 188. 复活币 matches exactly and fatigue cannot, so value1 is
// 抗魔值 -- the one row this server has no other source for, since nothing in
// this tree computes 抗魔.
//
// The reply the client actually reads was pinned by a marker probe (seven u16
// markers 0x1111..0x7777 at offsets 7/9/11/13/15/17/19 of a mode-10 op7): the
// dialog rendered 4369/8738/13107 under 疲劳值/虚弱状态/抗魔值 and ignored every
// marker past offset 11, so the tail is exactly three u16 at 7, 9, 11 in that
// order. Feeding the request's value1 into offset 7 is what put 抗魔值 on the
// 疲劳值 row.
type PeerNoticeStats struct {
// Fatigue is the requester's remaining fatigue (character stat "fatigue").
Fatigue uint16
// Weakness is the requester's 虚弱状态 on the client's own 0..100 scale,
// where 100 is healthy (see currentWeaknessCondition).
Weakness uint16
// Exorcism is the requester's 抗魔值, echoed from the request's value1.
Exorcism uint16
}
// BuildRequestPeerNotice constructs current NoPack class0/op7 for an online
// peer request. sub_1D82CF0 consumes a mode-specific tail: ordinary party
// mode 0 has three u16 values and one u32 server value, trade mode 1 has two
// u32 values. Street-assault modes 4/13 use BuildAssaultInvite instead:
// the generic op7 dialog has no mode-4 content or accepting callback.
//
// Modes 0/12 share mode 10's three value rows (same prompt widget, same
// offsets); only their trailing u32 differs and stays the request's value2.
func BuildRequestPeerNotice(sourceID uint16, req RequestPeerRequest, stats PeerNoticeStats) []byte {
var writer packetWriter
writer.writeUint16(sourceID)
writer.writeByte(req.Mode)
writer.writeUint32(req.Value0)
switch req.Mode {
case 0, 12:
// Same three rows as mode 10. The ordinary party invite renders the
// same prompt widget, so feeding it the request's value1 put 抗魔值 on
// its 疲劳值 row exactly as it did in the raid prompt (operator report
// 2026-09-26: the party prompt showed the same wrong rows until this
// arm was changed too). The trailing u32 stays the request's value2 --
// it is a further field this arm has never been shown to compute.
writer.writeUint16(stats.Fatigue)
writer.writeUint16(stats.Weakness)
writer.writeUint16(stats.Exorcism)
writer.writeUint32(req.Value2)
case 1:
writer.writeUint32(req.Value2)
case 10:
writer.writeUint16(stats.Fatigue)
writer.writeUint16(stats.Weakness)
writer.writeUint16(stats.Exorcism)
}
return writer.bytes()
}
// BuildResponsePeerAckPayload is appended after the class1/op11 success byte.
// Current sub_1D12480 always consumes target:u16 and mode:u8; trade mode 1
// consumes one additional state byte before entering its local trade path.
func BuildResponsePeerAckPayload(targetID uint16, mode byte) []byte {
var writer packetWriter
writer.writeUint16(targetID)
writer.writeByte(mode)
if mode == 1 {
writer.writeByte(0)
}
return writer.bytes()
}
// BuildResponsePeerNotice constructs class0/op8 for the original requester.
// Current sub_1D83250 consumes responder:u16, mode:u8 and value:u32.
func BuildResponsePeerNotice(responderID uint16, mode byte, value uint32) []byte {
var writer packetWriter
writer.writeUint16(responderID)
writer.writeByte(mode)
writer.writeUint32(value)
return writer.bytes()
}
// BuildAssaultInvite constructs class0/op443 (invite) and op444 (waiting).
// Current sub_33C8E20/sub_33C8D00 read actor:u16 and mode:u8.
func BuildAssaultInvite(inviterID uint16, mode byte) []byte {
var writer packetWriter
writer.writeUint16(inviterID)
writer.writeByte(mode)
return writer.bytes()
}
// BuildWalkoutPartyMemberNotice 构造 0x000A 队伍踢人/返回城镇通知:u8 slot,u8 mode。
func BuildWalkoutPartyMemberNotice(slot byte, mode byte) []byte {
var writer packetWriter
writer.writeByte(slot)
writer.writeByte(mode)
return writer.bytes()
}
// BuildChangePartyMemberPositionAck constructs the exact current-EXE op335
// result body: success followed by the authoritative source and target slots.
func BuildChangePartyMemberPositionAck(fromSlot byte, toSlot byte) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeByte(fromSlot)
writer.writeByte(toSlot)
return writer.bytes()
}
func partyMembers(state alignedcmd.PartyState) []alignedcmd.PartyMemberState {
members := make([]alignedcmd.PartyMemberState, 0, 4)
for _, member := range state.Members {
if member.UserID == 0 {
continue
}
members = append(members, member)
if len(members) == 4 {
break
}
}
if len(members) == 0 && state.UserID != 0 {
members = append(members, alignedcmd.PartyMemberState{
UserID: state.UserID,
UserState: state.UserState,
HPPercent: fullPartyMemberPercent,
MPPercent: fullPartyMemberPercent,
})
}
return members
}
func memberUserState(member alignedcmd.PartyMemberState, fallback byte) byte {
if member.UserState != 0 {
return member.UserState
}
if fallback != 0 {
return fallback
}
return defaultTownUserState
}
func memberPercentByte(value byte) byte {
if value == 0 {
return fullPartyMemberPercent
}
if value > fullPartyMemberPercent {
return fullPartyMemberPercent
}
return value
}