服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
551 lines
23 KiB
Go
551 lines
23 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
"longheng.io/server/internal/modules/dnf/progression"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
currentFinishLoadingCharacterStateBodySize = 87
|
|
currentFinishLoadingProtectedRequestBodySize = 16
|
|
currentFinishLoadingLegacyRequestBodySize = 8
|
|
currentIncreaseStatusResultMsgID = 30
|
|
currentIncreaseStatusResultBodySize = 5
|
|
)
|
|
|
|
func currentFinishLoadingRequestBodyAccepted(body []byte) bool {
|
|
switch len(body) {
|
|
case currentFinishLoadingLegacyRequestBodySize, currentFinishLoadingProtectedRequestBodySize:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Service) sendCurrentFinishLoadingCharacterState(session *gameSession, source string) error {
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
if !currentFinishLoadingHasDungeonRuntime(session) {
|
|
// 离开副本(回城)后重置;必须放在早退检查之前,
|
|
// 否则回城时若 state/completion 标志已消费完,函数会提前返回而不复位。
|
|
session.currentDungeonReviveReapplySent = false
|
|
}
|
|
stateNeeded := !session.currentFinishLoadingStateSent
|
|
followupsNeeded := !session.currentFinishLoadingCompletionSent ||
|
|
!session.postFinishLoadingPlayerStateSent
|
|
if !stateNeeded && !followupsNeeded {
|
|
// The caller owns the final empty class0/op42 suffix after every other
|
|
// finish-loading projection. Do not replay op37/op30 or op120 here.
|
|
return nil
|
|
}
|
|
|
|
if stateNeeded {
|
|
if currentInitialTownPlayerStateFinalized(session) && !currentFinishLoadingHasDungeonRuntime(session) {
|
|
// The native town actor was already fully initialized by mode0/mode1,
|
|
// op205, op19 and typed op24. Replaying the 87-byte class0/op37
|
|
// character snapshot here is not an ACK: the current EXE routes it
|
|
// through the secondary-profession qualification UI and opens the
|
|
// one-shot "learned skill" dialog on every scene reconstruction.
|
|
// Keep the client-authored op37 success ACK and the proven op30/op120
|
|
// suffix, but do not inject this duplicate town initialization packet.
|
|
s.logGameEvent(session, "game-main-finish-loading-town-state-suppressed",
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"msg_id", uint16(dnfenum.CmdPacketFinishLoading),
|
|
"classification", 0,
|
|
"body_len", currentFinishLoadingCharacterStateBodySize,
|
|
"reason", "initial_town_actor_already_initialized_and_op37_opens_secondary_profession_dialog")
|
|
} else {
|
|
if err := s.sendCurrentFinishLoadingCharacterStateSnapshot(session, source); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Commit this gate as soon as the state packet is on the wire. A later
|
|
// op30/post-state failure must not replay the absolute op37 snapshot.
|
|
session.currentFinishLoadingStateSent = true
|
|
}
|
|
if !session.currentFinishLoadingCompletionSent {
|
|
if err := s.sendCurrentIncreaseStatusResult(session, source); err != nil {
|
|
return err
|
|
}
|
|
session.currentFinishLoadingCompletionSent = true
|
|
}
|
|
if err := s.sendCurrentPostFinishLoadingPlayerState(session, source+"_after_finish_loading_state"); err != nil {
|
|
return err
|
|
}
|
|
// 蛇腹剑这类一次性 actor 被动:等客户端真正完成副本加载后再补学习回执,
|
|
// 此时技能管理器已经绑定到副本 actor。见 dungeon_atswordman_snake_sword.go。
|
|
if currentFinishLoadingHasDungeonRuntime(session) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
if charID, _, character, hasCharacter := s.selectedCharacterForEnter(ctx, session); hasCharacter && charID != 0 {
|
|
if err := s.sendDungeonActorPassiveLearnReplayAtFinishLoading(
|
|
ctx,
|
|
session,
|
|
character,
|
|
source+"_finish_loading",
|
|
); err != nil {
|
|
s.logGameEvent(session, "game-dungeon-passive-learn-replay-finish-loading-failed",
|
|
"source", source+"_finish_loading",
|
|
"character_id", character.CharacterID,
|
|
"error", err)
|
|
}
|
|
// 2026-10-20 实测回滚:FINISH_LOADING 之后再发完整 op19 会洗掉已挂的
|
|
// 蛇腹剑形态(未觉醒角色同样丢失),因此这里不再补发技能快照。
|
|
// 存活状态 op32 由 sendDungeonPassiveReviveReapply 显式开关控制。
|
|
if !session.currentDungeonReviveReapplySent &&
|
|
currentDungeonPassiveReviveReapplyEnabled() &&
|
|
currentNeedsSnakeSwordDungeonPrebind(character) {
|
|
if err := s.sendDungeonPassiveReviveReapply(
|
|
ctx,
|
|
session,
|
|
character,
|
|
source+"_finish_loading_revive_reapply",
|
|
); err != nil {
|
|
s.logGameEvent(session, "game-dungeon-passive-revive-reapply-failed",
|
|
"source", source+"_finish_loading_revive_reapply",
|
|
"character_id", character.CharacterID,
|
|
"error", err)
|
|
} else {
|
|
// 一次副本只补发一次,避免换图重复触发。
|
|
session.currentDungeonReviveReapplySent = true
|
|
}
|
|
}
|
|
}
|
|
cancel()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func currentFinishLoadingHasDungeonRuntime(session *gameSession) bool {
|
|
if session == nil {
|
|
return false
|
|
}
|
|
session.dungeon.mu.Lock()
|
|
hasRuntime := session.dungeon.runtime != nil
|
|
session.dungeon.mu.Unlock()
|
|
return hasRuntime
|
|
}
|
|
|
|
// sendCurrentFinishLoadingCharacterStateSnapshot writes the class0/op37
|
|
// character snapshot without consuming the request-owned lifecycle gates.
|
|
// Town op24 recovery uses the same authoritative body after rebuilding the
|
|
// selected actor, while ordinary client op37 handling keeps its own gates in
|
|
// sendCurrentFinishLoadingCharacterState.
|
|
func (s *Service) sendCurrentFinishLoadingCharacterStateSnapshot(session *gameSession, source string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
stepStarted := time.Now()
|
|
charID, _, character, hasCharacter := s.selectedCharacterForEnter(ctx, session)
|
|
s.probeSlowStep(session, source, "selected_character_for_enter", stepStarted)
|
|
if charID == 0 || !hasCharacter {
|
|
s.logGameEvent(session, "game-main-finish-loading-state-deferred",
|
|
"source", source,
|
|
"char_id", charID,
|
|
"reason", "selected_character_record_missing")
|
|
return fmt.Errorf(
|
|
"finish-loading character snapshot unavailable: selected=%d loaded=%d found=%t",
|
|
session.selectedCharacterID,
|
|
charID,
|
|
hasCharacter,
|
|
)
|
|
}
|
|
points := dnfrepo.SkillPointState{}
|
|
if repositories, ok := s.repositoryGroup(); ok && repositories.Skill != nil {
|
|
stepStarted = time.Now()
|
|
skill, found, err := repositories.Skill.Load(ctx, character.CharacterID)
|
|
s.probeSlowStep(session, source, "skill_load", stepStarted)
|
|
if err != nil {
|
|
return fmt.Errorf("load finish-loading skill points for character %s: %w", character.CharacterID, err)
|
|
}
|
|
if found {
|
|
activeTree, treeErr := currentSceneActiveSkillTree(character)
|
|
if treeErr == nil {
|
|
if state, treeFound := dnfrepo.SkillTree(skill, int(activeTree)); treeFound {
|
|
points = state.Points
|
|
}
|
|
}
|
|
stepStarted = time.Now()
|
|
synced, _, syncErr := s.syncCurrentSceneSkillPointLedgers(ctx, repositories, character, skill)
|
|
s.probeSlowStep(session, source, "skill_point_ledger_sync", stepStarted)
|
|
if syncErr == nil {
|
|
if state, treeFound := dnfrepo.SkillTree(synced, int(activeTree)); treeFound {
|
|
points = state.Points
|
|
}
|
|
} else {
|
|
// The op37 state packet existed before PVF ledger repair and must not
|
|
// disappear when a synthetic/test owner has no runtime PVF. Production
|
|
// records still use the persisted ledger, while the explicit event keeps
|
|
// the unsynchronised state observable instead of inventing point values.
|
|
s.logGameEvent(session, "game-main-finish-loading-skill-point-sync-skipped",
|
|
"source", source,
|
|
"char_id", charID,
|
|
"error", syncErr)
|
|
}
|
|
} else {
|
|
s.logGameEvent(session, "game-main-finish-loading-skill-point-ledger-missing",
|
|
"source", source,
|
|
"char_id", charID)
|
|
}
|
|
} else {
|
|
s.logGameEvent(session, "game-main-finish-loading-skill-point-repository-missing",
|
|
"source", source,
|
|
"char_id", charID)
|
|
}
|
|
stepStarted = time.Now()
|
|
presentation, err := s.currentFinishLoadingSnapshotPresentation(ctx, character)
|
|
s.probeSlowStep(session, source, "finish_loading_presentation", stepStarted)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve finish-loading experience baseline for character %s: %w", character.CharacterID, err)
|
|
}
|
|
body := buildCurrentFinishLoadingCharacterStateBodyWithPresentation(character, points, presentation)
|
|
if len(body) != currentFinishLoadingCharacterStateBodySize {
|
|
return fmt.Errorf("current finish-loading state body length %d, want %d", len(body), currentFinishLoadingCharacterStateBodySize)
|
|
}
|
|
s.logGameEvent(session, "game-main-finish-loading-state-send",
|
|
"source", source,
|
|
"char_id", charID,
|
|
"msg_id", uint16(dnfenum.CmdPacketFinishLoading),
|
|
"classification", 0,
|
|
"body_len", len(body),
|
|
"level", body[0],
|
|
"total_exp", statU32(character, "exp", 0),
|
|
"remaining_sp", points.RemainingSP,
|
|
"remaining_tp", points.RemainingTP,
|
|
"dynamic_count", body[0x2e],
|
|
"request_followups", true,
|
|
"body_source", "current_exe_sub_1D78240_real_character_minimum_dynamic_table_empty")
|
|
stepStarted = time.Now()
|
|
sendErr := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketFinishLoading), body, 0)
|
|
s.probeSlowStep(session, source, "send_finish_loading_body", stepStarted)
|
|
return sendErr
|
|
}
|
|
|
|
func (s *Service) sendCurrentIncreaseStatusResult(session *gameSession, source string) error {
|
|
body := buildCurrentIncreaseStatusResultBody()
|
|
if len(body) != currentIncreaseStatusResultBodySize {
|
|
return fmt.Errorf("current increase-status result body length %d, want %d", len(body), currentIncreaseStatusResultBodySize)
|
|
}
|
|
s.logGameEvent(session, "game-main-increase-status-result-send",
|
|
"source", source,
|
|
"msg_id", currentIncreaseStatusResultMsgID,
|
|
"classification", 0,
|
|
"body_len", len(body),
|
|
"duration", 0,
|
|
"selector", 0,
|
|
"actor_effect", "none",
|
|
"body_source", "current_exe_op30_increase_status_timer_ui_not_actor_commit")
|
|
return s.sendGameUpperRawClass(session, currentIncreaseStatusResultMsgID, body, 0)
|
|
}
|
|
|
|
func buildCurrentIncreaseStatusResultBody() []byte {
|
|
var writer packetWriter
|
|
writer.writeUint32(0) // No persisted optional duration/status state for this fresh character.
|
|
writer.writeByte(0)
|
|
return writer.bytes()
|
|
}
|
|
|
|
func (s *Service) sendCurrentPostFinishLoadingPlayerState(session *gameSession, source string) error {
|
|
if session == nil || session.postFinishLoadingPlayerStateSent {
|
|
return nil
|
|
}
|
|
initialTownFinalized := currentInitialTownPlayerStateFinalized(session)
|
|
if session.selectedCharacterID == 0 {
|
|
s.logGameEvent(session, "game-main-post-finish-loading-player-state-deferred",
|
|
"source", source,
|
|
"char_id", 0,
|
|
"reason", "selected_character_record_missing")
|
|
return nil
|
|
}
|
|
if initialTownFinalized {
|
|
// A real class1/op37 request may still occur in town, so acknowledge and
|
|
// publish its own op37/op30/op120 state. It does not own initial op19,
|
|
// which the staged post-op24 generation installed after its own op30.
|
|
// This prevents a later request from rebuilding the same native skill
|
|
// manager.
|
|
s.logGameEvent(session, "game-main-post-finish-loading-player-state-send",
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"sequence", "op120_current_hpmp_only_initial_op19_already_installed_after_post_op24_op30",
|
|
"state_source", "request_owned_finish_loading_does_not_replay_initial_town_skill_manager")
|
|
placementBody := buildCurrentSceneActorPlacementBody()
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketRequestBlacklist), placementBody, 0); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendCurrentTrainingRoomBuffRegistration(session, source); err != nil {
|
|
return err
|
|
}
|
|
session.postFinishLoadingPlayerStateSent = true
|
|
return nil
|
|
}
|
|
sequence := "op19_final_actor_skills_op120_current_hpmp_then_deferred_tutorial_op3"
|
|
if session.dungeonSceneSkillInfoSent {
|
|
sequence = "op120_current_hpmp_then_deferred_tutorial_op3_dungeon_skill_initialized_after_mode1"
|
|
}
|
|
s.logGameEvent(session, "game-main-post-finish-loading-player-state-send",
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"sequence", sequence,
|
|
"state_source", "pre_op27_mode0_and_post_op29_mode1_already_initialized_the_local_actor")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
charID, _, character, hasCharacter := s.selectedCharacterForEnter(ctx, session)
|
|
if charID == 0 || !hasCharacter {
|
|
s.logGameEvent(session, "game-main-post-finish-loading-player-state-deferred",
|
|
"source", source,
|
|
"char_id", charID,
|
|
"reason", "selected_character_record_missing")
|
|
return nil
|
|
}
|
|
if !session.dungeonSceneSkillInfoSent {
|
|
if err := s.sendCurrentSceneSkillInfo(session, ctx, character, source+"_final_actor_skills"); err != nil {
|
|
return err
|
|
}
|
|
session.dungeonSceneSkillInfoSent = true
|
|
} else {
|
|
s.logGameEvent(session, "game-main-post-finish-loading-skill-info-skipped",
|
|
"source", source,
|
|
"char_id", charID,
|
|
"reason", "dungeon_mode1_skill_projection_already_sent_before_scene_commit")
|
|
}
|
|
// The post-start-map path sends this after its own op19. If skill projection
|
|
// was deferred until the client finish-loading request, this is the matching
|
|
// safe boundary. The character-ID gate prevents a duplicate op205, and an
|
|
// unchanged profession is not replayed at all: on this 90CN client that
|
|
// replay is an acquisition notification, and a town generation whose op19
|
|
// was suppressed would open the qualification banner with it.
|
|
if err := s.sendCurrentExpertJobInfoForSceneRebuild(
|
|
session,
|
|
character,
|
|
source+"_after_final_scene_skill_manager",
|
|
); err != nil {
|
|
return fmt.Errorf("restore dungeon expert-job state after final skill manager: %w", err)
|
|
}
|
|
// The saved option table needs one replay here for the same reason the
|
|
// expert-job projection does: its client-side applier is gated on the
|
|
// local actor, which the scene-init send predates. Without it the
|
|
// skill-cooldown reminder stays off until the player sets it again.
|
|
if err := s.sendCurrentCharacterOptionSnapshotForActor(session, source+"_after_final_scene_skill_manager"); err != nil {
|
|
return fmt.Errorf("replay character option snapshot after final skill manager: %w", err)
|
|
}
|
|
// The current NoPack client clears the Dark Knight's separate native combo
|
|
// manager as this final scene transition completes. The normal op19 state
|
|
// is intentionally not replayed when it was already sent, but job 9 still
|
|
// requires its real class-0/op447 page restore here, after the final actor
|
|
// skill manager exists and before the placement packet makes the scene live.
|
|
// This is not the removed private op448 compatibility projection.
|
|
if err := s.sendCurrentDarkKnightComboInitialization(session, ctx, character, source+"_after_final_scene_skill_manager"); err != nil {
|
|
return err
|
|
}
|
|
// A newly created Creator enters its tutorial dungeon without ever crossing
|
|
// the town type1345 HUD-ready boundary. Apply the class-owned WASD/Space and
|
|
// 1..5 keymap here, after the final dungeon skill manager exists and before
|
|
// placement exposes the HUD. Ordinary jobs and Dark Knight remain untouched.
|
|
if err := s.sendCurrentCreatorKeymapInitialization(session, ctx, character, source+"_after_final_scene_skill_manager"); err != nil {
|
|
return err
|
|
}
|
|
// mode3 is the full personal-information/equipment snapshot and is not part
|
|
// of dungeon actor initialization. Replaying it after op37 opens that panel
|
|
// over the dungeon and can replace live room actor state, so this boundary
|
|
// deliberately emits only the skill refresh and current HP/MP placement.
|
|
placementBody := buildCurrentSceneActorPlacementBody()
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketRequestBlacklist), placementBody, 0); err != nil {
|
|
return err
|
|
}
|
|
// Only PVF tutorial scenes defer state 1 to this boundary. Ordinary
|
|
// dungeons sent it after their proven mode0/mode1 actor binding above;
|
|
// replaying it here suppresses the native GetItem writer on the current EXE.
|
|
objectKey := session.deferredDungeonUserStateObjectKey
|
|
if objectKey != 0 {
|
|
userStateBody, err := buildCurrentDungeonUserStateBody(objectKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-main-post-finish-loading-deferred-user-state-send",
|
|
"source", source,
|
|
"char_id", charID,
|
|
"object_key", objectKey,
|
|
"msg_id", uint16(dnfenum.CmdPacketNotifyUserState),
|
|
"classification", 0,
|
|
"user_state", currentDungeonPlayerUserState,
|
|
"body_len", len(userStateBody),
|
|
"body_source", "current_exe_sub_1D88A10_after_mode0_mode1_and_op120_room_object_manager_refresh")
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketNotifyUserState), userStateBody, 0); err != nil {
|
|
return err
|
|
}
|
|
session.deferredDungeonUserStateObjectKey = 0
|
|
}
|
|
if err := s.sendCurrentTrainingRoomBuffRegistration(session, source); err != nil {
|
|
return err
|
|
}
|
|
session.postFinishLoadingPlayerStateSent = true
|
|
return nil
|
|
}
|
|
|
|
// sendCurrentTrainingRoomBuffRegistration replays the durable BUFF
|
|
// registration for a training-room direct entry, which is the one dungeon
|
|
// route that never reaches the ordinary placement tail.
|
|
//
|
|
// Every other dungeon binds it from sendCurrentPostStartMapPlayerPlacement
|
|
// after the op124 layout commit, and the shared party route does the same
|
|
// after its final op19 - "otherwise party dungeons never bind the durable BUFF
|
|
// enhancement set at all". Direct entry sends neither op124 nor the placement
|
|
// tail, so before this the training room was the only map where a registered
|
|
// buff set simply did not apply. Doing it here, off the client's own finish
|
|
// loading, is the first moment its scene owns the skill-modifier rebuild that
|
|
// op559 triggers.
|
|
func (s *Service) sendCurrentTrainingRoomBuffRegistration(session *gameSession, source string) error {
|
|
if s == nil || session == nil || !session.trainingRoomBuffRegistrationPending {
|
|
return nil
|
|
}
|
|
session.trainingRoomBuffRegistrationPending = false
|
|
if session.selectedCharacterID == 0 {
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
if err := s.sendSkillSwitchInventoryAllDataRefresh(
|
|
ctx,
|
|
session,
|
|
source+"_training_room_after_finish_loading",
|
|
); err != nil {
|
|
return fmt.Errorf("rehydrate training-room BUFF registration: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func currentInitialTownPlayerStateFinalized(session *gameSession) bool {
|
|
if session == nil {
|
|
return false
|
|
}
|
|
session.townMu.Lock()
|
|
finalized := session.initialTownRouteCharacterID != 0 &&
|
|
session.initialTownRouteCharacterID == session.selectedCharacterID &&
|
|
session.initialTownRouteStage >= currentInitialTownRoutePlayerStateSent
|
|
session.townMu.Unlock()
|
|
return finalized
|
|
}
|
|
|
|
func buildCurrentFinishLoadingCharacterStateBody(
|
|
character dnfrepo.CharacterRecord,
|
|
points dnfrepo.SkillPointState,
|
|
) []byte {
|
|
return buildCurrentFinishLoadingCharacterStateBodyWithPresentation(character, points, nil)
|
|
}
|
|
|
|
type currentFinishLoadingExperiencePresentation struct {
|
|
GrowthContractBonus uint32
|
|
FatigueBatteryBonus uint32
|
|
// WelcomeBonus 是回归冒险家(onlineevent returnee)的 +20% 欢迎怪物经验,
|
|
// 在 op37 里有自己的分类字段,不能被折进 TotalExperience 里静默吃掉。
|
|
WelcomeBonus uint32
|
|
TotalExperience *uint32
|
|
}
|
|
|
|
func buildCurrentFinishLoadingCharacterStateBodyWithPresentation(
|
|
character dnfrepo.CharacterRecord,
|
|
points dnfrepo.SkillPointState,
|
|
presentation *currentFinishLoadingExperiencePresentation,
|
|
) []byte {
|
|
var writer packetWriter
|
|
totalExperience := statU32(character, "exp", 0)
|
|
if presentation != nil && presentation.TotalExperience != nil {
|
|
totalExperience = *presentation.TotalExperience
|
|
}
|
|
writer.writeByte(currentFinishLoadingLevel(character.Level))
|
|
writer.writeUint32(totalExperience)
|
|
writer.writeUint32(statU32(character, "finish_loading_exp_category_0", 0))
|
|
writer.writeUint16(currentFinishLoadingSkillPoint(points.RemainingSP))
|
|
writer.writeUint16(currentFinishLoadingSkillPoint(points.RemainingSP))
|
|
writer.writeUint16(currentFinishLoadingSkillPoint(points.RemainingTP))
|
|
writer.writeUint16(currentFinishLoadingSkillPoint(points.RemainingTP))
|
|
writer.writeUint32(statU32(character, "finish_loading_currency_slot2_total", 0))
|
|
// Current sub_1D78240 reads +0x15 as the 0xA5F4 fatigue-buff EXP
|
|
// category and subtracts it from the displayed base, not from total EXP.
|
|
fatigueBatteryBonus := statU32(character, "finish_loading_exp_category_1", 0)
|
|
if presentation != nil {
|
|
fatigueBatteryBonus = presentation.FatigueBatteryBonus
|
|
}
|
|
writer.writeUint32(fatigueBatteryBonus)
|
|
writer.writeByte(byte(statU32(character, "finish_loading_result_flag", 0)))
|
|
writer.writeUint32(statU32(character, "finish_loading_exp_category_2", 0))
|
|
growthContractBonus := statU32(character, "finish_loading_exp_category_3", 0)
|
|
if presentation != nil {
|
|
growthContractBonus = presentation.GrowthContractBonus
|
|
}
|
|
writer.writeUint32(growthContractBonus)
|
|
writer.writeUint32(statU32(character, "finish_loading_independent_scalar", 0))
|
|
writer.writeUint32(statU32(character, "finish_loading_exp_category_4", 0))
|
|
writer.writeUint32(statU32(character, "finish_loading_exp_category_5", 0))
|
|
writer.writeByte(0) // No persisted current dynamic EXP-category rows.
|
|
// Offset 47 is the current client's 80009/Buff parameter. Per-monster
|
|
// settlement values belong to op35's object-key dynamic table, never here.
|
|
writer.writeUint32(statU32(character, "finish_loading_exp_category_6", 0))
|
|
writer.writeUint32(statU32(character, "finish_loading_exp_category_7", 0))
|
|
writeCurrentHonorExpertState(&writer, character)
|
|
writer.writeUint32(statU32(character, "finish_loading_exp_category_8", 0))
|
|
writer.writeUint32(0) // Consumed but unused by the current reader.
|
|
// +0x4B 是原生"回归冒险家欢迎经验"分类:参考实现从这个偏移读,
|
|
// op37 的 75 字节校验也依赖它。没有 presentation 时回落到持久化分类。
|
|
welcomeBonus := statU32(character, "finish_loading_exp_category_9", 0)
|
|
if presentation != nil {
|
|
welcomeBonus = presentation.WelcomeBonus
|
|
}
|
|
writer.writeUint32(welcomeBonus)
|
|
writer.writeUint32(statU32(character, "finish_loading_exp_category_10", 0))
|
|
writer.writeUint32(statU32(character, "finish_loading_exp_category_11", 0))
|
|
return writer.bytes()
|
|
}
|
|
|
|
func (s *Service) currentFinishLoadingSnapshotPresentation(
|
|
ctx context.Context,
|
|
character dnfrepo.CharacterRecord,
|
|
) (*currentFinishLoadingExperiencePresentation, error) {
|
|
persisted := statU32(character, "exp", 0)
|
|
if character.Level < currentAdventureCharacterLevelCap {
|
|
return nil, nil
|
|
}
|
|
s.initialEquipmentMu.Lock()
|
|
archive, err := s.initialEquipmentArchiveLocked()
|
|
s.initialEquipmentMu.Unlock()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tables, err := progression.Load(ctx, archive)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
baseline, err := currentCharacterExperienceBaseline(tables, character.Level, persisted)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ¤tFinishLoadingExperiencePresentation{TotalExperience: &baseline}, nil
|
|
}
|
|
|
|
func currentFinishLoadingLevel(level int) byte {
|
|
switch {
|
|
case level < 1:
|
|
return newCharacterInitialLevel
|
|
case level > 0xff:
|
|
return 0xff
|
|
default:
|
|
return byte(level)
|
|
}
|
|
}
|
|
|
|
func currentFinishLoadingSkillPoint(value int) uint16 {
|
|
if value <= 0 {
|
|
return 0
|
|
}
|
|
if value > int(^uint16(0)) {
|
|
return ^uint16(0)
|
|
}
|
|
return uint16(value)
|
|
}
|