服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
499 lines
17 KiB
Go
499 lines
17 KiB
Go
package pvf
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"unicode/utf16"
|
|
)
|
|
|
|
// RawReplacement 描述一次 PVF 内已有文件的 raw 字节替换。
|
|
// 这里的 Data 是解压后的文件原始字节,文本类文件通常仍是 UTF-16LE 或 token 数据。
|
|
type RawReplacement struct {
|
|
Path string
|
|
Data []byte
|
|
}
|
|
|
|
// StringReplacement changes one existing shared-string value without moving
|
|
// its encoded offset. Old and New must therefore occupy exactly the same
|
|
// number of bytes in the target ANSI or UTF-16 pool.
|
|
type StringReplacement struct {
|
|
MagicOffset int
|
|
Old string
|
|
New string
|
|
}
|
|
|
|
// StringAppend describes one new shared-string value. Existing token offsets
|
|
// remain valid because appends never move the preceding ANSI or UTF-16 pool.
|
|
type StringAppend struct {
|
|
Value string
|
|
Wide bool
|
|
}
|
|
|
|
// RepackAppendStrings appends missing values to a protected archive's shared
|
|
// string pools without changing any existing offset or body token. Callers may
|
|
// then reopen the returned archive, resolve the new offsets by value, and use
|
|
// RepackRaw to install remapped type-1 scripts.
|
|
func (a *Archive) RepackAppendStrings(appends []StringAppend) ([]byte, error) {
|
|
if a == nil {
|
|
return nil, fmt.Errorf("%w: archive is nil", ErrInvalidArchive)
|
|
}
|
|
if len(appends) == 0 {
|
|
return nil, fmt.Errorf("%w: string append is empty", ErrInvalidArchive)
|
|
}
|
|
if a.format != FormatProtectedNKPI {
|
|
return nil, fmt.Errorf("%w: string append requires %q, got %q", ErrInvalidArchive, FormatProtectedNKPI, a.format)
|
|
}
|
|
layout, err := a.layout()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
strA := append([]byte(nil), a.strA...)
|
|
strW := append([]byte(nil), a.strW...)
|
|
ordered := append([]StringAppend(nil), appends...)
|
|
sort.SliceStable(ordered, func(left, right int) bool {
|
|
if ordered[left].Wide != ordered[right].Wide {
|
|
return !ordered[left].Wide
|
|
}
|
|
return ordered[left].Value < ordered[right].Value
|
|
})
|
|
seen := make(map[StringAppend]struct{}, len(ordered))
|
|
for _, appendValue := range ordered {
|
|
if appendValue.Value == "" || strings.IndexByte(appendValue.Value, 0) >= 0 {
|
|
return nil, fmt.Errorf("%w: appended string is empty or contains NUL", ErrInvalidArchive)
|
|
}
|
|
if _, duplicate := seen[appendValue]; duplicate {
|
|
continue
|
|
}
|
|
seen[appendValue] = struct{}{}
|
|
if appendValue.Wide {
|
|
strW = append(strW, utf16LEBytes(appendValue.Value)...)
|
|
strW = append(strW, 0, 0)
|
|
continue
|
|
}
|
|
strA = append(strA, []byte(appendValue.Value)...)
|
|
strA = append(strA, 0)
|
|
}
|
|
|
|
nameSection, err := a.encodeProtectedNameSection(layout, strA, strW)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
header := a.header
|
|
header.nameSize = len(nameSection)
|
|
out := a.encodeHeader(header)
|
|
out = append(out, encodeFileTable(a.items)...)
|
|
out = append(out, a.data[layout.hashOffset:layout.hashOffset+layout.hashSize]...)
|
|
out = append(out, nameSection...)
|
|
oldNameEnd := layout.nameOffset + layout.nameSize
|
|
out = append(out, a.data[oldNameEnd:layout.bodyOffset]...)
|
|
out = append(out, a.data[layout.bodyOffset:layout.bodyOffset+layout.bodySize]...)
|
|
return out, nil
|
|
}
|
|
|
|
// RepackStrings rebuilds the protected NKPI string-pool section while keeping
|
|
// file-table offsets and every body chunk unchanged. It is intentionally
|
|
// limited to same-width replacements, which prevents unrelated script-token
|
|
// offsets from shifting.
|
|
func (a *Archive) RepackStrings(replacements []StringReplacement) ([]byte, error) {
|
|
if a == nil {
|
|
return nil, fmt.Errorf("%w: archive is nil", ErrInvalidArchive)
|
|
}
|
|
if len(replacements) == 0 {
|
|
return nil, fmt.Errorf("%w: string replacement is empty", ErrInvalidArchive)
|
|
}
|
|
if a.format != FormatProtectedNKPI {
|
|
return nil, fmt.Errorf("%w: string repack requires %q, got %q", ErrInvalidArchive, FormatProtectedNKPI, a.format)
|
|
}
|
|
layout, err := a.layout()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
strA := append([]byte(nil), a.strA...)
|
|
strW := append([]byte(nil), a.strW...)
|
|
seen := make(map[int]StringReplacement, len(replacements))
|
|
for _, replacement := range replacements {
|
|
if replacement.MagicOffset < 0 {
|
|
return nil, fmt.Errorf("%w: negative string offset %d", ErrInvalidArchive, replacement.MagicOffset)
|
|
}
|
|
if previous, ok := seen[replacement.MagicOffset]; ok {
|
|
if previous.Old != replacement.Old || previous.New != replacement.New {
|
|
return nil, fmt.Errorf("%w: conflicting string replacements at offset %d", ErrInvalidArchive, replacement.MagicOffset)
|
|
}
|
|
continue
|
|
}
|
|
seen[replacement.MagicOffset] = replacement
|
|
if replacement.MagicOffset&1 != 0 {
|
|
start := (replacement.MagicOffset >> 1) * 2
|
|
oldBytes := utf16LEBytes(replacement.Old)
|
|
newBytes := utf16LEBytes(replacement.New)
|
|
if err := replaceStringBytes(strW, start, oldBytes, newBytes, 2); err != nil {
|
|
return nil, fmt.Errorf("replace UTF-16 string offset %d: %w", replacement.MagicOffset, err)
|
|
}
|
|
continue
|
|
}
|
|
start := replacement.MagicOffset >> 1
|
|
if err := replaceStringBytes(strA, start, []byte(replacement.Old), []byte(replacement.New), 1); err != nil {
|
|
return nil, fmt.Errorf("replace ANSI string offset %d: %w", replacement.MagicOffset, err)
|
|
}
|
|
}
|
|
|
|
nameSection, err := a.encodeProtectedNameSection(layout, strA, strW)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
header := a.header
|
|
header.nameSize = len(nameSection)
|
|
out := a.encodeHeader(header)
|
|
out = append(out, encodeFileTable(a.items)...)
|
|
out = append(out, a.data[layout.hashOffset:layout.hashOffset+layout.hashSize]...)
|
|
out = append(out, nameSection...)
|
|
oldNameEnd := layout.nameOffset + layout.nameSize
|
|
out = append(out, a.data[oldNameEnd:layout.bodyOffset]...)
|
|
out = append(out, a.data[layout.bodyOffset:layout.bodyOffset+layout.bodySize]...)
|
|
return out, nil
|
|
}
|
|
|
|
func replaceStringBytes(pool []byte, start int, oldBytes, newBytes []byte, terminatorWidth int) error {
|
|
if len(oldBytes) != len(newBytes) {
|
|
return fmt.Errorf("replacement width changed from %d to %d", len(oldBytes), len(newBytes))
|
|
}
|
|
end := start + len(oldBytes)
|
|
if start < 0 || end+terminatorWidth > len(pool) {
|
|
return fmt.Errorf("string range %d:%d exceeds pool size %d", start, end, len(pool))
|
|
}
|
|
if !bytes.Equal(pool[start:end], oldBytes) {
|
|
return fmt.Errorf("old string bytes do not match at %d", start)
|
|
}
|
|
for index := 0; index < terminatorWidth; index++ {
|
|
if pool[end+index] != 0 {
|
|
return fmt.Errorf("string at %d is not null terminated", start)
|
|
}
|
|
}
|
|
copy(pool[start:end], newBytes)
|
|
return nil
|
|
}
|
|
|
|
func utf16LEBytes(value string) []byte {
|
|
units := utf16.Encode([]rune(value))
|
|
out := make([]byte, len(units)*2)
|
|
for index, unit := range units {
|
|
binary.LittleEndian.PutUint16(out[index*2:index*2+2], unit)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *Archive) encodeProtectedNameSection(layout archiveLayout, strA, strW []byte) ([]byte, error) {
|
|
original := a.data[layout.nameOffset : layout.nameOffset+layout.nameSize]
|
|
if len(original) < 8 {
|
|
return nil, fmt.Errorf("%w: protected name section is too short", ErrInvalidArchive)
|
|
}
|
|
index := 8
|
|
for _, xorConst := range []uint32{0xAA74472E, 0x9A82F037} {
|
|
if index+8 > len(original) {
|
|
return nil, fmt.Errorf("%w: protected name section is missing a string buffer", ErrInvalidArchive)
|
|
}
|
|
compressedSize := int(binary.LittleEndian.Uint32(original[index:index+4]) ^ xorConst)
|
|
if compressedSize < 0 || index+8+compressedSize > len(original) {
|
|
return nil, fmt.Errorf("%w: protected string buffer exceeds name section", ErrInvalidArchive)
|
|
}
|
|
index += 8 + compressedSize
|
|
}
|
|
|
|
out := append([]byte(nil), original[:8]...)
|
|
encodedA, err := encodeProtectedStringBuffer("StRa", 0xAA74472E, strA)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, encodedA...)
|
|
encodedW, err := encodeProtectedStringBuffer("StRw", 0x9A82F037, strW)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, encodedW...)
|
|
out = append(out, original[index:]...)
|
|
return out, nil
|
|
}
|
|
|
|
func encodeProtectedStringBuffer(key string, xorConst uint32, raw []byte) ([]byte, error) {
|
|
compressed, err := compressZlib(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("compress protected string buffer %s: %w", key, err)
|
|
}
|
|
encrypted := append([]byte(nil), compressed...)
|
|
decryptPString(key, encrypted)
|
|
header := make([]byte, 8)
|
|
binary.LittleEndian.PutUint32(header[0:4], uint32(len(encrypted))^xorConst)
|
|
binary.LittleEndian.PutUint32(header[4:8], uint32(len(raw))^uint32(len(encrypted)))
|
|
out := append(header, encrypted...)
|
|
return out, nil
|
|
}
|
|
|
|
type archiveLayout struct {
|
|
hashOffset int
|
|
hashSize int
|
|
nameOffset int
|
|
nameSize int
|
|
bodyOffset int
|
|
bodySize int
|
|
}
|
|
|
|
// RepackRaw 只替换已有文件内容,不新增路径、不改字符串池和哈希表。
|
|
// 该能力用于离线修补 PVF 后重新封包,运行时查询仍通过启动期内存归档完成。
|
|
func (a *Archive) RepackRaw(replacements []RawReplacement) ([]byte, error) {
|
|
if a == nil {
|
|
return nil, fmt.Errorf("%w: archive is nil", ErrInvalidArchive)
|
|
}
|
|
if len(replacements) == 0 {
|
|
return nil, fmt.Errorf("%w: replacement is empty", ErrInvalidArchive)
|
|
}
|
|
if !a.CanReadFileData() {
|
|
return nil, fmt.Errorf("%w: format %q cannot repack", ErrInvalidArchive, a.Format())
|
|
}
|
|
layout, err := a.layout()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
replacedByIndex := make(map[int][]byte, len(replacements))
|
|
touchedChunks := make(map[int]bool, len(replacements))
|
|
for _, replacement := range replacements {
|
|
idx := a.FindFileIndex(replacement.Path)
|
|
if idx < 0 {
|
|
return nil, fmt.Errorf("%w: %s", ErrFileNotFound, replacement.Path)
|
|
}
|
|
item := a.items[idx]
|
|
if item.chunkIndex < 0 || item.chunkIndex >= len(a.groups) {
|
|
return nil, fmt.Errorf("%w: file %d chunk %d is invalid", ErrInvalidArchive, idx, item.chunkIndex)
|
|
}
|
|
replacedByIndex[idx] = append([]byte(nil), replacement.Data...)
|
|
touchedChunks[item.chunkIndex] = true
|
|
}
|
|
|
|
newItems := append([]fileItem(nil), a.items...)
|
|
chunkFiles, err := a.filesByChunk()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
body, newGroups, err := a.repackBody(layout, chunkFiles, touchedChunks, replacedByIndex, newItems)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) > math.MaxInt32 {
|
|
return nil, fmt.Errorf("%w: body too large: %d", ErrInvalidArchive, len(body))
|
|
}
|
|
|
|
header := a.header
|
|
header.bodySize = len(body)
|
|
header.groupCount = len(newGroups)
|
|
out := a.encodeHeader(header)
|
|
out = append(out, encodeFileTable(newItems)...)
|
|
out = append(out, a.data[layout.hashOffset:layout.hashOffset+layout.hashSize]...)
|
|
out = append(out, a.data[layout.nameOffset:layout.nameOffset+layout.nameSize]...)
|
|
out = append(out, a.encodeGroups(newGroups)...)
|
|
out = append(out, body...)
|
|
return out, nil
|
|
}
|
|
|
|
func (a *Archive) filesByChunk() (map[int][]int, error) {
|
|
chunkFiles := make(map[int][]int, len(a.groups))
|
|
for idx, item := range a.items {
|
|
if item.chunkIndex < 0 || item.chunkIndex >= len(a.groups) {
|
|
return nil, fmt.Errorf("%w: file %d chunk %d is invalid", ErrInvalidArchive, idx, item.chunkIndex)
|
|
}
|
|
chunkFiles[item.chunkIndex] = append(chunkFiles[item.chunkIndex], idx)
|
|
}
|
|
for chunkIndex := range chunkFiles {
|
|
sort.SliceStable(chunkFiles[chunkIndex], func(left, right int) bool {
|
|
leftItem := a.items[chunkFiles[chunkIndex][left]]
|
|
rightItem := a.items[chunkFiles[chunkIndex][right]]
|
|
if leftItem.dataOffset == rightItem.dataOffset {
|
|
return chunkFiles[chunkIndex][left] < chunkFiles[chunkIndex][right]
|
|
}
|
|
return leftItem.dataOffset < rightItem.dataOffset
|
|
})
|
|
}
|
|
return chunkFiles, nil
|
|
}
|
|
|
|
func (a *Archive) repackBody(layout archiveLayout, chunkFiles map[int][]int, touched map[int]bool, replacements map[int][]byte, items []fileItem) ([]byte, []groupItem, error) {
|
|
body := make([]byte, 0, layout.bodySize)
|
|
groups := make([]groupItem, len(a.groups))
|
|
cumulative := 0
|
|
for chunkIndex := range a.groups {
|
|
var encrypted []byte
|
|
originalSize := a.groups[chunkIndex].originalSize
|
|
var err error
|
|
if touched[chunkIndex] {
|
|
chunk, err := a.rebuildChunk(chunkIndex, chunkFiles[chunkIndex], replacements, items)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
originalSize = len(chunk)
|
|
encrypted, err = a.encryptChunk(chunk)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
} else {
|
|
encrypted, err = a.rawChunk(layout, chunkIndex)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
cumulative += len(encrypted)
|
|
if cumulative > math.MaxInt32 || originalSize > math.MaxInt32 {
|
|
return nil, nil, fmt.Errorf("%w: rebuilt chunk %d is too large", ErrInvalidArchive, chunkIndex)
|
|
}
|
|
groups[chunkIndex] = groupItem{
|
|
compressedSize: cumulative,
|
|
originalSize: originalSize,
|
|
}
|
|
body = append(body, encrypted...)
|
|
}
|
|
return body, groups, nil
|
|
}
|
|
|
|
func (a *Archive) rebuildChunk(chunkIndex int, fileIndexes []int, replacements map[int][]byte, items []fileItem) ([]byte, error) {
|
|
originalChunk, err := a.chunk(chunkIndex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]byte, 0, len(originalChunk))
|
|
prevEnd := 0
|
|
for _, fileIndex := range fileIndexes {
|
|
item := a.items[fileIndex]
|
|
if item.dataOffset < 0 || item.dataSize < 0 || item.dataOffset+item.dataSize > len(originalChunk) {
|
|
return nil, fmt.Errorf("%w: file %d range is invalid", ErrInvalidArchive, fileIndex)
|
|
}
|
|
if item.dataOffset > prevEnd {
|
|
out = append(out, originalChunk[prevEnd:item.dataOffset]...)
|
|
}
|
|
raw, ok := replacements[fileIndex]
|
|
if !ok {
|
|
raw, err = a.readRawIndex(fileIndex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if len(out) > math.MaxInt32 || len(raw) > math.MaxInt32 {
|
|
return nil, fmt.Errorf("%w: replacement %d is too large", ErrInvalidArchive, fileIndex)
|
|
}
|
|
items[fileIndex].dataOffset = len(out)
|
|
items[fileIndex].dataSize = len(raw)
|
|
out = append(out, raw...)
|
|
if end := item.dataOffset + item.dataSize; end > prevEnd {
|
|
prevEnd = end
|
|
}
|
|
}
|
|
if prevEnd < len(originalChunk) {
|
|
out = append(out, originalChunk[prevEnd:]...)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *Archive) encryptChunk(raw []byte) ([]byte, error) {
|
|
encrypted, err := compressZlib(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if a.format == FormatProtectedNKPI {
|
|
decryptProtected("bODy", encrypted)
|
|
} else {
|
|
decrypt("BodY", encrypted)
|
|
}
|
|
return encrypted, nil
|
|
}
|
|
|
|
func (a *Archive) rawChunk(layout archiveLayout, chunkIndex int) ([]byte, error) {
|
|
prev := 0
|
|
if chunkIndex > 0 {
|
|
prev = a.groups[chunkIndex-1].compressedSize
|
|
}
|
|
curr := a.groups[chunkIndex].compressedSize
|
|
if curr < prev {
|
|
return nil, fmt.Errorf("%w: chunk %d compressed size is invalid", ErrInvalidArchive, chunkIndex)
|
|
}
|
|
start := layout.bodyOffset + prev
|
|
size := curr - prev
|
|
if start < 0 || size < 0 || start+size > layout.bodyOffset+layout.bodySize {
|
|
return nil, fmt.Errorf("%w: chunk %d exceeds body", ErrInvalidArchive, chunkIndex)
|
|
}
|
|
return append([]byte(nil), a.data[start:start+size]...), nil
|
|
}
|
|
|
|
func (a *Archive) layout() (archiveLayout, error) {
|
|
tableSize, err := checkedMul(a.header.fileCount, fileItemSize)
|
|
if err != nil {
|
|
return archiveLayout{}, err
|
|
}
|
|
hashOffset := headerSize + tableSize
|
|
nameOffset := hashOffset + a.header.hashSize
|
|
groupSize, err := checkedMul(a.header.groupCount, groupItemSize)
|
|
if err != nil {
|
|
return archiveLayout{}, err
|
|
}
|
|
bodyOffset := nameOffset + a.header.nameSize + groupSize
|
|
if hashOffset < 0 || nameOffset < 0 || bodyOffset < 0 || bodyOffset+a.header.bodySize > len(a.data) {
|
|
return archiveLayout{}, fmt.Errorf("%w: archive layout exceeds data", ErrInvalidArchive)
|
|
}
|
|
return archiveLayout{
|
|
hashOffset: hashOffset,
|
|
hashSize: a.header.hashSize,
|
|
nameOffset: nameOffset,
|
|
nameSize: a.header.nameSize,
|
|
bodyOffset: bodyOffset,
|
|
bodySize: a.header.bodySize,
|
|
}, nil
|
|
}
|
|
|
|
func (a *Archive) encodeHeader(header pvfHeader) []byte {
|
|
out := append([]byte(nil), header.plain[:]...)
|
|
binary.LittleEndian.PutUint32(out[24:28], uint32(header.fileCount))
|
|
binary.LittleEndian.PutUint32(out[32:36], uint32(header.bodySize))
|
|
binary.LittleEndian.PutUint32(out[36:40], uint32(header.groupCount))
|
|
binary.LittleEndian.PutUint32(out[40:44], uint32(header.hashSize))
|
|
binary.LittleEndian.PutUint32(out[44:48], uint32(header.nameSize))
|
|
if a.format == FormatProtectedNKPI {
|
|
decryptProtected("hEAd", out)
|
|
return out
|
|
}
|
|
decrypt("HeaD", out)
|
|
decryptGuard(out)
|
|
return out
|
|
}
|
|
|
|
func encodeFileTable(items []fileItem) []byte {
|
|
out := make([]byte, len(items)*fileItemSize)
|
|
for idx, item := range items {
|
|
off := idx * fileItemSize
|
|
binary.LittleEndian.PutUint32(out[off:off+4], uint32(item.nameOffset))
|
|
binary.LittleEndian.PutUint32(out[off+4:off+8], uint32(item.pathOffset))
|
|
binary.LittleEndian.PutUint32(out[off+8:off+12], uint32(item.chunkIndex))
|
|
binary.LittleEndian.PutUint32(out[off+12:off+16], uint32(item.dataOffset))
|
|
binary.LittleEndian.PutUint32(out[off+16:off+20], uint32(item.dataSize))
|
|
binary.LittleEndian.PutUint32(out[off+20:off+24], uint32(item.dataType))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *Archive) encodeGroups(groups []groupItem) []byte {
|
|
out := make([]byte, len(groups)*groupItemSize)
|
|
for idx, group := range groups {
|
|
off := idx * groupItemSize
|
|
binary.LittleEndian.PutUint32(out[off:off+4], uint32(group.compressedSize))
|
|
binary.LittleEndian.PutUint32(out[off+4:off+8], uint32(group.originalSize))
|
|
}
|
|
if a.format == FormatProtectedNKPI {
|
|
decryptProtected("grpi", out)
|
|
return out
|
|
}
|
|
decrypt("GRPI", out)
|
|
return out
|
|
}
|