服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
271 lines
8.1 KiB
Go
271 lines
8.1 KiB
Go
// pvfstruct 打印 protected PVF 的容器结构:header 各段尺寸、HASH 段样本、
|
||
// 文件表原始字段与排序状况(只读)。
|
||
//
|
||
// 动机:客户端可能不是线性扫文件表,而是按 HASH 段或按「排序后的表」做查找。
|
||
// 若新增条目被追加到表尾而表本来是排序的,查找就会失效(新增道具查不到 → 邮件附件空白)。
|
||
//
|
||
// go run ./cmd/scratch/pvfstruct -pvf <Script.pvf> [-tail 16] [-hashdump 256]
|
||
package main
|
||
|
||
import (
|
||
"encoding/binary"
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"sort"
|
||
"strings"
|
||
"unicode/utf16"
|
||
|
||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||
)
|
||
|
||
const (
|
||
headerSize = 0x30
|
||
fileItemSize = 0x18
|
||
groupItemSize = 8
|
||
magicSignature = 0x69706B6E
|
||
)
|
||
|
||
func decryptProtectedCore(key string, buf []byte, magic uint32) {
|
||
if key == "" || len(buf) == 0 {
|
||
return
|
||
}
|
||
words := utf16.Encode([]rune(key))
|
||
if len(words) < 4 {
|
||
return
|
||
}
|
||
size := len(buf)
|
||
tail := size
|
||
seed := uint32(0x339E9711)*uint32(words[0]) +
|
||
0x393*(uint32(words[3])+0x393*(uint32(words[2])+0x393*uint32(words[1])))
|
||
if size >= 4 {
|
||
quadCount := size >> 2
|
||
tail = size - (quadCount << 2)
|
||
for idx := 0; idx < quadCount; idx++ {
|
||
t1 := 0x343FD*seed + magic
|
||
seed = 0x343FD*t1 + magic
|
||
xorKey := (t1 & 0xFFFF0000) + (seed >> 16)
|
||
off := idx << 2
|
||
binary.LittleEndian.PutUint32(buf[off:off+4], binary.LittleEndian.Uint32(buf[off:off+4])^xorKey)
|
||
}
|
||
}
|
||
if tail > 0 {
|
||
t1 := 0x343FD*seed + magic
|
||
t2 := 0x343FD*t1 + magic
|
||
finalKey := (t1 & 0xFFFF0000) + (t2 >> 16)
|
||
var kb [4]byte
|
||
binary.LittleEndian.PutUint32(kb[:], finalKey)
|
||
start := size - tail
|
||
for idx := 0; idx < tail; idx++ {
|
||
buf[start+idx] ^= kb[idx]
|
||
}
|
||
}
|
||
}
|
||
|
||
func i32(b []byte) int { return int(int32(binary.LittleEndian.Uint32(b))) }
|
||
|
||
func main() {
|
||
pvfPath := flag.String("pvf", "", "Script.pvf 路径")
|
||
tail := flag.Int("tail", 16, "打印文件表尾部多少条")
|
||
hashDump := flag.Int("hashdump", 256, "打印 HASH 段前多少字节")
|
||
order := flag.Int("order", 12, "打印前多少个排序破坏点")
|
||
rng := flag.String("range", "", "dump 文件表某区间,格式 from:to")
|
||
flag.Parse()
|
||
if *pvfPath == "" {
|
||
fmt.Fprintln(os.Stderr, "-pvf is required")
|
||
os.Exit(2)
|
||
}
|
||
|
||
data, err := os.ReadFile(*pvfPath)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "read:", err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("文件: %s\n大小: %d 字节\n", *pvfPath, len(data))
|
||
|
||
hdr := append([]byte(nil), data[:headerSize]...)
|
||
decryptProtectedCore("hEAd", hdr, 0x269EC3)
|
||
sig := binary.LittleEndian.Uint32(hdr[0:4])
|
||
fmt.Printf("\n=== header ===\nmagic: 0x%08X (%s)\n", sig, map[bool]string{true: "nkpi ✅", false: "不匹配 ❌"}[sig == magicSignature])
|
||
|
||
fileCount := i32(hdr[24:28])
|
||
bodySize := i32(hdr[32:36])
|
||
groupCount := i32(hdr[36:40])
|
||
hashSize := i32(hdr[40:44])
|
||
nameSize := i32(hdr[44:48])
|
||
fmt.Printf("fileCount = %d\n", fileCount)
|
||
fmt.Printf("bodySize = %d\n", bodySize)
|
||
fmt.Printf("groupCount = %d\n", groupCount)
|
||
fmt.Printf("hashSize = %d\n", hashSize)
|
||
fmt.Printf("nameSize = %d\n", nameSize)
|
||
|
||
tableOff := headerSize
|
||
hashOff := tableOff + fileCount*fileItemSize
|
||
nameOff := hashOff + hashSize
|
||
groupOff := nameOff + nameSize
|
||
bodyOff := groupOff + groupCount*groupItemSize
|
||
fmt.Printf("\n=== 段偏移 ===\n文件表: %d .. %d (%d 项 × 0x18)\n", tableOff, hashOff, fileCount)
|
||
fmt.Printf("HASH : %d .. %d (%d 字节)\n", hashOff, nameOff, hashSize)
|
||
fmt.Printf("字符串: %d .. %d (%d 字节)\n", nameOff, groupOff, nameSize)
|
||
fmt.Printf("组表 : %d .. %d (%d 项 × 8)\n", groupOff, bodyOff, groupCount)
|
||
fmt.Printf("body : %d .. %d (声明 %d)\n", bodyOff, bodyOff+bodySize, bodySize)
|
||
fmt.Printf("段之和 = %d,实际文件 = %d -> %s\n", bodyOff+bodySize, len(data),
|
||
map[bool]string{true: "一致 ✅", false: "不一致 ❌"}[bodyOff+bodySize == len(data)])
|
||
|
||
// ---- HASH 段样本 ----
|
||
if hashSize > 0 {
|
||
n := *hashDump
|
||
if n > hashSize {
|
||
n = hashSize
|
||
}
|
||
fmt.Printf("\n=== HASH 段前 %d 字节(原样,未解密)===\n", n)
|
||
for off := 0; off < n; off += 16 {
|
||
end := off + 16
|
||
if end > n {
|
||
end = n
|
||
}
|
||
var hexPart, asciiPart strings.Builder
|
||
for i := off; i < end; i++ {
|
||
fmt.Fprintf(&hexPart, "%02X ", data[hashOff+i])
|
||
if c := data[hashOff+i]; c >= 32 && c < 127 {
|
||
asciiPart.WriteByte(c)
|
||
} else {
|
||
asciiPart.WriteByte('.')
|
||
}
|
||
}
|
||
fmt.Printf(" %08X %-48s %s\n", off, hexPart.String(), asciiPart.String())
|
||
}
|
||
// 全零检查
|
||
allZero := true
|
||
for i := 0; i < hashSize; i++ {
|
||
if data[hashOff+i] != 0 {
|
||
allZero = false
|
||
break
|
||
}
|
||
}
|
||
fmt.Printf("HASH 段是否全零: %v\n", allZero)
|
||
fmt.Printf("HASH 段 / 24 = %.3f (若接近整数,可能是每文件一项)\n", float64(hashSize)/float64(fileItemSize))
|
||
fmt.Printf("HASH 段 / 文件数 = %.3f\n", float64(hashSize)/float64(fileCount))
|
||
}
|
||
|
||
// ---- 文件表尾部原始字段 ----
|
||
fmt.Printf("\n=== 文件表尾部 %d 条(原始字段)===\n", *tail)
|
||
fmt.Printf("%-9s %-10s %-10s %-6s %-10s %-8s %-4s\n", "idx", "nameOff", "pathOff", "chunk", "dataOff", "dataSize", "type")
|
||
start := fileCount - *tail
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
for idx := start; idx < fileCount; idx++ {
|
||
off := tableOff + idx*fileItemSize
|
||
fmt.Printf("%-9d %-10d %-10d %-6d %-10d %-8d %-4d\n", idx,
|
||
i32(data[off:off+4]), i32(data[off+4:off+8]), i32(data[off+8:off+12]),
|
||
i32(data[off+12:off+16]), i32(data[off+16:off+20]), i32(data[off+20:off+24]))
|
||
}
|
||
|
||
// ---- 用归档 API 看排序 ----
|
||
archive, err := platformpvf.Open(*pvfPath)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "open:", err)
|
||
os.Exit(1)
|
||
}
|
||
files := archive.Files()
|
||
fmt.Printf("\n=== 文件表排序检查(用解析出的路径)===\n")
|
||
if len(files) != fileCount {
|
||
fmt.Printf("注意: archive.Files() 数量 %d != header.fileCount %d\n", len(files), fileCount)
|
||
}
|
||
|
||
type row struct {
|
||
idx int
|
||
dir string
|
||
name string
|
||
full string
|
||
}
|
||
rows := make([]row, 0, len(files))
|
||
for _, f := range files {
|
||
rows = append(rows, row{idx: f.Index, dir: f.Path, name: f.Name, full: f.ArchivePath})
|
||
}
|
||
|
||
check := func(label string, key func(row) string) {
|
||
breaks := 0
|
||
var samples []string
|
||
for i := 1; i < len(rows); i++ {
|
||
if key(rows[i-1]) > key(rows[i]) {
|
||
breaks++
|
||
if len(samples) < *order {
|
||
samples = append(samples, fmt.Sprintf("idx%d %q > idx%d %q",
|
||
rows[i-1].idx, trunc(key(rows[i-1])), rows[i].idx, trunc(key(rows[i]))))
|
||
}
|
||
}
|
||
}
|
||
fmt.Printf("%s: 逆序点 %d / %d\n", label, breaks, len(rows)-1)
|
||
for _, s := range samples {
|
||
fmt.Printf(" %s\n", s)
|
||
}
|
||
}
|
||
check("按完整路径(小写)递增", func(r row) string { return strings.ToLower(r.full) })
|
||
check("按目录(小写)递增", func(r row) string { return strings.ToLower(r.dir) })
|
||
check("按文件名(小写)递增", func(r row) string { return strings.ToLower(r.name) })
|
||
|
||
// 目录是否成块(同一目录是否连续)
|
||
seen := map[string]bool{}
|
||
blocks, reopens := 0, 0
|
||
prev := ""
|
||
for _, r := range rows {
|
||
if r.dir != prev {
|
||
blocks++
|
||
if seen[r.dir] {
|
||
reopens++
|
||
}
|
||
seen[r.dir] = true
|
||
prev = r.dir
|
||
}
|
||
}
|
||
fmt.Printf("目录块数: %d,其中「目录再次出现」次数: %d\n", blocks, reopens)
|
||
|
||
// 末尾新增的目录
|
||
dirCount := map[string]int{}
|
||
for _, r := range rows {
|
||
dirCount[r.dir]++
|
||
}
|
||
var dirs []string
|
||
for d := range dirCount {
|
||
dirs = append(dirs, d)
|
||
}
|
||
sort.Strings(dirs)
|
||
fmt.Printf("目录总数: %d\n", len(dirs))
|
||
|
||
fmt.Printf("\n=== 尾部 20 条解析出的路径 ===\n")
|
||
for i := len(rows) - 20; i < len(rows); i++ {
|
||
if i < 0 {
|
||
continue
|
||
}
|
||
fmt.Printf(" idx=%-9d dir=%-28q name=%q\n", rows[i].idx, rows[i].dir, rows[i].name)
|
||
}
|
||
|
||
// ---- 指定区间 dump ----
|
||
if *rng != "" {
|
||
var from, to int
|
||
if _, scanErr := fmt.Sscanf(*rng, "%d:%d", &from, &to); scanErr != nil {
|
||
fmt.Printf("\n-range 格式应为 from:to(%v)\n", scanErr)
|
||
} else {
|
||
if from < 0 {
|
||
from = 0
|
||
}
|
||
if to > len(rows) {
|
||
to = len(rows)
|
||
}
|
||
fmt.Printf("\n=== 文件表区间 [%d, %d) ===\n", from, to)
|
||
for i := from; i < to && i < len(rows); i++ {
|
||
fmt.Printf(" idx=%-9d %s\n", rows[i].idx, rows[i].full)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func trunc(s string) string {
|
||
if len(s) > 46 {
|
||
return s[:46] + "…"
|
||
}
|
||
return s
|
||
}
|