Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

599 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Scratch: 把「文本形式」的脚本编码成客户端能解析的 type-1 令牌流,并安装进 Script.pvf。
//
// 背景(证据见 NOTES-pvf-new-item-encoding.md):
// - 游戏自带的 .stk 都是 dataType==1,内容是 5 字节一条的令牌流:
// 第 1 字节是令牌类型,后面 4 字节是 int32(数字,或字符串池偏移)。
// - 第三方 PVF 工具「新增文件」时写的是 dataType==3 的 UTF-16LE 纯文本(带 #PVF_File 头、
// 制表符缩进、CRLF)。服务端两种都读,客户端只认 type-1 令牌流,
// 于是新增道具在客户端解析失败(邮件附件空白 / 背包看不见),而改现有道具没问题。
//
// 用法:
//
// go run ./cmd/scratch/pvfimport -pvf <Script.pvf> -mode roundtrip -prefix stackable/10000001/ -limit 50
// go run ./cmd/scratch/pvfimport -pvf <Script.pvf> -mode encode -src <文本文件> -out <令牌流文件>
// go run ./cmd/scratch/pvfimport -pvf <Script.pvf> -mode install -src <文本文件路径> -dst <同一路径> -out <新 pvf> [-dry-run]
package main
import (
"flag"
"fmt"
"math"
"os"
"sort"
"strconv"
"strings"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// 令牌类型,与 internal/platform/pvf/decode.go 的 decodeScript 一一对应。
const (
tokenInt = 0 // 整数
tokenFloat = 2 // 浮点数
tokenLine = 3 // 独占一行的字符串([name]、[/usable job] 这类关键字)
tokenSix = 5 // {5=``},解码端不透明的占位令牌
tokenString = 6 // 反引号字符串
tokenSeven = 7 // {7=``}
)
type token struct {
kind int
i32 int32
f32 float32
str string
}
func main() {
pvfPath := flag.String("pvf", "", "Script.pvf 路径")
mode := flag.String("mode", "roundtrip", "roundtrip | encode | install")
prefix := flag.String("prefix", "stackable/10000001/", "roundtrip: 扫描前缀")
limit := flag.Int("limit", 50, "roundtrip: 最多检查多少个文件")
src := flag.String("src", "", "encode/install: 取文本的文件路径(PVF 内路径)")
textFile := flag.String("text-file", "", "encode: 直接读本地文本文件")
dst := flag.String("dst", "", "install: 目标文件路径(通常与 -src 相同)")
out := flag.String("out", "", "输出路径(encode 的令牌流 / install 的新 pvf)")
dryRun := flag.Bool("dry-run", false, "install: 只验证、不写文件")
flag.Parse()
switch *mode {
case "roundtrip":
runRoundTrip(*pvfPath, *prefix, *limit)
case "encode":
runEncode(*pvfPath, *src, *textFile, *out)
case "install":
runInstall(*pvfPath, *src, *dst, *out, *dryRun)
case "audit":
runAudit(*pvfPath, *prefix, *limit)
default:
fmt.Fprintln(os.Stderr, "unknown mode:", *mode)
os.Exit(2)
}
}
// extStat 按扩展名汇总往返审计结果。
type extStat struct {
files int
byType map[int]int
sampled int
byteExact int
rawDiff int
tokenSame int
tokenDiff int
skipMissing int
skipUnknownTok int
skipOther int
unknownTypes map[byte]int
examples []string
}
func pathExt(p string) string {
slash := strings.LastIndexByte(p, '/')
name := p
if slash >= 0 {
name = p[slash+1:]
}
dot := strings.LastIndexByte(name, '.')
if dot <= 0 {
return "(no-ext)"
}
return strings.ToLower(name[dot:])
}
// scanTokenTypes 收集一段令牌流里出现的令牌类型,并指出是否存在我们未支持的类型。
// 未支持类型在解码文本里是「看不见」的,只有扫原始字节才能发现——
// 这正是「写回后内容变样」的高危信号。
func scanTokenTypes(raw []byte, counts map[byte]int) bool {
unknown := false
for off := 0; off+5 <= len(raw); off += 5 {
tokenType := raw[off]
counts[tokenType]++
switch tokenType {
case 0, 2, 3, 5, 6, 7:
default:
unknown = true
}
}
return unknown
}
// runAudit 按扩展名抽样验证「解码→重新编码」能否逐字节还原原文件。
// 能逐字节还原的类型才可以安全地写回;不能还原的类型必须拒绝写,而不是猜着写。
func runAudit(pvfPath, prefix string, perExt int) {
archive, err := platformpvf.Open(pvfPath)
if err != nil {
fmt.Fprintln(os.Stderr, "open:", err)
os.Exit(1)
}
prefix = strings.ToLower(strings.TrimPrefix(prefix, "data/dnf/"))
// -prefix all / * 表示不做前缀过滤(审计整个库)。
if prefix == "all" || prefix == "*" {
prefix = ""
}
stats := make(map[string]*extStat)
totalFiles, tokenFiles := 0, 0
for _, f := range archive.Files() {
p := strings.ToLower(strings.TrimPrefix(f.ArchivePath, "data/dnf/"))
if prefix != "" && !strings.HasPrefix(p, prefix) {
continue
}
totalFiles++
ext := pathExt(p)
st := stats[ext]
if st == nil {
st = &extStat{byType: make(map[int]int), unknownTypes: make(map[byte]int)}
stats[ext] = st
}
st.files++
st.byType[f.DataType]++
// 只审计令牌流(dataType 1);dataType 3 是纯文本,读写没有编码歧义。
if f.DataType != 1 {
continue
}
tokenFiles++
if perExt > 0 && st.sampled >= perExt {
continue
}
st.sampled++
raw, err := archive.ReadRaw(f.ArchivePath)
if err != nil {
st.skipOther++
continue
}
if scanTokenTypes(raw, st.unknownTypes) {
st.skipUnknownTok++
}
text, err := archive.ReadText(f.ArchivePath)
if err != nil {
st.skipOther++
continue
}
tokens, values, err := tokenizeText(text)
if err != nil {
st.skipOther++
continue
}
resolve := archive.FindStringMagicOffsets(values)
encoded, missing := encodeTokens(tokens, resolve)
if len(missing) > 0 {
st.skipMissing++
continue
}
if string(encoded) == string(raw) {
st.byteExact++
} else {
st.rawDiff++
}
reTokens, _, err := tokenizeText(archive.DecodeScriptBytes(encoded))
if err == nil && tokensEqual(tokens, reTokens) {
st.tokenSame++
} else {
st.tokenDiff++
if len(st.examples) < 3 {
st.examples = append(st.examples, f.ArchivePath)
}
}
}
type row struct {
ext string
st *extStat
}
rows := make([]row, 0, len(stats))
for ext, st := range stats {
rows = append(rows, row{ext, st})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].st.files != rows[j].st.files {
return rows[i].st.files > rows[j].st.files
}
return rows[i].ext < rows[j].ext
})
fmt.Printf("archive=%s files=%d (令牌流 dataType=1: %d) 每扩展名抽样=%d\n\n",
pvfPath, totalFiles, tokenFiles, perExt)
fmt.Printf("%-12s %9s %-22s %6s %9s %8s %8s %8s %s\n",
"扩展名", "文件数", "dataType 分布", "抽样", "逐字节相同", "原文不同", "令牌相同", "跳过", "未知令牌类型")
var unsafe []string
for _, r := range rows {
st := r.st
if st.sampled == 0 {
continue
}
dist := ""
for _, t := range []int{1, 3} {
if n := st.byType[t]; n > 0 {
if dist != "" {
dist += " "
}
dist += fmt.Sprintf("t%d:%d", t, n)
}
}
fmt.Printf("%-12s %9d %-22s %6d %9d %8d %8d %8d %s\n",
r.ext, st.files, dist, st.sampled, st.byteExact, st.rawDiff, st.tokenSame,
st.skipMissing+st.skipUnknownTok+st.skipOther, formatTokenTypes(st.unknownTypes))
if st.rawDiff > 0 || st.tokenDiff > 0 || st.skipUnknownTok > 0 {
unsafe = append(unsafe, r.ext)
for _, example := range st.examples {
fmt.Printf(" 例:%s\n", example)
}
}
}
fmt.Printf("\n无法逐字节复现的类型(写回时必须拒绝,不能猜):%v\n", unsafe)
}
func formatTokenTypes(counts map[byte]int) string {
if len(counts) == 0 {
return "-"
}
keys := make([]int, 0, len(counts))
for k := range counts {
keys = append(keys, int(k))
}
sort.Ints(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%d×%d", k, counts[byte(k)]))
}
return strings.Join(parts, ",")
}
// tokenizeText 把文本脚本切成令牌。
// 规则来自 decodeScript 的输出形式:关键字独占一行、字符串带反引号、
// 数字/浮点直接写出来。制表符、CRLF、多余空格都只是排版,全部忽略。
func tokenizeText(text string) ([]token, []string, error) {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
var tokens []token
var literals []string
addString := func(value string) {
for _, existing := range literals {
if existing == value {
return
}
}
literals = append(literals, value)
}
i := 0
for i < len(text) {
c := text[i]
switch {
case c == ' ' || c == '\t' || c == '\n':
i++
case c == '`':
end := strings.IndexByte(text[i+1:], '`')
if end < 0 {
return nil, nil, fmt.Errorf("未闭合的反引号(偏移 %d)", i)
}
value := text[i+1 : i+1+end]
tokens = append(tokens, token{kind: tokenString, str: value})
addString(value)
i += end + 2
case c == '[':
// 关键字可能和数字写在同一行([cera] 1 100 1 [/cera]),所以只取到配对的 ']'。
end := strings.IndexByte(text[i:], ']')
if end < 0 {
return nil, nil, fmt.Errorf("未闭合的方括号(偏移 %d)", i)
}
value := text[i : i+end+1]
tokens = append(tokens, token{kind: tokenLine, str: value})
addString(value)
i += end + 1
default:
j := i
for j < len(text) && text[j] != ' ' && text[j] != '\t' && text[j] != '\n' &&
text[j] != '`' && text[j] != '[' {
j++
}
word := text[i:j]
i = j
switch {
case word == "#PVF_File":
// 编辑器导出文本的头部,令牌流里没有这一项(真实 .stk 也不含)。
case strings.HasPrefix(word, "//"):
// 注释在令牌流里不存在,直接跳到行尾。
if nl := strings.IndexByte(text[i:], '\n'); nl >= 0 {
i += nl + 1
} else {
i = len(text)
}
case word == "{5=``}":
tokens = append(tokens, token{kind: tokenSix})
case word == "{7=``}":
tokens = append(tokens, token{kind: tokenSeven})
default:
if value, err := strconv.ParseInt(word, 10, 32); err == nil {
tokens = append(tokens, token{kind: tokenInt, i32: int32(value)})
continue
}
if value, err := strconv.ParseFloat(word, 32); err == nil {
tokens = append(tokens, token{kind: tokenFloat, f32: float32(value)})
continue
}
// 少数脚本会把关键字写成不带方括号的裸词,按行字符串处理。
tokens = append(tokens, token{kind: tokenLine, str: word})
addString(word)
}
}
}
return tokens, literals, nil
}
// encodeTokens 按 5 字节一条写回令牌流,字符串用字符串池里的 magic offset 表示。
func encodeTokens(tokens []token, resolve map[string]int) ([]byte, []string) {
out := make([]byte, 0, len(tokens)*5)
var missing []string
for _, t := range tokens {
switch t.kind {
case tokenInt:
out = append(out, tokenInt, byte(t.i32), byte(t.i32>>8), byte(t.i32>>16), byte(t.i32>>24))
case tokenFloat:
bits := math.Float32bits(t.f32)
out = append(out, tokenFloat, byte(bits), byte(bits>>8), byte(bits>>16), byte(bits>>24))
case tokenLine, tokenString:
offset, ok := resolve[t.str]
if !ok {
missing = append(missing, t.str)
continue
}
out = append(out, byte(t.kind), byte(offset), byte(offset>>8), byte(offset>>16), byte(offset>>24))
default:
out = append(out, byte(t.kind), 0, 0, 0, 0)
}
}
return out, missing
}
// tokensEqual 用于验证:重新解码后的文本必须切出完全相同的令牌序列。
func tokensEqual(left, right []token) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i].kind != right[i].kind || left[i].i32 != right[i].i32 || left[i].str != right[i].str {
return false
}
}
return true
}
func runRoundTrip(pvfPath, prefix string, limit int) {
archive, err := platformpvf.Open(pvfPath)
if err != nil {
fmt.Fprintln(os.Stderr, "open:", err)
os.Exit(1)
}
prefix = strings.ToLower(prefix)
checked, exactBytes, sameTokens, skipped := 0, 0, 0, 0
for _, f := range archive.Files() {
path := strings.ToLower(strings.TrimPrefix(f.ArchivePath, "data/dnf/"))
if !strings.HasPrefix(path, prefix) || !strings.HasSuffix(path, ".stk") || f.DataType != 1 {
continue
}
if checked >= limit {
break
}
checked++
text, err := archive.ReadText(f.ArchivePath)
if err != nil {
skipped++
continue
}
tokens, values, err := tokenizeText(text)
if err != nil {
fmt.Printf(" tokenize fail %s: %v\n", f.ArchivePath, err)
skipped++
continue
}
resolve := archive.FindStringMagicOffsets(values)
encoded, missing := encodeTokens(tokens, resolve)
if len(missing) > 0 {
skipped++
continue
}
raw, err := archive.ReadRaw(f.ArchivePath)
if err == nil && string(raw) == string(encoded) {
exactBytes++
}
retext := archive.DecodeScriptBytes(encoded)
reTokens, _, err := tokenizeText(retext)
if err != nil {
skipped++
continue
}
if tokensEqual(tokens, reTokens) {
sameTokens++
} else {
fmt.Printf(" TOKEN MISMATCH %s (orig=%d new=%d)\n", f.ArchivePath, len(tokens), len(reTokens))
}
}
fmt.Printf("roundtrip: checked=%d byte-exact=%d token-identical=%d skipped=%d\n",
checked, exactBytes, sameTokens, skipped)
}
func runEncode(pvfPath, src, textFile, out string) {
archive, err := platformpvf.Open(pvfPath)
if err != nil {
fmt.Fprintln(os.Stderr, "open:", err)
os.Exit(1)
}
var text string
if textFile != "" {
raw, err := os.ReadFile(textFile)
if err != nil {
fmt.Fprintln(os.Stderr, "read:", err)
os.Exit(1)
}
text = string(raw)
} else {
value, err := archive.ReadText(src)
if err != nil {
fmt.Fprintln(os.Stderr, "ReadText:", err)
os.Exit(1)
}
text = value
}
tokens, values, err := tokenizeText(text)
if err != nil {
fmt.Fprintln(os.Stderr, "tokenize:", err)
os.Exit(1)
}
resolve := archive.FindStringMagicOffsets(values)
encoded, missing := encodeTokens(tokens, resolve)
fmt.Printf("tokens=%d strings=%d resolved=%d missing=%d bytes=%d\n",
len(tokens), len(values), len(resolve), len(missing), len(encoded))
for _, value := range missing {
fmt.Printf(" missing string: %q\n", value)
}
if out != "" && len(missing) == 0 {
if err := os.WriteFile(out, encoded, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write:", err)
os.Exit(1)
}
fmt.Println("written:", out)
}
}
func runInstall(pvfPath, src, dst, out string, dryRun bool) {
if dst == "" {
dst = src
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
fmt.Fprintln(os.Stderr, "open:", err)
os.Exit(1)
}
text, err := archive.ReadText(src)
if err != nil {
fmt.Fprintln(os.Stderr, "ReadText:", err)
os.Exit(1)
}
tokens, values, err := tokenizeText(text)
if err != nil {
fmt.Fprintln(os.Stderr, "tokenize:", err)
os.Exit(1)
}
fmt.Printf("source %s: text=%d bytes, tokens=%d, strings=%d\n", src, len(text), len(tokens), len(values))
resolve := archive.FindStringMagicOffsets(values)
var appends []platformpvf.StringAppend
for _, value := range values {
if _, ok := resolve[value]; ok {
continue
}
// 非 ASCII(中文名/说明)走 UTF-16 池,纯 ASCII 走 ANSI 池。
wide := false
for _, r := range value {
if r > 127 {
wide = true
break
}
}
appends = append(appends, platformpvf.StringAppend{Value: value, Wide: wide})
}
work := archive
if len(appends) > 0 {
fmt.Printf("appending %d new strings to the pool\n", len(appends))
for _, appendItem := range appends {
fmt.Printf(" + %q wide=%v\n", appendItem.Value, appendItem.Wide)
}
bytesWithStrings, err := archive.RepackAppendStrings(appends)
if err != nil {
fmt.Fprintln(os.Stderr, "RepackAppendStrings:", err)
os.Exit(1)
}
work, err = platformpvf.OpenBytes(bytesWithStrings)
if err != nil {
fmt.Fprintln(os.Stderr, "reopen after append:", err)
os.Exit(1)
}
resolve = work.FindStringMagicOffsets(values)
}
encoded, missing := encodeTokens(tokens, resolve)
if len(missing) > 0 {
fmt.Fprintf(os.Stderr, "无法解析的字符串 %d 个: %v\n", len(missing), missing)
os.Exit(1)
}
fmt.Printf("encoded token stream: %d bytes\n", len(encoded))
repacked, err := work.RepackRaw([]platformpvf.RawReplacement{{Path: dst, Data: encoded}})
if err != nil {
fmt.Fprintln(os.Stderr, "RepackRaw:", err)
os.Exit(1)
}
reopened, err := platformpvf.OpenBytes(repacked)
if err != nil {
fmt.Fprintln(os.Stderr, "reopen:", err)
os.Exit(1)
}
// RepackRaw 保留原 dataType(工具写下的 3),必须改成 1 客户端才当脚本解析。
final, err := reopened.PatchFileDataType(dst, 1)
if err != nil {
fmt.Fprintln(os.Stderr, "PatchFileDataType:", err)
os.Exit(1)
}
verify, err := platformpvf.OpenBytes(final)
if err != nil {
fmt.Fprintln(os.Stderr, "verify open:", err)
os.Exit(1)
}
entry, _ := verify.FindFile(dst)
fmt.Printf("verify: idx=%d type=%d size=%d\n", entry.Index, entry.DataType, entry.Size)
verifyText, err := verify.ReadText(dst)
if err != nil {
fmt.Fprintln(os.Stderr, "verify ReadText:", err)
os.Exit(1)
}
verifyTokens, _, err := tokenizeText(verifyText)
if err != nil {
fmt.Fprintln(os.Stderr, "verify tokenize:", err)
os.Exit(1)
}
if !tokensEqual(tokens, verifyTokens) {
fmt.Fprintf(os.Stderr, "验证失败:回读令牌 %d 个,期望 %d 个\n", len(verifyTokens), len(tokens))
os.Exit(1)
}
fmt.Printf("verify: 回读令牌一致 (%d 个)\n", len(verifyTokens))
preview := strings.Join(strings.Fields(strings.ReplaceAll(verifyText, "`", "")), " ")
if len(preview) > 200 {
preview = preview[:200]
}
fmt.Printf("verify preview: %s\n", preview)
if dryRun {
fmt.Println("dry-run: 未写文件")
return
}
if out == "" {
fmt.Fprintln(os.Stderr, "-out is required")
os.Exit(2)
}
if err := os.WriteFile(out, final, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write:", err)
os.Exit(1)
}
fmt.Printf("written: %s (%d bytes)\n", out, len(final))
}