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