服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
240 lines
8.5 KiB
Go
240 lines
8.5 KiB
Go
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] <a/s/d/f/g/h/q/w/e/r/t/y> 的按键程序
|
||
// 直接池 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
|
||
}
|