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

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)
}
}
}