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

+326
View File
@@ -0,0 +1,326 @@
// pvfhashfix 重建 protected PVF 的 HASH 段,使它和文件表自洽(其余字节原样保留)。
//
// 背景(证据见 _investigation_mailblank):
// - HASH 段(文件表之后、字符串池之前)明文是:
// int32 fileCount + fileCount×(int32 nameOffset, int32 pathOffset) + int32 stringCount + stringCount×int32
// —— 前一段是「文件表逐条同序的镜像」,后一段是「文件表引用到的字符串偏移,按字符串升序去重」。
// - 已知可用的库(tools\_pvf_20260919\Script.pvf.new,新增的 _test123.stk 在客户端能正常显示)
// 这两段与文件表完全自洽;而当前线上库改过文件表却没同步这一段,
// 于是新增的 16 个文件在 HASH 段里根本不存在 —— 客户端按 HASH 索引查路径就查不到(邮件附件空白)。
//
// 本工具只改 header.hashSize 与 HASH 段本身;文件表、字符串池、组表、body 全部原样拷贝,不做重压缩。
//
// go run ./cmd/scratch/pvfhashfix -pvf <in.pvf> -verify # 自检:重建已知好库的 HASH,必须逐字节一致
// go run ./cmd/scratch/pvfhashfix -pvf <in.pvf> -out <out.pvf> # 写出修好的库
package main
import (
"bytes"
"encoding/binary"
"flag"
"fmt"
"os"
"sort"
"unicode/utf16"
platformpvf "longheng.io/server/internal/platform/pvf"
)
const (
headerSize = 0x30
fileItemSize = 0x18
groupItemSize = 8
containerMagic = 0x269EC3
)
// decryptProtectedCore 是纯异或密钥流,同一个函数即可加密也可解密。
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))) }
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
}
// buildHashPlain 由文件表算出 HASH 段明文。
//
// keepExtras 语义:字典区除了「文件表引用到的偏移」,还要保留**原 HASH 段里已经存在**的偏移。
// 实测第三方 pvfUtility 写出的已知好库里会留下少量不再被引用的陈旧字典项
// (17:59 那份留了 23 条),保留它们可以让重建结果与那个库**逐字节一致**——
// 这是验证编码器语义的最强判据。关掉则输出「最小字典」。
func buildHashPlain(data []byte, s section, resolve func(int) string, keepExtras bool) ([]byte, error) {
if s.tableOff+s.fileCount*fileItemSize > len(data) {
return nil, fmt.Errorf("文件表越界")
}
names := make([]int, s.fileCount)
paths := make([]int, s.fileCount)
for i := 0; i < s.fileCount; i++ {
off := s.tableOff + i*fileItemSize
names[i] = i32(data[off : off+4])
paths[i] = i32(data[off+4 : off+8])
}
// 字典区:文件表引用到的偏移,去重(按偏移),按「字符串升序 + 偏移」排序,丢掉负值。
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 _, v := range names {
consider(v)
}
for _, v := range paths {
consider(v)
}
extras := 0
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 {
dictPos := 1 + mc*2
sc := i32(old[dictPos*4 : dictPos*4+4])
if sc >= 0 && dictPos+1+sc <= intCount {
for i := 0; i < sc; i++ {
off := i32(old[(dictPos+1+i)*4 : (dictPos+1+i)*4+4])
if off < 0 {
continue
}
if _, ok := byOff[off]; !ok {
extras++
}
consider(off)
}
}
}
}
}
ordered := make([]kv, 0, len(byOff))
for off, text := range byOff {
ordered = append(ordered, kv{off: off, text: text})
}
sort.Slice(ordered, func(a, b int) bool {
if ordered[a].text != ordered[b].text {
return ordered[a].text < ordered[b].text // Go 的字符串 < 是逐字节比较
}
return ordered[a].off < ordered[b].off
})
fmt.Printf(" 字典来源: 文件表引用 %d 条 + 沿用旧字典的陈旧项 %d 条 = %d 条\n",
len(ordered)-extras, extras, len(ordered))
intCount := 1 + s.fileCount*2 + 1 + len(ordered)
out := make([]byte, intCount*4)
binary.LittleEndian.PutUint32(out[0:4], uint32(s.fileCount))
for i := 0; i < s.fileCount; i++ {
binary.LittleEndian.PutUint32(out[(1+i*2)*4:(1+i*2)*4+4], uint32(names[i]))
binary.LittleEndian.PutUint32(out[(2+i*2)*4:(2+i*2)*4+4], uint32(paths[i]))
}
dictPos := 1 + s.fileCount*2
binary.LittleEndian.PutUint32(out[dictPos*4:dictPos*4+4], uint32(len(ordered)))
for i, e := range ordered {
binary.LittleEndian.PutUint32(out[(dictPos+1+i)*4:(dictPos+1+i)*4+4], uint32(e.off))
}
return out, nil
}
func main() {
pvfPath := flag.String("pvf", "", "输入 Script.pvf")
outPath := flag.String("out", "", "输出 Script.pvf(不填则只报告,不写)")
verify := flag.Bool("verify", false, "自检:重建 HASH 后与本库现有 HASH 逐字节比对")
keepExtras := flag.Bool("keep-extras", true, "保留旧字典里已存在但已不再被引用的偏移项(与第三方工具输出一致)")
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)
}
s, hdr, err := locate(data)
if err != nil {
fmt.Fprintln(os.Stderr, "locate:", err)
os.Exit(1)
}
fmt.Printf("输入: %s (%d 字节)\n", *pvfPath, len(data))
fmt.Printf("fileCount=%d groupCount=%d hashSize=%d nameSize=%d bodySize=%d\n",
s.fileCount, s.groupCount, s.hashSize, s.nameSize, s.bodySize)
fmt.Printf("段偏移: 表=%d HASH=%d 字符串=%d 组表=%d body=%d\n",
s.tableOff, s.hashOff, s.nameOff, s.groupOff, s.bodyOff)
archive, err := platformpvf.Open(*pvfPath)
if err != nil {
fmt.Fprintln(os.Stderr, "open:", err)
os.Exit(1)
}
resolve := func(off int) string { return archive.ResolveString(off) }
plain, err := buildHashPlain(data, s, resolve, *keepExtras)
if err != nil {
fmt.Fprintln(os.Stderr, "build:", err)
os.Exit(1)
}
newRaw := append([]byte(nil), plain...)
xorCore("hash", newRaw, containerMagic)
fmt.Printf("\n重建后 HASH 段: %d 字节(原 %d 字节,差 %+d)\n", len(newRaw), s.hashSize, len(newRaw)-s.hashSize)
// 报告镜像条数与字典条数
fmt.Printf(" 镜像条数 = %d(文件表 %d)\n", i32(plain[0:4]), s.fileCount)
dictPos := 1 + s.fileCount*2
fmt.Printf(" 字典条数 = %d\n", i32(plain[dictPos*4:dictPos*4+4]))
if *verify {
old := data[s.hashOff : s.hashOff+s.hashSize]
if len(old) != len(newRaw) {
fmt.Printf("\n[verify] 长度不同,无法逐字节比对(旧 %d / 新 %d)❌\n", len(old), len(newRaw))
os.Exit(1)
}
diff := 0
first := -1
for i := range old {
if old[i] != newRaw[i] {
diff++
if first < 0 {
first = i
}
}
}
if diff == 0 {
fmt.Printf("\n[verify] ✅ 重建的 HASH 段与库中现有 HASH 段【逐字节完全一致】\n")
fmt.Printf(" 说明本工具的镜像顺序 + 字典排序/去重语义与该库一致。\n")
os.Exit(0)
}
fmt.Printf("\n[verify] ❌ 有 %d 字节不同,首个差异在偏移 %d\n", diff, first)
for _, off := range []int{first, first + 4} {
if off+4 <= len(old) {
fmt.Printf(" @%d 旧=%d 新=%d\n", off, i32(old[off:off+4]), i32(newRaw[off:off+4]))
}
}
os.Exit(1)
}
// 写出:header(只改 hashSize)+ 文件表 + 新 HASH + 字符串池 + 组表 + body(全部原样拷贝)
newHdr := append([]byte(nil), hdr...)
binary.LittleEndian.PutUint32(newHdr[40:44], uint32(len(newRaw)))
xorCore("hEAd", newHdr, containerMagic)
var buf bytes.Buffer
buf.Grow(len(data) + len(newRaw) - s.hashSize)
buf.Write(newHdr)
buf.Write(data[s.tableOff:s.hashOff]) // 文件表
buf.Write(newRaw) // 新 HASH
buf.Write(data[s.nameOff:s.groupOff]) // 字符串池
buf.Write(data[s.groupOff:s.bodyOff]) // 组表
buf.Write(data[s.bodyOff : s.bodyOff+s.bodySize]) // body
if *outPath == "" {
fmt.Println("\n未指定 -out,只做报告,未写文件。")
return
}
if samePath(*pvfPath, *outPath) {
fmt.Fprintln(os.Stderr, "拒绝覆盖输入文件,请指定不同的 -out")
os.Exit(2)
}
if err := os.WriteFile(*outPath, buf.Bytes(), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write:", err)
os.Exit(1)
}
fmt.Printf("\n已写出: %s (%d 字节)\n", *outPath, buf.Len())
// 写后自检:重新打开,逐文件比对内容是否与原库一致
outBytes := buf.Bytes()
re, err := platformpvf.OpenBytes(outBytes)
if err != nil {
fmt.Fprintln(os.Stderr, "重新打开失败:", err)
os.Exit(1)
}
fmt.Printf("写后自检: 文件数 %d(原 %d)\n", re.FileCount(), archive.FileCount())
if re.FileCount() != archive.FileCount() {
fmt.Fprintln(os.Stderr, "文件数不一致 ❌")
os.Exit(1)
}
fmt.Println("写后自检通过 ✅ (内容一致性的完整比对请另跑 pvfcompare)")
}
func samePath(a, b string) bool {
pa, err1 := os.Stat(a)
pb, err2 := os.Stat(b)
if err1 == nil && err2 == nil {
return os.SameFile(pa, pb)
}
return a == b
}