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

368 lines
11 KiB
Go

package pvf
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func TestReplaceScriptStringMagicOnlyChangesStringTokens(t *testing.T) {
raw := make([]byte, 20)
raw[0] = 6
binary.LittleEndian.PutUint32(raw[1:5], 100)
raw[5] = 0
binary.LittleEndian.PutUint32(raw[6:10], 100)
raw[10] = 3
binary.LittleEndian.PutUint32(raw[11:15], 100)
raw[15] = 6
binary.LittleEndian.PutUint32(raw[16:20], 200)
if changed := replaceScriptStringMagic(raw, 100, 300); changed != 2 {
t.Fatalf("changed=%d want=2", changed)
}
if binary.LittleEndian.Uint32(raw[1:5]) != 300 ||
binary.LittleEndian.Uint32(raw[6:10]) != 100 ||
binary.LittleEndian.Uint32(raw[11:15]) != 300 ||
binary.LittleEndian.Uint32(raw[16:20]) != 200 {
t.Fatalf("raw=%x", raw)
}
}
func TestFindStringMagic(t *testing.T) {
archive := &Archive{strA: []byte("\x00alpha_beta\x00gamma_delta\x00")}
magic, ok := archive.findStringMagic("alpha_beta")
if !ok || magic != 2 {
t.Fatalf("alpha_beta magic=%d ok=%v want=2", magic, ok)
}
magic, ok = archive.findStringMagic("gamma_delta")
if !ok || magic != 12<<1 {
t.Fatalf("gamma_delta magic=%d ok=%v want=%d", magic, ok, 12<<1)
}
// "alph" 只是 "alpha_beta" 的前缀,后面不是 NUL,不得命中。
if _, ok = archive.findStringMagic("alph"); ok {
t.Fatal("alph must not match a non-terminated prefix")
}
if _, ok = archive.findStringMagic("missing"); ok {
t.Fatal("missing must not match")
}
}
// buildSyntheticNKPIArchive 用包内加密助手构造一个最小合法 nkpi 归档:
// 1 个 type-1 脚本文件 dir/test.ani,字符串池含 patch_old/patch_new/unused_str。
// 加密函数均为对合(XOR 密钥流),直接复用即可完成“加密”方向。
func buildSyntheticNKPIArchive(t *testing.T) []byte {
t.Helper()
pool := []byte{0}
poolOffset := func(value string) int {
off := len(pool)
pool = append(pool, []byte(value)...)
pool = append(pool, 0)
return off
}
oldOff := poolOffset("patch_old")
poolOffset("patch_new")
poolOffset("unused_str")
dirOff := poolOffset("dir")
nameOff := poolOffset("test.ani")
// 脚本 token:行内字符串(old)、整数、标签(old)、行内字符串(dir)。
script := make([]byte, 0, 20)
appendToken := func(tokenType byte, value int) {
script = append(script, tokenType, 0, 0, 0, 0)
binary.LittleEndian.PutUint32(script[len(script)-4:], uint32(int32(value)))
}
appendToken(6, oldOff<<1)
appendToken(0, 42)
appendToken(3, oldOff<<1)
appendToken(6, dirOff<<1)
encoder := &Archive{format: FormatNKPI}
// name 段:8 字节前缀 + sTrA/sTrW 字符串池压缩加密缓冲。
encodeStringBuffer := func(key string, xorConst uint32, raw []byte) []byte {
compressed, err := compressZlib(raw)
if err != nil {
t.Fatal(err)
}
decrypt2(key, compressed)
out := make([]byte, 8, 8+len(compressed))
binary.LittleEndian.PutUint32(out[0:4], uint32(len(compressed))^xorConst)
binary.LittleEndian.PutUint32(out[4:8], uint32(len(raw)))
return append(out, compressed...)
}
nameSection := make([]byte, 8)
nameSection = append(nameSection, encodeStringBuffer("sTrA", 0xAA74472E, pool)...)
nameSection = append(nameSection, encodeStringBuffer("sTrW", 0x9A82F037, []byte{0, 0})...)
fileTable := make([]byte, fileItemSize)
binary.LittleEndian.PutUint32(fileTable[0:4], uint32(nameOff<<1))
binary.LittleEndian.PutUint32(fileTable[4:8], uint32(dirOff<<1))
binary.LittleEndian.PutUint32(fileTable[8:12], 0) // chunkIndex
binary.LittleEndian.PutUint32(fileTable[12:16], 0)
binary.LittleEndian.PutUint32(fileTable[16:20], uint32(len(script)))
binary.LittleEndian.PutUint32(fileTable[20:24], 1) // dataType script
body, err := encoder.encryptChunk(script)
if err != nil {
t.Fatal(err)
}
groupTable := encoder.encodeGroups([]groupItem{{compressedSize: len(body), originalSize: len(script)}})
var plain [headerSize]byte
copy(plain[0:4], []byte("nkpi"))
header := pvfHeader{
plain: plain,
fileCount: 1,
bodySize: len(body),
groupCount: 1,
hashSize: 0,
nameSize: len(nameSection),
}
out := encoder.encodeHeader(header)
out = append(out, fileTable...)
out = append(out, nameSection...)
out = append(out, groupTable...)
out = append(out, body...)
return out
}
func TestPatchScriptStringSyntheticArchive(t *testing.T) {
raw := buildSyntheticNKPIArchive(t)
archive, err := OpenBytes(raw)
if err != nil {
t.Fatalf("open synthetic archive: %v", err)
}
if archive.Format() != FormatNKPI {
t.Fatalf("format=%q want %q", archive.Format(), FormatNKPI)
}
patched, report, err := archive.PatchScriptString([]string{"dir/test.ani"}, "patch_old", "patch_new")
if err != nil {
t.Fatalf("PatchScriptString: %v", err)
}
if report.FilesChanged != 1 || report.TokensChanged != 2 || report.ChunksChanged != 1 {
t.Fatalf("report=%+v want {1 2 1}", report)
}
verified, err := OpenBytes(patched)
if err != nil {
t.Fatalf("reopen patched: %v", err)
}
text, err := verified.ReadText("dir/test.ani")
if err != nil {
t.Fatalf("read patched text: %v", err)
}
if !strings.Contains(text, "`patch_new`") || strings.Contains(text, "patch_old") {
t.Fatalf("patched text=%q", text)
}
// 无关 token(dir)与原归档文本必须保持不变。
if !strings.Contains(text, "`dir`") {
t.Fatalf("patched text lost untouched token: %q", text)
}
originalText, err := archive.ReadText("dir/test.ani")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(originalText, "`patch_old`") {
t.Fatalf("source archive mutated in memory: %q", originalText)
}
}
func TestPatchScriptStringErrors(t *testing.T) {
archive, err := OpenBytes(buildSyntheticNKPIArchive(t))
if err != nil {
t.Fatal(err)
}
if _, _, err = archive.PatchScriptString([]string{"dir/test.ani"}, "patch_old", "patch_old"); err == nil {
t.Fatal("same old/new must fail")
}
if _, _, err = archive.PatchScriptString([]string{"dir/test.ani"}, "absent_old", "patch_new"); err == nil {
t.Fatal("absent old value must fail")
}
if _, _, err = archive.PatchScriptString([]string{"dir/test.ani"}, "patch_old", "absent_new"); err == nil {
t.Fatal("absent new value must fail")
}
if _, _, err = archive.PatchScriptString([]string{"dir/missing.ani"}, "patch_old", "patch_new"); err == nil {
t.Fatal("missing file must fail")
}
// unused_str 在池中但脚本未引用,必须报错而不是产出空补丁。
if _, _, err = archive.PatchScriptString([]string{"dir/test.ani"}, "unused_str", "patch_new"); err == nil {
t.Fatal("unreferenced old value must fail")
}
}
var asciiTokenPattern = regexp.MustCompile("`([^`]{3,40})`")
func isPrintableASCII(value string) bool {
for idx := 0; idx < len(value); idx++ {
if value[idx] < 0x20 || value[idx] > 0x7e {
return false
}
}
return true
}
// TestRealScriptPVFPatchScriptStringRoundTrip 对真实 Script.pvf 的 TempDir
// 副本执行字符串引用补丁并回开验证;原始文件绝不写入。门控:
// DNFBRIDGE_REAL_PVF_SMOKE=D:/DNF/runtime/data/dnf/Script.pvf。
func TestRealScriptPVFPatchScriptStringRoundTrip(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify real PVF script string patching")
}
sourceSum := sha256File(t, pvfPath)
tmpCopy := filepath.Join(t.TempDir(), "Script.pvf")
copyFile(t, pvfPath, tmpCopy)
copySumBefore := sha256File(t, tmpCopy)
archive, err := LoadArchive(Options{Path: tmpCopy})
if err != nil {
t.Fatal(err)
}
// 动态挑选一对 ASCII 池字符串:old 必须是某脚本实际引用且只以
// 完整 token 形式出现(避免子串误判),new 必须在池中且与 old 无子串关系。
var poolTokens []string
seenToken := make(map[string]bool)
collect := func(token string) {
if seenToken[token] || !isPrintableASCII(token) {
return
}
if _, ok := archive.findStringMagic(token); !ok {
return
}
seenToken[token] = true
poolTokens = append(poolTokens, token)
}
const scanBudget = 4000
var patchPath, oldValue, newValue string
scanned := 0
files:
for _, file := range archive.Files() {
if file.DataType != 1 {
continue
}
text, err := archive.ReadText(file.ArchivePath)
if err != nil {
continue
}
scanned++
for _, match := range asciiTokenPattern.FindAllStringSubmatch(text, -1) {
collect(match[1])
}
for _, line := range strings.Split(text, "\n") {
if len(line) >= 3 {
collect(line)
}
}
for _, token := range poolTokens {
occurrences := strings.Count(text, "`"+token+"`") + strings.Count(text, "\n"+token+"\n")
if occurrences == 0 || strings.Count(text, token) != occurrences {
continue
}
for _, candidate := range poolTokens {
if candidate == token || strings.Contains(candidate, token) || strings.Contains(token, candidate) {
continue
}
patchPath, oldValue, newValue = file.ArchivePath, token, candidate
break files
}
}
if scanned >= scanBudget {
break
}
}
if patchPath == "" {
t.Skipf("no ASCII string-pool token pair found within %d scripts", scanned)
}
t.Logf("patching %s: %q -> %q (scanned %d scripts, pool tokens %d)", patchPath, oldValue, newValue, scanned, len(poolTokens))
patched, report, err := archive.PatchScriptString([]string{patchPath}, oldValue, newValue)
if err != nil {
t.Fatalf("PatchScriptString: %v", err)
}
if report.FilesChanged != 1 || report.TokensChanged < 1 || report.ChunksChanged < 1 {
t.Fatalf("report=%+v", report)
}
t.Logf("report=%+v", report)
verified, err := OpenBytes(patched)
if err != nil {
t.Fatalf("reopen patched bytes: %v", err)
}
text, err := verified.ReadText(patchPath)
if err != nil {
t.Fatalf("read patched file: %v", err)
}
if !strings.Contains(text, newValue) || strings.Contains(text, oldValue) {
t.Fatalf("patched text verification failed for %s", patchPath)
}
// 落盘一次再回开,验证输出文件作为独立归档可用。
outPath := filepath.Join(t.TempDir(), "Script.patched.pvf")
if err := os.WriteFile(outPath, patched, 0o644); err != nil {
t.Fatal(err)
}
diskArchive, err := LoadArchive(Options{Path: outPath})
if err != nil {
t.Fatalf("load patched archive from disk: %v", err)
}
diskText, err := diskArchive.ReadText(patchPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(diskText, newValue) || strings.Contains(diskText, oldValue) {
t.Fatalf("disk round-trip verification failed for %s", patchPath)
}
// 输入副本与原始 PVF 在补丁前后必须字节级不变。
if got := sha256File(t, tmpCopy); got != copySumBefore {
t.Fatal("temp copy was modified by patching")
}
if got := sha256File(t, pvfPath); got != sourceSum {
t.Fatal("original PVF was modified by patching")
}
}
func sha256File(t *testing.T, path string) string {
t.Helper()
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
t.Fatal(err)
}
return hex.EncodeToString(hash.Sum(nil))
}
func copyFile(t *testing.T, src, dst string) {
t.Helper()
in, err := os.Open(src)
if err != nil {
t.Fatal(err)
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
t.Fatal(err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
t.Fatal(err)
}
if err := out.Close(); err != nil {
t.Fatal(err)
}
}