package main import ( "bytes" "crypto/sha256" "encoding/hex" "fmt" "os" "path/filepath" "sort" "strings" dnfcompanion "longheng.io/server/internal/modules/dnf/companion" platformpvf "longheng.io/server/internal/platform/pvf" ) // ProfileOverlayRequest describes an offline build. OutputPVF must point at a // review/temp directory; this API never writes the runtime PVF in place. type ProfileOverlayRequest struct { BasePVF string OutputPVF string Profiles []dnfcompanion.AIProfile Jobs []uint8 Capabilities dnfcompanion.StrategyCapabilities } type ProfileOverlayResult struct { OutputPVF string OutputSHA256 string Manifest dnfcompanion.PVFOverlayManifest } func sha256File(path string) (string, []byte, error) { data, err := os.ReadFile(path) if err != nil { return "", nil, err } sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]), data, nil } // BuildProfileOverlay compiles a profile into the scalar AIC values and the // native AI/key programs that the checked-in PVF grammar can execute. It // reopens the generated archive and validates every replacement. The output // path is caller-owned and should be outside runtime/current. func BuildProfileOverlay(req ProfileOverlayRequest) (result ProfileOverlayResult, err error) { if strings.TrimSpace(req.BasePVF) == "" || strings.TrimSpace(req.OutputPVF) == "" { return result, fmt.Errorf("base and output PVF paths are required") } if len(req.Profiles) == 0 || len(req.Profiles) != len(req.Jobs) { return result, fmt.Errorf("profiles/jobs must be non-empty and have equal length") } baseHash, _, err := sha256File(req.BasePVF) if err != nil { return result, fmt.Errorf("read base PVF: %w", err) } archive, err := platformpvf.Open(req.BasePVF) if err != nil { return result, err } caps := req.Capabilities if caps == (dnfcompanion.StrategyCapabilities{}) { caps = dnfcompanion.DefaultStrategyCapabilities() } artifacts := make([]dnfcompanion.AICArtifact, 0, len(req.Profiles)) replacements := make([]platformpvf.RawReplacement, 0, len(req.Profiles)*4) seen := make(map[string]string, len(req.Profiles)) replacementSeen := make(map[string]string, len(req.Profiles)*4) appendReplacement := func(replacement platformpvf.RawReplacement) error { key := strings.ToLower(strings.ReplaceAll(replacement.Path, "\\", "/")) hash := sha256.Sum256(replacement.Data) hashText := hex.EncodeToString(hash[:]) if previous, ok := replacementSeen[key]; ok { if previous != hashText { return fmt.Errorf("two profiles generate different data for %s", replacement.Path) } return nil } replacementSeen[key] = hashText replacements = append(replacements, replacement) return nil } for index, profile := range req.Profiles { profile = dnfcompanion.NormalizeAIProfile(profile) artifact, compileErr := dnfcompanion.CompileAICArtifactForJob(profile, caps, req.Jobs[index]) if compileErr != nil { return result, fmt.Errorf("compile profile %d: %w", index, compileErr) } path := artifact.AICPath key := strings.ToLower(strings.ReplaceAll(path, "\\", "/")) profileKey := fmt.Sprintf("%s|%s|%s|%t|%d|%t|%#v", profile.Behavior, profile.BuffPolicy, profile.ComboPreset, profile.ComboEnabled, profile.AOEMinTargets, profile.ProtectLeader, profile.Tuning) if previous, ok := seen[key]; ok { if previous != profileKey { return result, fmt.Errorf("two profiles target %s with different artifacts", path) } continue } raw, readErr := archive.ReadRaw(path) if readErr != nil { return result, fmt.Errorf("read base AIC %s: %w", path, readErr) } profileReplacements, patchErr := patchCompanionAICProfile(archive, raw, artifact, profile) if patchErr != nil { return result, fmt.Errorf("patch AIC %s: %w", path, patchErr) } for _, replacement := range profileReplacements { if err := appendReplacement(replacement); err != nil { return result, err } } artifacts = append(artifacts, artifact) seen[key] = profileKey } // 重写 24 个按键程序文件本体 —— 这是 legacy 路径(main.go 里 keyFiles / // directPoolFiles 那两个循环)一直在做、而 --profile-overlay 一直漏掉的一步。 // // 漏掉的后果(实测取证):AIC 的 [key stream] 引用的还是 DB4 那批 25 字节旧 // key(magician/biske2、magician/flea_inflame_hands2),零售客户端把它编译成 // **0x80 字节**的运行时按键程序,而默认绑定表的 // kRuntimeDirectKeyBytecodeSize 要求 **0x180**,于是客户端直接拒绝: // runtime-key projection blocked reason=direct-bytecode-shape ... size=128 // -> runtime_keys_applied=0 -> 快捷栏 12 格技能没有绑定 -> 支援兵不放技能。 // // 这里按 legacy 的同一套生成器重写: // 前段 12 格 -> [input] 的按键程序 // 直接池 12 格 -> 含 skill:511 的占位程序(对应 kPlaceholderSkillId=511, // 客户端会比对 kSkillCodeBias(0x100)+511 = 0x2FF) for index, path := range companionFrontKeyFiles { if index >= len(companionQuickSlotKeys) { break } if err := appendReplacement(platformpvf.RawReplacement{ Path: path, Data: buildQuickSlotKey(archive, companionQuickSlotKeys[index]), }); err != nil { return result, err } } for _, path := range companionDirectKeyFiles { if err := appendReplacement(platformpvf.RawReplacement{ Path: path, Data: buildDirectPlaceholderKey(archive), }); err != nil { return result, err } } sort.Slice(replacements, func(i, j int) bool { return strings.ToLower(replacements[i].Path) < strings.ToLower(replacements[j].Path) }) sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Path < artifacts[j].Path }) patched, err := archive.RepackRawAndStringPools(replacements) if err != nil { return result, fmt.Errorf("repack isolated PVF: %w", err) } if err := os.MkdirAll(filepath.Dir(req.OutputPVF), 0o755); err != nil { return result, err } if err := os.WriteFile(req.OutputPVF, patched, 0o644); err != nil { return result, err } verify, err := platformpvf.Open(req.OutputPVF) if err != nil { return result, fmt.Errorf("open generated PVF: %w", err) } for _, replacement := range replacements { got, err := verify.ReadRaw(replacement.Path) if err != nil { return result, fmt.Errorf("read generated program %s: %w", replacement.Path, err) } if !bytes.Equal(got, replacement.Data) { return result, fmt.Errorf("generated program %s differs from compiled data", replacement.Path) } } for _, artifact := range artifacts { raw, readErr := verify.ReadRaw(artifact.AICPath) if readErr != nil { return result, fmt.Errorf("read generated AIC %s: %w", artifact.AICPath, readErr) } if err := validateAICReadback(verify, raw, artifact); err != nil { return result, fmt.Errorf("validate generated AIC %s: %w", artifact.AICPath, err) } } manifest, err := dnfcompanion.NewPVFOverlayManifest(req.BasePVF, baseHash, artifacts) if err != nil { return result, err } outputHash, _, err := sha256File(req.OutputPVF) if err != nil { return result, err } return ProfileOverlayResult{OutputPVF: req.OutputPVF, OutputSHA256: outputHash, Manifest: manifest}, nil } func patchAICArtifactScalars(a *platformpvf.Archive, raw []byte, artifact dnfcompanion.AICArtifact) (patched []byte, err error) { defer func() { if recovered := recover(); recovered != nil { err = fmt.Errorf("scalar patch failed: %v", recovered) } }() values, err := dnfcompanion.ReadAICArtifact(artifact.AICText) if err != nil { return nil, err } patched = append([]byte(nil), raw...) for _, tag := range []string{"[think term]", "[destination change term]", "[keep distance with target]", "[warlike]", "[vision]"} { patched = replaceIntAfterTag(patched, toks(a, patched), tag, values[tag]) } return patched, nil } func validateAICReadback(a *platformpvf.Archive, raw []byte, artifact dnfcompanion.AICArtifact) error { if err := validateCompanionKeyStream(a, raw); err != nil { return err } want, err := dnfcompanion.ReadAICArtifact(artifact.AICText) if err != nil { return err } for key, value := range want { got, ok := readIntAfterTag(a, raw, key) if !ok || got != value { return fmt.Errorf("%s=%v want=%d", key, got, value) } } return nil } func readIntAfterTag(a *platformpvf.Archive, raw []byte, tag string) (int, bool) { items := toks(a, raw) for index, item := range items { if index+1 < len(items) && item.typ == 3 && strings.EqualFold(item.txt, tag) { next := items[index+1] if next.typ == 0 { return int(next.val), true } } } return 0, false }