DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,364 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user