服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
365 lines
16 KiB
Go
365 lines
16 KiB
Go
package dnfbridge
|
|
|
|
// C2S 695 `ENUM_CMDPACKET_RAID_MONSTER_HP` is the client's 战况表 entry point.
|
|
//
|
|
// Live 2026-09-17 the current EXE sent it twice within 25-31 ms of the only two
|
|
// client faults recorded for this build, both after clearing a hatchery gate
|
|
// (221-224) and returning to town:
|
|
//
|
|
// 03:49:22.291Z conn=game-000567 type=695 body_len=5 -> 11:49:22.316 local
|
|
// 08:52:12.668Z conn=game-000178 type=695 body_len=5 -> 16:52:12.699 local
|
|
// 90CN_trace.log: exception 0xC0000005 eip=0x01F95C63 module=DNF.exe
|
|
// offset=0x01B95C63 access_address=0x00000B98 ecx=0x00000000 (this=null)
|
|
//
|
|
// Those are the same fault address twice, and over the whole log every other
|
|
// raid return to town (0 of 126) left the process alive. The server had no
|
|
// handler at all: `dnfenum.CmdPacketRaidMonsterHp` was referenced only by its
|
|
// own name table, so the request was dropped silently and the client rendered
|
|
// the window from whatever it already had.
|
|
//
|
|
// The 86JP reference serves the same request in
|
|
// `RaidHandler.Situation.cs:137-192`: count==0 means "the window just opened (or
|
|
// is refreshing), resend the situation", count>=1 reports the runtime monster
|
|
// values of the sender's own gate. This file implements that handshake with the
|
|
// bodies this client already uses; it never invents a byte layout and never
|
|
// replays the one-shot op587 state=2 announcement.
|
|
//
|
|
// This is deliberately a pure addition: the opcode was unhandled before, so no
|
|
// existing packet is removed, reordered or altered by serving it.
|
|
|
|
import "fmt"
|
|
|
|
// The current EXE prefixes the value list with a dword and puts the count at +4,
|
|
// not +0:
|
|
//
|
|
// body_len=5 -> 00000000 00 -> count 0
|
|
// body_len=9 -> 00000000 01 64000000 -> count 1, value 100
|
|
// body_len=21 -> 00000000 04 64000000 x4 -> count 4
|
|
//
|
|
// The 86JP reader (`TryReadRaidMonsterRuntimeValues`) starts at body[0], which
|
|
// would read every one of these as count 0 and misclassify every report as a
|
|
// window refresh, so its offset is not copied.
|
|
//
|
|
// The leading dword is NOT reserved padding. tools/_probe_634_report.md §6c
|
|
// traces the client's own 695 sender: 0x00F17A5E-0x00F17A65 feeds the SAME value
|
|
// both into 0x17C2B80 (the per-dungeon record lookup) and into this body
|
|
// (0x00F17A7B). It is therefore the client's record-set key inside its own
|
|
// (dungeon -> key -> records) map, and 634 must echo it back unchanged: an
|
|
// unknown key makes the client create a default record that persists and then
|
|
// re-enters its own later 695 reports.
|
|
const (
|
|
raidSituationKeyBytes = 4
|
|
raidSituationMaxValues = 5
|
|
)
|
|
|
|
// decodeRaidSituationRequest splits C2S 695 into its record-set key and value
|
|
// list. A shape the current EXE has never produced is reported as invalid
|
|
// instead of guessed at.
|
|
func decodeRaidSituationRequest(body []byte) (recordSetKey uint32, values []uint32, err error) {
|
|
if len(body) < raidSituationKeyBytes+1 {
|
|
return 0, nil, fmt.Errorf("body too short for the leading key: len=%d", len(body))
|
|
}
|
|
count := int(body[raidSituationKeyBytes])
|
|
if count > raidSituationMaxValues {
|
|
return 0, nil, fmt.Errorf("value count out of range: count=%d", count)
|
|
}
|
|
if len(body) != raidSituationKeyBytes+1+count*4 {
|
|
return 0, nil, fmt.Errorf("body length does not match the value count: len=%d count=%d", len(body), count)
|
|
}
|
|
recordSetKey = uint32(body[0]) | uint32(body[1])<<8 | uint32(body[2])<<16 | uint32(body[3])<<24
|
|
values = make([]uint32, 0, count)
|
|
for index := 0; index < count; index++ {
|
|
offset := raidSituationKeyBytes + 1 + index*4
|
|
values = append(values, uint32(body[offset])|uint32(body[offset+1])<<8|
|
|
uint32(body[offset+2])<<16|uint32(body[offset+3])<<24)
|
|
}
|
|
return recordSetKey, values, nil
|
|
}
|
|
|
|
// handleOnlineRaidSituationMonsterHp serves C2S 695 while the session is on a
|
|
// raid channel.
|
|
//
|
|
// A malformed body is logged and swallowed: the 86JP handler answers neither, so
|
|
// returning an error here would only tear down the connection's read loop for a
|
|
// packet the client sends on a timer.
|
|
func (s *Service) handleOnlineRaidSituationMonsterHp(session *gameSession, typ uint16, body []byte) (bool, error) {
|
|
if s == nil || session == nil {
|
|
return false, nil
|
|
}
|
|
recordSetKey, values, err := decodeRaidSituationRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-raid-situation-monster-hp-body-invalid",
|
|
"type", typ, "body_len", len(body), "error", err)
|
|
return true, nil
|
|
}
|
|
if len(values) != 0 {
|
|
// count>=1 is the sender's own gate reporting runtime values. 86JP records
|
|
// them per (raid, squad, dungeon) and then broadcasts its 0x027B
|
|
// (RaidHandler.cs:82 `_raidMonsterRuntimeValues`, built at Situation.cs:433-456);
|
|
// this is the same handshake on this client's 0x027A. Nothing here may fail
|
|
// the caller: the request is telemetry from the client's HUD.
|
|
key, recorded := s.recordRuntimeRaidMonsterRuntimeValues(session, recordSetKey, values)
|
|
fields := []any{
|
|
"type", typ,
|
|
"char_id", session.selectedCharacterID,
|
|
"record_set_key", recordSetKey,
|
|
"values", fmt.Sprintf("%v", values),
|
|
"value_count", len(values),
|
|
"recorded", recorded,
|
|
"raid_object_replay", false,
|
|
}
|
|
if recorded {
|
|
fields = append(fields,
|
|
"raid_key", key.RaidKey,
|
|
"raid_group", key.Group,
|
|
"raid_dungeon", key.DungeonID,
|
|
"raid_solo_char_id", key.SoloCharID)
|
|
} else {
|
|
// A report that cannot be attributed to a started raid gate is dropped
|
|
// like 86JP's non-participant branch (Situation.cs:233-236): the client
|
|
// polls this opcode on a timer, so it also arrives outside a raid.
|
|
fields = append(fields, "reason", "not_inside_an_attributable_raid_gate")
|
|
}
|
|
// A recorded report changes what every squad's row should draw, so the whole
|
|
// raid is refreshed -- the client's row table is global to the raid, not per
|
|
// squad (_probe_634_report.md section 6c Q1).
|
|
if recorded {
|
|
if state, ok := s.runtimeRaidForMember(session.selectedCharacterID); ok {
|
|
if broadcastErr := s.broadcastRuntimeRaidMonsterState(state); broadcastErr != nil {
|
|
fields = append(fields, "broadcast_error", broadcastErr.Error())
|
|
} else {
|
|
fields = append(fields, "broadcast", true)
|
|
}
|
|
}
|
|
}
|
|
s.logGameEvent(session, "game-raid-situation-monster-hp-reported", fields...)
|
|
return true, nil
|
|
}
|
|
// count==0: the 战况表 is open (or refreshing) and the client asked for the
|
|
// situation. This is the state the client was left in when it faulted, so
|
|
// answer it with the same persistent projection the selector reopen uses:
|
|
// raid object mode0, roster mode3, symbol table, gate state table and the
|
|
// phase timers. sendRuntimeRaidSelectorSnapshot is guarded (raid mode, started,
|
|
// active phase) and deliberately does not replay the one-shot op587 state=2
|
|
// announcement, which makes it idempotent for a request the client repeats
|
|
// every 7-15s.
|
|
if runtimeRaidModeForSession(session) == 0 {
|
|
return true, nil
|
|
}
|
|
if err := s.sendRuntimeRaidSelectorSnapshot(session, "raid_situation_window"); err != nil {
|
|
s.logGameEvent(session, "game-raid-situation-refresh-failed",
|
|
"type", typ, "char_id", session.selectedCharacterID, "error", err)
|
|
}
|
|
// The records the board's cells are drawn from go BEFORE the rows: a cell whose
|
|
// member has no record in this client's user table draws as 单人 with a black
|
|
// avatar even though the row itself arrived, so repairing the records first is
|
|
// what makes the repaint correct on the first frame.
|
|
if state, ok := s.runtimeRaidForMember(session.selectedCharacterID); ok {
|
|
s.ensureRuntimeRaidBoardRowActors(session, state, "raid_situation_window_board_row_actors")
|
|
}
|
|
// The board's rows follow the snapshot: the snapshot is what re-sends the
|
|
// 0x0248 participation rows this packet's rows are resolved through, so the
|
|
// order matters (see raid_situation_broadcast.go). A raid with nobody in a
|
|
// gate sends nothing here rather than an empty table.
|
|
if state, ok := s.runtimeRaidForMember(session.selectedCharacterID); ok {
|
|
if err := s.sendRuntimeRaidMonsterState(session, state); err != nil {
|
|
s.logGameEvent(session, "game-raid-situation-rows-failed",
|
|
"type", typ, "char_id", session.selectedCharacterID, "error", err)
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// ensureRuntimeRaidBoardRowActors installs, record-only, the actor record of every
|
|
// member the board's rows are keyed by.
|
|
//
|
|
// A board cell is resolved through the client's OWN user table: the client looks
|
|
// the row's first member key up in it (sub_F14C40 -> sub_2699A30) and hides the
|
|
// whole cell on a miss -- which is the operator's "看不到是哪队进图 就显示个单
|
|
// 队长头像也是黑的". That table is per scene, and every push that fills it (this
|
|
// file's boundary fan-out, co-presence, admission) is edge-triggered, so a client
|
|
// that was mid-transition at the wrong instant keeps an unresolvable cell until
|
|
// something happens to re-install that member.
|
|
//
|
|
// The 战况表 window asks for this refresh every time it is opened or repainted
|
|
// (C2S 695 count==0, measured on the live server at a 0.3-3 min cadence per
|
|
// window), and that request is the one moment the client has just proved its scene
|
|
// is alive AND is about to draw the board. Re-installing here makes a lost record
|
|
// self-heal within one repaint instead of waiting for the next raid-wide sweep.
|
|
//
|
|
// Scope and form are deliberately cheap: one install per row (the row's first
|
|
// member, which is the key the cell resolves), never for the requesting character
|
|
// itself, and record-only (mode0 + the op9 party frame, no mode1 equipment
|
|
// binding) because a portrait, name and level are all that come from mode0
|
|
// (sendRuntimeRaidPeerActorRecord). Re-sending a record the client already has is
|
|
// harmless: it repaints the same actor, which is what the boundary fan-out has
|
|
// always done for its repeats.
|
|
func (s *Service) ensureRuntimeRaidBoardRowActors(session *gameSession, state runtimeRaidState, source string) {
|
|
if s == nil || session == nil || session.conn == nil ||
|
|
state.RaidKey == 0 || session.selectedCharacterID == 0 {
|
|
return
|
|
}
|
|
rows, ok := s.runtimeRaidMonsterStateRows(state)
|
|
if !ok {
|
|
return
|
|
}
|
|
installed := 0
|
|
for _, row := range rows {
|
|
if installed >= currentRuntimeRaidBoardRowActorInstallLimit {
|
|
// A raid with more squads standing in gates than the limit converges
|
|
// over successive refreshes rather than blocking one client loop for
|
|
// the whole set.
|
|
break
|
|
}
|
|
if len(row.Members) == 0 {
|
|
continue
|
|
}
|
|
identity := row.Members[0]
|
|
if identity == 0 || identity == session.selectedCharacterID {
|
|
continue
|
|
}
|
|
s.sendRuntimeRaidPeerActorRecord(state, session, identity, source)
|
|
installed++
|
|
}
|
|
}
|
|
|
|
// currentRuntimeRaidBoardRowActorInstallLimit is how many row records one board
|
|
// refresh is allowed to install.
|
|
//
|
|
// The scope is one install per row, so this only bites a raid whose squads are
|
|
// spread over more gates than this at once (runtimeRaidMaxGroups is 10). The
|
|
// bound exists because each record-only install costs about 50ms of the receiving
|
|
// loop (measured on this fan-out: seven installs 0.35s without the mode1 half), and
|
|
// this path runs inside the client's own refresh request -- the common Anton board
|
|
// (a handful of squads in gates) is unaffected, and a wider one repairs over the
|
|
// next refresh or two (windows ask every 0.3-3 min).
|
|
const currentRuntimeRaidBoardRowActorInstallLimit = 4
|
|
|
|
// runtimeRaidMonsterRuntimeKey identifies one squad's reported runtime values.
|
|
//
|
|
// The squad is the client's own roster byte (runtimeRaidMemberSquad) and the
|
|
// dungeon is the gate that squad is standing in. That pair is what 634's rows are
|
|
// keyed by, and keying by the pair is what stops a value reported inside 擎天之柱
|
|
// from being drawn in 舰炮.
|
|
//
|
|
// SoloCharID exists for the same reason 86JP's GetRaidMonsterRuntimeKey carries a
|
|
// SoloMemberKey (Situation.cs:485-492): an unassigned member renders as its own
|
|
// row with group 0, so the group alone cannot tell two unassigned members apart.
|
|
type runtimeRaidMonsterRuntimeKey struct {
|
|
RaidKey uint32
|
|
Group byte
|
|
SoloCharID uint16
|
|
DungeonID int
|
|
// RecordSetKey is the leading dword of the sender's own C2S 695 body. The
|
|
// client keeps its per-dungeon records in a (dungeon -> key -> records) map
|
|
// and uses this value as the inner key, so a report and the 634 row that
|
|
// answers it must carry the same number. Live bodies have always sent 0, but
|
|
// inventing a different value is not harmless: an unknown key makes the
|
|
// client create a default record that then persists and re-enters its own
|
|
// later reports (tools/_probe_634_report.md §6c Q4).
|
|
RecordSetKey uint32
|
|
}
|
|
|
|
// recordRuntimeRaidMonsterRuntimeValues attributes one C2S 695 report to the raid
|
|
// gate the sender is standing in and stores it.
|
|
//
|
|
// recorded=false covers every report that is not inside an attributable raid gate.
|
|
// That is the same drop 86JP performs for a stale or non-participant report
|
|
// (Situation.cs:233-236), and it matters here because the client sends this opcode
|
|
// on a 7-15s timer, so it also arrives in town and in ordinary dungeons.
|
|
//
|
|
// Lock order: session.dungeon.mu is taken only to read the binding and is released
|
|
// before s.raidMu is taken. That is the order the raid lifecycle already uses --
|
|
// notifyRuntimeRaidDungeonLifecycleLocked runs under dungeon.mu and then calls into
|
|
// the raid state -- so this never inverts it.
|
|
func (s *Service) recordRuntimeRaidMonsterRuntimeValues(
|
|
session *gameSession,
|
|
recordSetKey uint32,
|
|
values []uint32,
|
|
) (runtimeRaidMonsterRuntimeKey, bool) {
|
|
if s == nil || session == nil || len(values) == 0 {
|
|
return runtimeRaidMonsterRuntimeKey{}, false
|
|
}
|
|
session.dungeon.mu.Lock()
|
|
runtime := session.dungeon.runtime
|
|
var binding runtimeRaidDungeonBinding
|
|
if runtime != nil {
|
|
binding = runtime.raidDungeon
|
|
}
|
|
session.dungeon.mu.Unlock()
|
|
if binding.RaidKey == 0 || binding.MapID == 0 {
|
|
return runtimeRaidMonsterRuntimeKey{}, false
|
|
}
|
|
state, ok := s.runtimeRaidForMember(session.selectedCharacterID)
|
|
if !ok ||
|
|
state.RaidKey != binding.RaidKey ||
|
|
!state.Started ||
|
|
state.Lifecycle.Phase != binding.Phase {
|
|
return runtimeRaidMonsterRuntimeKey{}, false
|
|
}
|
|
member, ok := runtimeRaidFrozenMember(state, session.selectedCharacterID)
|
|
if !ok {
|
|
return runtimeRaidMonsterRuntimeKey{}, false
|
|
}
|
|
key := runtimeRaidMonsterRuntimeKey{
|
|
RaidKey: binding.RaidKey,
|
|
Group: runtimeRaidMemberSquad(member.GroupIndex),
|
|
DungeonID: binding.MapID,
|
|
RecordSetKey: recordSetKey,
|
|
}
|
|
if key.Group == 0 {
|
|
key.SoloCharID = session.selectedCharacterID
|
|
}
|
|
stored := append([]uint32(nil), values...)
|
|
|
|
s.raidMu.Lock()
|
|
defer s.raidMu.Unlock()
|
|
if s.raidSituationValues == nil {
|
|
s.raidSituationValues = make(map[runtimeRaidMonsterRuntimeKey][]uint32)
|
|
}
|
|
s.raidSituationValues[key] = stored
|
|
return key, true
|
|
}
|
|
|
|
// runtimeRaidMonsterRuntimeValuesFor returns a copy of every stored report of one
|
|
// raid so a caller can build 634 without holding the lock while it encodes.
|
|
//
|
|
// The backing store (Service.raidSituationValues) holds the runtime values each
|
|
// squad reported in C2S 695, keyed by (raid, squad, solo member, gate). It is the
|
|
// equivalent of 86JP's _raidMonsterRuntimeValues (RaidHandler.cs:82, key built at
|
|
// Situation.cs:479-492), and one 634 row is exactly "one squad plus the values of
|
|
// the gate it stands in". It is guarded by s.raidMu -- the same lock as s.raids --
|
|
// and is purged when the raid itself is removed.
|
|
func (s *Service) runtimeRaidMonsterRuntimeValuesFor(
|
|
raidKey uint32,
|
|
) map[runtimeRaidMonsterRuntimeKey][]uint32 {
|
|
if s == nil || raidKey == 0 {
|
|
return nil
|
|
}
|
|
s.raidMu.Lock()
|
|
defer s.raidMu.Unlock()
|
|
out := make(map[runtimeRaidMonsterRuntimeKey][]uint32)
|
|
for key, values := range s.raidSituationValues {
|
|
if key.RaidKey != raidKey {
|
|
continue
|
|
}
|
|
out[key] = append([]uint32(nil), values...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// purgeRuntimeRaidMonsterRuntimeValuesLocked drops every stored report of one raid.
|
|
// A report is only meaningful while the raid that produced it is alive, so keeping
|
|
// it would leak one entry per gate per squad for every raid the process has run.
|
|
// Caller holds s.raidMu.
|
|
func (s *Service) purgeRuntimeRaidMonsterRuntimeValuesLocked(raidKey uint32) {
|
|
if s == nil || raidKey == 0 {
|
|
return
|
|
}
|
|
for key := range s.raidSituationValues {
|
|
if key.RaidKey == raidKey {
|
|
delete(s.raidSituationValues, key)
|
|
}
|
|
}
|
|
}
|