package main import ( "fmt" pathpkg "path" "strings" dnfcompanion "longheng.io/server/internal/modules/dnf/companion" platformpvf "longheng.io/server/internal/platform/pvf" ) const companionQuickSlotCount = 12 const companionBasicAttackIndex = companionQuickSlotCount * 2 // companionQuickSlotKeys 是前段 12 格要按下的原生按键名,顺序与 // companionFrontKeyFiles 一一对应(与 legacy 路径的 quickSlotKeys 同源)。 var companionQuickSlotKeys = []string{ "a", "s", "d", "f", "g", "h", "q", "w", "e", "r", "t", "y", } var companionFrontKeyFiles = []string{ "aicharacter/magician/biske2/key/1.key", "aicharacter/magician/biske2/key/2.key", "aicharacter/magician/biske2/key/3.key", "aicharacter/magician/biske2/key/4.key", "aicharacter/magician/biske2/key/5.key", "aicharacter/magician/biske2/key/6.key", "aicharacter/magician/biske2/key/a.key", "aicharacter/magician/biske2/key/c.key", "aicharacter/magician/biske2/key/d.key", "aicharacter/magician/biske2/key/f.key", "aicharacter/magician/biske2/key/g.key", "aicharacter/magician/biske2/key/h.key", } var companionDirectKeyFiles = []string{ "aicharacter/magician/flea_inflame_hands2/key/1.key", "aicharacter/magician/flea_inflame_hands2/key/2.key", "aicharacter/magician/flea_inflame_hands2/key/3.key", "aicharacter/magician/flea_inflame_hands2/key/4.key", "aicharacter/magician/flea_inflame_hands2/key/5.key", "aicharacter/magician/flea_inflame_hands2/key/6.key", "aicharacter/magician/flea_inflame_hands2/key/a.key", "aicharacter/magician/flea_inflame_hands2/key/c.key", "aicharacter/magician/flea_inflame_hands2/key/d.key", "aicharacter/magician/flea_inflame_hands2/key/f.key", "aicharacter/magician/flea_inflame_hands2/key/g.key", "aicharacter/magician/flea_inflame_hands2/key/h.key", } // Use an existing, otherwise-unreferenced native key that contains a short // four-hit X loop. The old mirror_priest key emits one X input and then waits // 300 ms before the AI can choose it again, which makes a support soldier's // basic attack look like isolated taps. Keeping this as a donor path avoids // mutating the shared mirror_priest resource used by other APCs. const companionBasicAttackFile = "aicharacter/_nelios/priest/corps/key/x.key" // companionAIRefs resolves the [ai pattern] entries using the same relative // layout used by the native PVF loader. Short names are relative to the AIC's // sibling ai directory; fully-qualified entries are already archive paths. func companionAIRefs(a *platformpvf.Archive, aicPath string, raw []byte) (map[string]string, error) { ts := toks(a, raw) start, end := section(ts, "[ai pattern]", "[/ai pattern]") if start < 0 || end <= start { return nil, fmt.Errorf("AIC %s has no [ai pattern] section", aicPath) } refs := make(map[string]string) for i := start + 1; i < end; i++ { t := ts[i] if t.typ != 6 || !strings.HasSuffix(strings.ToLower(t.txt), ".ai") { continue } candidate := strings.ReplaceAll(t.txt, "\\", "/") if !strings.Contains(candidate, "/") { candidate = pathpkg.Join(pathpkg.Dir(aicPath), "ai", candidate) } file, ok := a.FindFile(candidate) if !ok { return nil, fmt.Errorf("AIC %s references missing AI %q (resolved %s)", aicPath, t.txt, candidate) } canonical := strings.ReplaceAll(file.ArchivePath, "\\", "/") base := strings.ToLower(pathpkg.Base(canonical)) switch { case strings.Contains(base, "event") && refs["event"] == "": refs["event"] = canonical case strings.Contains(base, "destination") && refs["destination"] == "": refs["destination"] = canonical case strings.Contains(base, "action") && refs["action"] == "": refs["action"] = canonical case strings.Contains(base, "move") && refs["move"] == "": refs["move"] = canonical } } if refs["action"] == "" { return nil, fmt.Errorf("AIC %s has no action AI reference", aicPath) } if refs["destination"] == "" { return nil, fmt.Errorf("AIC %s has no destination AI reference", aicPath) } if refs["move"] == "" { return nil, fmt.Errorf("AIC %s has no move AI reference", aicPath) } return refs, nil } func companionString(a *platformpvf.Archive, value string) []byte { off, err := a.EnsureANSIString(value) if err != nil { panic(err) } return rawToken(6, off) } func companionTag(a *platformpvf.Archive, value string) []byte { off, err := a.EnsureANSIString(value) if err != nil { panic(err) } return rawToken(3, off) } func companionAIThink(a *platformpvf.Archive, inner []byte) []byte { out := append([]byte{}, companionTag(a, "[think]")...) out = append(out, inner...) out = append(out, companionTag(a, "[/think]")...) return out } // A native think node stores one predicate and one true/false pair. A second // predicate in the same block replaces the first node's condition/branches; // r9 consequently loaded only its final area predicate, with both returns -1. // Always nest a new decision rather than appending conditions to one block. func companionBranch(a *platformpvf.Archive, condition, yes, no []byte) []byte { body := append([]byte{}, condition...) body = append(body, companionTag(a, "[true]")...) body = append(body, yes...) body = append(body, companionTag(a, "[false]")...) body = append(body, no...) return companionAIThink(a, body) } func companionYield(a *platformpvf.Archive) []byte { return append(companionTag(a, "[return]"), rawToken(0, -1)...) } // The projection contract fixes quick programs at indexes 0..11. Numeric // returns remove construction-time name lookup from action selection. // The r11 parsing capture also showed that numeric returns require valid // enclosing branches; an index alone cannot repair malformed think blocks. func companionReturnSlot(a *platformpvf.Archive, slot int) []byte { if slot < 0 || slot >= companionQuickSlotCount { panic(fmt.Sprintf("companion action slot out of range: %d", slot)) } return append(companionTag(a, "[return]"), rawToken(0, slot)...) } func companionCheckReady(a *platformpvf.Archive, key string) []byte { out := append([]byte{}, companionTag(a, "[check key cooltime]")...) out = append(out, companionString(a, key)...) return out } // companionReadyChain returns the first key that is not cooling down. The // native grammar reports true for a cooling key, so the true branch continues // and the false branch emits the key. This is the same pattern used by the // checked-in profession AIs and keeps the chain bounded. func companionReadyChain(a *platformpvf.Archive, slots []int) []byte { return companionReadyChainWithFallback(a, slots, companionYield(a)) } func companionReadyChainWithFallback(a *platformpvf.Archive, slots []int, fallback []byte) []byte { if len(slots) == 0 { // Native 0x026224e0 reads a predicate immediately after [think]. // A return-only think exits before consuming its closing delimiter; // r11's live capture then placed slot N in slot N-1's false branch. // A completed chain is a branch leaf, not another decision node. return fallback } slot := slots[0] key := fmt.Sprintf("CompanionSlot%02d", slot) var body []byte body = append(body, companionCheckReady(a, key)...) body = append(body, companionTag(a, "[true]")...) body = append(body, companionReadyChainWithFallback(a, slots[1:], fallback)...) body = append(body, companionTag(a, "[false]")...) body = append(body, companionReturnSlot(a, slot)...) return companionAIThink(a, body) } type companionBuffSpec struct { slot int family string skill int } // These two nodes are the verified native support-selector placeholders. // Every profession shares this grammar; the owned client selects the actual // learned quickbar skill. These IDs are not another profession's buff IDs. func companionBuffSpecs(job uint8) []companionBuffSpec { return []companionBuffSpec{{1, "priest", 7}, {5, "priest", 53}} } // companionPartyBuffChain returns the first configured buff whose party // target does not already have the corresponding native buff. The native // grammar reports true when the buff is present, so the true branch advances // and the false branch emits the matching quick-slot key. func companionPartyBuffChain(a *platformpvf.Archive, specs []companionBuffSpec, fallback []byte) []byte { if len(specs) == 0 { return fallback } spec := specs[0] key := fmt.Sprintf("CompanionSlot%02d", spec.slot) next := companionPartyBuffChain(a, specs[1:], fallback) pending := companionBranch(a, companionCheckReady(a, key), next, companionReturnSlot(a, spec.slot)) condition := append(companionTag(a, "[check party buff on]"), companionString(a, spec.family)...) condition = append(condition, rawToken(0, spec.skill)...) return companionBranch(a, condition, next, pending) } func companionAreaGate(a *platformpvf.Archive, x, y, halfX, halfY float32) []byte { var out []byte out = append(out, companionTag(a, "[void]")...) out = append(out, companionString(a, "is target in attack area()")...) out = append(out, rawFloat(x)...) out = append(out, rawFloat(y)...) out = append(out, rawFloat(halfX)...) out = append(out, rawFloat(halfY)...) return out } func companionEnemyCountGate(a *platformpvf.Archive, x, y, halfX, halfY float32, minimum int) []byte { var out []byte out = append(out, companionTag(a, "[void]")...) out = append(out, companionString(a, "enemy number of in attack area()")...) out = append(out, rawFloat(x)...) out = append(out, rawFloat(y)...) out = append(out, rawFloat(halfX)...) out = append(out, rawFloat(halfY)...) out = append(out, companionTag(a, "[think]")...) out = append(out, companionTag(a, "[last result]")...) out = append(out, companionString(a, ">=")...) out = append(out, rawFloat(float32(minimum))...) return out } func companionCountBranch(a *platformpvf.Archive, x, y float32, minimum int, yes, no []byte) []byte { body := companionEnemyCountGate(a, x, y, x/2, y/2, minimum) body = append(body, companionTag(a, "[true]")...) body = append(body, yes...) body = append(body, companionTag(a, "[false]")...) body = append(body, no...) body = append(body, companionTag(a, "[/think]")...) return companionAIThink(a, body) } func companionBasicCombatAI(a *platformpvf.Archive) []byte { yield := companionYield(a) basicReturn := append(companionTag(a, "[return]"), rawToken(0, companionBasicAttackIndex)...) ready := companionBranch(a, companionCheckReady(a, "CompanionBasicAttack"), yield, basicReturn) // Native 2495960 skips movement and destination selection whenever an // action is accepted. The r18 zero-cooldown fallback kept accepting x // from the 880-wide spell area, so the APC never approached its enemy. // Check both the selected target's melee area and nearby enemy count: // a stale target alone must not keep emitting attacks in an empty room. near := companionBranch(a, companionAreaGate(a, 200, 120, 100, 60), ready, yield) return companionCountBranch(a, 200, 120, 1, near, yield) } func companionAIHeader(a *platformpvf.Archive) []byte { return companionTag(a, "[ai pattern]") } func companionAIFooter(a *platformpvf.Archive) []byte { return companionTag(a, "[/ai pattern]") } // The owned native selector consumes this state-0 predicate as the per-source // mode switch. The original state-4 action guard still protects active casts. func companionModeGate(a *platformpvf.Archive, assist, aggressive []byte) []byte { mode := append(companionTag(a, "[my state]"), rawToken(0, 0)...) return companionBranch(a, mode, assist, aggressive) } func buildCompanionDestinationAI(a *platformpvf.Archive, profile dnfcompanion.AIProfile, job uint8) []byte { friend := append(companionTag(a, "[set friend character]"), rawToken(0, 0)...) follow := companionBranch(a, friend, append(companionTag(a, "[return]"), rawToken(0, 5)...), companionYield(a)) target := append(companionTag(a, "[target state]"), rawFloat(4)...) fight := companionBranch(a, target, append(companionTag(a, "[return]"), rawToken(0, 1)...), append(companionTag(a, "[return]"), rawToken(0, 0)...)) out := append(companionAIHeader(a), companionModeGate(a, follow, fight)...) return append(out, companionAIFooter(a)...) } func buildCompanionMoveAI(a *platformpvf.Archive, profile dnfcompanion.AIProfile, job uint8) []byte { tuning := dnfcompanion.EffectiveAITuning(dnfcompanion.NormalizeAIProfile(profile).Tuning) // Leader or enemy: stop close to the selected actor. ChaseRange controls // skill engagement, never a stand-off distance that prevents melee. makeMove := func() []byte { condition := append(companionTag(a, "[target distance]"), companionString(a, ">=")...) condition = append(condition, rawFloat(float32(tuning.FollowDistance))...) return companionBranch(a, condition, append(companionTag(a, "[return]"), rawFloat(1)...), append(companionTag(a, "[return]"), rawFloat(0)...)) } out := append(companionAIHeader(a), companionModeGate(a, makeMove(), makeMove())...) return append(out, companionAIFooter(a)...) } // The stock event scripts refer to profession-specific key names (combo1, // elemental, ...). Once the AIC key stream is replaced by the profile's // CompanionSlot contract those names no longer exist. Returning -1 keeps // attack-success events from bypassing the profile action state machine. func buildCompanionEventAI(a *platformpvf.Archive, profiles ...dnfcompanion.AIProfile) []byte { profile := dnfcompanion.DefaultAIProfile(4) if len(profiles) != 0 { profile = dnfcompanion.NormalizeAIProfile(profiles[0]) } tuning := dnfcompanion.EffectiveAITuning(profile.Tuning) // This remains a no-op event. The owned client selector also evaluates // this native predicate after selecting the party leader, so healing uses // the game's current HP percentage rather than a stale server snapshot. hp := append(companionTag(a, "[party member hp]"), companionString(a, "%<")...) hp = append(hp, rawFloat(float32(tuning.HealLeaderPercent))...) decision := companionBranch(a, hp, companionYield(a), companionYield(a)) out := append(companionAIHeader(a), decision...) return append(out, companionAIFooter(a)...) } func buildCompanionActionAI(a *platformpvf.Archive, profile dnfcompanion.AIProfile, job uint8) []byte { profile = dnfcompanion.NormalizeAIProfile(profile) yield := companionYield(a) state := append(companionTag(a, "[my state]"), rawToken(0, 4)...) party := append(companionTag(a, "[set party member]"), rawToken(0, 1)...) var decision []byte switch strings.ToLower(strings.TrimSpace(profile.ComboPreset)) { case "probe_slot1": decision = companionBranch(a, state, companionReturnSlot(a, 1), companionReturnSlot(a, 1)) case "assist_cooldown_probe": ready := companionBranch(a, companionCheckReady(a, "CompanionSlot01"), yield, companionReturnSlot(a, 1)) decision = companionBranch(a, state, yield, companionBranch(a, party, ready, yield)) default: tuning := dnfcompanion.EffectiveAITuning(profile.Tuning) attacks := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} // Descending chain is the serialized AoE-lane contract. The owned // client validates/binds it and skips skills without an area role. areaSlots := []int{11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0} rangeX := tuning.ChaseRange * 2 if rangeX < 200 { rangeX = 200 } rangeY := 200 rotation := companionReadyChainWithFallback(a, attacks, companionBasicCombatAI(a)) single := companionBranch(a, companionAreaGate(a, float32(rangeX), float32(rangeY), float32(rangeX/2), float32(rangeY/2)), rotation, yield) crowd := companionCountBranch(a, float32(rangeX), float32(rangeY), profile.AOEMinTargets, companionReadyChainWithFallback(a, areaSlots, single), single) aggressive := companionBranch(a, companionAreaGate(a, float32(rangeX), float32(rangeY), float32(rangeX/2), float32(rangeY/2)), crowd, yield) aggressive = companionCountBranch(a, float32(rangeX), float32(rangeY), 1, aggressive, yield) combat := companionModeGate(a, yield, aggressive) // Always include support selection. GM mode and actual learned/bar // state are runtime inputs; shared AI paths cannot hold per-job code. buffs := companionPartyBuffChain(a, companionBuffSpecs(job), combat) decision = companionBranch(a, state, yield, companionBranch(a, party, buffs, combat)) } out := append(companionAIHeader(a), decision...) return append(out, companionAIFooter(a)...) } // Reject the concrete r9 failure before repacking. Balanced delimiters alone // did not detect predicates and branch targets overwriting each other. func validateCompanionActionAI(a *platformpvf.Archive, raw []byte) error { type frame struct { predicates int yes, no bool } var stack []frame tokens := toks(a, raw) for i, token := range tokens { if token.typ != 3 { continue } switch token.txt { case "[return str]": return fmt.Errorf("companion action must use fixed program indexes") case "[return]": if i+1 >= len(tokens) || tokens[i+1].typ != 0 || tokens[i+1].val < -1 || (tokens[i+1].val >= companionQuickSlotCount && tokens[i+1].val != companionBasicAttackIndex) { return fmt.Errorf("companion action return must be -1, quick-program index 0..11, or basic attack 24") } if len(stack) == 0 || stack[len(stack)-1].predicates != 1 || (!stack[len(stack)-1].yes && !stack[len(stack)-1].no) { return fmt.Errorf("action return must belong to a predicate's true/false branch; return-only think desynchronizes native parsing") } case "[think]": stack = append(stack, frame{}) case "[/think]": if len(stack) == 0 { return fmt.Errorf("action AI has unmatched think close") } if stack[len(stack)-1].predicates != 1 { return fmt.Errorf("action think must contain exactly one predicate") } stack = stack[:len(stack)-1] case "[my state]", "[party member hp]", "[set party member]", "[check party buff on]", "[check key cooltime]", "[void]", "[last result]": if len(stack) == 0 { return fmt.Errorf("action predicate outside think") } f := &stack[len(stack)-1] f.predicates++ if f.predicates > 1 { return fmt.Errorf("action think contains multiple predicates; native branches would be overwritten") } case "[true]", "[false]": if len(stack) == 0 { return fmt.Errorf("action branch outside think") } f := &stack[len(stack)-1] if token.txt == "[true]" { if f.yes { return fmt.Errorf("action think repeats true branch") } f.yes = true } else { if f.no { return fmt.Errorf("action think repeats false branch") } f.no = true } } } if len(stack) != 0 { return fmt.Errorf("action AI has unclosed think blocks") } return nil } func companionKeyStream(a *platformpvf.Archive) ([]byte, []byte, error) { if len(companionFrontKeyFiles) != companionQuickSlotCount || len(companionDirectKeyFiles) != companionQuickSlotCount { return nil, nil, fmt.Errorf("companion key contract has invalid slot count") } var stream, cooldown []byte for i := 0; i < companionQuickSlotCount; i++ { front, ok := a.FindFile(companionFrontKeyFiles[i]) if !ok { return nil, nil, fmt.Errorf("missing front key %s", companionFrontKeyFiles[i]) } name := fmt.Sprintf("CompanionSlot%02d", i) stream = append(stream, companionString(a, name)...) stream = append(stream, companionString(a, strings.ReplaceAll(front.ArchivePath, "\\", "/"))...) cooldown = append(cooldown, companionString(a, name)...) cooldown = append(cooldown, rawToken(0, 600)...) } // ProjectionRuntimeKeyContract addresses front programs by i and direct // programs by 12+i. Interleaving the pairs puts a front key at index 12, // causing direct-bytecode-shape rejection before any skill is bound. for i := 0; i < companionQuickSlotCount; i++ { direct, ok := a.FindFile(companionDirectKeyFiles[i]) if !ok { return nil, nil, fmt.Errorf("missing direct key %s", companionDirectKeyFiles[i]) } stream = append(stream, companionString(a, fmt.Sprintf("CompanionDirectPool%02d", i))...) stream = append(stream, companionString(a, strings.ReplaceAll(direct.ArchivePath, "\\", "/"))...) } basic, ok := a.FindFile(companionBasicAttackFile) if !ok { return nil, nil, fmt.Errorf("missing basic attack key %s", companionBasicAttackFile) } stream = append(stream, companionString(a, "CompanionBasicAttack")...) stream = append(stream, companionString(a, strings.ReplaceAll(basic.ArchivePath, "\\", "/"))...) cooldown = append(cooldown, companionString(a, "CompanionBasicAttack")...) cooldown = append(cooldown, rawToken(0, 0)...) return stream, cooldown, nil } // Check the positional contract used by ApplyDynamicRuntimeKeyPrograms, not // just the presence of the slot names. An archive can contain every name and // still make the native client reject the entire pool if pairs are interleaved. func validateCompanionKeyStream(a *platformpvf.Archive, raw []byte) error { items := toks(a, raw) start, end := section(items, "[key stream]", "[/key stream]") if start < 0 || end-start-1 != 50 { return fmt.Errorf("companion key stream must contain 25 name/path pairs") } for i := 0; i < 25; i++ { name := "CompanionBasicAttack" path := companionBasicAttackFile if i < companionQuickSlotCount { name = fmt.Sprintf("CompanionSlot%02d", i) path = companionFrontKeyFiles[i] } else if i < companionQuickSlotCount*2 { name = fmt.Sprintf("CompanionDirectPool%02d", i-companionQuickSlotCount) path = companionDirectKeyFiles[i-companionQuickSlotCount] } nameToken, pathToken := items[start+1+i*2], items[start+2+i*2] if nameToken.typ != 6 || pathToken.typ != 6 || nameToken.txt != name || !strings.EqualFold(pathToken.txt, path) { return fmt.Errorf("companion key program %d is %q/%q, want %q/%q", i, nameToken.txt, pathToken.txt, name, path) } } return nil } func patchCompanionAICProfile(a *platformpvf.Archive, aicRaw []byte, artifact dnfcompanion.AICArtifact, profile dnfcompanion.AIProfile) ([]platformpvf.RawReplacement, error) { refs, err := companionAIRefs(a, artifact.AICPath, aicRaw) if err != nil { return nil, err } stream, cooldown, err := companionKeyStream(a) if err != nil { return nil, err } patched := append([]byte(nil), aicRaw...) ts := toks(a, patched) patched = replaceSection(patched, ts, "[key stream]", "[/key stream]", stream) ts = toks(a, patched) patched = replaceOrInsertSection(a, patched, ts, "[key cooltime]", "[/key cooltime]", "[/key stream]", cooldown) ts = toks(a, patched) values, err := dnfcompanion.ReadAICArtifact(artifact.AICText) if err != nil { return nil, err } for tag, value := range values { patched = replaceIntAfterTag(patched, toks(a, patched), tag, value) } replacements := []platformpvf.RawReplacement{{Path: artifact.AICPath, Data: patched}} action := buildCompanionActionAI(a, profile, artifact.Job) if err := validateCompanionActionAI(a, action); err != nil { return nil, fmt.Errorf("invalid generated action %s: %w", refs["action"], err) } replacements = append(replacements, platformpvf.RawReplacement{Path: refs["action"], Data: action}, platformpvf.RawReplacement{Path: refs["destination"], Data: buildCompanionDestinationAI(a, profile, artifact.Job)}, platformpvf.RawReplacement{Path: refs["move"], Data: buildCompanionMoveAI(a, profile, artifact.Job)}, ) if refs["event"] != "" { replacements = append(replacements, platformpvf.RawReplacement{Path: refs["event"], Data: buildCompanionEventAI(a, profile)}, ) } return replacements, nil }