// pvfchapterbind 把 etc/storydigest/{scene,digest}.etc 重建为「区域 ↔ 章节影片段」 // 的 1:1 绑定,且**无重复**。 // // 目标: // // 区域 39·0 开区等级 17 → 段 901 → origin_cinematic_01.avi // 区域 40·4 开区等级 24 → 段 902 → origin_cinematic_02.avi // ...(13 个区域,13 个互不相同的影片段号) // // 机制:客户端按**角色等级**选出 digest 段,播放该段 [ending] 指向的影片段。 // 因此要让「完成某区域的开区任务时播它自己的章节影片」,必须: // 1. 该影片所在的影片段号 = 900 + 区域序号; // 2. 该区域的开区等级落在「[ending] = 该段号」的那个 digest 段区间内。 // // 约束:PVF 字符串是全局共享池引用,脚本文件里没有明文;新增字符串需扩建池并 // 重算偏移,超出安全修改范围。因此只改整数 token —— 段号与等级区间。 // // 安全性:等级区间全部限定在正常等级范围(1..99),不使用非常规数值, // 避免客户端在处理越界区间时出问题。 package main import ( "encoding/binary" "flag" "fmt" "os" "path/filepath" platformpvf "longheng.io/server/internal/platform/pvf" ) const ( refLevel = 0x003566E2 // "[level]" refEnding = 0x025138D6 // "[ending]" ) // 影片名(去扩展名前的基名)→ 目标影片段号 = 900 + 区域序号 var filmToSegment = map[string]uint32{ "origin_cinematic_01.avi": 901, "origin_cinematic_02.avi": 902, "origin_cinematic_03.avi": 903, "origin_cinematic_04.avi": 904, "origin_cinematic_05.avi": 905, "origin_cinematic_06.avi": 906, "origin_cinematic_07.avi": 907, "origin_cinematic_08.avi": 908, "origin_cinematic_09.avi": 909, "origin_cinematic_10.avi": 910, "origin_cinematic_11.avi": 911, "origin_cinematic_12.avi": 912, "origin_cinematic_13.avi": 913, } // 区域开区等级(升序)。区域 i(从 1 起)对应段号 900+i。 // 约束:区域 i 的等级必须落在「[ending] = 900+i」的那个 digest 段区间内。 var regionLevels = []uint32{17, 24, 30, 35, 40, 46, 50, 63, 71, 75, 81, 85, 87} // digest 段区间与 ending(共 17 段)。 // // 段 i(1..13)的 ending = 900+i,其等级区间必须包含 regionLevels[i-1]; // 另外 4 段(14..17)为填充,ending 用剩余未被占用的影片段号(914..917), // 等级区间也限定在正常范围(≤99)。 var digestLayout = [][3]uint32{ {17, 23, 901}, // 区域 1 39·0 赫顿玛尔 {24, 29, 902}, // 区域 2 40·4 天帷巨兽 {30, 34, 903}, // 区域 3 41·0 阿法利亚 {35, 39, 904}, // 区域 4 42·0 暗黑城 {40, 45, 905}, // 区域 5 43·0 斯顿雪域 {46, 49, 906}, // 区域 6 39·5 诺斯玛尔 {50, 52, 907}, // 区域 7 46·0 镜像阿拉德 {53, 62, 914}, // 填充 {63, 70, 908}, // 区域 8 6·0 根特 {71, 74, 909}, // 区域 9 9·0 鲁夫特悬空海港 {75, 80, 910}, // 区域 10 11·0 素喃 {81, 84, 911}, // 区域 11 14·0 斯乌工业基地 {85, 86, 912}, // 区域 12 22·0 克洛诺斯岛 {87, 89, 913}, // 区域 13 30·0 地轨中心 {90, 92, 915}, // 填充 {93, 96, 916}, // 填充 {97, 99, 917}, // 填充 } func u32(b []byte) uint32 { return binary.LittleEndian.Uint32(b) } func put32(b []byte, v uint32) { binary.LittleEndian.PutUint32(b, v) } func main() { input := flag.String("pvf", "", "input PVF") dir := flag.String("dir", "", "raw patch directory output") dry := flag.Bool("dry-run", false, "只检查,不写文件") flag.Parse() archive, err := platformpvf.Open(*input) if err != nil { panic(err) } // ---------- scene.etc ---------- scene, err := archive.ReadRaw("etc/storydigest/scene.etc") if err != nil { panic(err) } stoks := len(scene) / 5 type vblock struct{ numTok, refTok int } var blocks []vblock for i := 0; i+5 < stoks; i++ { if scene[i*5] == 3 && scene[(i+1)*5] == 0 && scene[(i+2)*5] == 6 && scene[(i+3)*5] == 3 && scene[(i+4)*5] == 6 && scene[(i+5)*5] == 3 { blocks = append(blocks, vblock{i + 1, i + 4}) } } fmt.Println("scene.etc 影片段:", len(blocks)) // 现有引用 → 影片名(由 pvftokendump 实测的引用表) refToFilm := map[uint32]string{ 0x02513B14: "origin_cinematic_01.avi", 0x02513BAC: "origin_cinematic_02.avi", 0x02513C4E: "origin_cinematic_03.avi", 0x02513CE8: "origin_cinematic_04.avi", 0x02513D84: "origin_cinematic_05.avi", 0x02513EC8: "origin_cinematic_06.avi", 0x02513F60: "origin_cinematic_09.avi", 0x025140F8: "cinematic_06.avi", 0x0251417C: "cinematic_07.avi", 0x0251420C: "origin_cinematic_07.avi", 0x0251429E: "cinematic_09.avi", 0x0251436A: "origin_cinematic_08.avi", 0x025143FA: "cinematic_11.avi", } // 目标段号集合(13 个区域 + 4 个填充 = 17) targets := []uint32{901, 902, 903, 904, 905, 906, 907, 908, 909, 910, 911, 912, 913, 914, 915, 916, 917} // 分配:每个目标段号需要一个影片块。 // 先满足 13 个区域影片;剩余影片块分给填充段号(同影片只保留一个块)。 assigned := map[int]uint32{} // blockIndex -> target segment usedTarget := map[uint32]bool{} // 第一轮:按影片匹配区域目标 for bi, b := range blocks { film := refToFilm[u32(scene[b.refTok*5+1:b.refTok*5+5])] want, ok := filmToSegment[film] if !ok { continue } if usedTarget[want] { continue } assigned[bi] = want usedTarget[want] = true } // 第二轮:剩余块分给未占用的填充段号 spareIdx := 0 spare := []uint32{} for _, t := range targets { if !usedTarget[t] { spare = append(spare, t) } } for bi := range blocks { if _, ok := assigned[bi]; ok { continue } for spareIdx < len(spare) && usedTarget[spare[spareIdx]] { spareIdx++ } if spareIdx >= len(spare) { break } assigned[bi] = spare[spareIdx] usedTarget[spare[spareIdx]] = true spareIdx++ } fmt.Println("--- scene.etc 段号重排 ---") changed := 0 for bi, b := range blocks { film := refToFilm[u32(scene[b.refTok*5+1:b.refTok*5+5])] old := u32(scene[b.numTok*5+1 : b.numTok*5+5]) want, ok := assigned[bi] if !ok { fmt.Printf(" %-26s 段号 %d (无目标,保持)\n", film, old) continue } if old != want { put32(scene[b.numTok*5+1:], want) fmt.Printf(" %-26s 段号 %d → %d\n", film, old, want) changed++ } } fmt.Println("改动:", changed) // 唯一性 + 覆盖校验 seen := map[uint32]string{} for bi, b := range blocks { n := u32(scene[b.numTok*5+1 : b.numTok*5+5]) film := refToFilm[u32(scene[b.refTok*5+1:b.refTok*5+5])] if prev, dup := seen[n]; dup { panic(fmt.Sprintf("段号 %d 重复:%s 与 %s", n, prev, film)) } seen[n] = film _ = bi } for film, seg := range filmToSegment { got, ok := seen[seg] if !ok { panic(fmt.Sprintf("段号 %d (%s) 未被分配", seg, film)) } if got != film { panic(fmt.Sprintf("段号 %d 应为 %s,实际 %s", seg, film, got)) } } fmt.Println("scene.etc 校验通过:13 个区域影片段号唯一且正确") // ---------- digest.etc ---------- digest, err := archive.ReadRaw("etc/storydigest/digest.etc") if err != nil { panic(err) } dtoks := len(digest) / 5 isSec := func(buf []byte, n, i int, ref uint32) bool { return i < n && buf[i*5] == 3 && u32(buf[i*5+1:i*5+5]) == ref } type slot struct{ loTok, hiTok, endTok int } var slots []slot for i := 0; i < dtoks; i++ { if !isSec(digest, dtoks, i, refLevel) { continue } endTok := -1 for j := i + 3; j < dtoks; j++ { if isSec(digest, dtoks, j, refEnding) { endTok = j + 1 break } if isSec(digest, dtoks, j, refLevel) { break } } if endTok < 0 { panic("找不到 [ending]") } slots = append(slots, slot{i + 1, i + 2, endTok}) } fmt.Println("digest.etc 段数:", len(slots), " 布局段数:", len(digestLayout)) if len(slots) != len(digestLayout) { panic("段数不一致") } for i, s := range slots { put32(digest[s.loTok*5+1:], digestLayout[i][0]) put32(digest[s.hiTok*5+1:], digestLayout[i][1]) put32(digest[s.endTok*5+1:], digestLayout[i][2]) } fmt.Println("--- digest.etc 新布局 ---") for i, s := range slots { fmt.Printf(" 段%-3d %3d-%-3d ending %d\n", i+1, u32(digest[s.loTok*5+1:]), u32(digest[s.hiTok*5+1:]), u32(digest[s.endTok*5+1:])) } // 逐区域校验:开区等级落在 ending = 900+i 的段区间内 fmt.Println("--- 逐个区域校验 ---") ok := true for i, lv := range regionLevels { seg := uint32(901 + i) found := false for _, row := range digestLayout { if row[2] == seg && lv >= row[0] && lv <= row[1] { found = true break } } status := "OK" if !found { status = "❌ 等级未落在该段区间" ok = false } fmt.Printf(" 区域序号 %2d 开区等级 %-3d 段号 %d %s\n", i+1, lv, seg, status) } if !ok { panic("区域等级与段区间不匹配") } fmt.Println("全部区域校验通过") if *dry { fmt.Println("dry-run,未写文件") return } for path, data := range map[string][]byte{ "etc/storydigest/scene.etc": scene, "etc/storydigest/digest.etc": digest, } { full := filepath.Join(*dir, filepath.FromSlash(path)) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { panic(err) } if err := os.WriteFile(full, data, 0o644); err != nil { panic(err) } fmt.Println("已写出:", full, len(data), "字节") } }