Files
90CNServer/server/internal/platform/pvf/repack.go
T
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

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
}