package dnfbridge import ( "context" "crypto/sha256" "encoding/binary" "encoding/hex" "os" "sort" "strconv" "strings" "time" "unicode/utf16" dnfcompanion "longheng.io/server/internal/modules/dnf/companion" dnfequip "longheng.io/server/internal/modules/dnf/equip" dnfrepo "longheng.io/server/internal/modules/dnf/repository" ) const ( currentDungeonCompanionProjectionSourceEnv = "DNF_COMPANION_P0_SOURCE_CHARACTER_ID" currentDungeonCompanionProjectionOpcode = uint16(0x7F41) currentDungeonCompanionProjectionMagic = uint32(0x314A5250) // PRJ1 currentDungeonCompanionProjectionVersion = byte(3) currentDungeonCompanionProjectionFlagPresent = byte(1) currentDungeonCompanionProjectionFlagAIPlan = byte(4) currentDungeonCompanionProjectionHeaderSize = 72 currentDungeonCompanionProjectionMaxEquip = 32 currentDungeonCompanionProjectionMaxSkills = 128 currentDungeonCompanionProjectionMaxQuick = 16 currentDungeonCompanionProjectionObjectKey = uint16(0xFFFF) currentDungeonCompanionMode0ObjectKeyOffset = 0x4C currentDungeonCompanionMode1ObjectKeyOffset = 0x15 ) func currentDungeonCompanionProjectionSourceIDValue(value string) (uint16, bool) { value = strings.TrimSpace(value) if value == "" { return 0, false } parsed, err := strconv.ParseUint(value, 10, 16) if err != nil || parsed == 0 || parsed >= uint64(^uint16(0)) || strconv.FormatUint(parsed, 10) != value { return 0, false } return uint16(parsed), true } type currentDungeonCompanionProjectionSkillPair struct { ID uint32 Level uint32 } type currentDungeonCompanionProjectionAIPlan struct { Behavior byte ComboPresetHash byte ClassID uint16 ComboEnabled bool } func currentDungeonCompanionProjectionAIPlanFor( profile dnfcompanion.AIProfile, job byte, ) currentDungeonCompanionProjectionAIPlan { profile = dnfcompanion.NormalizeAIProfile(profile) behavior := byte(1) switch profile.Behavior { case "aggressive": behavior = 2 } classID := profile.ClassID if classID == 0 { classID = uint16(job) } // Keep the packet compact; the full preset name remains in GM settings. // The client needs a stable selector until the native action ABI is proved. var hash byte = 216 for i := 0; i < len(profile.ComboPreset); i++ { hash ^= profile.ComboPreset[i] hash *= 167 } return currentDungeonCompanionProjectionAIPlan{ Behavior: behavior, ComboPresetHash: hash, ClassID: classID, ComboEnabled: profile.ComboEnabled, } } func currentDungeonCompanionProjectionAIPlanWord( plan currentDungeonCompanionProjectionAIPlan, ) uint32 { flags := plan.Behavior & 0x03 if plan.ComboEnabled { flags |= 0x04 } return uint32(flags) | uint32(plan.ComboPresetHash)<<8 | uint32(plan.ClassID)<<16 } func currentDungeonCompanionEquipmentIDs( record dnfrepo.EquipmentRecord, now time.Time, ) ([]uint32, error) { if now.IsZero() { return nil, dnfcompanion.ErrSnapshot } type equipmentItem struct { slot int16 id uint32 } equipmentRows := make([]equipmentItem, 0, len(record.Entries)) slots := make(map[int16]struct{}, len(record.Entries)) for _, entry := range record.Entries { if entry.SlotIndex < 0 || entry.SlotIndex > 255 || entry.ItemID <= 0 || entry.ItemID > int64(^uint32(0)) { return nil, dnfcompanion.ErrSnapshot } if _, exists := slots[entry.SlotIndex]; exists { return nil, dnfcompanion.ErrSnapshot } slots[entry.SlotIndex] = struct{}{} if !entry.ExpireAt.IsZero() && !entry.ExpireAt.After(now) { continue } itemID := entry.ItemID if raw := strings.TrimSpace(entry.Extra[dnfequip.CloneAvatarDisplayItemIDExtraKey]); raw != "" { value, err := strconv.ParseInt(raw, 10, 64) if err != nil || value <= 0 || value > int64(^uint32(0)) { return nil, dnfcompanion.ErrSnapshot } itemID = value } equipmentRows = append(equipmentRows, equipmentItem{ slot: entry.SlotIndex, id: uint32(itemID), }) } sort.Slice(equipmentRows, func(i, j int) bool { if equipmentRows[i].slot != equipmentRows[j].slot { return equipmentRows[i].slot < equipmentRows[j].slot } return equipmentRows[i].id < equipmentRows[j].id }) if len(equipmentRows) > currentDungeonCompanionProjectionMaxEquip { return nil, dnfcompanion.ErrSnapshot } equipmentIDs := make([]uint32, len(equipmentRows)) for i := range equipmentRows { equipmentIDs[i] = equipmentRows[i].id } return equipmentIDs, nil } func buildCurrentDungeonCompanionProjectionInstanceState( source dnfrepo.CompanionSourceState, now time.Time, ) ([]uint32, []currentDungeonCompanionProjectionSkillPair, []currentDungeonCompanionProjectionSkillPair, error) { if now.IsZero() || !source.HasEquipment || !source.HasSkill || source.Equipment.CharacterID != source.Source.CharacterID || source.Skill.CharacterID != source.Source.CharacterID { return nil, nil, nil, dnfcompanion.ErrSnapshot } equipmentIDs, err := currentDungeonCompanionEquipmentIDs( source.Equipment, now) if err != nil { return nil, nil, nil, err } skills := make([]currentDungeonCompanionProjectionSkillPair, 0, len(source.Skill.Skills)) for id, state := range source.Skill.Skills { if !state.Enabled || state.Level <= 0 { continue } if id <= 0 || id > int64(^uint32(0)) || state.Level > int(^uint16(0)) { return nil, nil, nil, dnfcompanion.ErrSnapshot } skills = append(skills, currentDungeonCompanionProjectionSkillPair{ ID: uint32(id), Level: uint32(state.Level), }) } sort.Slice(skills, func(i, j int) bool { return skills[i].ID < skills[j].ID }) if len(skills) > currentDungeonCompanionProjectionMaxSkills { return nil, nil, nil, dnfcompanion.ErrSnapshot } // The current EXE owns two six-slot quickbars in tree zero: // 0..5 => A/S/D/F/G/H // 198..203 => Q/W/E/R/T/Y // Preserve all 12 positions, including empty slots. Compressing the vector // would shift later keyboard bindings whenever a middle slot is empty. quickSlotOrder := [...]int{0, 1, 2, 3, 4, 5, 198, 199, 200, 201, 202, 203} quick := make([]currentDungeonCompanionProjectionSkillPair, len(quickSlotOrder)) if layout := source.Skill.Layouts[0]; layout != nil { for index, slot := range quickSlotOrder { rawID, exists := layout[slot] if !exists || rawID == 0 { continue } id := uint32(rawID) state, ok := source.Skill.Skills[int64(id)] if !ok || !state.Enabled || state.Level <= 0 { continue } quick[index] = currentDungeonCompanionProjectionSkillPair{ ID: id, Level: uint32(state.Level), } } } // An empty quickbar is authoritative. Do not fill it from the learned // skill vector: learned skills in the tree are not necessarily placed in a // quickbar, and doing so makes the companion cast skills the player did not // put in any of the twelve visible slots. Keep all twelve positions (and // their empty 0/0 pairs) so the client key-program indexes remain stable. if len(quick) > currentDungeonCompanionProjectionMaxQuick { return nil, nil, nil, dnfcompanion.ErrSnapshot } return equipmentIDs, skills, quick, nil } func buildCurrentDungeonCompanionProjectionEnvelope( source dnfrepo.CompanionSourceState, selection dnfcompanion.Selection, now time.Time, options ...currentDungeonCompanionProjectionOptions, ) (dnfcompanion.VisualProjection, []byte, error) { projection, err := dnfcompanion.BuildVisualProjection(source, selection, now) if err != nil { return dnfcompanion.VisualProjection{}, nil, err } var option currentDungeonCompanionProjectionOptions if len(options) > 0 { option = options[0] } profile := option.AIProfile if profile.Behavior == "" { profile = dnfcompanion.DefaultAIProfile(uint16(projection.Job)) } if err := profile.Validate(); err != nil { return dnfcompanion.VisualProjection{}, nil, err } if option.EffectiveLevel > 0 { if option.EffectiveLevel > 90 { return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot } projection.Level = option.EffectiveLevel } character := dnfrepo.CloneCharacter(source.Source) if option.EffectiveLevel > 0 { character.Level = option.EffectiveLevel } equipment := dnfrepo.CloneEquipment(source.Equipment) rows, err := buildCurrentActorMode0AppearanceSummaryFromEquipment(equipment) if err != nil { return dnfcompanion.VisualProjection{}, nil, err } character.Roster.Entry.EquipSummary = cloneRosterEquipSummary(rows) mode0 := buildCurrentSceneObjectListBodyInContext( currentDungeonCompanionProjectionObjectKey, character, true, character.Name, currentSceneObjectContext) // mode0 always owns source identity and complete current-EXE appearance. // Balanced mode optionally adds one mode1 stat blob after APC registration; // original mode keeps mode1 empty and therefore remains byte-compatible. mode1 := append([]byte(nil), option.Mode1...) if len(mode0) <= currentDungeonCompanionMode0ObjectKeyOffset+1 || mode0[0] != 0 || binary.LittleEndian.Uint16( mode0[currentDungeonCompanionMode0ObjectKeyOffset:currentDungeonCompanionMode0ObjectKeyOffset+2]) != currentDungeonCompanionProjectionObjectKey { return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot } if len(mode1) != 0 && (len(mode1) <= currentDungeonCompanionMode1ObjectKeyOffset+1 || mode1[0] != 1 || binary.LittleEndian.Uint16( mode1[currentDungeonCompanionMode1ObjectKeyOffset:currentDungeonCompanionMode1ObjectKeyOffset+2]) != currentDungeonCompanionProjectionObjectKey) { return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot } fingerprintHex := projection.Fingerprint() fingerprint, err := hex.DecodeString(fingerprintHex) if err != nil || len(fingerprint) != 32 { return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot } if option.EffectiveLevel > 0 || len(mode1) != 0 { hash := sha256.New() _, _ = hash.Write(fingerprint) _, _ = hash.Write([]byte{byte(option.EffectiveLevel), 1}) _, _ = hash.Write(mode1) fingerprint = hash.Sum(nil) } revision := binary.LittleEndian.Uint64(fingerprint[:8]) if revision == 0 { revision = 1 } nameUTF16 := utf16.Encode([]rune(projection.Name)) if len(nameUTF16) == 0 || len(nameUTF16) > 32 { return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot } equipmentIDs, skills, quickSkills, err := buildCurrentDungeonCompanionProjectionInstanceState(source, now) // These IDs populate the copied native AIC before APC construction. They // select the actual worn weapon and avatars, including clone appearances. // Use the frozen source/runtime equipment; substituting owner gear strips // source avatars and can give the APC a weapon for the wrong profession. if err != nil || projection.Level < 1 || projection.Level > 90 { return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot } dynamicBytes := len(nameUTF16)*2 + len(equipmentIDs)*4 + len(skills)*8 + len(quickSkills)*8 body := make([]byte, currentDungeonCompanionProjectionHeaderSize+ dynamicBytes+len(mode0)+len(mode1)) binary.LittleEndian.PutUint32( body[0:4], currentDungeonCompanionProjectionMagic) body[4] = currentDungeonCompanionProjectionVersion body[5] = currentDungeonCompanionProjectionFlagPresent | currentDungeonCompanionProjectionFlagAIPlan binary.LittleEndian.PutUint16(body[6:8], uint16(mustProjectionCharacterID(projection.SourceCharacterID))) binary.LittleEndian.PutUint64(body[8:16], revision) binary.LittleEndian.PutUint32(body[16:20], uint32(len(mode0))) binary.LittleEndian.PutUint32(body[20:24], uint32(len(mode1))) copy(body[24:56], fingerprint) body[56] = projection.Job body[57] = projection.GrowType binary.LittleEndian.PutUint16(body[58:60], uint16(len(nameUTF16))) binary.LittleEndian.PutUint16(body[60:62], uint16(len(equipmentIDs))) binary.LittleEndian.PutUint16(body[62:64], uint16(projection.Level)) binary.LittleEndian.PutUint16(body[64:66], uint16(len(skills))) binary.LittleEndian.PutUint16(body[66:68], uint16(len(quickSkills))) binary.LittleEndian.PutUint32(body[68:72], currentDungeonCompanionProjectionAIPlanWord( currentDungeonCompanionProjectionAIPlanFor(profile, projection.Job))) cursor := currentDungeonCompanionProjectionHeaderSize for _, unit := range nameUTF16 { binary.LittleEndian.PutUint16(body[cursor:cursor+2], unit) cursor += 2 } for _, itemID := range equipmentIDs { binary.LittleEndian.PutUint32(body[cursor:cursor+4], itemID) cursor += 4 } for _, skill := range skills { binary.LittleEndian.PutUint32(body[cursor:cursor+4], skill.ID) binary.LittleEndian.PutUint32(body[cursor+4:cursor+8], skill.Level) cursor += 8 } for _, skill := range quickSkills { binary.LittleEndian.PutUint32(body[cursor:cursor+4], skill.ID) binary.LittleEndian.PutUint32(body[cursor+4:cursor+8], skill.Level) cursor += 8 } copy(body[cursor:cursor+len(mode0)], mode0) cursor += len(mode0) copy(body[cursor:], mode1) return projection, body, nil } func mustProjectionCharacterID(value string) uint64 { parsed, _ := strconv.ParseUint(value, 10, 16) return parsed } func (s *Service) currentDungeonCompanionProjectionSourceID( ctx context.Context, session *gameSession, repos dnfrepo.Group, ) (uint16, string, bool, error) { if s == nil || session == nil || session.selectedCharacterID == 0 { return 0, "", false, nil } ownerID := strconv.FormatUint( uint64(session.selectedCharacterID), 10) if repos.Settings != nil { scope, err := dnfcompanion.SettingsScope(ownerID) if err != nil { return 0, "settings", false, err } record, found, err := repos.Settings.Load(ctx, scope) if err != nil { return 0, "settings", false, err } if found { source, selected, err := dnfcompanion.SourceCharacterIDFromSettings( record, ownerID) if err != nil { return 0, "settings", false, err } if !selected { return 0, "settings", false, nil } sourceID, ok := currentDungeonCompanionProjectionSourceIDValue(source) if !ok { return 0, "settings", false, dnfcompanion.ErrSelection } return sourceID, "settings", true, nil } } sourceID, enabled := currentDungeonCompanionProjectionSourceIDValue( os.Getenv( currentDungeonCompanionProjectionSourceEnv)) if !enabled { return 0, "", false, nil } return sourceID, "environment_fallback", true, nil } func (s *Service) sendCurrentDungeonCompanionProjection( session *gameSession, source string, ) error { if s == nil || session == nil || currentDungeonCompanionP0CaptureBudget() == 0 || session.selectedCharacterID == 0 { return nil } repos, ok := s.repositoryGroup() if !ok || repos.Character == nil { s.logGameEvent(session, "game-dungeon-companion-projection-skipped", "source", source, "reason", "repository_unavailable") return nil } selectionCtx, selectionCancel := context.WithTimeout( context.Background(), currentRepositorySnapshotTimeout) defer selectionCancel() sourceID, selectionSource, enabled, selectionErr := s.currentDungeonCompanionProjectionSourceID( selectionCtx, session, repos) if selectionErr != nil { s.logGameEvent(session, "game-dungeon-companion-projection-skipped", "source", source, "selection_source", selectionSource, "reason", "source_selection_invalid", "error", selectionErr) return nil } if !enabled { s.logGameEvent(session, "game-dungeon-companion-projection-skipped", "source", source, "selection_source", selectionSource, "reason", "source_not_selected") return nil } if sourceID == session.selectedCharacterID { s.logGameEvent(session, "game-dungeon-companion-projection-skipped", "source", source, "source_character_id", sourceID, "selection_source", selectionSource, "reason", "source_is_owner") return nil } reader, ok := repos.Character.(dnfrepo.CompanionSourceReader) if !ok { s.logGameEvent(session, "game-dungeon-companion-projection-skipped", "source", source, "source_character_id", sourceID, "selection_source", selectionSource, "reason", "consistent_reader_unavailable") return nil } accountID := strings.TrimSpace(s.accountIDForSession(session)) if accountID == "" { return nil } _, sourceOnline := s.onlineGameSession(sourceID) ctx, cancel := context.WithTimeout( context.Background(), currentRepositorySnapshotTimeout) defer cancel() state, err := reader.ReadCompanionSource( ctx, accountID, strconv.FormatUint(uint64(session.selectedCharacterID), 10), strconv.FormatUint(uint64(sourceID), 10)) if err != nil { s.logGameEvent(session, "game-dungeon-companion-projection-skipped", "source", source, "source_character_id", sourceID, "selection_source", selectionSource, "reason", "consistent_read_failed", "error", err) return nil } selection := dnfcompanion.Selection{ AccountID: accountID, OwnerCharacterID: strconv.FormatUint( uint64(session.selectedCharacterID), 10), SourceCharacterID: strconv.FormatUint( uint64(sourceID), 10), OnlineStateKnown: true, SourceOnline: sourceOnline, } projection, body, err := buildCurrentDungeonCompanionProjectionEnvelope( state, selection, s.gameplayNow()) if err != nil { s.logGameEvent(session, "game-dungeon-companion-projection-skipped", "source", source, "source_character_id", sourceID, "selection_source", selectionSource, "source_online", sourceOnline, "reason", "projection_invalid", "error", err) return nil } weaponID := int64(0) if projection.Weapon != nil { weaponID = projection.Weapon.DisplayItemID } s.logGameEvent(session, "game-dungeon-companion-projection-send", "source", source, "selection_source", selectionSource, "owner_character_id", session.selectedCharacterID, "source_character_id", sourceID, "name", projection.Name, "job", projection.Job, "grow_type", projection.GrowType, "level", projection.Level, "weapon_item_id", weaponID, "avatar_count", len(projection.Avatars), "aic_equipment_count", int(binary.LittleEndian.Uint16(body[60:62])), "aic_skill_count", int(binary.LittleEndian.Uint16(body[64:66])), "aic_quick_skill_count", int(binary.LittleEndian.Uint16(body[66:68])), "projection_hash", projection.Fingerprint(), "placeholder_object_key", currentDungeonCompanionProjectionObjectKey, "private_opcode", currentDungeonCompanionProjectionOpcode, "body_len", len(body), "template_mutation", false) return s.sendGameUpperRawClass( session, currentDungeonCompanionProjectionOpcode, body, 0) }