// pvfcutscenefix 重构 etc/storydigest/{scene,digest}.etc,使 13 个城镇区域的 // 过场在各自开区等级所在的 story digest 段播出。 // // 机制(PVF 实测):客户端按**角色等级**选出 digest 段,播放该段 [ending] // 指向的影片段。因此要让某区域的影片在其开区等级播出,必须让「包含该等级的 // 段」的 [ending] 指向承载该影片的影片段。 // // 约束:PVF 字符串是全局共享池引用,脚本文件里没有明文;新增字符串需扩建池并 // 重算偏移,超出安全修改范围。因此只改整数 token: // - scene.etc:改影片段的段号(复用其已有的视频引用) // - digest.etc:改各段的 [level] 区间与 [ending] package main import ( "encoding/binary" "flag" "fmt" "os" "path/filepath" platformpvf "longheng.io/server/internal/platform/pvf" ) const ( refLevel = 0x003566E2 // "[level]" refEnding = 0x025138D6 // "[ending]" ) // 现有影片段号 -> (视频池引用, 影片名)。引用值由 pvftokendump 实测得到。 var existingSegments = []struct { num uint32 ref uint32 film string }{ {901, 0x02513B14, "origin_cinematic_01.avi"}, {902, 0x02513BAC, "origin_cinematic_02.avi"}, {903, 0x02513C4E, "origin_cinematic_03.avi"}, {904, 0x02513CE8, "origin_cinematic_04.avi"}, {905, 0x02513D84, "origin_cinematic_05.avi"}, {906, 0x02513EC8, "origin_cinematic_06.avi"}, {907, 0x02513F60, "origin_cinematic_09.avi"}, {910, 0x025140F8, "cinematic_06.avi"}, {911, 0x0251417C, "cinematic_07.avi"}, {912, 0x0251420C, "origin_cinematic_07.avi"}, {913, 0x0251429E, "cinematic_09.avi"}, {915, 0x0251436A, "origin_cinematic_08.avi"}, {916, 0x025143FA, "cinematic_11.avi"}, {917, 0x025143FA, "cinematic_11.avi"}, } // 目标:每个区域 -> (影片名, 目标影片段号) // // 影片段号是该影片段在 scene.etc 中的段号,必须等于 digest 中对应段的 [ending]。 // 段号分配避免冲突:同一影片只能占一个段号。 var regionFilms = []struct { town, area uint32 need uint32 film string seg uint32 }{ {39, 0, 17, "origin_cinematic_01.avi", 901}, {40, 4, 24, "origin_cinematic_02.avi", 902}, {41, 0, 30, "origin_cinematic_03.avi", 903}, {42, 0, 35, "origin_cinematic_04.avi", 904}, {43, 0, 40, "origin_cinematic_05.avi", 905}, {39, 5, 46, "origin_cinematic_06.avi", 906}, {46, 0, 50, "origin_cinematic_07.avi", 907}, {6, 0, 63, "origin_cinematic_08.avi", 908}, {9, 0, 71, "origin_cinematic_09.avi", 909}, {11, 0, 75, "cinematic_06.avi", 910}, {14, 0, 81, "cinematic_07.avi", 911}, {22, 0, 85, "cinematic_11.avi", 912}, {30, 0, 87, "cinematic_09.avi", 913}, } // 根据区域开区等级切出 digest 段区间。 // 每个区域独占一段,区间上界 = 下一个区域的 need-1(最后一段到 999)。 func buildLayout() [][3]uint32 { filler := []uint32{914, 915, 916, 917} var out [][3]uint32 for i, r := range regionFilms { lo := r.need hi := uint32(9998) if i+1 < len(regionFilms) { hi = regionFilms[i+1].need - 1 } out = append(out, [3]uint32{lo, hi, r.seg}) } // 区域只占 13 段;digest 有 17 段,末尾补 4 段填充。 // 填充段用 9999 以上的等级区间(游戏内等级远达不到,不会被选中)。 fillerLo := uint32(9999) for i, f := range filler { lo := fillerLo hi := lo + 999 if i == len(filler)-1 { hi = 999999 } out = append(out, [3]uint32{lo, hi, f}) fillerLo = hi + 1 } return out } 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 // 目标段号 -> 影片名 segToFilm := map[uint32]string{} for _, r := range regionFilms { if prev, dup := segToFilm[r.seg]; dup { panic(fmt.Sprintf("目标段号 %d 重复:%s 与 %s", r.seg, prev, r.film)) } segToFilm[r.seg] = r.film } // 填充段也需存在 for _, f := range []uint32{914, 915, 916, 917} { segToFilm[f] = "" // 空 = 用现有引用占位 } // 现有影片段:按引用找到它当前的实际段号 token type vblock struct { numTok, refTok int ref uint32 oldNum uint32 } 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, u32(scene[(i+4)*5+1 : (i+4)*5+5]), u32(scene[(i+1)*5+1 : (i+1)*5+5])}) } } fmt.Println("scene.etc 影片段:", len(blocks)) // 用引用反查影片名 refToFilm := map[uint32]string{} for _, e := range existingSegments { refToFilm[e.ref] = e.film } // 分配:每个目标段号需要一个影片块。 // 做法:先按影片名把「目标段号」配给「已持有该影片的块」; // 若同一影片有多个块,多余的块挪到填充段号。 filmToTarget := map[string]uint32{} for _, r := range regionFilms { filmToTarget[r.film] = r.seg } used := map[uint32]bool{} spare := []uint32{914, 915, 916, 917} spareIdx := 0 changed := 0 for bi := range blocks { b := &blocks[bi] film := refToFilm[b.ref] target, ok := filmToTarget[film] if !ok || used[target] { // 该影片无区域需要,或目标号已被同影片的首个块占用 if spareIdx < len(spare) { target = spare[spareIdx] spareIdx++ } else { target = b.oldNum } } used[target] = true if b.oldNum != target { put32(scene[b.numTok*5+1:], target) fmt.Printf(" %-26s 段号 %d → %d\n", film, b.oldNum, target) changed++ } } fmt.Println("scene.etc 改动:", changed) // 唯一性校验 seen := map[uint32]bool{} for _, b := range blocks { n := u32(scene[b.numTok*5+1 : b.numTok*5+5]) if seen[n] { panic(fmt.Sprintf("scene.etc 段号 %d 重复", n)) } seen[n] = true } fmt.Println("scene.etc 段号唯一性 OK:", len(seen)) // 校验:每个区域的目标段号都存在 for _, r := range regionFilms { if !seen[r.seg] { panic(fmt.Sprintf("区域 %d·%d 的目标段号 %d 不存在", r.town, r.area, r.seg)) } } fmt.Println("全部区域目标段号均存在 OK") // ---------- 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}) } layout := buildLayout() fmt.Println("digest.etc 段数:", len(slots), " 布局段数:", len(layout)) if len(slots) != len(layout) { panic("段数不一致") } for i, s := range slots { put32(digest[s.loTok*5+1:], layout[i][0]) put32(digest[s.hiTok*5+1:], layout[i][1]) put32(digest[s.endTok*5+1:], layout[i][2]) } fmt.Println("digest.etc 新布局:") for i, s := range slots { fmt.Printf(" 段%-3d level %5d-%-5d ending %d\n", i+1, u32(digest[s.loTok*5+1:]), u32(digest[s.hiTok*5+1:]), u32(digest[s.endTok*5+1:])) } 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), "字节") } }