服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
405 lines
12 KiB
Go
405 lines
12 KiB
Go
package pvf
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"math/rand"
|
|
"testing"
|
|
)
|
|
|
|
func TestKnownFormatsSyntheticRoundTrip(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
keys keySet
|
|
}{
|
|
{"standard", standardKeys()}, {"alternate", variantKeys()},
|
|
} {
|
|
for _, guard := range []bool{false, true} {
|
|
t.Run(tc.name+map[bool]string{false: "/plain", true: "/guard"}[guard], func(t *testing.T) {
|
|
a := New()
|
|
a.keys, a.guard = tc.keys, guard
|
|
_, err := a.AddFileText("etc/a.txt", "hello", TypeUnicode)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var original bytes.Buffer
|
|
if err := a.SaveTo(&original); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := Parse(original.Bytes())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if b.Format() != tc.name || b.UsesGuard() != guard || b.keys.header != tc.keys.header {
|
|
t.Fatal("wrong detected format")
|
|
}
|
|
var unchanged bytes.Buffer
|
|
if err := b.SaveTo(&unchanged); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(original.Bytes(), unchanged.Bytes()) {
|
|
t.Fatal("unmodified bytes changed")
|
|
}
|
|
if err := b.SetText(0, "changed"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b.AddFile("etc/b.txt", utf16le("new"), TypeUnicode)
|
|
if _, err := b.RemoveFiles([]int32{0}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var edited bytes.Buffer
|
|
if err := b.SaveTo(&edited); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := Parse(edited.Bytes())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertSavedHashMatchesFiles(t, c)
|
|
if _, ok := c.Find("etc/a.txt"); ok {
|
|
t.Fatal("removed entry remains")
|
|
}
|
|
i, ok := c.Find("etc/b.txt")
|
|
if !ok {
|
|
t.Fatal("added entry missing")
|
|
}
|
|
if text, err := c.Text(i); err != nil || text != "new" {
|
|
t.Fatal("payload did not round trip")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnknownHashWritePolicy(t *testing.T) {
|
|
for _, paged := range []bool{false, true} {
|
|
for _, mutation := range []string{"payload", "add", "remove", "pool"} {
|
|
t.Run(map[bool]string{false: "plain", true: "paged"}[paged]+"/"+mutation, func(t *testing.T) {
|
|
a := New()
|
|
a.AddFile("etc/a.txt", utf16le("old"), TypeUnicode)
|
|
var packed bytes.Buffer
|
|
if err := a.SaveTo(&packed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.keys.hash = sectionKey{}
|
|
a.keys.hashKnown = false
|
|
if paged {
|
|
a.format = paged110Profile
|
|
a.pageKeys = make([]byte, 32)
|
|
a.strA = nil
|
|
}
|
|
if caps := a.WriteCapabilities(); !caps.CanSave || caps.CanChangeStructure {
|
|
t.Fatal("incorrect write capability")
|
|
}
|
|
switch mutation {
|
|
case "payload":
|
|
_ = a.SetRawBytes(0, utf16le("replacement"))
|
|
case "add":
|
|
a.AddFile("etc/b.txt", utf16le("new"), TypeUnicode)
|
|
case "remove":
|
|
_, _ = a.RemoveFiles([]int32{0})
|
|
case "pool":
|
|
a.UnicodeStringOffset("new string")
|
|
}
|
|
var out bytes.Buffer
|
|
err := a.SaveTo(&out)
|
|
if mutation == "payload" {
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
} else {
|
|
if !errors.Is(err, a.structureLockedError()) {
|
|
t.Fatalf("expected structure rejection, got %v", err)
|
|
}
|
|
if out.Len() != 0 {
|
|
t.Fatal("rejected save wrote bytes")
|
|
}
|
|
if !a.Modified() {
|
|
t.Fatal("rejected save discarded edits")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBatchClonePreservesContainerAndRules(t *testing.T) {
|
|
for _, profile := range []formatProfile{paged110Profile, paged115Profile, paged115CNProfile, pagedProfile, paged100CNProfile} {
|
|
t.Run(profile.id, func(t *testing.T) {
|
|
a := New()
|
|
a.format = profile
|
|
a.pageKeys = make([]byte, 32)
|
|
stage := a.CloneForBatch()
|
|
if !stage.IsPaged() || stage.ContentRules() != a.ContentRules() || stage.Format() != profile.id {
|
|
t.Fatal("clone lost format")
|
|
}
|
|
if !stage.WriteCapabilities().CanSave {
|
|
t.Fatal("clone lost write state")
|
|
}
|
|
if offset := stage.StringOffset("ascii"); (offset&1 != 0) != profile.rules.UTF16Only {
|
|
t.Fatal("clone lost string pool policy")
|
|
}
|
|
stage.pageKeys[0] = 1
|
|
if a.pageKeys[0] != 0 {
|
|
t.Fatal("clone aliases mutable page state")
|
|
}
|
|
if len(a.strW) != 2 {
|
|
t.Fatal("clone modified original pool")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestContentRulesIndependentOfContainer(t *testing.T) {
|
|
a := New()
|
|
a.format.rules = ContentRules{UTF16Only: true, RequiresIndexHash: true}
|
|
if a.IsPaged() {
|
|
t.Fatal("content rules changed container")
|
|
}
|
|
if offset := a.StringOffset("ascii"); offset&1 == 0 {
|
|
t.Fatal("content policy ignored")
|
|
}
|
|
if offset := a.scriptStringOffset("script ascii", ScriptPoolUTF8); offset&1 == 0 {
|
|
t.Fatal("script policy ignored")
|
|
}
|
|
var out bytes.Buffer
|
|
if err := a.SaveTo(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Parse(out.Bytes()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestPagedWriteRequiresPageState(t *testing.T) {
|
|
for _, profile := range []formatProfile{paged110Profile, paged115Profile, paged115CNProfile, pagedProfile, paged100CNProfile} {
|
|
t.Run(profile.id, func(t *testing.T) {
|
|
a := New()
|
|
a.format = profile
|
|
before := append([]byte(nil), a.strA...)
|
|
var out bytes.Buffer
|
|
if err := a.SaveTo(&out); !errors.Is(err, ErrPagedReadOnly) {
|
|
t.Fatalf("got %v", err)
|
|
}
|
|
if !bytes.Equal(before, a.strA) || out.Len() != 0 {
|
|
t.Fatal("rejected write changed state")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestKnownZeroHashSeedAllowsStructureChanges(t *testing.T) {
|
|
a := New()
|
|
a.keys.hash.seed = 0
|
|
a.AddFile("etc/a.txt", utf16le("a"), TypeUnicode)
|
|
var out bytes.Buffer
|
|
if err := a.SaveTo(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !a.WriteCapabilities().CanChangeStructure {
|
|
t.Fatal("zero seed treated as unknown")
|
|
}
|
|
a.AddFile("etc/b.txt", utf16le("b"), TypeUnicode)
|
|
out.Reset()
|
|
if err := a.SaveTo(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plain := append([]byte(nil), a.data[a.hashOff:a.hashOff+a.hashSize]...)
|
|
cryptSeed(a.keys.hash.seed, a.keys.hash.magic, plain)
|
|
entries, _ := parseHashTable(plain)
|
|
if len(entries) != 2 {
|
|
t.Fatal("HASH not rebuilt")
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsInvalidNameSection(t *testing.T) {
|
|
a := New()
|
|
a.AddFile("etc/a.txt", utf16le("a"), TypeUnicode)
|
|
var out bytes.Buffer
|
|
if err := a.SaveTo(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, lengthField := range []bool{false, true} {
|
|
data := append([]byte(nil), out.Bytes()...)
|
|
offset := a.nameOff + 8
|
|
if lengthField {
|
|
offset += 4
|
|
}
|
|
// Invalidate either the compressed size or the declared original length.
|
|
data[offset+3] ^= 0x80
|
|
if _, err := Parse(data); !errors.Is(err, ErrInvalidSection) {
|
|
t.Fatalf("expected section error, got %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// This exercises page wrapping, parsing, edits and clone/save with synthetic
|
|
// page state, independently of proprietary sidecar fixtures.
|
|
func TestPagedContainerSyntheticRoundTrip(t *testing.T) {
|
|
for _, profile := range []formatProfile{paged110Profile, paged115Profile, paged115CNProfile, pagedProfile, paged100CNProfile} {
|
|
t.Run(profile.id, func(t *testing.T) {
|
|
a := New()
|
|
a.format = profile
|
|
a.keys = paged110Keys()
|
|
a.keys.hash = standardKeys().hash
|
|
a.keys.hashKnown = true
|
|
a.pageKeys = make([]byte, paged110PageKeySize)
|
|
payload := make([]byte, 20000)
|
|
_, _ = rand.New(rand.NewSource(42)).Read(payload)
|
|
a.AddFile("etc/padding.txt", payload, TypeUnicode)
|
|
i, err := a.AddFileText("etc/edit.txt", "before", TypeUnicode)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
openLogical := func(packed []byte) *Archive {
|
|
t.Helper()
|
|
logical := append([]byte(nil), packed...)
|
|
if decryptPageGuards(logical, a.pageKeys) != 1 {
|
|
t.Fatal("page not unlocked")
|
|
}
|
|
detected, ok := probeHeader(logical, a.keys, false)
|
|
if !ok {
|
|
t.Fatal("logical header not recognized")
|
|
}
|
|
detected.format, detected.pageKeys = profile, append([]byte(nil), a.pageKeys...)
|
|
b, err := parseDetected(detected)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
var original bytes.Buffer
|
|
if err := a.SaveTo(&original); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := openLogical(original.Bytes())
|
|
var unchanged bytes.Buffer
|
|
if err := b.SaveTo(&unchanged); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(original.Bytes(), unchanged.Bytes()) {
|
|
t.Fatal("unchanged page container differs")
|
|
}
|
|
stage := b.CloneForBatch()
|
|
if err := stage.SetText(i, "after"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stage.AddFile("etc/new.txt", utf16le("new"), TypeUnicode)
|
|
var edited bytes.Buffer
|
|
if err := stage.SaveTo(&edited); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := openLogical(edited.Bytes())
|
|
assertSavedHashMatchesFiles(t, c)
|
|
if text, err := c.Text(i); err != nil || text != "after" {
|
|
t.Fatal("edit did not round trip")
|
|
}
|
|
if _, ok := c.Find("etc/new.txt"); !ok {
|
|
t.Fatal("new entry missing")
|
|
}
|
|
if text, err := b.Text(i); err != nil || text != "before" {
|
|
t.Fatal("staging mutated source")
|
|
}
|
|
if (len(c.strA) == 0) != profile.rules.UTF16Only {
|
|
t.Fatal("string pool policy not retained")
|
|
}
|
|
if c.Format() != profile.id || c.ClientVersion() != a.ClientVersion() {
|
|
t.Fatal("edited archive lost client identity")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnknownHeaderFallsBackToRecovery(t *testing.T) {
|
|
a := New()
|
|
// Synthetic format: only the header differs from a known container.
|
|
a.keys.header.seed = 0x12345678
|
|
a.AddFile("etc/a.txt", utf16le("sample"), TypeUnicode)
|
|
// HASH recovery deliberately requires enough evidence (at least 64 bytes).
|
|
for _, name := range []string{"b", "c", "d", "e"} {
|
|
a.AddFile("etc/"+name+".txt", utf16le("sample"), TypeUnicode)
|
|
}
|
|
var out bytes.Buffer
|
|
if err := a.SaveTo(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := Parse(out.Bytes())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if b.Format() != FormatRecovered {
|
|
t.Fatal("recovery format was not retained")
|
|
}
|
|
if !b.keys.hashKnown || !b.WriteCapabilities().CanChangeStructure {
|
|
t.Fatal("recovered HASH capability missing")
|
|
}
|
|
if text, err := b.Text(0); err != nil || text != "sample" {
|
|
t.Fatal("recovered content mismatch")
|
|
}
|
|
}
|
|
|
|
func assertSavedHashMatchesFiles(t *testing.T, a *Archive) {
|
|
t.Helper()
|
|
plain := append([]byte(nil), a.data[a.hashOff:a.hashOff+a.hashSize]...)
|
|
cryptSeed(a.keys.hash.seed, a.keys.hash.magic, plain)
|
|
entries, _ := parseHashTable(plain)
|
|
if len(entries) != len(a.items) {
|
|
t.Fatal("persisted HASH count differs from file count")
|
|
}
|
|
for i, entry := range entries {
|
|
if entry.nameOff != a.items[i].nameOff || entry.pathOff != a.items[i].pathOff {
|
|
t.Fatal("persisted HASH references do not match file table")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClientVersion(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
profile formatProfile
|
|
want string
|
|
}{
|
|
{standardProfile, "90US"}, {alternateProfile, "90CN"}, {paged110Profile, "110US"},
|
|
{paged115Profile, "115US"}, {paged115CNProfile, "115CN"}, {pagedProfile, ""}, {recoveredProfile, ""}, {formatProfile{id: "future"}, ""}, {paged100CNProfile, "100CN"}, {twProfile, "TW"},
|
|
} {
|
|
a := New()
|
|
a.format = tc.profile
|
|
if got := a.ClientVersion(); got != tc.want {
|
|
t.Fatalf("%s: got %q, want %q", tc.profile.id, got, tc.want)
|
|
}
|
|
if got := a.CloneForBatch().ClientVersion(); got != tc.want {
|
|
t.Fatalf("clone version = %q", got)
|
|
}
|
|
}
|
|
var absent *Archive
|
|
if absent.ClientVersion() != "" {
|
|
t.Fatal("nil archive must have unknown version")
|
|
}
|
|
}
|
|
|
|
func TestPagedClientProfiles(t *testing.T) {
|
|
for _, client := range bundledPagedClients() {
|
|
if got := pagedClientProfile(&client.key.PublicKey); got.id != client.profile.id {
|
|
t.Fatal("bundled public identity changed")
|
|
}
|
|
}
|
|
for _, profile := range []formatProfile{paged110Profile, paged115Profile, paged115CNProfile, pagedProfile, paged100CNProfile} {
|
|
a := New()
|
|
a.format = profile
|
|
if !a.IsPaged() || !a.IsPaged110() || !a.Info().Paged110 {
|
|
t.Fatal("shared paged capability or legacy compatibility lost")
|
|
}
|
|
if a.ContentRules().UTF16Only != (profile.id != FormatPaged100CN) ||
|
|
!a.ContentRules().RequiresIndexHash || !a.ContentRules().SupportsStringReferences {
|
|
t.Fatal("paged content rules diverged")
|
|
}
|
|
if a.Info().Format != profile.id {
|
|
t.Fatal("archive info lost client format")
|
|
}
|
|
if !errors.Is(ErrPaged110Keys, ErrPagedKeys) ||
|
|
!errors.Is(ErrPaged110ReadOnly, ErrPagedReadOnly) ||
|
|
!errors.Is(ErrPaged110StructureLocked, ErrPagedStructureLocked) {
|
|
t.Fatal("legacy error identity changed")
|
|
}
|
|
}
|
|
}
|