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

864 lines
36 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}