服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
461 lines
15 KiB
Go
461 lines
15 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
currentSceneOp9ActorDisplayKind byte = 0
|
|
currentSceneOp9ActorRemoveKind byte = 3
|
|
currentSceneOp9StableSceneValue uint16 = 9999
|
|
)
|
|
|
|
func buildCurrentSceneOp9NoopBody() []byte {
|
|
var writer packetWriter
|
|
writer.writeUint16(0)
|
|
writer.writeUint16(currentSceneOp9StableSceneValue)
|
|
return writer.bytes()
|
|
}
|
|
|
|
func buildCurrentSceneOp9ActorRemovalBody(sceneObjectKey uint16) []byte {
|
|
return buildCurrentSceneOp9ActorRemovalBodyInContext(
|
|
sceneObjectKey,
|
|
currentSceneObjectContext,
|
|
)
|
|
}
|
|
|
|
func buildCurrentSceneOp9ActorRemovalBodyInContext(
|
|
sceneObjectKey uint16,
|
|
ownerChannel byte,
|
|
) []byte {
|
|
var writer packetWriter
|
|
writer.writeUint16(1)
|
|
writer.writeUint16(currentSceneOp9StableSceneValue)
|
|
writer.writeUint16(sceneObjectKey)
|
|
writer.writeByte(0)
|
|
writer.writeByte(ownerChannel)
|
|
writer.writeByte(0)
|
|
writer.writeByte(currentSceneOp9ActorRemoveKind)
|
|
return writer.bytes()
|
|
}
|
|
|
|
func (s *Service) sendCurrentSceneOp9PreviewActorRemovalOnce(session *gameSession, source string) error {
|
|
if session == nil || !session.selectPreviewObjectStateSent || session.selectPreviewActorRemoved {
|
|
return nil
|
|
}
|
|
objectKey := currentSceneActorObjectKey(session.selectedCharacterID)
|
|
if objectKey == 0 {
|
|
return nil
|
|
}
|
|
body := buildCurrentSceneOp9ActorRemovalBodyInContext(
|
|
objectKey,
|
|
currentSceneObjectContext,
|
|
)
|
|
s.logGameEvent(session, "game-upper-current-scene-op9-preview-actor-remove-send",
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"object_key", objectKey,
|
|
"msg_id", uint16(dnfenum.CmdPacketRecoverStamina),
|
|
"classification", 0,
|
|
"record_kind", currentSceneOp9ActorRemoveKind,
|
|
"body_len", len(body),
|
|
"body_source", "current_exe_sub_1D64CA0_kind3_clear_slots_and_remove_target",
|
|
"reason", "evict_transition_preview_before_final_actor_mode0_mode1_bind")
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketRecoverStamina), body, 0); err != nil {
|
|
return err
|
|
}
|
|
session.selectPreviewActorRemoved = true
|
|
session.currentSceneObjectListSent = false
|
|
return nil
|
|
}
|
|
|
|
func buildCurrentSceneOp9ActorDisplayBody(sceneObjectKey uint16, character dnfrepo.CharacterRecord, hasCharacter bool, fallbackName string) []byte {
|
|
return buildCurrentSceneOp9ActorDisplayBodyInContext(sceneObjectKey, character, hasCharacter, fallbackName, currentSceneObjectContext)
|
|
}
|
|
|
|
func buildCurrentSceneOp9ActorDisplayBodyInContext(sceneObjectKey uint16, character dnfrepo.CharacterRecord, hasCharacter bool, fallbackName string, ownerChannel byte) []byte {
|
|
return buildCurrentSceneOp9ActorPartyDisplayBodyInContext(
|
|
sceneObjectKey,
|
|
character,
|
|
hasCharacter,
|
|
fallbackName,
|
|
ownerChannel,
|
|
ownerChannel,
|
|
nil,
|
|
0,
|
|
)
|
|
}
|
|
|
|
type currentSceneOp9PartyMemberProjection struct {
|
|
State alignedcmd.PartyMemberState
|
|
Name string
|
|
Job byte
|
|
Level byte
|
|
Grow byte
|
|
}
|
|
|
|
func buildCurrentSceneOp9ActorPartyDisplayBodyInContext(
|
|
sceneObjectKey uint16,
|
|
character dnfrepo.CharacterRecord,
|
|
hasCharacter bool,
|
|
fallbackName string,
|
|
ownerChannel byte,
|
|
localChannel byte,
|
|
members []currentSceneOp9PartyMemberProjection,
|
|
selectedMemberSlot byte,
|
|
) []byte {
|
|
if sceneObjectKey == 0 {
|
|
sceneObjectKey = currentSceneBootstrapObjectKey
|
|
}
|
|
name := fallbackName
|
|
if name == "" && hasCharacter {
|
|
name = character.Name
|
|
}
|
|
level := byte(1)
|
|
job := byte(0)
|
|
grow := byte(0)
|
|
if hasCharacter {
|
|
if character.Level > 0 && character.Level < 256 {
|
|
level = byte(character.Level)
|
|
}
|
|
job = byte(numericCharacterStat(character.Job))
|
|
grow = byte(numericCharacterStatValue(character, "grow_type"))
|
|
}
|
|
|
|
var writer packetWriter
|
|
writer.writeUint16(1)
|
|
writer.writeUint16(currentSceneOp9StableSceneValue)
|
|
|
|
writer.writeUint16(sceneObjectKey)
|
|
writer.writeByte(0)
|
|
writer.writeByte(ownerChannel)
|
|
writer.writeByte(0)
|
|
writer.writeByte(currentSceneOp9ActorDisplayKind)
|
|
|
|
writer.writeByte(currentSceneObjectRoute)
|
|
// sub_1D64CA0 uses this nested byte as the inline-identity discriminator,
|
|
// not as the actor-owner key. Zero is required for the following DSTR name
|
|
// branch; the real actor owner is already carried by the outer record.
|
|
writer.writeByte(currentSceneObjectContext)
|
|
writer.writeRawDstr(rosterNameBytes(name))
|
|
writer.writeByte(job)
|
|
writer.writeByte(level)
|
|
writer.writeUint32(0)
|
|
writer.writeByte(grow)
|
|
writer.writeUint16(0)
|
|
writer.writeByte(0)
|
|
writer.writeByte(0)
|
|
writer.writeUint16(0)
|
|
|
|
members = currentSceneOp9PartyMembers(members)
|
|
writer.writeByte(byte(len(members)))
|
|
for slot, member := range members {
|
|
writer.writeByte(byte(slot))
|
|
writer.writeUint16(member.State.UserID)
|
|
if ownerChannel != localChannel {
|
|
writer.writeByte(member.Job)
|
|
writer.writeByte(member.Level)
|
|
writer.writeByte(member.Grow)
|
|
writer.writeByte(0)
|
|
writer.writeRawDstr(rosterNameBytes(member.Name))
|
|
}
|
|
writer.writeByte(0)
|
|
writer.writeByte(currentSceneOp9PartyMemberState(member.State.UserState))
|
|
writer.writeByte(0)
|
|
}
|
|
writer.writeByte(0)
|
|
writer.writeByte(selectedMemberSlot)
|
|
writer.writeByte(0)
|
|
writer.writeByte(0)
|
|
return writer.bytes()
|
|
}
|
|
|
|
// buildCurrentSceneOp9PartyInformationBodyInContext constructs the complete
|
|
// same-version kind-0 party aggregate consumed by NoPack sub_1D64CA0. The
|
|
// outer key is the stable party model ID shared by every observer, not a
|
|
// selected character's actor key. The byte after the member table is the
|
|
// authoritative leader slot; it must be identical on every member client.
|
|
func buildCurrentSceneOp9PartyInformationBodyInContext(
|
|
partyModelKey uint16,
|
|
ownerServer byte,
|
|
ownerChannel byte,
|
|
state alignedcmd.PartyState,
|
|
members []currentSceneOp9PartyMemberProjection,
|
|
leaderSlot byte,
|
|
) []byte {
|
|
var writer packetWriter
|
|
writer.writeUint16(1)
|
|
writer.writeUint16(currentSceneOp9StableSceneValue)
|
|
writer.writeUint16(partyModelKey)
|
|
writer.writeByte(0)
|
|
writer.writeByte(ownerChannel)
|
|
writer.writeByte(0)
|
|
writer.writeByte(currentSceneOp9ActorDisplayKind)
|
|
|
|
writer.writeByte(ownerServer)
|
|
writer.writeByte(state.RequestPrefix1)
|
|
if state.RequestPrefix1 == 0 {
|
|
writer.writeRawDstr(state.NameBytes)
|
|
}
|
|
writer.writeByte(ownerChannel)
|
|
maxMembers := state.MaxMembers
|
|
if maxMembers == 0 || maxMembers > 4 {
|
|
maxMembers = 4
|
|
}
|
|
writer.writeByte(maxMembers)
|
|
writer.writeUint32(state.SelectionID)
|
|
writer.writeByte(state.SelectionCode)
|
|
writer.writeUint16(state.SelectionValue)
|
|
writer.writeByte(state.RecruitFlag)
|
|
writer.writeByte(state.TargetMode)
|
|
writer.writeUint16(state.TargetDungeonID)
|
|
|
|
members = currentSceneOp9PartyMembers(members)
|
|
writer.writeByte(byte(len(members)))
|
|
for _, member := range members {
|
|
writer.writeByte(member.State.Slot)
|
|
writer.writeUint16(member.State.UserID)
|
|
writer.writeByte(0)
|
|
writer.writeByte(currentSceneOp9PartyMemberState(member.State.UserState))
|
|
writer.writeByte(0)
|
|
}
|
|
writer.writeByte(0)
|
|
writer.writeByte(leaderSlot)
|
|
writer.writeByte(0)
|
|
writer.writeByte(0)
|
|
return writer.bytes()
|
|
}
|
|
|
|
// buildCurrentSceneOp9PartyMembershipBodyInContext constructs the complete
|
|
// kind-2/kind-6 fixed-slot replacement used after leave, kick and area detach.
|
|
// Unlike kind 0 it carries no recruitment metadata.
|
|
func buildCurrentSceneOp9PartyMembershipBodyInContext(
|
|
partyModelKey uint16,
|
|
ownerChannel byte,
|
|
members []alignedcmd.PartyMemberState,
|
|
leaderSlot byte,
|
|
kind byte,
|
|
) []byte {
|
|
if kind != 2 && kind != 6 {
|
|
return nil
|
|
}
|
|
var writer packetWriter
|
|
writer.writeUint16(1)
|
|
writer.writeUint16(currentSceneOp9StableSceneValue)
|
|
writer.writeUint16(partyModelKey)
|
|
writer.writeByte(0)
|
|
writer.writeByte(ownerChannel)
|
|
writer.writeByte(0)
|
|
writer.writeByte(kind)
|
|
writer.writeByte(byte(len(members)))
|
|
for _, member := range members {
|
|
writer.writeByte(member.Slot)
|
|
writer.writeUint16(member.UserID)
|
|
writer.writeByte(0)
|
|
writer.writeByte(currentSceneOp9PartyMemberState(member.UserState))
|
|
writer.writeByte(0)
|
|
}
|
|
writer.writeByte(0)
|
|
writer.writeByte(leaderSlot)
|
|
writer.writeByte(0)
|
|
writer.writeByte(0)
|
|
return writer.bytes()
|
|
}
|
|
|
|
func currentSceneOp9SelectedPartySlot(members []currentSceneOp9PartyMemberProjection, selectedMemberID uint16) (byte, bool) {
|
|
for _, member := range members {
|
|
if member.State.UserID == selectedMemberID {
|
|
return member.State.Slot, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func currentSceneOp9PartyMembers(members []currentSceneOp9PartyMemberProjection) []currentSceneOp9PartyMemberProjection {
|
|
const maxCurrentScenePartySlots = 8
|
|
capacity := len(members)
|
|
if capacity > maxCurrentScenePartySlots {
|
|
capacity = maxCurrentScenePartySlots
|
|
}
|
|
filtered := make([]currentSceneOp9PartyMemberProjection, 0, capacity)
|
|
for _, member := range members {
|
|
if member.State.UserID == 0 {
|
|
continue
|
|
}
|
|
duplicate := false
|
|
for _, existing := range filtered {
|
|
if existing.State.UserID == member.State.UserID {
|
|
duplicate = true
|
|
break
|
|
}
|
|
}
|
|
if duplicate {
|
|
continue
|
|
}
|
|
filtered = append(filtered, member)
|
|
if len(filtered) == maxCurrentScenePartySlots {
|
|
break
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func currentSceneOp9PartyMemberState(value byte) byte {
|
|
if value == 0 || value == 1 {
|
|
return 100
|
|
}
|
|
return value
|
|
}
|
|
|
|
// currentSceneOp9PartySlotRow is one explicit-slot member row of a dungeon-room
|
|
// kind-0 record. The town display body numbers its rows densely by list
|
|
// position; a room record must instead carry every member's real fixed slot,
|
|
// because sub_269FB90 (IsLocalPlayerHost) compares the LOCAL actor's index in
|
|
// sceneActorManager+0x48 against the host slot at +0x68. A dense index would
|
|
// make every member land on slot 0 and therefore claim to be the host.
|
|
type currentSceneOp9PartySlotRow struct {
|
|
Slot byte
|
|
Member currentSceneOp9PartyMemberProjection
|
|
}
|
|
|
|
// currentSceneOp9PartyMemberSlotPresent is the third trailing byte of a member
|
|
// row. sub_1D64CA0 stores it into an eight-byte stack array that is fully zero
|
|
// initialised, and after the row loop it walks all eight slots and calls
|
|
// sub_2FB5E50(slot, value == 0 ? 1 : 0), writing sceneRoot[0x20A09C + slot*4].
|
|
// A reader at 0x02FB67CE skips any slot whose entry is non-zero, so a row must
|
|
// send a NON-ZERO byte here to be counted: zero inverts to 1 and the slot is
|
|
// dropped, making real members indistinguishable from untouched empty slots.
|
|
// (Reverse-engineered on the old tree's party-dungeon-sync branch, 2026-08-19.)
|
|
const currentSceneOp9PartyMemberSlotPresent byte = 1
|
|
|
|
// currentSceneOp9PartyMemberCap resolves the member cap the party window shows
|
|
// as the denominator of its "x / y" label. sub_1D64CA0 stores the byte whole
|
|
// into party_info+0x4c and 0x00A07A87 reads that dword when formatting the
|
|
// label; the numerator is never a packet field.
|
|
func currentSceneOp9PartyMemberCap(state alignedcmd.PartyState) byte {
|
|
const maxCurrentScenePartySlots = 8
|
|
memberCap := state.MaxMembers
|
|
if memberCap == 0 {
|
|
return 0
|
|
}
|
|
if memberCap > maxCurrentScenePartySlots {
|
|
memberCap = maxCurrentScenePartySlots
|
|
}
|
|
return memberCap
|
|
}
|
|
|
|
// currentSceneOp9PartyTypeAux carries the party type the list window
|
|
// categorises by (party_info+0x62). The current client reads op12's own order
|
|
// of normal, story, loop; anything above 2 means the create window never chose
|
|
// a type.
|
|
func currentSceneOp9PartyTypeAux(state alignedcmd.PartyState) byte {
|
|
const maxPartyType = 2
|
|
if state.TargetMode > maxPartyType {
|
|
return 0
|
|
}
|
|
return state.TargetMode
|
|
}
|
|
|
|
// buildCurrentSceneOp9DungeonRoomSlotTableBodyInContext constructs the kind-0
|
|
// record a party member receives inside its initial dungeon room.
|
|
//
|
|
// The current EXE arbitrates who simulates the room from this record.
|
|
// sub_1D64CA0 first calls the scene actor manager's reset (0x01D66275),
|
|
// clearing the whole +0x48 slot table, then repopulates it through the by-id
|
|
// writer sub_26A2BB0 (its only caller, 0x01D66620) for all eight slots and sets
|
|
// the host slot through set_host (0x01D666AF, validated `slot <= 7`).
|
|
// sub_269FB90 - the predicate 46 combat, AI and UI call sites use - then
|
|
// answers "am I the host" by locating the LOCAL actor inside that slot table and
|
|
// comparing its index against the host slot. A member whose actor is absent
|
|
// scores -1 and answers false, so an empty table makes *every* member a
|
|
// non-host: nobody runs the authoritative simulation, and everything only the
|
|
// controller publishes over P2P - including the packed level/attributes of a
|
|
// party buff - is never sent.
|
|
//
|
|
// Two shape rules were paid for with live client faults on the old tree:
|
|
// - every uid named here must resolve to an actor already created in the
|
|
// receiver's room (mode0/mode1 first, slot table after), otherwise
|
|
// sub_1D64CA0 dereferences a missing actor and walks off its stack buffer;
|
|
// - a record whose owner context is the dungeon's 0 is parsed with the
|
|
// EXTENDED member branch (job, level, grow, flag, name DSTR before the
|
|
// three state bytes). The short town row makes sub_34E3AC0 read a garbage
|
|
// DSTR length and memcpy past the stack.
|
|
//
|
|
// The record actor is the receiver's own; rows carry each member's real fixed
|
|
// slot; the tail's second byte is the leader slot and is identical on every
|
|
// member client, which is what makes exactly one of them the host.
|
|
func buildCurrentSceneOp9DungeonRoomSlotTableBodyInContext(
|
|
sceneObjectKey uint16,
|
|
character dnfrepo.CharacterRecord,
|
|
hasCharacter bool,
|
|
fallbackName string,
|
|
ownerChannel byte,
|
|
rows []currentSceneOp9PartySlotRow,
|
|
leaderSlot byte,
|
|
maxMembers byte,
|
|
partyType byte,
|
|
) []byte {
|
|
if sceneObjectKey == 0 {
|
|
sceneObjectKey = currentSceneBootstrapObjectKey
|
|
}
|
|
name := fallbackName
|
|
if name == "" && hasCharacter {
|
|
name = character.Name
|
|
}
|
|
job := byte(0)
|
|
grow := byte(0)
|
|
if hasCharacter {
|
|
job = byte(numericCharacterStat(character.Job))
|
|
grow = byte(numericCharacterStatValue(character, "grow_type"))
|
|
}
|
|
|
|
var writer packetWriter
|
|
writer.writeUint16(1)
|
|
writer.writeUint16(currentSceneOp9StableSceneValue)
|
|
|
|
writer.writeUint16(sceneObjectKey)
|
|
writer.writeByte(0)
|
|
writer.writeByte(ownerChannel)
|
|
writer.writeByte(0)
|
|
writer.writeByte(currentSceneOp9ActorDisplayKind)
|
|
|
|
writer.writeByte(currentSceneObjectRoute)
|
|
// sub_1D64CA0 reads this byte into party_info+0x04 and uses zero to select
|
|
// the inline-identity name branch; the real actor owner is already carried
|
|
// by the outer record.
|
|
writer.writeByte(currentSceneObjectContext)
|
|
writer.writeRawDstr(rosterNameBytes(name))
|
|
writer.writeByte(job)
|
|
writer.writeByte(maxMembers)
|
|
writer.writeUint32(0)
|
|
writer.writeByte(grow)
|
|
writer.writeUint16(0)
|
|
writer.writeByte(0)
|
|
writer.writeByte(partyType)
|
|
writer.writeUint16(0)
|
|
|
|
writer.writeByte(byte(len(rows)))
|
|
for _, row := range rows {
|
|
member := row.Member
|
|
writer.writeByte(row.Slot)
|
|
writer.writeUint16(member.State.UserID)
|
|
writer.writeByte(member.Job)
|
|
writer.writeByte(member.Level)
|
|
writer.writeByte(member.Grow)
|
|
writer.writeByte(0)
|
|
writer.writeRawDstr(rosterNameBytes(member.Name))
|
|
writer.writeByte(0)
|
|
writer.writeByte(currentSceneOp9PartyMemberState(member.State.UserState))
|
|
writer.writeByte(currentSceneOp9PartyMemberSlotPresent)
|
|
}
|
|
// GameWorld::send_party_info_to_all closes the roster with
|
|
// IsReturnUserParty, the leader slot, is_quick_party and IsEventCharacParty.
|
|
writer.writeByte(0)
|
|
writer.writeByte(leaderSlot)
|
|
writer.writeByte(0)
|
|
writer.writeByte(0)
|
|
return writer.bytes()
|
|
}
|