DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,863 @@
package dnfbridge
import (
"context"
"encoding/binary"
"fmt"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
const currentTownActorSceneSnapshotMsgID uint16 = 0x0320
// buildCurrentTownActorSceneSnapshotBody matches the current EXE's scene
// snapshot reader (sub_1D6C6F0):
//
// u8 actor_count, actor_count * { u16 existing_actor_key, u8 child_count }
//
// The reader first resolves every actor key through sub_2699A30, then builds
// its scene-side bindings. A selected character has no server-owned children
// at town entry, so child_count is zero. The key is session data, never a
// captured DOVE key.
func buildCurrentTownActorSceneSnapshotBody(actorKey uint16) []byte {
body := make([]byte, 4)
body[0] = 1
binary.LittleEndian.PutUint16(body[1:3], actorKey)
return body
}
// sendCurrentInitialTownActorSceneSnapshotLocked completes the mode0-created
// selected actor's scene binding before full player state/location packets.
// The caller holds session.townMu.
func (s *Service) sendCurrentInitialTownActorSceneSnapshotLocked(
session *gameSession,
characterID uint16,
) error {
if session == nil || characterID == 0 ||
session.initialTownRouteCharacterID != characterID ||
session.initialTownRouteStage < currentInitialTownRouteActorBound ||
session.initialTownActorSceneSnapshotSent {
return nil
}
actorKey := currentSceneActorObjectKey(characterID)
body := buildCurrentTownActorSceneSnapshotBody(actorKey)
if err := s.sendCurrentSceneFixedClass0Packet(
session,
currentTownActorSceneSnapshotMsgID,
body,
"initial_town_actor_scene_snapshot_current_exe_sub_1D6C6F0",
); err != nil {
return err
}
session.initialTownActorSceneSnapshotSent = true
s.logGameEvent(session, "game-initial-town-actor-scene-snapshot-sent",
"char_id", characterID,
"actor_object_key", actorKey,
"child_count", 0,
"sequence", "mode0_actor_created_then_noti0x320_scene_snapshot_then_full_player_state")
return nil
}
// sendCurrentInitialTownActorRoutePacketsLocked owns completed character-login
// ordering. Unlike a dungeon return, it finishes the full actor state before
// op24 becomes the final transition commit. The caller must hold session.townMu.
func (s *Service) sendCurrentInitialTownActorRoutePacketsLocked(
session *gameSession,
characterID uint16,
townID byte,
areaID byte,
row currentSceneTransitionRow,
objectBody []byte,
mode1Body []byte,
transitionBody []byte,
) error {
return s.sendCurrentInitialTownActorRoutePacketsWithOptionsLocked(
session,
characterID,
townID,
areaID,
row,
objectBody,
mode1Body,
transitionBody,
currentInitialTownRoutePolicy{
includeReportClientSpec: true,
// The selected actor is created by mode0. The complete mode1 is delayed
// until the ordinary skill snapshot is ready; state-only expert-job
// projection follows op19 so existing skills are never interpreted as a
// newly acquired qualification on login.
includeSecondFullMode1: true,
skipPrerequisiteMode1: true,
deferClearQuestListUntilLocationNotifications: true,
},
)
}
func (s *Service) sendCurrentChannelReconnectTownActorRoutePacketsLocked(
session *gameSession,
characterID uint16,
townID byte,
areaID byte,
row currentSceneTransitionRow,
objectBody []byte,
mode1Body []byte,
transitionBody []byte,
) error {
return s.sendCurrentInitialTownActorRoutePacketsWithOptionsLocked(
session,
characterID,
townID,
areaID,
row,
objectBody,
mode1Body,
transitionBody,
currentInitialTownRoutePolicy{
includeReportClientSpec: true,
includeSecondFullMode1: true,
skipPrerequisiteMode1: false,
},
)
}
func (s *Service) sendCurrentInitialTownActorRoutePacketsWithOptionsLocked(
session *gameSession,
characterID uint16,
townID byte,
areaID byte,
row currentSceneTransitionRow,
objectBody []byte,
mode1Body []byte,
transitionBody []byte,
policy currentInitialTownRoutePolicy,
) error {
prerequisiteMode1Body := mode1Body
if policy.skipPrerequisiteMode1 {
prerequisiteMode1Body = nil
}
if err := s.sendCurrentTownActorPrerequisitesLocked(session, characterID, objectBody, prerequisiteMode1Body); err != nil {
return err
}
if policy.commitTransitionEarly {
objectKey := currentSceneActorObjectKey(characterID)
if err := s.sendCurrentAdventureInfoPushFromAccount(
session,
objectKey,
"return_select_after_mode0_before_single_full_mode1",
); err != nil {
return err
}
// Cross-character reselect keeps the previous actor's skill manager
// until op19 is consumed. The equipment-bearing mode1 below immediately
// validates worn equipment, so preload the selected character's op19
// first; otherwise weapon-mastery jobs queue a client-authored unequip.
// sendCurrentTownPlayerStateLocked replays the identical projection at
// its established final boundary.
if err := s.sendCurrentReturnSelectSkillInfoPreloadLocked(
session,
characterID,
"return_select_before_single_full_mode1_weapon_validation",
); err != nil {
return err
}
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketSetUDPIPPort), mode1Body, 0); err != nil {
return err
}
if err := captureCurrentPartyActorProjection(session, characterID, objectBody, mode1Body); err != nil {
s.logGameEvent(session, "game-party-actor-full-projection-refresh-rejected",
"source", "return_select_single_full_mode1_before_early_op24",
"char_id", characterID,
"character_generation", session.characterGeneration,
"mode0_len", len(objectBody),
"mode1_len", len(mode1Body),
"reason", err)
}
// The collection-box projection was published with the mode0 actor above.
// Refresh the selected box again now that the complete equipment-bearing
// mode1 has bound the native actor the collect-box stat owner belongs to.
if err := s.replayCollectboxSelectedRefreshAfterMode1(
context.Background(),
session,
"return_select_after_single_full_mode1",
); err != nil {
s.logGameEvent(session, "game-collectbox-selection-refresh-deferred",
"char_id", characterID,
"source", "return_select_after_single_full_mode1",
"reason", err)
}
}
if err := s.sendCurrentInitialTownActorSceneSnapshotLocked(session, characterID); err != nil {
return fmt.Errorf("initialize selected town actor scene snapshot: %w", err)
}
if len(policy.earlyActorDisplayBody) != 0 {
if err := s.sendCurrentInitialTownActorDisplayAndPlacementLocked(
session,
"return_select_after_op800_before_location",
policy.earlyActorDisplayBody,
); err != nil {
return err
}
}
// Publish the final channel-owned town/area as soon as the actor scene
// binding exists.
if err := s.sendCurrentInitialTownLocationNotificationsLocked(session, characterID, townID, areaID, row); err != nil {
return fmt.Errorf("initialize selected town location notifications: %w", err)
}
if policy.commitTransitionEarly {
if policy.deferClearQuestListUntilLocationNotifications {
if err := s.sendCurrentInitialTownClearQuestListAfterLocationLocked(
session,
"return_select_after_location_notifications_before_early_op24",
); err != nil {
return fmt.Errorf("initialize return-select clear quest list before early transition: %w", err)
}
}
if err := s.sendCurrentSpendTimeInitialStateOnce(
session,
"return_select_before_early_op24_activity_state",
); err != nil {
return fmt.Errorf("initialize return-select activity state before early transition: %w", err)
}
if err := s.sendCurrentTownTransitionBeforePlayerStateLocked(session, characterID, townID, areaID, row, transitionBody); err != nil {
return err
}
if err := s.sendCurrentInitialTownDungeonPermissionSnapshotLocked(
session,
"return_select_after_op24_scene_commit",
); err != nil {
return err
}
}
if policy.commitTransitionEarly || session.initialTownRouteStage < currentInitialTownRoutePlayerStatePrepared {
policy.actorMode0Body = append([]byte(nil), objectBody...)
if err := s.sendCurrentTownPlayerStateLocked(
session,
"initial_town_before_final_transition",
policy,
); err != nil {
return fmt.Errorf("initialize selected town actor before transition: %w", err)
}
}
if policy.deferClearQuestListUntilLocationNotifications && !policy.commitTransitionEarly {
if err := s.sendCurrentInitialTownClearQuestListAfterLocationLocked(
session,
"initial_town_after_location_notifications_before_op24",
); err != nil {
return fmt.Errorf("initialize selected town clear quest list after location notifications: %w", err)
}
}
if err := s.sendCurrentSpendTimeInitialStateOnce(
session,
"initial_town_before_op24_activity_state",
); err != nil {
return fmt.Errorf("initialize selected town activity state before transition: %w", err)
}
if err := s.sendCurrentTownTransitionLocked(session, characterID, townID, areaID, row, transitionBody); err != nil {
return err
}
// Cold login and return-select commit op24 before their final town task
// projection. Channel reconnect already owns a separate verified post-op24
// refresh and must not receive a duplicate sequence.
if !session.channelReconnect {
if err := s.sendCurrentPostTownTransitionQuestSnapshotsLocked(
session,
"current_exe_initial_town_after_op24_scene_commit",
); err != nil {
return err
}
}
// Character selection rebuilds the native scene owner. Restore the
// repository-backed class-0 JOIN_GUILD_INFO notification only after typed
// op24 and the route-specific quest/skill refreshes; this notification is
// what writes the client's primary current-guild ID.
if err := s.sendCurrentInitialTownGuildStateProjectionLocked(
session,
"current_exe_initial_town_after_op24_scene_commit",
); err != nil {
return fmt.Errorf("restore current guild after initial town transition: %w", err)
}
// Relog/reselect has no local op44 activation event. Restore the durable
// effect item after op24 and its post-transition skill replay.
if err := s.sendCurrentExperienceConsumableBuffProjection(session, "initial_town_after_op24"); err != nil {
return fmt.Errorf("restore experience consumable HUD after initial town transition: %w", err)
}
// Same reason, for the durable item cooltime list: the client's cooldown gate
// is process-local memory, so a 小退/重上 made a 1-day-cooldown consumable look
// ready again even though the server still refuses it (live 2026-09-17).
if err := s.sendCurrentStatChangePotionBuffProjection(session, "initial_town_after_op24"); err != nil {
return err
}
if err := s.sendCurrentItemCooltimeProjection(session, "initial_town_after_op24"); err != nil {
return fmt.Errorf("restore item cooltime list after initial town transition: %w", err)
}
// 商城"限购已买满"的商品列表(类 0 通知 860):客户端的限购记忆只在会话内存里,
// 不重放的话小退/大退/重启服务端之后那些商品又会出现在商城里(现场 2026-09-18:
// "买完从商品列表消失、重上又出现")。计数是持久化的,所以重放结果稳定。
// 纯展示:失败只记日志,不影响回城/选角流程。
if err := s.sendCurrentCeraShopBuyRestrictProjection(context.Background(), session, "initial_town_after_op24"); err != nil {
s.logGameEvent(session, "game-cera-shop-buy-restrict-send-failed", "source", "initial_town_after_op24", "err", err)
}
// 商城三个钱包数字(点券 / 代币券 / 欢乐代币券,类 0 通知 53):客户端没有
// "请求钱包"的上行包,纯靠服务端推。不推的话选角进城后商城那三个数字是 0,
// 玩家会以为代币券没到账。
//
// 只挂在选角进城这一处,不挂回城:钱包变化只有两个来源——购买(tera_shop_purchase.go
// 已经在成功后推了真实值)和代币券兑换(兑换点自己推)。回城重放属于投机性多发,
// 只会污染无关的地城返回包序列,没有实际收益。
// 纯展示:失败只记日志,不影响选角/进城流程。
if err := s.sendCurrentCeraShopWalletProjection(context.Background(), session, "initial_town_after_op24"); err != nil {
s.logGameEvent(session, "game-cera-shop-wallet-send-failed", "source", "initial_town_after_op24", "err", err)
}
// 多买多送"已购买次数"(类 0 通知 1035,包体 = 一个 u32 绝对次数)。同样没有上行
// 请求包:客户端窗口不会自己算次数,只在开窗时读服务端推来的值。活动没配置时
// 这个函数直接返回,不发包。
if err := s.sendCurrentCeraShopBuyCountGiftProjection(context.Background(), session, "initial_town_after_op24"); err != nil {
s.logGameEvent(session, "game-cera-shop-buycount-gift-send-failed", "source", "initial_town_after_op24", "err", err)
}
// Same reason, for the weakness health cap: selecting a character rebuilds
// the local player at a healthy 100, and the subtype0 stamina byte alone
// does not restore the cap. Without this republish a weakened character
// came back from character selection at full health -- which looked like
// re-selecting cured the penalty, even though the stored level never moved.
if err := s.sendCurrentWeaknessConditionRefresh(session, "initial_town_after_op24"); err != nil {
return fmt.Errorf("restore weakness health cap after initial town transition: %w", err)
}
// 公会地下城 progression. The client keeps this table per session and gates
// entry on it locally, so it has to be republished whenever the player is
// rebuilt. See guild_dungeon.go.
s.sendCurrentGuildDungeonState(session, "initial_town_after_op24")
// The endpoint handshake is connection-scoped and completes before the
// selected-town route. Never follow op24 with another class0/op1: it would
// arm a fresh client watchdog after the one allowed class1/op1 success.
s.logGameEvent(session, "game-initial-town-route-endpoint-preserved",
"char_id", characterID,
"town_id", townID,
"area_id", areaID,
"route_stage", session.initialTownRouteStage,
"resident_notice_sent", session.currentChannelResidentNoticeSent,
"town_actor_owner_channel", session.townActorOwnerChannel,
"endpoint_policy", "channelinfo_then_single_requested_class1_op1")
return nil
}
// sendCurrentReturnSelectSkillInfoPreloadLocked installs the selected
// character's op19 before the single equipment-bearing mode1 of the
// return-select route. It mirrors the cold-login weapon-validation preload and
// is a no-op when the skill projection is not ready yet.
func (s *Service) sendCurrentReturnSelectSkillInfoPreloadLocked(
session *gameSession,
characterID uint16,
source string,
) error {
if s == nil || session == nil || characterID == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
charID, _, character, hasCharacter := s.selectedCharacterForEnter(ctx, session)
if !hasCharacter || charID != characterID {
return nil
}
projection, ready, err := s.buildCurrentSceneSkillInfoProjection(session, ctx, character, source)
if err != nil {
return fmt.Errorf("build return-select skill preload: %w", err)
}
if !ready {
return nil
}
return s.sendCurrentSceneSkillInfoProjectionBeforeEquipment(session, projection, source)
}
func (s *Service) sendCurrentInitialTownDungeonPermissionSnapshotLocked(
session *gameSession,
source string,
) error {
if session == nil || session.initialTownDungeonPermissionSent {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
if err := s.sendCurrentDungeonPermissionSnapshot(ctx, session, source); err != nil {
return err
}
session.initialTownDungeonPermissionSent = true
return nil
}
// sendCurrentTownTransitionBeforePlayerStateLocked commits the return-select
// map without completing the still-pending player-state stream.
func (s *Service) sendCurrentTownTransitionBeforePlayerStateLocked(
session *gameSession,
characterID uint16,
townID byte,
areaID byte,
row currentSceneTransitionRow,
transitionBody []byte,
) error {
if session == nil || characterID == 0 ||
session.initialTownRouteCharacterID != characterID ||
session.initialTownRouteStage < currentInitialTownRouteActorBound ||
session.initialTownRouteStage >= currentInitialTownRouteTransitionSent {
return nil
}
if err := s.sendGameUpperRawClass(session, currentSceneTransitionMsgID, transitionBody, 0); err != nil {
return err
}
setCurrentTownPositionSceneFromTransitionLocked(session, characterID, townID, areaID, row)
session.initialTownRouteStage = currentInitialTownRouteTransitionSent
return nil
}
// waitCurrentInitialTownSceneCommitLocked preserves the former visual-cover
// boundary as a zero-duration test seam. The caller continues directly into
// the route-specific scene finalizers.
func (s *Service) waitCurrentInitialTownSceneCommitLocked(
session *gameSession,
characterID uint16,
) {
if s == nil || session == nil || s.initialTownEntryWait == nil {
return
}
s.logGameEvent(session, "game-initial-town-client-loading-cover-disabled",
"char_id", characterID,
"duration", currentInitialTownEntryDelay.String(),
"owner", "none",
"server_packet_stream_blocked", false)
s.initialTownEntryWait(currentInitialTownEntryDelay)
}
// sendCurrentTownActorRoutePacketsLocked preserves the accepted completed
// dungeon-return ordering. The result-page actor remains addressable by its
// stable key, so the mode0 row is an event-suppressed state refresh; the
// following equipment-bearing mode1 rebinds its durable equipment graph. The
// caller must hold session.townMu.
func (s *Service) sendCurrentTownActorRoutePacketsLocked(
ctx context.Context,
session *gameSession,
characterID uint16,
townID byte,
areaID byte,
row currentSceneTransitionRow,
objectBody []byte,
mode1Body []byte,
transitionBody []byte,
refreshCompletedCreatureState bool,
seedCompletedQuestState bool,
) error {
if err := s.sendCurrentTownActorPrerequisitesLocked(session, characterID, objectBody, nil); err != nil {
return err
}
if err := s.sendCurrentCompletedTownReturnPlayerStateLocked(session, characterID, objectBody, mode1Body); err != nil {
return err
}
if refreshCompletedCreatureState &&
session.initialTownRouteStage < currentInitialTownRouteCreatureStateSent {
if err := s.sendSelectedCreatureCompletedTownReturnStateAfterMode1(
session,
"completed_dungeon_return_after_mode1_before_op24",
); err != nil {
return fmt.Errorf("rehydrate completed-return creature state: %w", err)
}
session.initialTownRouteStage = currentInitialTownRouteCreatureStateSent
}
if _, err := s.sendSelectedGuardianShieldDeckInfo(
session,
"completed_dungeon_return_guardian_shield_deck_after_mode1_before_op24",
); err != nil {
return fmt.Errorf("rehydrate completed-return guardian shield deck: %w", err)
}
if _, err := s.sendSelectedGuardianShieldTownAppearanceRefreshOnce(
session,
"completed_dungeon_return_guardian_shield_appearance_after_deck_before_op24",
); err != nil {
return fmt.Errorf("rehydrate completed-return guardian shield appearance: %w", err)
}
if _, err := s.sendSelectedChatEmoticonCatalog(
session,
"completed_dungeon_return_chat_emoticon_catalog_after_mode1_before_op24",
); err != nil {
return fmt.Errorf("rehydrate completed-return chat emoticon catalog: %w", err)
}
// Completed dungeon return deliberately bypasses the heavy cold-login
// container bootstrap. Rehydrate the durable slot-32 row after its first
// equipment-bearing mode1 and before the town transition.
if !session.initialTownGuardianGemRefreshSent {
if err := s.sendSelectedGuardianGemWornMedalRefresh(
session,
"completed_dungeon_return_after_mode1_before_op24",
); err != nil {
return fmt.Errorf("rehydrate completed-return guardian-gem medal: %w", err)
}
session.initialTownGuardianGemRefreshSent = true
}
// Same boundary, same reason: the worn title's raw random-enchant bytes are
// not carried by the create rows either (random_title_enchant_refresh.go).
if err := s.sendSelectedWornTitleEnchantRowRefresh(
session,
"completed_dungeon_return_after_mode1_before_op24",
); err != nil {
return fmt.Errorf("rehydrate completed-return worn title enchant row: %w", err)
}
// The current client rebuilds NPC task markers from op21/op574 only after
// the selected actor exists. Completed dungeon return bypasses the full
// cold-login initialization, so publish the repository/PVF snapshots at
// the same safe pre-op24 boundary instead of leaving the task manual stale.
// The completed bitmap (op356) must seed BEFORE these snapshots: op574 only
// upserts active rows, so an earlier bitmap is what drops the just-finished
// dungeon child from the HUD and keeps the map [quest condition] gates
// addressable when the typed op24 below rebuilds the town scene. This is the
// reference completed-return order (mode1 -> op356 -> op21/op574 -> op22/op23
// -> op24) and differs from the dungeon-selector prefix which still emits
// op22/op23 before op356.
//
// An interrupted (abandon) return is different: no dungeon quest was finished,
// so the client still owns the pre-entry quest state it left the town with.
// Replaying op356/op21/op574 here rebuilds that state from the repository and
// drops the client's cached [quest condition] town gates. The reference
// active-giveup route (sendCurrentDungeonReturnToTownLocked) publishes only
// mode0/mode1 then op22/op23/op24 and leaves the quest state untouched, so
// this caller passes seedCompletedQuestState=false and only the location pair
// is emitted below.
if seedCompletedQuestState {
if !session.initialTownLocationNotificationsSent {
if err := s.sendCurrentDungeonClearQuestSeedLocked(
ctx,
session,
characterID,
"current_exe_dungeon_return_town",
); err != nil {
return err
}
}
if err := s.sendCurrentInitialTownQuestSnapshotsLocked(
session,
"completed_dungeon_return_after_mode1_before_typed_op24",
); err != nil {
return err
}
}
if !session.initialTownLocationNotificationsSent {
if err := s.sendCurrentTownLocationNotifications(
session,
characterID,
townID,
areaID,
row,
"completed_dungeon_return_pre_op24",
); err != nil {
return err
}
session.initialTownLocationNotificationsSent = true
}
if err := s.sendCurrentTownTransitionLocked(session, characterID, townID, areaID, row, transitionBody); err != nil {
return err
}
// 回城和登录一样会重建客户端场景:HUD 上的持久投影(经验药水 / 公会状态+属性 buff /
// 属性药水 / 物品冷却)都要重放。公会那条尤其不能只补 buff —— 客户端的公会状态
// (guild ID、徽章 aura)也在重建中被清掉,只重发 buff 会出现"两个公会属性只回来一个"
//(2026-10-08 现场:448 基础 +120 → 只回 +60)。
if err := s.restoreCurrentSceneHudProjections(session, "dungeon_return_town_after_op24"); err != nil {
return fmt.Errorf("restore scene HUD projections after dungeon return: %w", err)
}
// 婚房状态(家具/情侣树)是结婚戒指上"婚房属性"的唯一数据源,场景重建后必须补发,
// 否则回城之后那枚戒指既不显示也不生效(只能靠小退重进婚房救回来)。
s.restoreCurrentSceneWeddingRoomState(session, characterID, "dungeon_return_town_after_op24")
if err := s.sendCurrentCeraShopBuyRestrictProjection(context.Background(), session, "dungeon_return_town_after_op24"); err != nil {
s.logGameEvent(session, "game-cera-shop-buy-restrict-send-failed", "source", "dungeon_return_town_after_op24", "err", err)
}
// 这里刻意不推商城钱包(见 initial_town 处的说明):回城的包序列已经被大量
// 地城返回测试逐步钉死,钱包值在这条路径上不会变化,多发只会惊动无关用例。
return nil
}
// sendCurrentCompletedTownReturnPlayerStateLocked rebuilds only the selected
// actor data that is absent after a completed-dungeon scene change, then writes
// the first equipment-bearing mode1. The dungeon scene already owns the skill
// manager, and replaying its full op19 snapshot here makes the current client
// interpret existing auxiliary-profession skills as a newly acquired
// qualification. Each successful native frame advances the route cursor so a
// transport failure retries only the remaining suffix. The caller must hold
// session.townMu.
func (s *Service) sendCurrentCompletedTownReturnPlayerStateLocked(
session *gameSession,
characterID uint16,
objectBody []byte,
mode1Body []byte,
) error {
if session == nil || characterID == 0 ||
session.initialTownRouteCharacterID != characterID ||
session.initialTownRouteStage < currentInitialTownRouteActorBound {
return nil
}
if session.initialTownRouteStage < currentInitialTownRouteSkillStateSent {
// A completed return keeps the learned-skill manager from the dungeon
// generation. Native 86JP's return route likewise does not send op19.
// Replaying it after a new town mode0 is not an initialization packet on
// this 90CN client: it opens the expert-job qualification dialog.
session.initialTownSkillInfoPrepared = false
session.initialTownSkillInfoSent = true
session.initialTownSkillInfo = currentSceneSkillInfoProjection{}
session.initialTownRouteStage = currentInitialTownRouteSkillStateSent
s.logGameEvent(session, "game-dungeon-completed-town-skill-projection-suppressed",
"char_id", characterID,
"reason", "dungeon_owned_skill_manager_must_not_replay_op19_as_a_qualification_notification")
}
if session.initialTownRouteStage < currentInitialTownRouteEquipmentMode1Sent {
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketSetUDPIPPort),
mode1Body,
0,
); err != nil {
return err
}
if err := captureCurrentPartyActorProjection(session, characterID, objectBody, mode1Body); err != nil {
s.logGameEvent(session, "game-party-actor-owner-projection-rejected",
"char_id", characterID,
"character_generation", session.characterGeneration,
"mode0_len", len(objectBody),
"mode1_len", len(mode1Body),
"reason", err)
}
// Same boundary as the return-select route: the town-side op1027
// projection is published between mode0 and this mode1, so the selected
// box is refreshed once more against the final actor.
if err := s.replayCollectboxSelectedRefreshAfterMode1(
context.Background(),
session,
"completed_dungeon_return_after_full_mode1",
); err != nil {
s.logGameEvent(session, "game-collectbox-selection-refresh-deferred",
"char_id", characterID,
"source", "completed_dungeon_return_after_full_mode1",
"reason", err)
}
session.initialTownRouteStage = currentInitialTownRouteEquipmentMode1Sent
}
if session.initialTownRouteStage < currentInitialTownRouteSkinAppearanceRefreshSent {
// The initial mode0 carries the correct skin row, but the subsequent
// complete mode1 initializes slot 8's detail object after that paper-doll
// pass. Reapply the complete mode0 table only when the skin row is
// occupied. This is the native existing-actor incremental refresh: its
// actor-state event is suppressed, so it cannot replay qualification UI.
if skinItemID, found := currentSceneObjectAppearanceSlotItemID(objectBody, 8); found &&
skinItemID != currentActorMode0AppearanceEmptyItem {
if err := s.sendSelectedActorMode0AppearanceRefresh(
session,
"completed_dungeon_return_skin_avatar",
"mode0_generation_then_mode1_then_event_suppressed_mode0_skin_avatar_refresh_before_op559",
); err != nil {
return fmt.Errorf("rehydrate completed-return skin avatar appearance: %w", err)
}
}
session.initialTownRouteStage = currentInitialTownRouteSkinAppearanceRefreshSent
}
if session.initialTownRouteStage < currentInitialTownRouteBuffRegistrationSent {
// op24 creates the final town scene feature used by NoPack's op559
// calculator. Advance the retry cursor here, but let the transition writer
// send the actual registration immediately after op24. Keeping an explicit
// pending bit makes a failed post-op24 write resume without replaying op24.
session.completedTownReturnBuffRegistrationPending = true
session.initialTownRouteStage = currentInitialTownRouteBuffRegistrationSent
}
if session.initialTownRouteStage < currentInitialTownRouteProfessionStateSent {
// Completed return deliberately retains the dungeon skill manager and does
// not replay op19. Restoring the durable expert-job projection here is
// therefore only safe while it differs from the exact op205 body this
// client already accepted: on this 90CN client op205 is an
// acquisition/mutation notification, so replaying an unchanged
// profession after a scene rebuild opens the "副职业资格认证" banner and
// the one-shot "您学会新技能了" message. A real state change still
// publishes; BUFF registration stays deferred until the final typed op24
// scene commit below.
if err := s.sendCurrentExpertJobInfoForSceneRebuildForSelectedCharacter(
session,
"completed_dungeon_return_after_mode1_before_typed_op24",
); err != nil {
return fmt.Errorf("rehydrate completed-return expert-job state: %w", err)
}
session.initialTownRouteStage = currentInitialTownRouteProfessionStateSent
s.logGameEvent(session, "game-dungeon-completed-town-expert-job-state-restored",
"char_id", characterID,
"order", "event_suppressed_mode0_mode1_retained_skills_then_changed_state_only_op205",
"qualification_packets_replayed", false)
}
if session.initialTownRouteStage < currentInitialTownRoutePlayerStatePrepared {
session.initialTownRouteStage = currentInitialTownRoutePlayerStatePrepared
session.selectedUserInfoRefreshSent = true
s.logGameEvent(session, "game-dungeon-town-route-player-state-ready",
"char_id", characterID,
"route_stage", session.initialTownRouteStage,
"sequence", "event_suppressed_mode0_equipment_mode1_optional_event_suppressed_skin_mode0_state_only_op205_typed_op24_then_op14_registered_chronicle_op559_buff_registration",
"reason", "completed_return_keeps_the_dungeon_skill_manager_and_restores_only_expert_job_state_without_acquisition")
}
return nil
}
func (s *Service) sendCurrentTownActorPrerequisitesLocked(
session *gameSession,
characterID uint16,
objectBody []byte,
mode1Body []byte,
) error {
if session == nil || characterID == 0 ||
session.initialTownRouteCharacterID != characterID ||
session.initialTownRouteStage < currentInitialTownRouteArmed {
return nil
}
if session.initialTownRouteStage < currentInitialTownRouteActionTableSent {
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketPVPMissionHpPercent),
buildCurrentActionTableStateBody(),
0,
); err != nil {
return err
}
session.initialTownRouteStage = currentInitialTownRouteActionTableSent
}
if session.returnSelectTownReentryPending &&
session.returnSelectPreviousCharacterID == characterID &&
!session.returnSelectRetainedActorRemoved {
if err := s.sendCurrentSceneFixedClass0Packet(
session,
currentTownUserLeaveMsgID,
buildTownUserLeaveBody(characterID),
"same_character_return_select_before_mode0",
); err != nil {
return err
}
session.returnSelectRetainedActorRemoved = true
s.logGameEvent(session, "game-return-select-retained-actor-remove-send",
"char_id", characterID,
"msg_id", currentTownUserLeaveMsgID,
"reason", "same_character_reselect_removes_existing_town_actor_before_mode0")
}
if session.initialTownRouteStage < currentInitialTownRouteObjectSent {
objectPacket := csharpSelectInitPacket{
class: 0,
msgID: uint16(dnfenum.CmdPacketSetUDPIPPort),
kind: csharpCurrentSceneObjectListKind,
}
if err := s.sendCSharpSelectInitPacket(session, objectPacket, objectBody); err != nil {
return err
}
session.initialTownRouteStage = currentInitialTownRouteObjectSent
}
if session.initialTownRouteStage < currentInitialTownRouteActorBound {
if len(mode1Body) > 0 {
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketSetUDPIPPort),
mode1Body,
0,
); err != nil {
return err
}
if err := captureCurrentPartyActorProjection(
session,
characterID,
objectBody,
mode1Body,
); err != nil {
// This cache is a party-admission prerequisite, not a login
// prerequisite. Keep the already-proved single-player town route
// unchanged and let party admission fail closed if an exact row pair
// was unavailable.
s.logGameEvent(session, "game-party-actor-owner-projection-rejected",
"char_id", characterID,
"character_generation", session.characterGeneration,
"mode0_len", len(objectBody),
"mode1_len", len(mode1Body),
"reason", err)
} else {
s.logGameEvent(session, "game-party-actor-owner-projection-captured",
"char_id", characterID,
"character_generation", session.characterGeneration,
"owner_server", objectBody[3],
"owner_channel", objectBody[4],
"mode0_len", len(objectBody),
"mode1_len", len(mode1Body),
"source", "successful_selected_actor_mode0_mode1_route")
}
} else {
s.logPacketEvent("game-initial-town-prerequisite-mode1-skipped",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"char_id", characterID,
"object_key", currentSceneActorObjectKey(characterID),
"reason", "route_policy_omits_actor_binding_mode1")
}
// A successfully created selected actor is a new native generation. The
// next post-skill op205 must not be suppressed by the preceding actor's
// character-ID cache; retries skip this reset because the route cursor has
// already advanced to ActorBound.
session.expertJobInfoCharacterID = 0
session.initialTownRouteStage = currentInitialTownRouteActorBound
}
// mode0 has created the selected actor. Replay the authoritative collection-
// box state at this generation boundary so the native owner applies its
// combat-stat bonuses before the scene snapshot/op24 transition.
if !session.collectboxSceneProjectionSent {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
err := s.sendCollectboxSceneInitializationStates(ctx, session)
cancel()
if err != nil {
s.logGameEvent(session, "game-collectbox-scene-projection-deferred",
"char_id", characterID,
"source", "town_actor_after_mode1",
"reason", err)
}
}
return nil
}
func (s *Service) sendCurrentTownTransitionLocked(
session *gameSession,
characterID uint16,
townID byte,
areaID byte,
row currentSceneTransitionRow,
transitionBody []byte,
) error {
if session == nil || characterID == 0 ||
session.initialTownRouteCharacterID != characterID ||
session.initialTownRouteStage < currentInitialTownRouteActorBound {
return nil
}
if session.initialTownRouteStage < currentInitialTownRouteTransitionSent {
if err := s.sendGameUpperRawClass(session, currentSceneTransitionMsgID, transitionBody, 0); err != nil {
return err
}
setCurrentTownPositionSceneFromTransitionLocked(session, characterID, townID, areaID, row)
session.initialTownRouteStage = currentInitialTownRouteTransitionSent
}
if session.initialTownRouteStage == currentInitialTownRouteTransitionSent {
if session.completedTownReturnBuffRegistrationPending {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
err := s.sendSkillSwitchInventoryAllDataRefresh(
ctx,
session,
"completed_dungeon_return_after_typed_op24",
)
cancel()
if err != nil {
return fmt.Errorf("rehydrate completed-return BUFF registration after op24: %w", err)
}
session.completedTownReturnBuffRegistrationPending = false
}
// The selected actor's authoritative op19 was installed before mode1 and
// op24. Consume its route invariant without replaying it: a duplicate
// initial snapshot is interpreted as a newly learned qualification skill.
if err := s.commitPreparedInitialTownSkillInfoAfterTransition(
session,
"initial_town_after_typed_op24",
); err != nil {
return err
}
session.initialTownRouteStage = currentInitialTownRoutePlayerStateSent
session.townSceneReadyCharacterID = characterID
}
return nil
}