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