DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

+469
View File
@@ -0,0 +1,469 @@
// pvfcrd —— protected PVF 的「增 / 删」文件操作,并在**同一次写盘里**重建 HASH 段,
// 让包始终保持自洽。只做元数据级改动,body 里的数据字节一律不重压缩。
//
// 为什么需要它:第三方 pvfUtility 增删文件后会留下陈旧的 HASH 段(见
// tools\_pvf_20260919\根因-HASH段未同步_20260921.md),客户端就查不到新条目。
// 这个工具把「改文件表」和「重建 HASH」绑成一次原子操作,从根上避免失同步。
//
// 用法:
//
// # 删(纯元数据删除:条目从文件表与 HASH 移除,数据字节留在原 chunk 成为孤儿)
// go run ./cmd/scratch/pvfcrd -pvf <in> -out <out> -mode delete -path stackable/10000001/_x.stk
//
// # 增(把已有文件的原始字节与 dataType 原样复制成新路径,最稳的一条路)
// go run ./cmd/scratch/pvfcrd -pvf <in> -out <out> -mode add \
// -src stackable/10000001/10000219.stk -dst stackable/10000001/ab_B.stk
//
// # 增 + 同时在 .lst 注册新 id(路径相对 .lst 所在目录)
// go run ./cmd/scratch/pvfcrd -pvf <in> -out <out> -mode add \
// -src stackable/10000001/10000219.stk -dst stackable/abtest/ab_A.stk \
// -list stackable/stackable.lst -list-id 202600901 -list-rel abtest/ab_A.stk
package main
import (
"encoding/binary"
"flag"
"fmt"
"os"
"path"
"sort"
"strings"
"unicode/utf16"
platformpvf "longheng.io/server/internal/platform/pvf"
)
const (
headerSize = 0x30
fileItemSize = 0x18
groupItemSize = 8
containerMagic = 0x269EC3
)
func xorCore(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 put(b []byte, v int) { binary.LittleEndian.PutUint32(b, uint32(int32(v))) }
type section struct {
fileCount, bodySize, groupCount, hashSize, nameSize int
tableOff, hashOff, nameOff, groupOff, bodyOff int
}
func locate(data []byte) (section, []byte, error) {
var s section
if len(data) < headerSize {
return s, nil, fmt.Errorf("文件太短")
}
hdr := append([]byte(nil), data[:headerSize]...)
xorCore("hEAd", hdr, containerMagic)
if binary.LittleEndian.Uint32(hdr[0:4]) != 0x69706B6E {
return s, nil, fmt.Errorf("不是 protected(nkpi)PVF")
}
s.fileCount = i32(hdr[24:28])
s.bodySize = i32(hdr[32:36])
s.groupCount = i32(hdr[36:40])
s.hashSize = i32(hdr[40:44])
s.nameSize = i32(hdr[44:48])
s.tableOff = headerSize
s.hashOff = s.tableOff + s.fileCount*fileItemSize
s.nameOff = s.hashOff + s.hashSize
s.groupOff = s.nameOff + s.nameSize
s.bodyOff = s.groupOff + s.groupCount*groupItemSize
if s.bodyOff+s.bodySize > len(data) {
return s, nil, fmt.Errorf("各段之和 %d 超出文件长度 %d", s.bodyOff+s.bodySize, len(data))
}
return s, hdr, nil
}
// row 是文件表一行的 6 个 int32。
type row [6]int
func parseTable(data []byte, s section) []row {
out := make([]row, s.fileCount)
for i := 0; i < s.fileCount; i++ {
off := s.tableOff + i*fileItemSize
for k := 0; k < 6; k++ {
out[i][k] = i32(data[off+k*4 : off+k*4+4])
}
}
return out
}
func encodeTable(rows []row) []byte {
out := make([]byte, len(rows)*fileItemSize)
for i, r := range rows {
for k := 0; k < 6; k++ {
put(out[i*fileItemSize+k*4:i*fileItemSize+k*4+4], r[k])
}
}
return out
}
// rebuildHash 由文件表重算 HASH 段密文;keepExtras 保留旧字典里不再被引用的陈旧项。
func rebuildHash(data []byte, s section, rows []row, resolve func(int) string, keepExtras bool) []byte {
type kv struct {
off int
text string
}
byOff := map[int]string{}
consider := func(off int) {
if off < 0 {
return
}
if _, ok := byOff[off]; ok {
return
}
byOff[off] = resolve(off)
}
for _, r := range rows {
consider(r[0])
consider(r[1])
}
if keepExtras && s.hashSize > 0 && s.hashOff+s.hashSize <= len(data) {
old := append([]byte(nil), data[s.hashOff:s.hashOff+s.hashSize]...)
xorCore("hash", old, containerMagic)
intCount := len(old) / 4
if intCount >= 2 {
mc := i32(old[0:4])
if mc >= 0 && 1+mc*2+1 <= intCount {
dp := 1 + mc*2
sc := i32(old[dp*4 : dp*4+4])
if sc >= 0 && dp+1+sc <= intCount {
for i := 0; i < sc; i++ {
consider(i32(old[(dp+1+i)*4 : (dp+1+i)*4+4]))
}
}
}
}
}
ordered := make([]kv, 0, len(byOff))
for off, text := range byOff {
ordered = append(ordered, kv{off, text})
}
sort.Slice(ordered, func(a, b int) bool {
if ordered[a].text != ordered[b].text {
return ordered[a].text < ordered[b].text
}
return ordered[a].off < ordered[b].off
})
out := make([]byte, (1+len(rows)*2+1+len(ordered))*4)
put(out[0:4], len(rows))
for i, r := range rows {
put(out[(1+i*2)*4:(1+i*2)*4+4], r[0])
put(out[(2+i*2)*4:(2+i*2)*4+4], r[1])
}
dp := 1 + len(rows)*2
put(out[dp*4:dp*4+4], len(ordered))
for i, e := range ordered {
put(out[(dp+1+i)*4:(dp+1+i)*4+4], e.off)
}
xorCore("hash", out, containerMagic)
return out
}
// assemble 把 header(改过 fileCount/hashSize)+ 新文件表 + 新 HASH + 其余段原样 拼起来。
func assemble(data []byte, s section, hdr []byte, rows []row, hash []byte) []byte {
nh := append([]byte(nil), hdr...)
put(nh[24:28], len(rows))
put(nh[40:44], len(hash))
xorCore("hEAd", nh, containerMagic)
out := make([]byte, 0, len(data)+len(hash)-s.hashSize)
out = append(out, nh...)
out = append(out, encodeTable(rows)...)
out = append(out, hash...)
out = append(out, data[s.nameOff:s.groupOff]...)
out = append(out, data[s.groupOff:s.bodyOff]...)
out = append(out, data[s.bodyOff:s.bodyOff+s.bodySize]...)
return out
}
func main() {
pvfPath := flag.String("pvf", "", "输入 Script.pvf")
outPath := flag.String("out", "", "输出 Script.pvf")
mode := flag.String("mode", "", "add | delete")
delPath := flag.String("path", "", "delete: 要删除的归档路径")
src := flag.String("src", "", "add: 源文件(内容与 dataType 原样复制)")
dst := flag.String("dst", "", "add: 新路径")
listPath := flag.String("list", "", "add: 要追加条目的 .lst(可空)")
listID := flag.Int("list-id", 0, "add: .lst 里的 id")
listRel := flag.String("list-rel", "", "add: .lst 里的相对路径")
flag.Parse()
if *pvfPath == "" || *outPath == "" || *mode == "" {
fmt.Fprintln(os.Stderr, "-pvf -out -mode are required")
os.Exit(2)
}
orig, err := os.ReadFile(*pvfPath)
if err != nil {
fmt.Fprintln(os.Stderr, "read:", err)
os.Exit(1)
}
switch *mode {
case "delete":
runDelete(orig, *pvfPath, *outPath, *delPath)
case "add":
runAdd(orig, *pvfPath, *outPath, *src, *dst, *listPath, *listID, *listRel)
default:
fmt.Fprintln(os.Stderr, "未知模式:", *mode)
os.Exit(2)
}
}
func runDelete(orig []byte, pvfPath, outPath, delPath string) {
if delPath == "" {
fmt.Fprintln(os.Stderr, "delete 需要 -path")
os.Exit(2)
}
s, hdr, err := locate(orig)
if err != nil {
fmt.Fprintln(os.Stderr, "locate:", err)
os.Exit(1)
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
fmt.Fprintln(os.Stderr, "open:", err)
os.Exit(1)
}
idx := archive.FindFileIndex(delPath)
if idx < 0 {
fmt.Fprintf(os.Stderr, "归档里没有 %s\n", delPath)
os.Exit(1)
}
rows := parseTable(orig, s)
fmt.Printf("删除: %s (idx=%d) 文件表 %d -> %d\n", delPath, idx, len(rows), len(rows)-1)
rows = append(rows[:idx:idx], rows[idx+1:]...)
hash := rebuildHash(orig, s, rows, archive.ResolveString, true)
out := assemble(orig, s, hdr, rows, hash)
write2(outPath, out)
selfCheck(out, pvfPath, delPath, len(rows), true)
}
func runAdd(orig []byte, pvfPath, outPath, src, dst, listPath string, listID int, listRel string) {
if src == "" || dst == "" {
fmt.Fprintln(os.Stderr, "add 需要 -src 与 -dst")
os.Exit(2)
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
fmt.Fprintln(os.Stderr, "open:", err)
os.Exit(1)
}
if archive.FindFileIndex(dst) >= 0 {
fmt.Fprintf(os.Stderr, "目标路径已存在,拒绝覆盖: %s\n", dst)
os.Exit(1)
}
srcIdx := archive.FindFileIndex(src)
if srcIdx < 0 {
fmt.Fprintf(os.Stderr, "源文件不存在: %s\n", src)
os.Exit(1)
}
srcFiles := archive.Files()
srcEntry := srcFiles[srcIdx]
fmt.Printf("源: %s (idx=%d type=%d size=%d)\n", src, srcIdx, srcEntry.DataType, srcEntry.Size)
fmt.Printf("目标: %s\n", dst)
// 1) 先把新路径需要的两个字符串追加进池(只追加,旧偏移全部保持有效)。
dstDir := path.Dir(strings.ReplaceAll(dst, "\\", "/"))
dstName := path.Base(strings.ReplaceAll(dst, "\\", "/"))
if dstDir == "." {
dstDir = ""
}
appends := []platformpvf.StringAppend{{Value: dstName, Wide: false}}
if dstDir != "" {
appends = append([]platformpvf.StringAppend{{Value: dstDir, Wide: false}}, appends...)
}
step1, err := archive.RepackAppendStrings(appends)
if err != nil {
fmt.Fprintln(os.Stderr, "RepackAppendStrings:", err)
os.Exit(1)
}
a1, err := platformpvf.OpenBytes(step1)
if err != nil {
fmt.Fprintln(os.Stderr, "reopen(1):", err)
os.Exit(1)
}
offs := a1.FindStringMagicOffsets(appends2values(appends))
nameOff, ok1 := offs[dstName]
pathOff, ok2 := offs[dstDir]
if !ok1 {
fmt.Fprintln(os.Stderr, "找不到新文件名偏移")
os.Exit(1)
}
if dstDir != "" && !ok2 {
fmt.Fprintln(os.Stderr, "找不到新目录偏移")
os.Exit(1)
}
if dstDir == "" {
pathOff = -1
}
// 2) 在文件表尾部追加一行,数据直接**别名**源文件那一份字节(不重压缩)。
s1, hdr1, err := locate(step1)
if err != nil {
fmt.Fprintln(os.Stderr, "locate(1):", err)
os.Exit(1)
}
rows := parseTable(step1, s1)
srcRow := rows[srcIdx]
newRow := row{nameOff, pathOff, srcRow[2], srcRow[3], srcRow[4], srcRow[5]}
rows = append(rows, newRow)
fmt.Printf("文件表 %d -> %d(新条目别名源数据: chunk=%d off=%d size=%d type=%d)\n",
len(rows)-1, len(rows), newRow[2], newRow[3], newRow[4], newRow[5])
hash := rebuildHash(step1, s1, rows, a1.ResolveString, true)
step2 := assemble(step1, s1, hdr1, rows, hash)
// 3) 可选:往 .lst 追加一条 id + 反引号路径(令牌流:0x00+int32 / 0x06+int32 偏移)。
final := step2
if listPath != "" {
final = appendListEntry(step2, listPath, listID, listRel)
}
write2(outPath, final)
selfCheck(final, pvfPath, dst, len(rows), false)
}
func appends2values(a []platformpvf.StringAppend) []string {
out := make([]string, 0, len(a))
for _, x := range a {
out = append(out, x.Value)
}
return out
}
func appendListEntry(in []byte, listPath string, id int, rel string) []byte {
if rel == "" {
fmt.Fprintln(os.Stderr, "给了 -list 就必须给 -list-rel")
os.Exit(2)
}
a, err := platformpvf.OpenBytes(in)
if err != nil {
fmt.Fprintln(os.Stderr, "open(list):", err)
os.Exit(1)
}
old, err := a.ReadRaw(listPath)
if err != nil {
fmt.Fprintf(os.Stderr, "读不到 %s: %v\n", listPath, err)
os.Exit(1)
}
// 字符串用反斜杠还是斜杠看现有习惯,这里照抄 .lst 里的写法。
appended, err := a.RepackAppendStrings([]platformpvf.StringAppend{{Value: rel, Wide: false}})
if err != nil {
fmt.Fprintln(os.Stderr, "append lst string:", err)
os.Exit(1)
}
a2, err := platformpvf.OpenBytes(appended)
if err != nil {
fmt.Fprintln(os.Stderr, "reopen(list):", err)
os.Exit(1)
}
off, ok := a2.FindStringMagicOffsets([]string{rel})[rel]
if !ok {
fmt.Fprintln(os.Stderr, "找不到 lst 路径字符串偏移")
os.Exit(1)
}
// 覆盖 .lst 之前,必须先按 a2 的视图重新取一次旧字节(池变大后 body 位置变了)。
old2, err := a2.ReadRaw(listPath)
if err != nil {
fmt.Fprintln(os.Stderr, "重读 lst:", err)
os.Exit(1)
}
_ = old
tok := make([]byte, 0, 20)
t := make([]byte, 5)
t[0] = 0x00
put(t[1:5], id)
tok = append(tok, t...)
t2 := make([]byte, 5)
t2[0] = 0x06
put(t2[1:5], off)
tok = append(tok, t2...)
next, err := a2.RepackRaw([]platformpvf.RawReplacement{{Path: listPath, Data: append(append([]byte(nil), old2...), tok...)}})
if err != nil {
fmt.Fprintln(os.Stderr, "RepackRaw(lst):", err)
os.Exit(1)
}
fmt.Printf("已在 %s 追加: %d `%s`\n", listPath, id, rel)
return next
}
func write2(outPath string, out []byte) {
if err := os.WriteFile(outPath, out, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write:", err)
os.Exit(1)
}
fmt.Printf("已写出: %s (%d 字节)\n", outPath, len(out))
}
func selfCheck(out []byte, origPath, probe string, wantCount int, wantAbsent bool) {
a, err := platformpvf.OpenBytes(out)
if err != nil {
fmt.Fprintln(os.Stderr, "自检 open:", err)
os.Exit(1)
}
fmt.Printf("自检: 文件数 = %d (期望 %d) %s\n", a.FileCount(), wantCount,
map[bool]string{true: "OK", false: "FAIL"}[a.FileCount() == wantCount])
if a.FileCount() != wantCount {
os.Exit(1)
}
found := a.FindFileIndex(probe) >= 0
if wantAbsent && found {
fmt.Fprintf(os.Stderr, "自检 FAIL: %s 仍在\n", probe)
os.Exit(1)
}
if !wantAbsent && !found {
fmt.Fprintf(os.Stderr, "自检 FAIL: %s 不在\n", probe)
os.Exit(1)
}
if !wantAbsent {
if t, err := a.ReadText(probe); err == nil {
prev := strings.Join(strings.Fields(t), " ")
if len(prev) > 120 {
prev = prev[:120] + "…"
}
fmt.Printf("自检: %s 可读 -> %s\n", probe, prev)
} else {
fmt.Fprintf(os.Stderr, "自检 FAIL: 读 %s: %v\n", probe, err)
os.Exit(1)
}
}
fmt.Println("自检通过。内容一致性与 HASH 自洽性请另跑 pvfcompare / pvfhashcheck。")
}