package dnfbridge import ( "context" "encoding/binary" "encoding/hex" "fmt" dnfguild "longheng.io/server/internal/modules/dnf/guild" dnfrepo "longheng.io/server/internal/modules/dnf/repository" "strconv" "strings" ) func (s *Service) selectedSceneStageCharacter(session *gameSession) (dnfrepo.CharacterRecord, bool, string) { if session == nil || session.selectedCharacterID == 0 { return dnfrepo.CharacterRecord{}, false, "" } ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout) defer cancel() _, charName, character, hasCharacter := s.selectedCharacterForEnter(ctx, session) return character, hasCharacter, charName } func (s *Service) buildCurrentSceneObjectListBodyForSession(ctx context.Context, session *gameSession, charID uint16, charName string, character dnfrepo.CharacterRecord, hasCharacter bool) []byte { return s.buildCurrentSceneObjectListBodyForSessionInContext( ctx, session, charID, charName, character, hasCharacter, currentSceneObjectContext, ) } func (s *Service) buildCurrentSceneObjectListBodyForSessionInContext(ctx context.Context, session *gameSession, charID uint16, charName string, character dnfrepo.CharacterRecord, hasCharacter bool, ownerChannel byte) []byte { body, _ := s.buildCurrentSceneObjectListBodyForSessionInContextWithPolicy( ctx, session, charID, charName, character, hasCharacter, ownerChannel, false, ) return body } func (s *Service) buildCurrentSceneObjectListBodyForSessionInContextStrict( ctx context.Context, session *gameSession, charID uint16, charName string, character dnfrepo.CharacterRecord, hasCharacter bool, ownerChannel byte, ) ([]byte, error) { return s.buildCurrentSceneObjectListBodyForSessionInContextWithPolicy( ctx, session, charID, charName, character, hasCharacter, ownerChannel, true, ) } func (s *Service) buildCurrentSceneObjectListBodyForSessionInContextWithPolicy( ctx context.Context, session *gameSession, charID uint16, charName string, character dnfrepo.CharacterRecord, hasCharacter bool, ownerChannel byte, strict bool, ) ([]byte, error) { if charID == 0 { charID, charName, character, hasCharacter = s.selectedCharacterForEnter(ctx, session) } if charName == "" { charName = csharpSelectCharacterName(charID) } // Mode0 owns the visible Black Diamond badge. The entitlement remains // account/session scoped; inject only a transient character copy so no // presentation bit is persisted into the character table. if hasCharacter && session != nil && charID == session.selectedCharacterID && s.currentBlackDiamondActive(session) { character = dnfrepo.CloneCharacter(character) if character.Stats == nil { character.Stats = make(map[string]int64, 1) } character.Stats[currentBlackDiamondNameplateStat] = 1 } privateStorePermission := false if hasCharacter { accountID := strings.TrimSpace(character.AccountID) if accountID == "" && session != nil && charID == session.selectedCharacterID { accountID = strings.TrimSpace(s.accountIDForSession(session)) } entitled, entitlementErr := s.currentPrivateStoreEntitledForAccount(ctx, accountID) if entitlementErr != nil { if session != nil { s.logGameEvent(session, "game-upper-current-scene-object-private-store-load-failed", "char_id", charID, "account_id", accountID, "error", entitlementErr) } if strict { return nil, fmt.Errorf("load selected actor private-store permission: %w", entitlementErr) } } else if entitled { character = dnfrepo.CloneCharacter(character) if character.Stats == nil { character.Stats = make(map[string]int64, 1) } character.Stats[currentPrivateStoreScenePermissionStat] = 1 privateStorePermission = true } } mode0EquipSummarySource := "character_roster_partial_current_exe_typed_tail" if fullRows, source, found, err := s.loadCurrentSelectedActorMode0AppearanceSummary(ctx, session, charID); err != nil { if session != nil { s.logGameEvent(session, "game-upper-current-scene-object-mode0-appearance-load-failed", "char_id", charID, "error", err) } if strict { return nil, fmt.Errorf("load selected actor mode0 equipment appearance: %w", err) } } else if found { // Work on the value copy only. The selected character repository remains // unchanged; this replaces the packet projection with all slots 0..13. character.Roster.Entry.EquipSummary = fullRows mode0EquipSummarySource = source } // PR #239: inject name tag state from equipment slot 30 into character // stats so the mode0 tail carries the correct item ID and expiry for // the client's ReadAndApplyActorNameTagState (sub_2008D80). if itemID, expireTime, found, err := s.loadCurrentSelectedActorNameTagState(ctx, session, charID); err != nil { if session != nil { s.logGameEvent(session, "game-upper-current-scene-object-name-tag-load-failed", "char_id", charID, "error", err) } if strict { return nil, fmt.Errorf("load selected actor mode0 name-tag appearance: %w", err) } } else if found { character = dnfrepo.CloneCharacter(character) if character.Stats == nil { character.Stats = make(map[string]int64) } character.Stats["name_tag_item_id"] = int64(itemID) character.Stats["name_tag_expire_time"] = int64(expireTime) } objectKey := currentSceneActorObjectKey(charID) creature, creatureErr := s.currentEquippedCreatureForCharacterWithError(ctx, strconv.Itoa(int(charID))) if creatureErr != nil { if session != nil { s.logGameEvent(session, "game-upper-current-scene-object-creature-load-failed", "char_id", charID, "error", creatureErr) } if strict { return nil, fmt.Errorf("load selected actor mode0 creature appearance: %w", creatureErr) } creature = currentEquippedCreatureSnapshot{} } adventureName := s.currentSceneObjectAdventureOverheadName(ctx, session, charID, character, hasCharacter) guildName := "" // Guild membership is registry-owned; these fields only exist in this wire copy. character = dnfrepo.CloneCharacter(character) if character.Stats == nil { character.Stats = make(map[string]int64, 2) } character.Stats["__scene_guild_id"] = 0 character.Stats["__scene_guild_mark"] = 0 if hasCharacter && charID != 0 { guildErr := s.readCurrentGuild(ctx, func(_ dnfrepo.Group, registry dnfguild.Registry) error { if guild, _, found := registry.GuildForCharacter(strconv.Itoa(int(charID))); found && guild != nil { guildName = strings.TrimSpace(guild.Name) character.Stats["__scene_guild_id"] = int64(guild.ID) character.Stats["__scene_guild_mark"] = int64(guild.Mark) } return nil }) if guildErr != nil && session != nil { s.logGameEvent(session, "game-upper-current-scene-object-guild-load-failed", "char_id", charID, "error", guildErr) } } body := buildCurrentSceneObjectListBodyWithCreatureAndNamesInContext(objectKey, character, hasCharacter, charName, creature, adventureName, guildName, ownerChannel) rows := currentSceneObjectEquipSummary(character, hasCharacter) rawBytes := 0 for _, row := range rows { rawBytes += len(row.RawEntry) } objectLevel, tailStart, levelOK := currentSceneObjectLevelForLog(body) mode0EquipRows, mode0V113, mode0TailOK := currentSceneObjectTailSummaryForLog(body) mode0NoAttachedUIResource, mode0NoAttachedUIResourceOK := currentSceneObjectTailNoAttachedUIResourceForLog(body) mode0ActorStateEventArg, mode0ActorStateEventArgOK := currentSceneObjectTailActorStateEventArgForLog(body) mode0VisualState, mode0VisualStateOK := currentSceneObjectTailVisualStateForLog(body) mode0RawSource, mode0RawHeadHex, mode0RawWord2, mode0RawWord5, mode0RawDword0F, mode0RawDword19, mode0Raw25, mode0Raw26, mode0Raw2C, mode0Raw43, mode0RawOK := currentSceneObjectRawStateForLog(body) headLen := len(body) if headLen > 96 { headLen = 96 } if session != nil { s.logPacketEvent("game-upper-current-scene-object-list-built", "conn_id", session.connID, "channel_id", session.channel.ID, "char_id", charID, "object_key", objectKey, "owner_channel", ownerChannel, "adventure_overhead_name_set", adventureName != "", "guild_name_set", guildName != "", "guild_id", character.Stats["__scene_guild_id"], "guild_mark", character.Stats["__scene_guild_mark"], "body_len", len(body), "object_level", objectLevel, "object_level_ok", levelOK, "object_tail_start", tailStart, "body_head_hex", hex.EncodeToString(body[:headLen]), "equip_rows", len(rows), "equip_raw_source_bytes", rawBytes, "mode0_equip_summary_source", mode0EquipSummarySource, "mode0_equip_summary_rows", mode0EquipRows, "mode0_v113", mode0V113, "mode0_tail_ok", mode0TailOK, "mode0_sub2008d80_no_attached_ui_resource", mode0NoAttachedUIResource, "mode0_sub2008d80_no_attached_ui_resource_ok", mode0NoAttachedUIResourceOK, "mode0_actor_state_event_arg", mode0ActorStateEventArg, "mode0_actor_state_event_arg_ok", mode0ActorStateEventArgOK, "mode0_sub20042f0_visual_state", mode0VisualState, "mode0_sub20042f0_normal_town", mode0VisualStateOK && mode0VisualState == currentSceneNormalTownVisualState, "mode0_raw_state_source", mode0RawSource, "mode0_raw_state_ok", mode0RawOK, "mode0_raw_state_head_hex", mode0RawHeadHex, "mode0_raw_word2", mode0RawWord2, "mode0_raw_word5", mode0RawWord5, "mode0_raw_dword0f", mode0RawDword0F, "mode0_raw_dword19", mode0RawDword19, "mode0_raw25", mode0Raw25, "mode0_raw26_dword", mode0Raw26, "mode0_raw2c", mode0Raw2C, "mode0_raw43_dword", mode0Raw43, "mode0_creature_item_id", creature.itemID, "mode0_creature_serial_or_handle", creature.serialOrHandle, "mode0_creature_level", creature.level, "mode0_creature_town_display", creature.townDisplay, "mode0_creature_source", creature.source, "mode0_private_store_permission", privateStorePermission, "type44_raw_block", "empty_until_current_variant_traced") } return body, nil } // currentSceneObjectAdventureOverheadName projects only the selected local // actor's persisted adventure-group name. Peer actor packets must never reuse // the viewer's account label; unavailable account data leaves this optional // display field empty without blocking town-object creation. func (s *Service) currentSceneObjectAdventureOverheadName( ctx context.Context, session *gameSession, charID uint16, character dnfrepo.CharacterRecord, hasCharacter bool, ) string { if s == nil || session == nil || !hasCharacter || charID == 0 || charID != session.selectedCharacterID { return "" } accountID := strings.TrimSpace(s.accountIDForSession(session)) if accountID == "" || accountID != strings.TrimSpace(character.AccountID) { return "" } name, _, _, err := s.currentRepresentAccountIdentity(ctx, session) if err != nil || strings.TrimSpace(name) == "" { return "" } return name } func currentSceneObjectLevelForLog(body []byte) (byte, int, bool) { const nameLenOffset = 5 + 0x47 + 2 if len(body) < nameLenOffset+4 { return 0, 0, false } nameLen := int(binary.LittleEndian.Uint32(body[nameLenOffset : nameLenOffset+4])) tailStart := nameLenOffset + 4 + nameLen if nameLen < 0 || tailStart+2 >= len(body) { return 0, tailStart, false } return body[tailStart+2], tailStart, true } func currentSceneObjectRawStateForLog(body []byte) (string, string, uint16, uint16, uint32, uint32, byte, uint32, byte, uint32, bool) { const rawOffset = 5 const rawLen = 0x47 if len(body) < rawOffset+rawLen { return "", "", 0, 0, 0, 0, 0, 0, 0, 0, false } raw := body[rawOffset : rawOffset+rawLen] source := "current_exe_structured_raw" if bytesAllZero(raw) { source = "current_exe_zero_initialized_raw" } headLen := 16 if len(raw) < headLen { headLen = len(raw) } return source, hex.EncodeToString(raw[:headLen]), binary.LittleEndian.Uint16(raw[0x02:0x04]), binary.LittleEndian.Uint16(raw[0x05:0x07]), binary.LittleEndian.Uint32(raw[0x0f:0x13]), binary.LittleEndian.Uint32(raw[0x19:0x1d]), raw[0x25], binary.LittleEndian.Uint32(raw[0x26:0x2a]), raw[0x2c], binary.LittleEndian.Uint32(raw[0x43:0x47]), true } func bytesAllZero(raw []byte) bool { for _, value := range raw { if value != 0 { return false } } return true } func currentSceneObjectTailSummaryForLog(body []byte) (int, uint32, bool) { _, tailStart, ok := currentSceneObjectLevelForLog(body) if !ok || tailStart >= len(body) { return 0, 0, false } tail := body[tailStart:] if len(tail) <= 6 { return 0, 0, false } equipEnd, ok := currentSceneObjectEquipSummaryEnd(tail, 6) if !ok || equipEnd+4 > len(tail) { return int(tail[6]), 0, false } return int(tail[6]), binary.LittleEndian.Uint32(tail[equipEnd : equipEnd+4]), true } // currentSceneObjectAppearanceSlotItemID reads one typed mode0 appearance row. // Existing-actor redraws deliberately use this same complete table; it is not // valid to synthesize a one-row mode0 packet for a skin/avatar change. func currentSceneObjectAppearanceSlotItemID(body []byte, wantSlot byte) (uint32, bool) { _, tailStart, ok := currentSceneObjectLevelForLog(body) if !ok || tailStart >= len(body) { return 0, false } tail := body[tailStart:] if len(tail) <= 6 { return 0, false } count := int(tail[6]) pos := 7 for row := 0; row < count; row++ { if pos+currentActorMode0AppearanceRowBytes > len(tail) { return 0, false } if tail[pos] == wantSlot { return binary.LittleEndian.Uint32(tail[pos+1 : pos+5]), true } pos += currentActorMode0AppearanceRowBytes } return 0, false } func currentSceneObjectTailNoAttachedUIResourceForLog(body []byte) (bool, bool) { _, tailStart, ok := currentSceneObjectLevelForLog(body) if !ok || tailStart >= len(body) { return false, false } tail := body[tailStart:] if len(tail) <= 6 { return false, false } equipEnd, ok := currentSceneObjectEquipSummaryEnd(tail, 6) if !ok { return false, false } rawOffset := equipEnd + 8 if rawOffset+8 > len(tail) { return false, false } return binary.LittleEndian.Uint32(tail[rawOffset:rawOffset+4]) == currentSceneNoAttachedUIResourceID && binary.LittleEndian.Uint32(tail[rawOffset+4:rawOffset+8]) == currentSceneNoAttachedUIResourceID, true } func currentSceneObjectTailActorStateEventArgForLog(body []byte) (uint32, bool) { offset, ok := currentSceneObjectTailActorStateEventArgOffset(body) if !ok { return 0, false } return binary.LittleEndian.Uint32(body[offset : offset+4]), true } func currentSceneObjectTailPrivateStorePermissionForLog(body []byte) (byte, bool) { offset, ok := currentSceneObjectTailPrivateStorePermissionOffset(body) if !ok { return 0, false } return body[offset], true } func currentSceneObjectTailPrivateStorePermissionOffset(body []byte) (int, bool) { _, tailStart, ok := currentSceneObjectLevelForLog(body) if !ok || tailStart >= len(body) { return 0, false } tail := body[tailStart:] equipEnd, ok := currentSceneObjectEquipSummaryEnd(tail, 6) if !ok { return 0, false } // v113 + v114/v115/v104/v97 + name-tag item/expiry + sub_2003A20 // + sub_2003AD0 lead directly to the one-byte permission field. pos := equipEnd + 4 + 4 + 8 + 1 + 4 if pos >= len(tail) { return 0, false } return tailStart + pos, true } func currentSceneObjectTailActorStateEventArgOffset(body []byte) (int, bool) { _, tailStart, ok := currentSceneObjectLevelForLog(body) if !ok || tailStart >= len(body) { return 0, false } tail := body[tailStart:] if len(tail) <= 6 { return 0, false } equipEnd, ok := currentSceneObjectEquipSummaryEnd(tail, 6) if !ok { return 0, false } // After the variable equipment summary, consume the current EXE's typed // fields up to v121: v113, four flags, raw8, resource flags, creature, // three single-byte states, v120, organization DSTR, and state u32. pos := equipEnd + 4 + 4 + 8 + 1 + 4 + 1 if pos+4 > len(tail) { return 0, false } pos += 4 // creature template if pos+4 > len(tail) { return 0, false } creatureNameLen := int(binary.LittleEndian.Uint32(tail[pos : pos+4])) pos += 4 + creatureNameLen + 1 if creatureNameLen < 0 || pos+1+1+4+4 > len(tail) { return 0, false } pos += 1 + 1 + 4 // sub_2003B80, sub_2003C90, v120 if pos+4 > len(tail) { return 0, false } organizationNameLen := int(binary.LittleEndian.Uint32(tail[pos : pos+4])) pos += 4 + organizationNameLen + 4 // DSTR plus sub_2003D40 state value if organizationNameLen < 0 || pos+4 > len(tail) { return 0, false } return tailStart + pos, true } func suppressCurrentSceneObjectActorStateEvent(body []byte) bool { offset, ok := currentSceneObjectTailActorStateEventArgOffset(body) if !ok { return false } binary.LittleEndian.PutUint32(body[offset:offset+4], 0) return true } func currentSceneObjectTailVisualStateForLog(body []byte) (byte, bool) { _, tailStart, ok := currentSceneObjectLevelForLog(body) if !ok || tailStart >= len(body) { return 0, false } tail := body[tailStart:] // In the current typed mode0 writer, sub_20042F0's u8 is followed by the // fixed 19-byte finalizer state (u32/u8/u16/u8/u16/u8/u16/u8/u8/u32). const finalizerBytesAfterVisualState = 19 if len(tail) <= finalizerBytesAfterVisualState { return 0, false } return tail[len(tail)-1-finalizerBytesAfterVisualState], true }