服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
176 lines
6.4 KiB
Go
176 lines
6.4 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
|
)
|
|
|
|
var errCurrentDungeonCardPacketShape = errors.New("current dungeon card packet shape is invalid")
|
|
|
|
type currentDungeonOp71Request struct {
|
|
ValueA byte
|
|
ValueB byte
|
|
ValueCount byte
|
|
ScreenSlot byte
|
|
HasScreenSlot bool
|
|
}
|
|
|
|
type currentDungeonOp72Request struct {
|
|
ValueA byte
|
|
ValueB byte
|
|
}
|
|
|
|
func decodeCurrentDungeonOp71Request(body []byte) (currentDungeonOp71Request, error) {
|
|
// Current class1/op71 writers (sub_32B1410/sub_32B14F0 and the direct path in
|
|
// sub_1D00F60) emit one or two semantic bytes. Live unpatched clients
|
|
// deliver an opaque protected 8-byte block whose first bytes are not the
|
|
// selector; the selective plaintext clone delivers the semantic bytes plus
|
|
// the private four-byte trailer, stripped at the exact 6-byte boundary by
|
|
// normalizeLegacyGameBody. Accept any body of at least one byte and read
|
|
// only the proved prefix instead of rejecting the trailing bytes, mirroring
|
|
// the op72 relaxation.
|
|
if len(body) < 1 {
|
|
return currentDungeonOp71Request{}, errCurrentDungeonCardPacketShape
|
|
}
|
|
request := currentDungeonOp71Request{ValueA: body[0], ValueCount: 1}
|
|
if len(body) >= 2 {
|
|
request.ValueB = body[1]
|
|
request.ValueCount = 2
|
|
// Current compatibility DLL tags the otherwise unused high bits of
|
|
// the member byte with the clicked screen slot. Keep the native size.
|
|
if body[1]&0x80 != 0 {
|
|
request.HasScreenSlot = true
|
|
request.ScreenSlot = (body[1] >> 4) & 7
|
|
request.ValueB = body[1] & 15
|
|
if request.ScreenSlot >= dungeonCardSlotsPerSide || request.ValueB >= dungeonCardSlotsPerSide {
|
|
return currentDungeonOp71Request{}, errCurrentDungeonCardPacketShape
|
|
}
|
|
}
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func decodeCurrentDungeonOp72Request(body []byte) (currentDungeonOp72Request, error) {
|
|
// Current class1/op72 (sub_1CF68F0) reads exactly two u8s — state then
|
|
// option. Live unpatched clients deliver an opaque protected 16-byte body
|
|
// whose first bytes are ciphertext, so a state/option read from it is
|
|
// meaningless (the ACK echo is ignored by the client and no action is
|
|
// taken); the selective plaintext clone instead delivers the two semantic
|
|
// bytes plus a private four-byte trailer, which normalizeLegacyGameBody
|
|
// strips at the exact 6-byte boundary. Accept any body of at least two
|
|
// bytes and read only the proved prefix, like the reference handler,
|
|
// instead of rejecting the trailing bytes.
|
|
if len(body) < 2 {
|
|
return currentDungeonOp72Request{}, errCurrentDungeonCardPacketShape
|
|
}
|
|
return currentDungeonOp72Request{ValueA: body[0], ValueB: body[1]}, nil
|
|
}
|
|
|
|
func validateCurrentDungeonBodylessRequest(body []byte) error {
|
|
if len(body) != 0 {
|
|
return errCurrentDungeonCardPacketShape
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildCurrentDungeonOp69SuccessBody is only the common success byte. The
|
|
// current class1/op69 handler sub_1CFE660 reads no command payload before it
|
|
// advances the result UI or emits bodyless op70.
|
|
func buildCurrentDungeonOp69SuccessBody() []byte {
|
|
return []byte{1}
|
|
}
|
|
|
|
// buildCurrentDungeonOp70CommonOnlySuccessBody matches the conditional branch
|
|
// in sub_1D00F60 that consumes no command payload. The condition selecting
|
|
// this branch has not yet been mapped to a safe server-side runtime predicate.
|
|
func buildCurrentDungeonOp70CommonOnlySuccessBody() []byte {
|
|
return []byte{1}
|
|
}
|
|
|
|
// buildCurrentDungeonOp70EightValueSuccessBody matches the other proven
|
|
// sub_1D00F60 branch: common success followed by exactly eight opaque u16s.
|
|
func buildCurrentDungeonOp70EightValueSuccessBody(values [dungeonCardWireSlotCount]uint16) []byte {
|
|
body := make([]byte, 1+dungeonCardWireSlotCount*2)
|
|
body[0] = 1
|
|
for index, value := range values {
|
|
binary.LittleEndian.PutUint16(body[1+index*2:1+(index+1)*2], value)
|
|
}
|
|
return body
|
|
}
|
|
|
|
type currentDungeonOp71RewardTuple struct {
|
|
ValueA uint32
|
|
ValueB uint32
|
|
}
|
|
|
|
type currentDungeonOp71Slot struct {
|
|
StateA byte
|
|
StateB byte
|
|
Rewards []currentDungeonOp71RewardTuple
|
|
TerminalFlag byte
|
|
}
|
|
|
|
func buildCurrentDungeonOp71SuccessBody(slots [dungeonCardWireSlotCount]currentDungeonOp71Slot) ([]byte, error) {
|
|
size := 1
|
|
for _, slot := range slots {
|
|
if len(slot.Rewards) > 255 {
|
|
return nil, errCurrentDungeonCardPacketShape
|
|
}
|
|
size += 3 + len(slot.Rewards)*8
|
|
}
|
|
body := make([]byte, 0, size)
|
|
body = append(body, 1)
|
|
for _, slot := range slots {
|
|
body = append(body, slot.StateA, slot.StateB, byte(len(slot.Rewards)))
|
|
for _, reward := range slot.Rewards {
|
|
var raw [8]byte
|
|
binary.LittleEndian.PutUint32(raw[0:4], reward.ValueA)
|
|
binary.LittleEndian.PutUint32(raw[4:8], reward.ValueB)
|
|
body = append(body, raw[:]...)
|
|
}
|
|
body = append(body, slot.TerminalFlag)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func buildCurrentDungeonOp72SuccessBody(valueA byte, valueB byte) []byte {
|
|
return []byte{1, valueA, valueB}
|
|
}
|
|
|
|
// normalCurrentDungeonRetryRequest makes the settlement dialog's server-driven
|
|
// re-entry an ordinary run. Practice and Hell Party are one-shot selector
|
|
// choices: only a fresh client-authored op16 may enable either mode. Difficulty,
|
|
// dungeon, party and the remaining opaque request fields are retained.
|
|
func normalCurrentDungeonRetryRequest(request dungeoncmd.SelectDungeonRequest) dungeoncmd.SelectDungeonRequest {
|
|
request.SelectionMode = 0
|
|
request.RuntimeState = byte(currentDungeonProtocolModeNormal)
|
|
request.RuntimeToken = 0xffff
|
|
request.SpecialMode = 0
|
|
return request
|
|
}
|
|
|
|
// encodeCurrentDungeonSelectRequest re-encodes the exact 21-byte op16 request
|
|
// layout parsed by dungeoncmd.DecodeSelectDungeonRequest. The settlement retry
|
|
// owner passes a normalized, policy-owned request through the standard entry
|
|
// flow rather than leaking a previous run's one-shot selector modes.
|
|
func encodeCurrentDungeonSelectRequest(request dungeoncmd.SelectDungeonRequest) []byte {
|
|
body := make([]byte, dungeoncmd.SelectDungeonRequestSize)
|
|
binary.LittleEndian.PutUint32(body[0:4], request.DungeonID)
|
|
body[4] = request.Difficulty
|
|
binary.LittleEndian.PutUint16(body[5:7], request.EntryOption)
|
|
body[7] = request.SelectionMode
|
|
body[8] = request.RuntimeState
|
|
binary.LittleEndian.PutUint16(body[9:11], request.RuntimeToken)
|
|
binary.LittleEndian.PutUint32(body[11:15], request.Reserved)
|
|
body[15] = request.PartyState
|
|
binary.LittleEndian.PutUint32(body[16:20], request.LeaderObjectKey)
|
|
body[20] = request.SpecialMode
|
|
return body
|
|
}
|
|
|
|
func buildCurrentDungeonOp72FailureBody(detail byte) []byte {
|
|
return []byte{0, detail}
|
|
}
|