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

497 lines
19 KiB
Go

package dnfbridge
import (
"errors"
"fmt"
"strings"
"longheng.io/server/internal/modules/dnf/worldmap"
)
var (
errDungeonStartMapCoordinateRange = errors.New("dnf start-map coordinate exceeds current EXE range")
errDungeonStartMapMapIDRange = errors.New("dnf start-map map id exceeds current EXE range")
errDungeonStartMapActorCount = errors.New("dnf start-map actor count exceeds current EXE range")
errDungeonStartMapObjectKeyRange = errors.New("dnf start-map actor object key exceeds current EXE range")
errDungeonStartMapActorCodeRange = errors.New("dnf start-map actor code exceeds current EXE range")
errDungeonStartMapMonsterRank = errors.New("dnf start-map monster rank is unsupported")
errDungeonStartMapMonsterLevel = errors.New("dnf start-map monster level is invalid")
errDungeonStartMapOpaqueHostile = errors.New("dnf start-map contains an unsupported hostile actor")
errDungeonStartMapSpecialObject = errors.New("dnf start-map contains an unsupported special passive object")
errDungeonStartMapSceneMismatch = errors.New("dnf start-map scene does not match its runtime owner")
errDungeonStartMapExtraCount = errors.New("dnf start-map extra entry count exceeds current EXE range")
errDungeonStartMapGroupCount = errors.New("dnf start-map ridable group count exceeds current EXE range")
)
const (
currentDungeonNPCActorType byte = 4
// The highest level the current client accepts for a dungeon monster.
// A resolved [monster] level at or above this is pinned here instead of
// being rejected: the PVF ships rows that mean "cap it", not "fail".
currentDungeonMonsterLevelMax = 200
currentDungeonStartMapOperationCurrent byte = 0
currentDungeonStartMapOperationAdvanceLayer byte = 1
currentDungeonStartMapOperationRestoreBase byte = 2
currentDungeonStartMapPayloadCached byte = 0
currentDungeonStartMapPayloadBuild byte = 1
currentDungeonStartMapPayloadRefresh byte = 2
)
type currentDungeonStartMapActor struct {
TemplateOrder uint16
PacketIndex uint32
ObjectKey uint16
Code uint32
Level byte
Type byte
Flag0 byte
Flag1 byte
ExtraState uint32
// Blocking is the current EXE's reverse-polarity room-clear byte: zero
// makes an ordinary monster participate in the alive scan; one skips it.
Blocking byte
}
// The current EXE reads this row as 21 bytes. The older C# implementation
// wrote a 19-byte row and is not wire-compatible here.
type currentDungeonStartMapExtraEntry struct {
PassiveObjectIndex byte
GlobalSequence uint32
ItemID uint32
StackCount uint32
Endurance uint16
AmplifyType byte
AmplifyValue uint16
ExtendedValue uint16
ExtendedFlag byte
}
// currentDungeonStartMapRidableEntry is one 0x1D479A0 (op29) association row.
//
// The row is five u32 and the current client branches on the LAST one only:
// sub_2359970 (0x023599E3 mov eax,[edi+esi+0x10]) does
// `sub eax,0 / je case0 ; dec eax / jne next row`, i.e.
// `switch(row[4]) { case 0, case 1, default: ignore the whole row }`. Writing a
// runtime object key there (this server used 402, 403, ...) made the client drop
// every row, which is why no randomized object was ever materialized -- including
// the ones on maps that were believed to work. 86JP's builder writes the same
// table as (PosX, PosY, ObjectIndex, Faction, SpawnMode) with SpawnMode in {0,1}.
//
// PosX/PosY are the MAP position of the object inside its room, i.e. the PVF
// `[pos]` pair -- NOT the room index. The row carries no room at all, so the
// server must scope the table to the room being loaded (see
// currentDungeonRidableGroupsForRoom); RoomX/RoomY below exist only for that
// server-side scoping and never reach the wire.
type currentDungeonStartMapRidableEntry struct {
PosX int64
PosY int64
ObjectIndex uint32
Faction uint32
SpawnMode uint32
// RoomX/RoomY are the PVF `[map]` coordinate of the candidate this row was
// created from, and RuntimeObjectKey is the op316 bookkeeping key. Neither is
// part of the wire row any more.
RoomX uint32
RoomY uint32
RuntimeObjectKey uint32
}
// currentDungeonRidableGroupsForRoom scopes the op29 association table to the
// room being loaded.
//
// The client never filters these rows by room: sub_2359970 hands every row of
// every group to the slot spawner together with row[0]/row[1] as the spawn
// position, and it runs once per room entry (flag +0xC3C armed by sub_264A7F0).
// Announcing the whole table on every room therefore re-materialized every
// randomized object in every room -- live 2026-09-16T14:2xZ on EnergyCut (218):
// the Anton transmission device and the 58530 rider appeared in all 18 rooms at
// the wrong pixel position, and the boss room (0:2), which owns no candidate at
// all, inherited rows it must never receive. 86JP scopes server-side
// (DungeonMapHandler.GetRidableEntriesForRoom) and so does this.
//
// Group indexes are preserved because the client reads a u8 group count followed
// by each group's u8 row count; empty groups are legal, and a room with no
// object at all writes the same zero group count 86JP writes for an empty table.
func currentDungeonRidableGroupsForRoom(
groups [][]currentDungeonStartMapRidableEntry,
room worldmap.RoomCoordinate,
) [][]currentDungeonStartMapRidableEntry {
if len(groups) == 0 {
return nil
}
filtered := make([][]currentDungeonStartMapRidableEntry, len(groups))
occupied := false
for index, group := range groups {
rows := make([]currentDungeonStartMapRidableEntry, 0, len(group))
for _, entry := range group {
if int64(entry.RoomX) != room.X || int64(entry.RoomY) != room.Y {
continue
}
rows = append(rows, entry)
}
if len(rows) > 0 {
occupied = true
}
filtered[index] = rows
}
if !occupied {
return nil
}
return filtered
}
type currentDungeonStartMap struct {
X byte
Y byte
LayeredRoomFlag byte
Seed uint32
HellPartyMode byte
UnknownAfterHellPartyMode byte
RoomStateValue uint32
RoomStateFlag byte
MapID uint32
Actors []currentDungeonStartMapActor
ExtraEntries []currentDungeonStartMapExtraEntry
HellPartyFogFlag byte
RidableGroups [][]currentDungeonStartMapRidableEntry
PartyMemberIndex byte
}
// currentDungeonStartMapState contains protocol state that is not owned by a
// map PVF record. Callers must provide it explicitly; this layer does not
// invent a seed, mode, room state, drop, or ridable-object value.
type currentDungeonStartMapState struct {
LayeredRoomFlag byte
Seed uint32
HellPartyMode byte
UnknownAfterHellPartyMode byte
RoomStateValue uint32
RoomStateFlag byte
ExtraEntries []currentDungeonStartMapExtraEntry
HellPartyFogFlag byte
RidableGroups [][]currentDungeonStartMapRidableEntry
PartyMemberIndex byte
}
func currentDungeonStartMapFromRuntime(
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
state currentDungeonStartMapState,
) (currentDungeonStartMap, error) {
if scene.Coordinate.X < 0 || scene.Coordinate.X > 0xff || scene.Coordinate.Y < 0 || scene.Coordinate.Y > 0xff {
return currentDungeonStartMap{}, fmt.Errorf("%w: room=%s", errDungeonStartMapCoordinateRange, scene.Coordinate)
}
if scene.Map.Map.ID <= 0 || scene.Map.Map.ID > int64(^uint32(0)) {
return currentDungeonStartMap{}, fmt.Errorf("%w: map=%d", errDungeonStartMapMapIDRange, scene.Map.Map.ID)
}
actors, err := currentDungeonStartMapActors(runtime, scene)
if err != nil {
return currentDungeonStartMap{}, err
}
return currentDungeonStartMap{
X: byte(scene.Coordinate.X),
Y: byte(scene.Coordinate.Y),
LayeredRoomFlag: state.LayeredRoomFlag,
Seed: state.Seed,
HellPartyMode: state.HellPartyMode,
UnknownAfterHellPartyMode: state.UnknownAfterHellPartyMode,
RoomStateValue: state.RoomStateValue,
RoomStateFlag: state.RoomStateFlag,
MapID: uint32(scene.Map.Map.ID),
Actors: actors,
ExtraEntries: append([]currentDungeonStartMapExtraEntry(nil), state.ExtraEntries...),
HellPartyFogFlag: state.HellPartyFogFlag,
RidableGroups: cloneCurrentDungeonRidableGroups(state.RidableGroups),
PartyMemberIndex: state.PartyMemberIndex,
}, nil
}
func cloneCurrentDungeonRidableGroups(values [][]currentDungeonStartMapRidableEntry) [][]currentDungeonStartMapRidableEntry {
if len(values) == 0 {
return nil
}
result := make([][]currentDungeonStartMapRidableEntry, len(values))
for index, group := range values {
result[index] = append([]currentDungeonStartMapRidableEntry(nil), group...)
}
return result
}
func (packet currentDungeonStartMap) Build() ([]byte, error) {
if len(packet.Actors) > 0xff {
return nil, fmt.Errorf("%w: count=%d", errDungeonStartMapActorCount, len(packet.Actors))
}
if len(packet.ExtraEntries) > 0xff {
return nil, fmt.Errorf("%w: count=%d", errDungeonStartMapExtraCount, len(packet.ExtraEntries))
}
if len(packet.RidableGroups) > 0xff {
return nil, fmt.Errorf("%w: count=%d", errDungeonStartMapGroupCount, len(packet.RidableGroups))
}
for groupIndex, group := range packet.RidableGroups {
if len(group) > 0xff {
return nil, fmt.Errorf("%w: group=%d count=%d", errDungeonStartMapGroupCount, groupIndex, len(group))
}
}
var writer packetWriter
writer.writeByte(packet.X)
writer.writeByte(packet.Y)
writer.writeByte(packet.LayeredRoomFlag)
writer.writeUint32(packet.Seed)
writer.writeByte(packet.HellPartyMode)
writer.writeByte(packet.UnknownAfterHellPartyMode)
writer.writeUint32(packet.RoomStateValue)
writer.writeByte(packet.RoomStateFlag)
writer.writeUint32(packet.MapID)
writer.writeByte(byte(len(packet.Actors)))
for _, actor := range packet.Actors {
writer.writeUint16(actor.TemplateOrder)
writer.writeUint32(actor.PacketIndex)
writer.writeUint16(actor.ObjectKey)
writer.writeUint32(actor.Code)
writer.writeByte(actor.Level)
writer.writeByte(actor.Type)
writer.writeByte(actor.Flag0)
writer.writeByte(actor.Flag1)
writer.writeUint32(actor.ExtraState)
writer.writeByte(actor.Blocking)
}
writer.writeByte(byte(len(packet.ExtraEntries)))
for _, entry := range packet.ExtraEntries {
writer.writeByte(entry.PassiveObjectIndex)
writer.writeUint32(entry.GlobalSequence)
writer.writeUint32(entry.ItemID)
writer.writeUint32(entry.StackCount)
writer.writeUint16(entry.Endurance)
writer.writeByte(entry.AmplifyType)
writer.writeUint16(entry.AmplifyValue)
writer.writeUint16(entry.ExtendedValue)
writer.writeByte(entry.ExtendedFlag)
}
writer.writeByte(packet.HellPartyFogFlag)
writer.writeByte(byte(len(packet.RidableGroups)))
for _, group := range packet.RidableGroups {
writer.writeByte(byte(len(group)))
for _, entry := range group {
// (PosX, PosY, ObjectIndex, Faction, SpawnMode): the position is the
// PVF `[pos]` pixel pair, never the room index -- see the entry type.
writer.writeUint32(uint32(entry.PosX))
writer.writeUint32(uint32(entry.PosY))
writer.writeUint32(entry.ObjectIndex)
writer.writeUint32(entry.Faction)
// The client's only branch on this row: 0 = monster spawn, 1 = passive
// object spawn. Anything else discards the row.
writer.writeUint32(entry.SpawnMode)
}
}
writer.writeByte(packet.PartyMemberIndex)
return writer.bytes(), nil
}
// currentDungeonCachedStartMap is the exact mode0 branch consumed by the
// current client. It stops before MapID/actor rows, then reads only the ridable
// group count and party-member index, for a fixed 16-byte body.
type currentDungeonCachedStartMap struct {
X byte
Y byte
Operation byte
Seed uint32
HellPartyMode byte
UnknownAfterHellPartyMode byte
RoomStateValue uint32
RidableGroupCount byte
PartyMemberIndex byte
}
func (packet currentDungeonCachedStartMap) Build() ([]byte, error) {
if packet.Operation > currentDungeonStartMapOperationRestoreBase {
return nil, fmt.Errorf("unsupported cached start-map operation=%d", packet.Operation)
}
var writer packetWriter
writer.writeByte(packet.X)
writer.writeByte(packet.Y)
writer.writeByte(packet.Operation)
writer.writeUint32(packet.Seed)
writer.writeByte(packet.HellPartyMode)
writer.writeByte(packet.UnknownAfterHellPartyMode)
writer.writeUint32(packet.RoomStateValue)
writer.writeByte(currentDungeonStartMapPayloadCached)
writer.writeByte(packet.RidableGroupCount)
writer.writeByte(packet.PartyMemberIndex)
body := writer.bytes()
if len(body) != 16 {
return nil, fmt.Errorf("cached start-map body length=%d want=16", len(body))
}
return body, nil
}
func currentDungeonStartMapActors(
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
) ([]currentDungeonStartMapActor, error) {
if runtime == nil || runtime.Room == nil {
return nil, errDungeonWorldMapUnavailable
}
snapshot := runtime.Room.Snapshot()
if snapshot.Coordinate != scene.Coordinate || snapshot.MapID != scene.Map.Map.ID || len(snapshot.Monsters) != len(scene.Monsters) {
return nil, fmt.Errorf("%w: runtime_room=%s runtime_map=%d scene_room=%s scene_map=%d runtime_monsters=%d scene_monsters=%d",
errDungeonStartMapSceneMismatch,
snapshot.Coordinate,
snapshot.MapID,
scene.Coordinate,
scene.Map.Map.ID,
len(snapshot.Monsters),
len(scene.Monsters),
)
}
if snapshot.SpecialObjectCount != len(scene.SpecialPassiveObjects) || snapshot.AICharacterCount != len(scene.AICharacters) {
return nil, fmt.Errorf("%w: planned_special=%d scene_special=%d planned_AI=%d scene_AI=%d",
errDungeonStartMapSceneMismatch,
snapshot.SpecialObjectCount,
len(scene.SpecialPassiveObjects),
snapshot.AICharacterCount,
len(scene.AICharacters),
)
}
if len(snapshot.OpaqueHostiles) != 0 {
return nil, fmt.Errorf("%w: count=%d", errDungeonStartMapOpaqueHostile, len(snapshot.OpaqueHostiles))
}
if len(snapshot.Monsters)+len(snapshot.ExtendedActors) > 0xff {
return nil, fmt.Errorf("%w: normal=%d extended=%d", errDungeonStartMapActorCount, len(snapshot.Monsters), len(snapshot.ExtendedActors))
}
actors := make([]currentDungeonStartMapActor, 0, len(snapshot.Monsters)+len(snapshot.ExtendedActors))
for index, monster := range snapshot.Monsters {
if monster.ObjectKey == 0 || monster.ObjectKey > uint32(^uint16(0)) {
return nil, fmt.Errorf("%w: index=%d object_key=%d", errDungeonStartMapObjectKeyRange, index, monster.ObjectKey)
}
if monster.Spawn.MonsterID <= 0 || monster.Spawn.MonsterID > int64(^uint32(0)) {
return nil, fmt.Errorf("%w: index=%d code=%d", errDungeonStartMapActorCodeRange, index, monster.Spawn.MonsterID)
}
actorType, err := currentDungeonMonsterActorType(monster.Spawn)
if err != nil {
return nil, fmt.Errorf("monster index %d code %d: %w", index, monster.Spawn.MonsterID, err)
}
level, err := currentDungeonMonsterLevel(monster.Spawn, runtime)
if err != nil {
return nil, fmt.Errorf("monster index %d code %d: %w", index, monster.Spawn.MonsterID, err)
}
if index == 0 && currentDungeonReplacesMobLevelWithCharacter(runtime.Dungeon) {
}
blocking := monster.RoomClearSkipByte
if actorType == currentDungeonNPCActorType {
blocking = 1
}
actors = append(actors, currentDungeonStartMapActor{
PacketIndex: uint32(index),
ObjectKey: uint16(monster.ObjectKey),
Code: uint32(monster.Spawn.MonsterID),
Level: level,
Type: actorType,
Blocking: blocking,
})
}
for _, actor := range snapshot.ExtendedActors {
actors = append(actors, actor.Packet)
}
return actors, nil
}
func currentDungeonMonsterType(rank string) (byte, error) {
switch strings.ToLower(strings.Trim(rank, "[] \t\r\n")) {
case "normal", "dummy":
return 0, nil
case "champion":
return 1, nil
case "super champion", "superchampion":
return 2, nil
case "boss":
return 3, nil
default:
return 0, fmt.Errorf("%w: rank=%q", errDungeonStartMapMonsterRank, rank)
}
}
func currentDungeonMonsterActorType(spawn worldmap.MonsterSpawn) (byte, error) {
if strings.EqualFold(strings.Trim(spawn.SuffixMarker, "[] \t\r\n"), "boss") {
return 3, nil
}
if strings.EqualFold(strings.Trim(spawn.Rank, "[] \t\r\n"), "npc") {
return currentDungeonNPCActorType, nil
}
return currentDungeonMonsterType(spawn.Rank)
}
func currentDungeonIsBossActorType(actorType byte) bool {
// 86JP mirrors the old df_game_r kill_monster domain rule:
// ordinary boss monsters are actor type 3, hostile AI/APC bosses are
// actor type 8. Both mean "boss death can end combat"; do not restrict
// the rule to PVF [monster] rows only.
return actorType == 3 || actorType == 8
}
// currentDungeonMonsterLevel resolves one PVF [monster] row's level from its
// second and third columns. The second column (Spawn.Level) is only a switch:
// any nonzero value — 1, 2, 10 alike — makes the third column (Spawn.AutoLevel)
// a level relative to the dungeon's [basis level]; zero makes the third column
// the absolute level. The relative branch has no fallback: a dungeon without
// [basis level] cannot be resolved, and inventing one would hide the missing
// dependency behind plausible monsters. Every other row resolves, clamped.
func currentDungeonMonsterLevel(spawn worldmap.MonsterSpawn, runtime *runtimeDungeonState) (byte, error) {
// 【2026-10-12 接通】周常五图(4906~4910)声明 [mob level charac level
// replace flag] 1:怪物等级改用**入场那一刻的角色等级**作为本局基础等级。
//
// 用 runtime.Character.Level 而不是实时等级:它和 entryCharacterLevel 一样
// 都是进图快照,不会因为本局升级而让同一张图的怪物等级中途漂移。
// 只影响本局运行时解析结果,不改共享 PVF 表。
if runtime != nil && currentDungeonReplacesMobLevelWithCharacter(runtime.Dungeon) {
level := int64(runtime.Character.Level)
if level > 0 {
return clampCurrentDungeonMonsterLevel(level), nil
}
// 快照缺失时回落到 entryCharacterLevel,再回落到原逻辑。
if runtime.entryCharacterLevel > 0 {
return clampCurrentDungeonMonsterLevel(int64(runtime.entryCharacterLevel)), nil
}
}
basis := worldmap.OptionalInt{}
if runtime != nil {
basis = runtime.Dungeon.Metadata.BasisLevel
}
level := spawn.AutoLevel
if spawn.Level != 0 {
if !basis.Set {
return 0, fmt.Errorf("%w: relative=%d auto=%d basis=unset", errDungeonStartMapMonsterLevel, spawn.Level, spawn.AutoLevel)
}
level += basis.Value
}
return clampCurrentDungeonMonsterLevel(level), nil
}
// currentDungeonReplacesMobLevelWithCharacter 报告 PVF 的
// [mob level charac level replace flag] 是否为非零。该段被 worldmap 收进
// DungeonMetadata.IntegerValues,缺失或解析失败时按 0 处理(保持原行为)。
func currentDungeonReplacesMobLevelWithCharacter(dungeon worldmap.Dungeon) bool {
if dungeon.Metadata.IntegerValues == nil {
return false
}
return dungeon.Metadata.IntegerValues["mob level charac level replace flag"] != 0
}
// clampCurrentDungeonMonsterLevel holds a resolved level inside the range the
// current client accepts. The lower bound of 1 is not cosmetic: the packet
// field is one byte, so a zero or negative row would otherwise be truncated
// into a 240+ monster.
func clampCurrentDungeonMonsterLevel(level int64) byte {
if level >= currentDungeonMonsterLevelMax {
return byte(currentDungeonMonsterLevelMax)
}
if level <= 0 {
return 1
}
return byte(level)
}