DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,264 @@
# 支援兵 AIC 覆盖(companionpvf)使用说明
本文件说明如何重新生成、校验、启用支援兵的 **PVF AIC 覆盖**。
覆盖的作用:把服务端编译出的 AI 程序真正写进 `aicharacter/**.aic`,
让支援兵的行为与 GM 工具里的 AI 档位一致;只调 GM 滑块而不做覆盖时,
支援兵跑的是客户端 PVF 里的**原版 AIC**。
## 一、工具位置
| 项 | 路径 |
|---|---|
| 源码 | `go-server/cmd/companionpvf/` |
| 可执行 | `runtime/bin/companionpvf.exe` |
| 暂存目录 | `runtime/companion-overlay/` |
移植时补上的两处基础设施(原树没有):
- `internal/platform/pvf/ensure_string.go`
- `Archive.EnsureANSIString` / `Archive.EnsureUnicodeString`
- `Archive.RepackRawAndStringPools` ← **关键**:`RepackRaw` 只重写文件体、
不写回被追加过的字符串池,补丁引用会解析成空串;本方法会用当前
`strA/strW` 重新编码 name 段。
- `Archive.ansiOffsets` 字段(ANSI 池偏移缓存)
## 二、生成
`profiles.json`(**必须无 BOM**,否则 `invalid character '\ufeff'`):
```json
{
"profiles": [
{
"behavior": "aggressive",
"class_id": 0,
"buff_policy": "leader_first",
"combo_preset": "auto",
"combo_enabled": true,
"aoe_min_targets": 3,
"protect_leader": true
}
],
"jobs": [0]
}
```
`jobs[i]` 与 `profiles[i]` 一一对应。**同一个 job 只能有一个 profile** ——
两个 profile 指向同一个 `.aic` 且内容不同会直接报错
(`two profiles target ... with different artifacts`),因为一个人物职业只有
一条 AIC 路径。
命令:
```bat
companionpvf.exe --profile-overlay ^
D:\90server\Server\runtime\data\dnf\Script.pvf ^
D:\90server\Server\runtime\companion-overlay\Script.companion.pvf ^
D:\90server\Server\runtime\companion-overlay\profiles.json
```
输出 JSON 里带 `Manifest`,包含每个 artifact 的 `apc_code`/`aic_path`/
`aic_text`/`sha256`/`strategy_status`/`missing_capabilities`。
## 三、职业 → APC / AIC 映射
来自 `internal/modules/dnf/companion/template.go` `aiTemplatePairs`:
| job | 职业 | APC | AIC 路径 |
|---|---|---|---|
| 0 | swordman 剑士 | **900** | `aicharacter/_bizarre/swordman/mirror_swordman/mirror_swordman.aic` |
| 1 | fighter 格斗 | 5405 | `aicharacter/fighter/araf_fighter/araf_fighter_doll.aic` |
| 2 | gunner 枪手 | 10903 | `aicharacter/_bizarre/gunner/mirror_gunner/mirror_gunner.aic` |
| 3 | mage 法师 | 15902 | `aicharacter/_bizarre/magician/mirror_magician/mirror_magician.aic` |
本部署的支援兵「测试3」是 **job 0**,所以覆盖的是 APC **900** 的
`mirror_swordman.aic`。
## 四、校验(独立于工具自身的回读)
工具内部已经回读校验过;需要再次人工确认时,用 `platformpvf` 打开两个归档:
- 文件数必须相同(只替换已有文件,不增删路径)
- 目标 `.aic` 的字节必须不同
- 目标 `.aic` 必须含 `CompanionSlot00`…`CompanionSlot11`、
`CompanionDirectPool00`…、`CompanionBasicAttack`
- 五个标量标签必须等于 `aic_text` 里的值:
`[think term]` `[destination change term]` `[keep distance with target]`
`[warlike]` `[vision]`
## 五、已知缺口(工具自己会报)
最近一次生成(job 0 / aggressive)结果:
```
strategy_status = compiled_with_gaps
missing_capabilities = [stuck_detour, target_sticky]
```
含义:卡住绕行与目标粘性这两步**没有可用的原生能力回执**,不会被编译进
AIC。其余(思考间隔、目的地切换、保持距离、好战度、视野)都已写入。
## 六、启用(**未自动执行,需人工确认**)
覆盖要生效,客户端必须加载这份 PVF —— DNF 只读 `Script.pvf` 这个固定文件名,
所以是**替换**而不是追加:
1. 备份客户端 `D:\90server\Client\Script.pvf`(110,794,637 B)
2. 用 `Script.companion.pvf` 覆盖 `D:\90server\Client\Script.pvf`
3. 服务端 `runtime\data\dnf\Script.pvf` 也要一致
(`Server\START.bat` / `Client\开始游戏.bat` 会在启动时按哈希把客户端 PVF
同步到服务端,所以第 2 步做完通常会自动处理)
**回滚**:把备份的 `Script.pvf` 拷回去即可。
### 风险(务必知悉)
- 覆盖改的是 `aicharacter/_bizarre/swordman/mirror_swordman/mirror_swordman.aic`。
`_bizarre` 目录下的 AIC 属于"镜像/特殊"类资源,**如果还有别的地方复用这条
AIC**(例如某些副本的镜像敌人),它们的行为会一起改变。
- PVF 是 105 MB 级文件,替换前确认磁盘余量。
- 覆盖只影响客户端表现;服务端 `0x7F41` 投影里的档位参数仍然照常下发。
## 七、全职业覆盖(推荐做法)
`profiles.json` 的 `jobs` 可以一次给多个职业,工具会逐条编译并回读校验。
当前部署已覆盖 **job 0–14 共 15 个职业**(`jobs: [0..14]`,全部 aggressive):
| job | 职业 | APC | AIC |
|---|---|---|---|
| 0 | swordman | 900 | `_bizarre/swordman/mirror_swordman/mirror_swordman.aic` |
| 1 | fighter | 5405 | `fighter/arad_fighter/arad_fighter_doll.aic` |
| 2 | gunner | 10903 | `_bizarre/gunner/mirror_gunner/mirror_gunner.aic` |
| 3 | mage | 15902 | `_bizarre/magician/mirror_magician/mirror_magician.aic` |
| 4 | priest | 20902 | `_bizarre/priest/mirror_priest/mirror_priest.aic` |
| 5 | atgunner | 25900 | `_bizarre/atgunner/mirror_atgunner/mirror_atgunner.aic` |
| 6 | thief | 30901 | `_bizarre/Thief/mirror_Thief/mirror_Thief.aic` |
| 7 | atfighter | 35903 | `_bizarre/atfighter/mirror_atfighter/mirror_atfighter.aic` |
| 8 | atmage | 40800 | `autocharacter/atmage/auto1/auto_atmage1.aic` |
| 9 | demonic_swordman | 902 | `_bizarre/swordman/despair_darkknight/despair_darkknight.aic` |
| 10 | creator | 15903 | `_bizarre/magician/despair_creator/despair_creator.aic` |
| 11 | atswordman | 56901 | `_bizarre/atswordman/despair_atswordman/despair_atswordman.aic` |
| 12 | knight | 60901 | `_bizarre/knight/despair_knight/despair_knight.aic` |
| 13 | demonic_lancer | 65001 | `demoniclancer/mirror_demonicLancer/mirror_demonicLancer.aic` |
| 14 | atpriest | 70003 | `atpriest/despair_atpriest/despair_atpriest.aic` |
| **15** | **gunblader** | 75001 | **`gunblader/mirror_gunblader/mirror_gunblader.aic` —— 无法覆盖** |
16 条 AIC 路径互不相同,不会触发"两个 profile 指向同一 AIC"的冲突。
### job 15(gunblader)为什么覆盖不了
`companionAIRefs` 要求 `[ai pattern]` 段里同时存在 `action` / `destination` /
`move` 三个 `.ai` 程序引用。实际内容对比:
```
job 14 atpriest : `Event.ai` `DestinationSelect.ai` `Action.ai` `MoveMethod.ai`
job 15 gunblader: `Action.h`
```
gunblader 用的是**另一代 AIC 格式**:只引用一个 `.h` 头文件,没有独立的
action/destination/move 程序槽,因此 AI 程序无处可写。这是格式代差,不是
补丁器 bug —— 强行写入会破坏该 AIC。它的支援兵仍然能召唤并自带原版 AI,
只是拿不到本工具的 12 格快捷栏/普攻程序。
## 八、两个必须知道的坑
### 1. `behavior: "assist"` 会让支援兵**站着不打**
```go
func aicWarlike(profile AIProfile) int {
if profile.Behavior == "aggressive" { return 80 }
return 0 // assist 及其它一律 0
}
```
`[warlike] = 0` 就是不主动攻击。而且 assist 想要的能力(`leader_buff`、
`leader_follow`)当前都缺回执,实测等于"不动"。**想让支援兵主动作战必须用
`aggressive`。**
### 2. GM 克隆出来的角色,快照可能无效
GM 工具的"克隆角色"生成的记录不完全满足投影约束,preflight 会报:
```
reason = snapshot_invalid
message = 所选角色的装备/技能快照不完整或不符合投影约束
ai_template = @{key=; apc_code=0; aic_path=; ...} ← 全空
snapshot_at = 0001-01-01T00:00:00Z
```
这类角色**不能**当支援兵。换一个正常创建的角色即可(`preflight` 会显示
每个源的 `ready` / `reason`)。
## 九、按键程序必须重写("支援兵不放技能"的根因)
`--profile-overlay` 原来**只替换 AIC + 4 个 `.ai` 程序**,**没有重写 key 文件本体**;
legacy 路径(`main.go` 里 `keyFiles` / `directPoolFiles` 两个循环)一直在重写。
这个差异导致支援兵能召唤、能跟随、却不放技能。
### 症状(客户端 `90CN_companion_p0.log`)
```
runtime-key projection blocked reason=direct-bytecode-shape slot=0 ... size=128
projection APC registered ... runtime_keys_applied=0
```
### 原因
| 项 | 值 |
|---|---|
| 客户端默认绑定表要求 | `kRuntimeDirectKeyBytecodeSize = 0x180`(384 字节)|
| 客户端 DB4 表要求 | `0x1BC`(444 字节)|
| DB4 那批 25 字节旧 key 编译后 | **128 字节 (0x80)** |
| 客户端占位技能码 | `kSkillCodeBias(0x100) + kPlaceholderSkillId(511)` = **0x2FF** |
AIC 的 `[key stream]` 引用的是 `magician/biske2` 与 `magician/flea_inflame_hands2`
下那批 25 字节 key —— 它们是给 DB4 客户端做的。零售客户端把它编译成 128 字节,
尺寸校验直接拒绝,于是 `runtime_keys_applied=0`,12 格快捷栏没有绑定,AIC 无键可按。
### 修复
在 `BuildProfileOverlay` 重打包前,补上与 legacy 完全一致的两个循环:
```go
for index, path := range companionFrontKeyFiles {
appendReplacement(RawReplacement{Path: path,
Data: buildQuickSlotKey(archive, companionQuickSlotKeys[index])})
}
for _, path := range companionDirectKeyFiles {
appendReplacement(RawReplacement{Path: path,
Data: buildDirectPlaceholderKey(archive)})
}
```
`companionQuickSlotKeys = a s d f g h q w e r t y`(前段 12 格要按的键)。
生成结果:
| key | 原始 | 重写后 | 内容 |
|---|---|---|---|
| `biske2/key/*.key` | 25 B | 50 B | `[input] `target current direction` 100 `time` 50 [input] `a` 30 `time` 100` |
| `flame_hands2/key/*.key` | 25 B | 80 B | `... [input] `skill:511` 100 `time` 200` |
### 验证成功的标志
```
projection APC registered key=1 ... runtime_keys_applied=1
projection APC registered key=3 ... runtime_keys_applied=1
```
`runtime_keys_applied` 从 0 变 1,且日志里不再出现 `direct-bytecode-shape`。
### 覆盖范围
`已覆盖职业: 15 / 16` —— job 0–14 全部 25 组键流 + `CompanionSlot00`。
**job 15(gunblader 枪剑士)无法覆盖**:它的 `[ai pattern]` 只有 `` `Action.h` ``
单头文件,没有 action/destination/move 三个程序槽,`companionAIRefs` 直接报错,
`companionpvf` 不会为它生成任何东西。它的支援兵仍可召唤,但拿不到支援兵 AI 与
12 格快捷栏程序。
### key 文件是共享资产
重写的 24 个 key 文件是**全局共享**的,所以一次重写对所有已覆盖职业同时生效;
代价是 `mirror_magician` 等原本用这批 key 的 APC 的按键行为也会跟着变(官方
legacy 路径同样如此)。
@@ -0,0 +1,536 @@
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
}
+848
View File
@@ -0,0 +1,848 @@
package main
import (
"encoding/binary"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"strings"
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
platformpvf "longheng.io/server/internal/platform/pvf"
)
type token struct {
typ byte
val int32
txt string
idx int
}
func toks(a *platformpvf.Archive, raw []byte) []token {
var out []token
for off := 0; off+5 <= len(raw); off += 5 {
t := token{typ: raw[off], val: int32(binary.LittleEndian.Uint32(raw[off+1 : off+5])), idx: off / 5}
if t.typ == 3 || t.typ == 6 {
t.txt = strings.TrimSpace(a.ResolveString(int(t.val)))
}
out = append(out, t)
}
return out
}
func section(ts []token, open, close string) (int, int) {
start := -1
for i, t := range ts {
if t.typ != 3 {
continue
}
if strings.EqualFold(t.txt, open) {
start = i
continue
}
if start >= 0 && strings.EqualFold(t.txt, close) {
return start, i
}
}
return -1, -1
}
func rawToken(typ byte, value int) []byte {
out := make([]byte, 5)
out[0] = typ
binary.LittleEndian.PutUint32(out[1:5], uint32(value))
return out
}
func rawFloat(value float32) []byte {
out := make([]byte, 5)
out[0] = 2
binary.LittleEndian.PutUint32(out[1:5], math.Float32bits(value))
return out
}
func actualPath(a *platformpvf.Archive, p string) string {
f, ok := a.FindFile(p)
if !ok {
panic("missing pvf path: " + p)
}
return strings.ReplaceAll(f.ArchivePath, "\\", "/")
}
func replaceSection(raw []byte, ts []token, open, close string, interior []byte) []byte {
s, e := section(ts, open, close)
if s < 0 || e <= s {
panic("section missing: " + open)
}
out := make([]byte, 0, len(raw)+len(interior))
out = append(out, raw[:(s+1)*5]...)
out = append(out, interior...)
out = append(out, raw[e*5:]...)
return out
}
func replaceOrInsertSection(
a *platformpvf.Archive,
raw []byte,
ts []token,
open, close, afterClose string,
interior []byte,
) []byte {
if s, e := section(ts, open, close); s >= 0 && e > s {
return replaceSection(raw, ts, open, close, interior)
}
insertAfter := -1
for i, t := range ts {
if t.typ == 3 && strings.EqualFold(t.txt, afterClose) {
insertAfter = i
break
}
}
if insertAfter < 0 {
panic("insert anchor missing: " + afterClose)
}
openOff, err := a.EnsureANSIString(open)
if err != nil {
panic(err)
}
closeOff, err := a.EnsureANSIString(close)
if err != nil {
panic(err)
}
block := append([]byte{}, rawToken(3, openOff)...)
block = append(block, interior...)
block = append(block, rawToken(3, closeOff)...)
out := make([]byte, 0, len(raw)+len(block))
out = append(out, raw[:(insertAfter+1)*5]...)
out = append(out, block...)
out = append(out, raw[(insertAfter+1)*5:]...)
return out
}
func removeStandaloneTagIfPresent(raw []byte, ts []token, tag string) []byte {
index := -1
for i, t := range ts {
if t.typ == 3 && strings.EqualFold(t.txt, tag) {
if index >= 0 {
panic("duplicate standalone tag: " + tag)
}
index = i
}
}
if index < 0 {
return raw
}
out := make([]byte, 0, len(raw)-5)
out = append(out, raw[:index*5]...)
out = append(out, raw[(index+1)*5:]...)
return out
}
func replaceIntAfterTagIfPresent(raw []byte, ts []token, tag string, value int) []byte {
out := append([]byte(nil), raw...)
for i := 0; i+1 < len(ts); i++ {
if ts[i].typ == 3 && strings.EqualFold(ts[i].txt, tag) &&
ts[i+1].typ == 0 {
binary.LittleEndian.PutUint32(
out[(i+1)*5+1:(i+1)*5+5], uint32(value))
return out
}
}
return raw
}
func firstNumericInSection(
a *platformpvf.Archive,
raw []byte,
open, close string,
) (float64, byte, int, bool) {
ts := toks(a, raw)
s, e := section(ts, open, close)
if s < 0 || e <= s {
return 0, 0, -1, false
}
for i := s + 1; i < e; i++ {
switch ts[i].typ {
case 0:
return float64(ts[i].val), 0, i, true
case 2:
return float64(math.Float32frombits(uint32(ts[i].val))), 2, i, true
}
}
return 0, 0, -1, false
}
func firstNumericAfterTag(
a *platformpvf.Archive,
raw []byte,
tag string,
) (float64, byte, int, bool) {
ts := toks(a, raw)
for i := 0; i+1 < len(ts); i++ {
if ts[i].typ != 3 || !strings.EqualFold(ts[i].txt, tag) {
continue
}
switch ts[i+1].typ {
case 0:
return float64(ts[i+1].val), 0, i + 1, true
case 2:
return float64(math.Float32frombits(uint32(ts[i+1].val))), 2, i + 1, true
default:
return 0, 0, -1, false
}
}
return 0, 0, -1, false
}
func setCompanionHPRate(
a *platformpvf.Archive,
raw []byte,
baseHP float64,
) []byte {
current, typ, index, ok := firstNumericInSection(
a, raw, "[character status rate]", "[/character status rate]")
if !ok {
panic("character status rate HP missing")
}
target := baseHP * 20
if math.Abs(current-baseHP) > 0.0001 &&
math.Abs(current-target) > 0.0001 {
panic(fmt.Sprintf(
"unexpected companion HP rate current=%g base=%g target=%g",
current, baseHP, target))
}
out := append([]byte(nil), raw...)
offset := index*5 + 1
switch typ {
case 0:
if target != math.Trunc(target) ||
target < math.MinInt32 || target > math.MaxInt32 {
panic(fmt.Sprintf("integer HP rate out of range: %g", target))
}
binary.LittleEndian.PutUint32(
out[offset:offset+4], uint32(int32(target)))
case 2:
binary.LittleEndian.PutUint32(
out[offset:offset+4], math.Float32bits(float32(target)))
default:
panic("unsupported HP rate token type")
}
return out
}
func removeStandaloneTag(raw []byte, ts []token, tag string) []byte {
index := -1
for i, t := range ts {
if t.typ == 3 && strings.EqualFold(t.txt, tag) {
if index >= 0 {
panic("duplicate standalone tag: " + tag)
}
index = i
}
}
if index < 0 {
panic("standalone tag missing: " + tag)
}
out := make([]byte, 0, len(raw)-5)
out = append(out, raw[:index*5]...)
out = append(out, raw[(index+1)*5:]...)
return out
}
func replaceIntAfterTag(raw []byte, ts []token, tag string, value int) []byte {
out := append([]byte(nil), raw...)
for i := 0; i+1 < len(ts); i++ {
if ts[i].typ == 3 && strings.EqualFold(ts[i].txt, tag) &&
ts[i+1].typ == 0 {
binary.LittleEndian.PutUint32(
out[(i+1)*5+1:(i+1)*5+5], uint32(value))
return out
}
}
panic("integer after tag missing: " + tag)
}
// replaceFloatAfterTag patches a scalar float without changing the token
// shape of the surrounding AIC. Callers use the fixed support-AIC allowlist;
// ordinary APC AIC files never reach this function.
func replaceFloatAfterTag(raw []byte, ts []token, tag string, value float32) []byte {
for i := 0; i+1 < len(ts); i++ {
if ts[i].typ != 3 || !strings.EqualFold(ts[i].txt, tag) {
continue
}
next := ts[i+1]
offset := (i + 1) * 5
out := append([]byte(nil), raw...)
switch next.typ {
case 2:
binary.LittleEndian.PutUint32(out[offset+1:offset+5], math.Float32bits(value))
return out
case 0:
if value != float32(int(value)) {
panic("float value cannot be represented by integer AIC token: " + tag)
}
binary.LittleEndian.PutUint32(out[offset+1:offset+5], uint32(int(value)))
return out
default:
panic("tag is not a numeric AIC token: " + tag)
}
}
panic("float tag missing: " + tag)
}
func patchCooldownMap(a *platformpvf.Archive, raw []byte, mapping map[int32]int32) []byte {
out := append([]byte(nil), raw...)
ts := toks(a, out)
counts := map[int32]int{}
for i := 0; i+1 < len(ts); i++ {
if ts[i].typ != 6 || !strings.EqualFold(ts[i].txt, "is the skill in cooltime()") || ts[i+1].typ != 0 {
continue
}
next, ok := mapping[ts[i+1].val]
if !ok {
continue
}
binary.LittleEndian.PutUint32(out[(i+1)*5+1:(i+1)*5+5], uint32(next))
counts[ts[i+1].val]++
}
for old := range mapping {
if counts[old] == 0 {
panic(fmt.Sprintf("action cooldown id %d not found", old))
}
}
return out
}
func buildQuickSlotKey(a *platformpvf.Archive, key string) []byte {
tag := func(value string) []byte {
off, err := a.EnsureANSIString(value)
if err != nil {
panic(err)
}
return rawToken(3, off)
}
str := func(value string) []byte {
off, err := a.EnsureANSIString(value)
if err != nil {
panic(err)
}
return rawToken(6, off)
}
var out []byte
out = append(out, tag("[input]")...)
out = append(out, str("target current direction")...)
out = append(out, rawToken(0, 100)...)
out = append(out, str("time")...)
out = append(out, rawToken(0, 50)...)
out = append(out, tag("[input]")...)
out = append(out, str(key)...)
out = append(out, rawToken(0, 30)...)
out = append(out, str("time")...)
out = append(out, rawToken(0, 100)...)
return out
}
func buildDirectPlaceholderKey(a *platformpvf.Archive) []byte {
tag := func(value string) []byte {
off, err := a.EnsureANSIString(value)
if err != nil {
panic(err)
}
return rawToken(3, off)
}
str := func(value string) []byte {
off, err := a.EnsureANSIString(value)
if err != nil {
panic(err)
}
return rawToken(6, off)
}
var out []byte
out = append(out, tag("[input]")...)
out = append(out, str("")...)
out = append(out, rawToken(0, 50)...)
out = append(out, str("time")...)
out = append(out, rawToken(0, 50)...)
out = append(out, tag("[input]")...)
out = append(out, tag("[state check]")...)
out = append(out, str("target current direction x")...)
out = append(out, rawToken(0, 100)...)
out = append(out, str("time")...)
out = append(out, rawToken(0, 50)...)
out = append(out, tag("[input]")...)
out = append(out, str("skill:511")...)
out = append(out, rawToken(0, 100)...)
out = append(out, str("time")...)
out = append(out, rawToken(0, 200)...)
return out
}
// buildDefaultCompanionAI keeps the legacy generator on the same native
// state/party/mode tree used by the profile overlay. The old default path
// emitted a random key bucket that the current client could not bind, leaving
// the companion able to chase but unable to select attacks.
func buildDefaultCompanionAI(
a *platformpvf.Archive,
aiFiles []string,
profile dnfcompanion.AIProfile,
) ([]platformpvf.RawReplacement, error) {
if len(aiFiles) != 4 {
return nil, fmt.Errorf("default companion AI requires event, destination, action and move paths")
}
profile = dnfcompanion.NormalizeAIProfile(profile)
action := buildCompanionActionAI(a, profile, 4)
if err := validateCompanionActionAI(a, action); err != nil {
return nil, fmt.Errorf("generated default action: %w", err)
}
return []platformpvf.RawReplacement{
{Path: aiFiles[0], Data: buildCompanionEventAI(a, profile)},
{Path: aiFiles[1], Data: buildCompanionDestinationAI(a, profile, 4)},
{Path: aiFiles[2], Data: action},
{Path: aiFiles[3], Data: buildCompanionMoveAI(a, profile, 4)},
}, nil
}
func main() {
if len(os.Args) >= 2 && os.Args[1] == "--skill-policy" {
if len(os.Args) != 6 {
panic("usage: companionpvf --skill-policy <base.pvf> <roles.json> <output.h> <catalog.json>")
}
if err := writeSkillPolicy(os.Args[2], os.Args[3], os.Args[4], os.Args[5]); err != nil {
panic(err)
}
return
}
if len(os.Args) >= 2 && os.Args[1] == "--profile-overlay" {
if len(os.Args) < 5 {
panic("usage: companionpvf --profile-overlay <base.pvf> <output.pvf> <profiles.json>")
}
data, err := os.ReadFile(os.Args[4])
if err != nil {
panic(err)
}
var input struct {
Profiles []dnfcompanion.AIProfile `json:"profiles"`
Jobs []uint8 `json:"jobs"`
}
if err := json.Unmarshal(data, &input); err != nil {
panic(fmt.Errorf("read profile overlay JSON: %w", err))
}
result, err := BuildProfileOverlay(ProfileOverlayRequest{
BasePVF: os.Args[2], OutputPVF: os.Args[3],
Profiles: input.Profiles, Jobs: input.Jobs,
})
if err != nil {
panic(err)
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(result); err != nil {
panic(err)
}
return
}
root := ".."
if len(os.Args) >= 2 && strings.TrimSpace(os.Args[1]) != "" {
root = os.Args[1]
}
absoluteRoot, err := filepath.Abs(root)
if err != nil {
panic(err)
}
projectRoot := filepath.Clean(absoluteRoot)
src := filepath.Join(projectRoot, "runtime", "data", "dnf", "Script.pvf")
outPath := filepath.Join(projectRoot, "runtime", "Script.companion-12slot-dynamic.pvf")
a, err := platformpvf.Open(src)
if err != nil {
panic(err)
}
companionBaseHPRates := map[string]float64{
"aicharacter/_bizarre/swordman/mirror_swordman/mirror_swordman.aic": 210,
"aicharacter/fighter/arad_fighter/arad_fighter_doll.aic": 6,
"aicharacter/_bizarre/gunner/mirror_gunner/mirror_gunner.aic": 210,
"aicharacter/_bizarre/magician/mirror_magician/mirror_magician.aic": 210,
"aicharacter/_bizarre/priest/mirror_priest/mirror_priest.aic": 210,
"aicharacter/_bizarre/atgunner/mirror_atgunner/mirror_atgunner.aic": 210,
"aicharacter/_bizarre/Thief/mirror_Thief/mirror_Thief.aic": 210,
"aicharacter/_bizarre/atfighter/mirror_atfighter/mirror_atfighter.aic": 210,
"aicharacter/autocharacter/atmage/auto1/auto_atmage1.aic": 270,
"aicharacter/_bizarre/swordman/despair_darkknight/despair_darkknight.aic": 400,
"aicharacter/_bizarre/magician/despair_creator/despair_creator.aic": 400,
"aicharacter/_bizarre/atswordman/despair_atswordman/despair_atswordman.aic": 400,
"aicharacter/_bizarre/knight/despair_knight/despair_knight.aic": 400,
"aicharacter/demoniclancer/mirror_demonicLancer/mirror_demonicLancer.aic": 210,
"aicharacter/atpriest/despair_atpriest/despair_atpriest.aic": 400,
"aicharacter/gunblader/mirror_gunblader/mirror_gunblader.aic": 210,
}
targetAICs := []string{
"aicharacter/_bizarre/swordman/mirror_swordman/mirror_swordman.aic",
"aicharacter/fighter/arad_fighter/arad_fighter_doll.aic",
"aicharacter/_bizarre/gunner/mirror_gunner/mirror_gunner.aic",
"aicharacter/_bizarre/magician/mirror_magician/mirror_magician.aic",
"aicharacter/_bizarre/priest/mirror_priest/mirror_priest.aic",
"aicharacter/_bizarre/atgunner/mirror_atgunner/mirror_atgunner.aic",
"aicharacter/_bizarre/Thief/mirror_Thief/mirror_Thief.aic",
"aicharacter/_bizarre/atfighter/mirror_atfighter/mirror_atfighter.aic",
"aicharacter/autocharacter/atmage/auto1/auto_atmage1.aic",
"aicharacter/_bizarre/swordman/despair_darkknight/despair_darkknight.aic",
"aicharacter/_bizarre/magician/despair_creator/despair_creator.aic",
"aicharacter/_bizarre/atswordman/despair_atswordman/despair_atswordman.aic",
"aicharacter/_bizarre/knight/despair_knight/despair_knight.aic",
"aicharacter/demoniclancer/mirror_demonicLancer/mirror_demonicLancer.aic",
"aicharacter/atpriest/despair_atpriest/despair_atpriest.aic",
"aicharacter/gunblader/mirror_gunblader/mirror_gunblader.aic",
}
aiFiles := []string{
"aicharacter/atgunner/blackwidow/ai/event.ai",
"aicharacter/atgunner/blackwidow/ai/destinationselect.ai",
"aicharacter/atgunner/blackwidow/ai/action.ai",
"aicharacter/atgunner/blackwidow/ai/movemethod.ai",
}
keyFiles := []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",
}
directPoolFiles := []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",
}
basicAttackFile := companionBasicAttackFile
basicAttackName := "CompanionBasicAttack"
quickSlotKeys := []string{"a", "s", "d", "f", "g", "h", "q", "w", "e", "r", "t", "y"}
aiOffsets := make([]int, len(aiFiles))
for i, p := range aiFiles {
full := actualPath(a, p)
off, e := a.EnsureANSIString(full)
if e != nil {
panic(e)
}
aiOffsets[i] = off
fmt.Printf("AI_PATH[%d] off=%d %s\n", i, off, full)
}
keyOffsets := make([]int, len(keyFiles))
for i, p := range keyFiles {
full := actualPath(a, p)
off, e := a.EnsureANSIString(full)
if e != nil {
panic(e)
}
keyOffsets[i] = off
fmt.Printf("KEY_PATH[%d] off=%d %s\n", i, off, full)
}
directPoolOffsets := make([]int, len(directPoolFiles))
directPoolNameOffsets := make([]int, len(directPoolFiles))
for i, p := range directPoolFiles {
full := actualPath(a, p)
pathOff, e := a.EnsureANSIString(full)
if e != nil {
panic(e)
}
nameOff, e := a.EnsureANSIString(
fmt.Sprintf("CompanionDirectPool%02d", i))
if e != nil {
panic(e)
}
directPoolOffsets[i] = pathOff
directPoolNameOffsets[i] = nameOff
}
basicAttackPathOffset, err := a.EnsureANSIString(
actualPath(a, basicAttackFile))
if err != nil {
panic(err)
}
basicAttackNameOffset, err := a.EnsureANSIString(basicAttackName)
if err != nil {
panic(err)
}
var aiInterior []byte
for _, off := range aiOffsets {
aiInterior = append(aiInterior, rawToken(6, off)...)
}
keyNames := make([]string, len(keyFiles))
keyNameOffsets := make([]int, len(keyFiles))
for i := range keyNames {
keyNames[i] = fmt.Sprintf("CompanionSlot%02d", i)
off, e := a.EnsureANSIString(keyNames[i])
if e != nil {
panic(e)
}
keyNameOffsets[i] = off
}
var keyInterior []byte
for i := range keyFiles {
keyInterior = append(keyInterior, rawToken(6, keyNameOffsets[i])...)
keyInterior = append(keyInterior, rawToken(6, keyOffsets[i])...)
}
for i := range directPoolFiles {
keyInterior = append(keyInterior, rawToken(6, directPoolNameOffsets[i])...)
keyInterior = append(keyInterior, rawToken(6, directPoolOffsets[i])...)
}
keyInterior = append(keyInterior, rawToken(6, basicAttackNameOffset)...)
keyInterior = append(keyInterior, rawToken(6, basicAttackPathOffset)...)
keyCooldowns := make([]int, len(keyFiles))
var keyCooldownInterior []byte
for i := range keyFiles {
keyCooldowns[i] = 600
keyCooldownInterior = append(keyCooldownInterior, rawToken(6, keyNameOffsets[i])...)
keyCooldownInterior = append(keyCooldownInterior, rawToken(0, keyCooldowns[i])...)
}
keyCooldownInterior = append(keyCooldownInterior, rawToken(6, basicAttackNameOffset)...)
keyCooldownInterior = append(keyCooldownInterior, rawToken(0, 0)...)
type scalarPatch struct {
tag string
value int
}
profile := dnfcompanion.DefaultAIProfile(4)
profile.Behavior = "aggressive"
tuning := dnfcompanion.EffectiveAITuning(profile.Tuning)
scalarPatches := []scalarPatch{
{"[think term]", tuning.ThinkIntervalMS},
{"[destination change term]", tuning.RetryMS},
{"[keep distance with target]", tuning.FollowDistance},
{"[warlike]", 80},
{"[vision]", tuning.VisionRange},
}
const companionAttackDamageRate float32 = 1.0
baselineRaw := make(map[string][]byte, len(targetAICs))
for _, targetAIC := range targetAICs {
targetRaw, e := a.ReadRaw(targetAIC)
if e != nil {
panic(e)
}
baseHP, ok := companionBaseHPRates[targetAIC]
if !ok {
panic("companion HP baseline missing: " + targetAIC)
}
targetRaw = setCompanionHPRate(a, targetRaw, baseHP)
targetTs := toks(a, targetRaw)
targetRaw = replaceSection(
targetRaw, targetTs,
"[ai pattern]", "[/ai pattern]",
aiInterior)
targetTs = toks(a, targetRaw)
targetRaw = replaceSection(
targetRaw, targetTs,
"[key stream]", "[/key stream]",
keyInterior)
targetTs = toks(a, targetRaw)
targetRaw = replaceOrInsertSection(
a, targetRaw, targetTs,
"[key cooltime]", "[/key cooltime]",
"[/key stream]",
keyCooldownInterior)
targetTs = toks(a, targetRaw)
targetRaw = removeStandaloneTagIfPresent(
targetRaw, targetTs, "[no cooltime]")
for _, patch := range scalarPatches {
targetTs = toks(a, targetRaw)
targetRaw = replaceIntAfterTag(
targetRaw, targetTs,
patch.tag, patch.value)
}
targetTs = toks(a, targetRaw)
targetRaw = replaceIntAfterTagIfPresent(
targetRaw, targetTs,
"[far attack reaction rate]", 100)
targetTs = toks(a, targetRaw)
targetRaw = replaceFloatAfterTag(
targetRaw, targetTs,
"[attack damage rate]", companionAttackDamageRate)
baselineRaw[targetAIC] = targetRaw
fmt.Printf("BASELINE_PATCH %s attack_damage_rate=%.1f\n",
targetAIC, companionAttackDamageRate)
}
// The runtime quickbar selector binds the full state/party/mode tree.
// Keep the default builder on the same grammar as --profile-overlay;
// a random key tree cannot be bound and executes empty quickbar slots.
repls, err := buildDefaultCompanionAI(a, aiFiles, profile)
if err != nil {
panic(err)
}
for _, targetAIC := range targetAICs {
repls = append(repls, platformpvf.RawReplacement{
Path: targetAIC,
Data: baselineRaw[targetAIC],
})
}
for i, p := range keyFiles {
raw := buildQuickSlotKey(a, quickSlotKeys[i])
repls = append(repls, platformpvf.RawReplacement{Path: p, Data: raw})
}
for _, p := range directPoolFiles {
raw := buildDirectPlaceholderKey(a)
repls = append(repls, platformpvf.RawReplacement{Path: p, Data: raw})
}
patched, err := a.RepackRaw(repls)
if err != nil {
panic(err)
}
if err := os.WriteFile(outPath, patched, 0644); err != nil {
panic(err)
}
verify, err := platformpvf.Open(outPath)
if err != nil {
panic(err)
}
fmt.Printf("VERIFY format=%s files=%d size=%d\n", verify.Format(), verify.FileCount(), len(patched))
for _, targetAIC := range targetAICs {
targetText, e := verify.ReadText(targetAIC)
if e != nil {
panic(e)
}
targetFlat := strings.Join(strings.Fields(targetText), " ")
targetLower := strings.ToLower(targetFlat)
if strings.Contains(targetLower, "[no cooltime]") {
panic("baseline still has [no cooltime]: " + targetAIC)
}
raw, e := verify.ReadRaw(targetAIC)
if e != nil {
panic(e)
}
actualHP, _, _, ok := firstNumericInSection(
verify, raw, "[character status rate]", "[/character status rate]")
if !ok || math.Abs(actualHP-companionBaseHPRates[targetAIC]*20) > 0.0001 {
panic(fmt.Sprintf(
"baseline %s HP multiplier mismatch actual=%g want=%g",
targetAIC, actualHP, companionBaseHPRates[targetAIC]*20))
}
actualAttackDamageRate, _, _, ok := firstNumericAfterTag(
verify, raw, "[attack damage rate]")
if !ok || math.Abs(actualAttackDamageRate-float64(companionAttackDamageRate)) > 0.0001 {
panic(fmt.Sprintf(
"baseline %s attack damage rate mismatch actual=%g want=%g",
targetAIC, actualAttackDamageRate, companionAttackDamageRate))
}
for _, check := range []string{
fmt.Sprintf("[think term] %d", tuning.ThinkIntervalMS),
fmt.Sprintf("[destination change term] %d", tuning.RetryMS),
fmt.Sprintf("[keep distance with target] %d", tuning.FollowDistance),
"[warlike] 80",
fmt.Sprintf("[vision] %d", tuning.VisionRange),
} {
if !strings.Contains(targetLower, check) {
panic(fmt.Sprintf("baseline %s missing %s", targetAIC, check))
}
}
for _, name := range keyNames {
if strings.Count(targetFlat, name) < 2 {
panic(fmt.Sprintf(
"baseline %s missing stream/cooldown key %s",
targetAIC, name))
}
}
for i := range directPoolFiles {
name := fmt.Sprintf("CompanionDirectPool%02d", i)
if !strings.Contains(targetFlat, name) {
panic(fmt.Sprintf(
"baseline %s missing direct pool %s",
targetAIC, name))
}
}
if !strings.Contains(targetFlat, basicAttackName) {
panic("baseline missing basic attack program: " + targetAIC)
}
fmt.Printf("VERIFY_BASELINE %s attack_damage_rate=%.1f programs=%d+%d+1\n",
targetAIC, companionAttackDamageRate, len(keyFiles), len(directPoolFiles))
}
actionText, err := verify.ReadText(aiFiles[2])
if err != nil {
panic(err)
}
fmt.Println("ACTION", strings.Join(strings.Fields(actionText), " "))
actionFlat := " " + strings.Join(strings.Fields(actionText), " ") + " "
actionRaw, err := verify.ReadRaw(aiFiles[2])
if err != nil {
panic(err)
}
if err := validateCompanionActionAI(verify, actionRaw); err != nil {
panic(fmt.Errorf("default companion action readback: %w", err))
}
if strings.Contains(strings.ToLower(actionFlat), "get random()") {
panic("default companion action still uses an unbound random key tree")
}
for index, name := range keyNames {
if !strings.Contains(actionFlat, name) || !strings.Contains(actionFlat, fmt.Sprintf(" %d ", index)) {
panic(fmt.Sprintf("action missing key/index %s/%d", name, index))
}
}
if strings.Contains(strings.ToLower(actionFlat), "is the skill in cooltime()") {
panic("action still depends on skill cooldown")
}
if !strings.Contains(actionFlat, fmt.Sprintf(" %d ", companionBasicAttackIndex)) {
panic("action missing basic-attack fallback index")
}
basicAttackText, err := verify.ReadText(basicAttackFile)
if err != nil {
panic(err)
}
basicAttackFlat := strings.ToLower(strings.Join(
strings.Fields(basicAttackText), " "))
basicToken := string(rune(96)) + "x" + string(rune(96))
if !strings.Contains(basicAttackFlat, basicToken) {
panic("basic attack key does not contain native X input")
}
for i, p := range keyFiles {
text, e := verify.ReadText(p)
if e != nil {
panic(e)
}
flat := strings.Join(strings.Fields(text), " ")
fmt.Printf("KEY[%d] %s => %s\n", i, p, flat)
needle := "`" + quickSlotKeys[i] + "`"
if !strings.Contains(strings.ToLower(flat), needle) {
panic(fmt.Sprintf("key %d missing quick-slot input %s", i, quickSlotKeys[i]))
}
if strings.Contains(strings.ToLower(flat), "skill:") {
panic(fmt.Sprintf("key %d still hardcodes a skill id", i))
}
}
jisunPath := "aicharacter/fighter/jisun/ai/action.ai"
jisunText, err := verify.ReadText(jisunPath)
if err != nil {
panic(err)
}
jisunFlat := " " + strings.Join(strings.Fields(jisunText), " ") + " "
if !strings.Contains(jisunFlat, " 58 ") || !strings.Contains(jisunFlat, " 0 ") {
panic("Jisun action changed unexpectedly")
}
fmt.Println("JISUN_UNCHANGED", strings.Join(strings.Fields(jisunText), " "))
fmt.Println("WROTE", outPath)
}
+239
View File
@@ -0,0 +1,239 @@
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
}
+312
View File
@@ -0,0 +1,312 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"math"
"os"
"path"
"sort"
"strconv"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
platformpvf "longheng.io/server/internal/platform/pvf"
)
type skillRoleInput struct {
Schema int `json:"schema"`
RefreshMS uint32 `json:"refresh_ms"`
Common map[string]json.RawMessage `json:"common"`
Jobs map[string]map[string]json.RawMessage `json:"jobs"`
PassiveGrow []struct {
Job uint8 `json:"job"`
File string `json:"file"`
Grow uint8 `json:"grow"`
} `json:"passive_grow"`
}
type skillPolicyRow struct {
Job uint8 `json:"job"`
ID uint16 `json:"id"`
Purpose uint8 `json:"purpose"`
Flags uint8 `json:"flags"`
Group uint8 `json:"exclusive_group"`
PassiveGrow uint16 `json:"passive_grow"`
Cooldown uint32 `json:"cooldown_ms"`
Refresh uint32 `json:"refresh_ms"`
Path string `json:"path"`
Name string `json:"name"`
}
var policyPurposes = map[string]uint8{
"party_buff": 2, "once_party_buff": 2, "heal": 3, "support_awakening": 5,
"self_buff": 6, "once_self_buff": 6, "defensive_support": 7, "unsupported": 8, "summon": 9, "self_heal": 10,
}
// Only the main dungeon cooldown is authoritative. Apocalypse has a 145000
// reference-skill value before its actual 170000 dungeon cooldown. Never use
// first-number lookup or the PvP table for native action timing.
func currentDungeonCooldown(a *platformpvf.Archive, raw []byte) (uint32, error) {
depth, reference, alternate := 0, 0, 0
var global uint32
items := toks(a, raw)
for i, t := range items {
if t.typ != 3 {
continue
}
switch strings.ToLower(t.txt) {
case "[reference skill]":
reference++
case "[/reference skill]":
reference--
case "[dungeon]":
depth++
case "[/dungeon]":
depth--
case "[pvp]", "[death tower]", "[warroom]":
alternate++
case "[/pvp]", "[/death tower]", "[/warroom]":
alternate--
case "[cool time]":
if alternate != 0 || reference != 0 {
continue
}
maximum := float64(0)
count := 0
for j := i + 1; j < len(items) && items[j].typ != 3; j++ {
v := float64(items[j].val)
if items[j].typ == 2 {
v = float64(math.Float32frombits(uint32(items[j].val)))
} else if items[j].typ != 0 {
continue
}
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 3600000 {
return 0, fmt.Errorf("invalid main dungeon cooldown %g", v)
}
maximum = math.Max(maximum, v)
count++
}
if count != 2 {
return 0, fmt.Errorf("dungeon cooldown has %d values, expected rank endpoints", count)
}
value := uint32(math.Ceil(maximum)) // Conservative rank maximum, no guessed interpolation.
if depth > 0 {
return value, nil
}
global = value // Files without [dungeon] store the main value at top level.
}
}
return global, nil
}
func compileSkillPolicy(a *platformpvf.Archive, input skillRoleInput) ([]skillPolicyRow, error) {
if input.Schema != 1 || input.RefreshMS < 1000 || input.RefreshMS > 3600000 {
return nil, fmt.Errorf("invalid skill role schema/refresh policy")
}
master, err := a.ReadText("skill/skilllist.lst")
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse("skill/skilllist.lst", master)
if err != nil {
return nil, err
}
var rows []skillPolicyRow
seenJobs := map[uint8]bool{}
for _, entry := range dnfpvf.ParseList(doc) {
if entry.ID < 0 || entry.ID >= 16 {
return nil, fmt.Errorf("unsupported job list %d", entry.ID)
}
job := uint8(entry.ID)
seenJobs[job] = true
jobRules, ok := input.Jobs[strconv.Itoa(int(job))]
if !ok {
return nil, fmt.Errorf("missing job rules %d", job)
}
listPath := path.Join("skill", entry.Path)
text, err := a.ReadText(listPath)
if err != nil {
return nil, err
}
list, err := dnfpvf.Parse(listPath, text)
if err != nil {
return nil, err
}
seenFiles := map[string]bool{}
roleFiles := map[string]string{}
nativeFiles := map[string]bool{}
groups := map[string]uint8{}
for _, set := range []map[string]json.RawMessage{input.Common, jobRules} {
for role, encoded := range set {
if role == "exclusive_groups" {
var named [][]string
if err := json.Unmarshal(encoded, &named); err != nil {
return nil, err
}
for group, files := range named {
for _, file := range files {
groups[strings.ToLower(file)] = uint8(group + 1)
}
}
continue
}
var files []string
if err := json.Unmarshal(encoded, &files); err != nil {
return nil, err
}
for _, file := range files {
key := strings.ToLower(file)
if role == "native_buff" {
nativeFiles[key] = true
continue
}
if _, ok := policyPurposes[role]; !ok {
return nil, fmt.Errorf("unknown purpose %s", role)
}
if old, exists := roleFiles[key]; exists && old != role {
return nil, fmt.Errorf("conflicting roles job=%d %s", job, file)
}
roleFiles[key] = role
}
}
}
seenIDs := map[uint16]bool{}
for _, skill := range dnfpvf.ParseList(list) {
if skill.ID < 0 || skill.ID >= 512 {
return nil, fmt.Errorf("job %d skill %d exceeds native projection contract", job, skill.ID)
}
id := uint16(skill.ID)
if seenIDs[id] {
return nil, fmt.Errorf("duplicate job %d skill %d", job, id)
}
seenIDs[id] = true
skillPath := path.Join(path.Dir(listPath), skill.Path)
text, err := a.ReadText(skillPath)
if err != nil {
return nil, err
}
d, err := dnfpvf.Parse(skillPath, text)
if err != nil {
return nil, err
}
name, _ := d.Text("name")
kind, _ := d.Text("type")
row := skillPolicyRow{Job: job, ID: id, Path: skillPath, Name: name, Purpose: 4}
file := strings.ToLower(path.Base(skillPath))
seenFiles[file] = true
if strings.EqualFold(kind, "[active]") {
row.Purpose = 1
role, exists := roleFiles[file]
if exists {
row.Purpose = policyPurposes[role]
if strings.HasPrefix(role, "once_") || role == "summon" {
row.Flags |= 1
}
row.Group = groups[file]
if nativeFiles[file] {
if job != 4 || row.Purpose != 2 {
return nil, fmt.Errorf("unverified native buff rule %d/%s", job, file)
}
row.Flags |= 2
}
if row.Purpose == 2 || row.Purpose == 6 {
row.Refresh = input.RefreshMS
}
}
raw, err := a.ReadRaw(skillPath)
if err != nil {
return nil, err
}
row.Cooldown, err = currentDungeonCooldown(a, raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", skillPath, err)
}
if row.Purpose == 1 {
explain, _ := d.Text("explain")
for _, phrase := range []string{"周围敌人", "周围的敌人", "范围内的敌人", "范围内所有敌人", "结界内的敌人", "所有敌人", "大范围", "广范围", "多名敌人"} {
if strings.Contains(explain, phrase) {
row.Flags |= 4
break
}
}
}
}
for _, exception := range input.PassiveGrow {
if exception.Job == job && strings.EqualFold(exception.File, file) {
if exception.Grow >= 16 {
return nil, fmt.Errorf("invalid passive grow")
}
row.PassiveGrow |= 1 << exception.Grow
}
}
rows = append(rows, row)
}
for file := range roleFiles {
if !seenFiles[file] {
return nil, fmt.Errorf("job %d role references missing skill file %s", job, file)
}
}
for file := range nativeFiles {
if !seenFiles[file] {
return nil, fmt.Errorf("missing native buff %d/%s", job, file)
}
}
}
if len(seenJobs) != 16 {
return nil, fmt.Errorf("expected 16 current job lists, got %d", len(seenJobs))
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Job != rows[j].Job {
return rows[i].Job < rows[j].Job
}
return rows[i].ID < rows[j].ID
})
return rows, nil
}
func writeSkillPolicy(base, rulesPath, headerPath, catalogPath string) error {
a, err := platformpvf.Open(base)
if err != nil {
return err
}
raw, err := os.ReadFile(rulesPath)
if err != nil {
return err
}
var input skillRoleInput
if err = json.Unmarshal(raw, &input); err != nil {
return err
}
rows, err := compileSkillPolicy(a, input)
if err != nil {
return err
}
baseHash, _, err := sha256File(base)
if err != nil {
return err
}
var b bytes.Buffer
fmt.Fprintf(&b, "// Generated by companionpvf --skill-policy from current PVF %s.\n// Edit data/companion_skill_roles.json and regenerate; do not edit this table.\n#pragma once\n#include <cstdint>\n#include <cstddef>\nnamespace dnf90_companion_skill_data {\nstruct Rule { std::uint8_t job; std::uint16_t id; std::uint8_t purpose,flags,group; std::uint16_t passiveGrow; std::uint32_t cooldown,refresh; };\nconstexpr Rule rules[] = {\n", baseHash)
for _, r := range rows {
fmt.Fprintf(&b, "{%d,%d,%d,%d,%d,%d,%d,%d}, // %s\n", r.Job, r.ID, r.Purpose, r.Flags, r.Group, r.PassiveGrow, r.Cooldown, r.Refresh, r.Path)
}
fmt.Fprintln(&b, "};\ninline const Rule* Find(std::uint8_t job, std::uint32_t id) {\n if (job>=16 || id>=512) return nullptr;\n std::size_t low=0, high=sizeof(rules)/sizeof(rules[0]); const auto key=(std::uint32_t(job)<<16)|id;\n while (low<high) {const auto mid=low+(high-low)/2; const auto candidate=(std::uint32_t(rules[mid].job)<<16)|rules[mid].id; if (candidate<key) low=mid+1; else high=mid;}\n return low<sizeof(rules)/sizeof(rules[0]) && rules[low].job==job && rules[low].id==id ? &rules[low] : nullptr;\n}\n}")
if err = os.WriteFile(headerPath, b.Bytes(), 0644); err != nil {
return err
}
catalog := struct {
PVFSHA256 string `json:"pvf_sha256"`
Rows []skillPolicyRow `json:"skills"`
}{baseHash, rows}
data, err := json.MarshalIndent(catalog, "", " ")
if err != nil {
return err
}
if err = os.WriteFile(catalogPath, data, 0644); err != nil {
return err
}
fmt.Printf("generated jobs=16 skills=%d header=%s catalog=%s\n", len(rows), headerPath, catalogPath)
return nil
}