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

+387
View File
@@ -0,0 +1,387 @@
package pvf
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
)
const (
headerSize = 0x30
fileItemSize = 0x18
groupItemSize = 8
magicSignature = 0x69706B6E
)
type ArchiveFormat string
const (
// FormatNKPI 表示旧版 DNF PVF `nkpi` 归档格式。
FormatNKPI ArchiveFormat = "nkpi"
// FormatProtectedNKPI 表示 23.4.15.0 之后使用 UTF-16 seed 密钥流的 `nkpi` 归档格式。
FormatProtectedNKPI ArchiveFormat = "protected_nkpi"
)
var (
ErrInvalidArchive = errors.New("pvf archive is invalid")
ErrFileNotFound = errors.New("pvf file not found")
)
type ArchiveSnapshot struct {
Format ArchiveFormat `json:"format"`
Path string `json:"path"`
Size int64 `json:"size"`
Checksum string `json:"checksum"`
LoadedAt string `json:"loaded_at"`
FileCount int `json:"file_count"`
GroupCount int `json:"group_count"`
CachedChunks int `json:"cached_chunks"`
CachedChunkBytes int64 `json:"cached_chunk_bytes"`
CachedTexts int `json:"cached_texts"`
CachedTextBytes int64 `json:"cached_text_bytes"`
CacheHits uint64 `json:"cache_hits"`
CacheMisses uint64 `json:"cache_misses"`
CacheEvictions uint64 `json:"cache_evictions"`
ChunkCacheLimit int64 `json:"chunk_cache_limit"`
TextCacheLimit int64 `json:"text_cache_limit"`
}
type PreloadResult struct {
Groups int `json:"groups"`
Cached int `json:"cached"`
}
type File struct {
Index int `json:"index"`
Path string `json:"path"`
Name string `json:"name"`
ArchivePath string `json:"archive_path"`
DataType int `json:"data_type"`
Size int `json:"size"`
}
type Archive struct {
snapshot Snapshot
format ArchiveFormat
header pvfHeader
// data 是启动期读入的完整 PVF 字节,后续查询不再访问磁盘。
data []byte
items []fileItem
groups []groupItem
// pathIdx 保存归一化路径到文件表下标的映射,查询时避免扫描目录。
pathIdx map[string]int
// tableOff 是文件表在归档里的绝对偏移(parseNKPITables 里算出),
// 供定点改写表项字段(如 dataType)使用。
tableOff int
bodyOff int
strA []byte
strW []byte
// ansiOffsets 缓存 strA 里已有字符串的魔法偏移,供 EnsureANSIString 复用。
// 追加只动池子尾部,已有偏移不变,所以文件表与 body 无需重算。
ansiOffsets map[string]int
// chunks 缓存已解密解压的 body chunk,texts 缓存已解码的脚本文本。
chunks *byteLRU
texts *byteLRU
chunkFlights indexFlight
textFlights indexFlight
}
func Open(path string) (*Archive, error) {
return LoadArchive(Options{Path: path})
}
func OpenBytes(data []byte) (*Archive, error) {
if len(data) == 0 {
return nil, fmt.Errorf("%w: empty pvf data", ErrInvalidArchive)
}
copied := append([]byte(nil), data...)
sum := sha256.Sum256(copied)
return OpenArchive(&Bundle{
snapshot: Snapshot{
Size: int64(len(copied)),
Checksum: hex.EncodeToString(sum[:]),
LoadedAt: time.Now().UTC(),
},
data: copied,
})
}
func LoadArchive(options Options) (*Archive, error) {
bundle, err := Load(options)
if err != nil {
return nil, err
}
archive, err := OpenArchive(bundle)
if err != nil {
return nil, err
}
return archive, nil
}
func OpenArchive(bundle *Bundle) (*Archive, error) {
if bundle == nil || len(bundle.data) == 0 {
return nil, fmt.Errorf("%w: empty pvf data", ErrInvalidArchive)
}
chunkCacheBytes := bundle.chunkCacheBytes
if chunkCacheBytes <= 0 {
chunkCacheBytes = DefaultChunkCacheBytes
}
textCacheBytes := bundle.textCacheBytes
if textCacheBytes <= 0 {
textCacheBytes = DefaultTextCacheBytes
}
archive := &Archive{
snapshot: bundle.snapshot,
data: bundle.data,
pathIdx: make(map[string]int),
chunks: newByteLRU(chunkCacheBytes),
texts: newByteLRU(textCacheBytes),
}
if err := archive.parse(); err != nil {
return nil, err
}
return archive, nil
}
func (a *Archive) Snapshot() ArchiveSnapshot {
if a == nil {
return ArchiveSnapshot{}
}
chunkSnapshot := a.chunks.snapshot()
textSnapshot := a.texts.snapshot()
return ArchiveSnapshot{
Format: a.format,
Path: a.snapshot.Path,
Size: a.snapshot.Size,
Checksum: a.snapshot.Checksum,
LoadedAt: a.snapshot.LoadedAt.Format(rfc3339Nano),
FileCount: len(a.items),
GroupCount: len(a.groups),
CachedChunks: chunkSnapshot.entries,
CachedChunkBytes: chunkSnapshot.bytes,
CachedTexts: textSnapshot.entries,
CachedTextBytes: textSnapshot.bytes,
CacheHits: chunkSnapshot.hits + textSnapshot.hits,
CacheMisses: chunkSnapshot.misses + textSnapshot.misses,
CacheEvictions: chunkSnapshot.evictions + textSnapshot.evictions,
ChunkCacheLimit: a.chunks.limit(),
TextCacheLimit: a.texts.limit(),
}
}
// Format 返回当前 PVF 归档格式,供 smoke 报告和兼容排查使用。
func (a *Archive) Format() ArchiveFormat {
if a == nil {
return ""
}
return a.format
}
func (a *Archive) Files() []File {
if a == nil || len(a.items) == 0 {
return nil
}
out := make([]File, len(a.items))
for idx := range a.items {
out[idx] = a.fileAt(idx)
}
return out
}
// RangePaths visits the archive's normalized lookup paths without allocating a
// second million-entry file table. The archive is immutable after loading, so
// callers may safely use this method concurrently with reads.
func (a *Archive) RangePaths(yield func(string) bool) {
if a == nil || yield == nil {
return
}
for archivePath := range a.pathIdx {
if !yield(archivePath) {
return
}
}
}
func (a *Archive) FileCount() int {
if a == nil {
return 0
}
return len(a.items)
}
func (a *Archive) CanReadFileData() bool {
if a == nil {
return false
}
return a.format == FormatNKPI || a.format == FormatProtectedNKPI
}
func (a *Archive) FindFile(relativePath string) (File, bool) {
if a == nil {
return File{}, false
}
idx, ok := a.pathIdx[pathKey(relativePath)]
if !ok {
return File{}, false
}
return a.fileAt(idx), true
}
func (a *Archive) fileAt(idx int) File {
if idx < 0 || idx >= len(a.items) {
return File{}
}
item := a.items[idx]
name := a.resolveString(item.nameOffset)
dir := a.resolveString(item.pathOffset)
return File{
Index: idx,
Path: dir,
Name: name,
ArchivePath: joinArchivePath(dir, name),
DataType: item.dataType,
Size: item.dataSize,
}
}
func (a *Archive) FindFileIndex(relativePath string) int {
if a == nil {
return -1
}
idx, ok := a.pathIdx[pathKey(relativePath)]
if !ok {
return -1
}
return idx
}
func (a *Archive) ReadText(relativePath string) (string, error) {
if a == nil {
return "", fmt.Errorf("%w: archive is nil", ErrInvalidArchive)
}
idx, ok := a.pathIdx[pathKey(relativePath)]
if !ok {
return "", fmt.Errorf("%w: %s", ErrFileNotFound, relativePath)
}
return a.readTextIndex(idx)
}
func (a *Archive) ReadRaw(relativePath string) ([]byte, error) {
if a == nil {
return nil, fmt.Errorf("%w: archive is nil", ErrInvalidArchive)
}
idx, ok := a.pathIdx[pathKey(relativePath)]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrFileNotFound, relativePath)
}
return a.readRawIndex(idx)
}
// ResolveString exposes one script token's string-pool value. PVF type-1
// scripts store labels and quoted values as encoded offsets into the archive's
// shared ANSI/UTF-16 pools; callers must keep the encoded offset unchanged
// unless they rebuild every referring script token.
func (a *Archive) ResolveString(magicOffset int) string {
if a == nil {
return ""
}
return a.resolveString(magicOffset)
}
// FindStringMagicOffsets returns one current-archive token offset for every
// requested shared-string value that is already present. It is intended for
// safe type-1 script transplantation: raw token offsets from another PVF are
// archive-local and must be resolved by value before reuse.
func (a *Archive) FindStringMagicOffsets(values []string) map[string]int {
result := make(map[string]int)
if a == nil || len(values) == 0 {
return result
}
wanted := make(map[string]struct{}, len(values))
for _, value := range values {
if value != "" {
wanted[value] = struct{}{}
}
}
for start := 0; start < len(a.strA) && len(result) < len(wanted); {
end := bytes.IndexByte(a.strA[start:], 0)
if end < 0 {
break
}
if end > 0 {
value := readUTF8String(a.strA, start)
if _, requested := wanted[value]; requested {
if _, found := result[value]; !found {
result[value] = start << 1
}
}
}
start += end + 1
}
for start := 0; start+1 < len(a.strW) && len(result) < len(wanted); {
end := start
for end+1 < len(a.strW) && (a.strW[end] != 0 || a.strW[end+1] != 0) {
end += 2
}
if end > start {
value := readUTF16String(a.strW, start)
if _, requested := wanted[value]; requested {
if _, found := result[value]; !found {
result[value] = ((start / 2) << 1) | 1
}
}
}
start = end + 2
}
return result
}
func (a *Archive) FileText(idx int) (string, error) {
if a == nil {
return "", fmt.Errorf("%w: archive is nil", ErrInvalidArchive)
}
return a.readTextIndex(idx)
}
func (a *Archive) FileRaw(idx int) ([]byte, error) {
if a == nil {
return nil, fmt.Errorf("%w: archive is nil", ErrInvalidArchive)
}
return a.readRawIndex(idx)
}
func (a *Archive) PreloadAll() (PreloadResult, error) {
if a == nil {
return PreloadResult{}, fmt.Errorf("%w: archive is nil", ErrInvalidArchive)
}
result := PreloadResult{Groups: len(a.groups)}
for idx := range a.groups {
if _, err := a.chunk(idx); err != nil {
return result, err
}
result.Cached++
}
return result, nil
}
func (a *Archive) Bytes() []byte {
if a == nil {
return nil
}
out := make([]byte, len(a.data))
copy(out, a.data)
return out
}
func (a *Archive) Reader() *bytes.Reader {
if a == nil {
return bytes.NewReader(nil)
}
return bytes.NewReader(a.data)
}
@@ -0,0 +1,41 @@
package pvf
import (
"os"
"runtime"
"testing"
)
func TestRealArchiveRetainedMemory(t *testing.T) {
archivePath := os.Getenv("DNF90_REAL_PVF_MEMORY")
if archivePath == "" {
t.Skip("set DNF90_REAL_PVF_MEMORY to run the real archive memory regression probe")
}
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
archive, err := LoadArchive(Options{
Path: archivePath,
ChunkCacheBytes: 1,
TextCacheBytes: 1,
})
if err != nil {
t.Fatalf("load real archive: %v", err)
}
runtime.GC()
var after runtime.MemStats
runtime.ReadMemStats(&after)
runtime.KeepAlive(archive)
retainedHeap := after.HeapAlloc - before.HeapAlloc
retainedObjects := after.HeapObjects - before.HeapObjects
t.Logf("files=%d retained_heap_bytes=%d retained_heap_objects=%d", archive.FileCount(), retainedHeap, retainedObjects)
// The current compatibility archive has 1,057,625 files. Keeping a second
// materialized File table pushes its retained heap above 500 MiB; the compact
// metadata representation must remain below 400 MiB for this exact fixture.
if archive.FileCount() == 1_057_625 && retainedHeap > 400*1024*1024 {
t.Fatalf("real archive retained heap=%d exceeds compact index budget", retainedHeap)
}
}
+113
View File
@@ -0,0 +1,113 @@
package pvf
import (
"container/list"
"sync"
"sync/atomic"
)
const (
DefaultChunkCacheBytes int64 = 96 * 1024 * 1024
DefaultTextCacheBytes int64 = 64 * 1024 * 1024
)
type byteLRUEntry struct {
key int
value any
bytes int64
}
type byteLRUSnapshot struct {
entries int
bytes int64
hits uint64
misses uint64
evictions uint64
}
// byteLRU bounds decoded PVF data by resident bytes. Cached values are never
// mutated by callers: raw reads return copies and decoded text is immutable.
type byteLRU struct {
mu sync.Mutex
limitB int64
bytesB int64
entries map[int]*list.Element
order *list.List
hits atomic.Uint64
misses atomic.Uint64
evictions atomic.Uint64
}
func newByteLRU(limit int64) *byteLRU {
if limit <= 0 {
limit = 1
}
return &byteLRU{limitB: limit, entries: make(map[int]*list.Element), order: list.New()}
}
func (c *byteLRU) get(key int) (any, bool) {
if c == nil {
return nil, false
}
c.mu.Lock()
defer c.mu.Unlock()
element, ok := c.entries[key]
if !ok {
c.misses.Add(1)
return nil, false
}
c.order.MoveToFront(element)
c.hits.Add(1)
return element.Value.(byteLRUEntry).value, true
}
func (c *byteLRU) add(key int, value any, size int64) {
if c == nil || value == nil || size <= 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if existing, ok := c.entries[key]; ok {
entry := existing.Value.(byteLRUEntry)
c.bytesB -= entry.bytes
entry.value = value
entry.bytes = size
existing.Value = entry
c.bytesB += size
c.order.MoveToFront(existing)
} else {
element := c.order.PushFront(byteLRUEntry{key: key, value: value, bytes: size})
c.entries[key] = element
c.bytesB += size
}
for c.bytesB > c.limitB && c.order.Len() > 0 {
oldest := c.order.Back()
entry := oldest.Value.(byteLRUEntry)
delete(c.entries, entry.key)
c.order.Remove(oldest)
c.bytesB -= entry.bytes
c.evictions.Add(1)
}
}
func (c *byteLRU) snapshot() byteLRUSnapshot {
if c == nil {
return byteLRUSnapshot{}
}
c.mu.Lock()
entries, bytes := c.order.Len(), c.bytesB
c.mu.Unlock()
return byteLRUSnapshot{
entries: entries, bytes: bytes,
hits: c.hits.Load(), misses: c.misses.Load(), evictions: c.evictions.Load(),
}
}
func (c *byteLRU) limit() int64 {
if c == nil {
return 0
}
c.mu.Lock()
defer c.mu.Unlock()
return c.limitB
}
+111
View File
@@ -0,0 +1,111 @@
package pvf
import (
"sync"
"testing"
)
func TestByteLRUEvictsByResidentBytes(t *testing.T) {
cache := newByteLRU(5)
cache.add(1, "1234", 4)
cache.add(2, "56", 2)
if _, ok := cache.get(1); ok {
t.Fatal("oldest entry was not evicted")
}
value, ok := cache.get(2)
if !ok || value.(string) != "56" {
t.Fatalf("newest entry=%v ok=%v", value, ok)
}
snapshot := cache.snapshot()
if snapshot.entries != 1 || snapshot.bytes != 2 || snapshot.evictions != 1 {
t.Fatalf("snapshot=%+v", snapshot)
}
}
func TestByteLRUReplacesEntryWithoutExceedingLimit(t *testing.T) {
cache := newByteLRU(8)
cache.add(1, []byte("1234"), 4)
cache.add(1, []byte("12345678"), 8)
value, ok := cache.get(1)
if !ok || string(value.([]byte)) != "12345678" {
t.Fatalf("value=%v ok=%v", value, ok)
}
if got := cache.snapshot(); got.entries != 1 || got.bytes != 8 {
t.Fatalf("snapshot=%+v", got)
}
}
func TestByteLRUConcurrentAccess(t *testing.T) {
cache := newByteLRU(1024)
var wg sync.WaitGroup
for worker := 0; worker < 8; worker++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
for index := 0; index < 500; index++ {
key := worker*500 + index
cache.add(key, "value", 5)
cache.get(key)
}
}(worker)
}
wg.Wait()
if got := cache.snapshot(); got.bytes > 1024 {
t.Fatalf("resident bytes=%d exceed limit", got.bytes)
}
}
func TestArchiveReadRemainsStableAfterCacheEviction(t *testing.T) {
archive, err := OpenArchive(&Bundle{
data: buildSyntheticNKPIArchive(t),
textCacheBytes: 1,
chunkCacheBytes: 1,
})
if err != nil {
t.Fatalf("open archive: %v", err)
}
first, err := archive.ReadText("dir/test.ani")
if err != nil {
t.Fatalf("first read: %v", err)
}
second, err := archive.ReadText("dir/test.ani")
if err != nil {
t.Fatalf("second read: %v", err)
}
if first != second {
t.Fatalf("read changed after eviction: first=%q second=%q", first, second)
}
snapshot := archive.Snapshot()
if snapshot.CachedChunkBytes > 1 || snapshot.CachedTextBytes > 1 {
t.Fatalf("cache limits exceeded: %+v", snapshot)
}
}
func TestArchiveMaterializesFileMetadataOnDemand(t *testing.T) {
archive, err := OpenBytes(buildSyntheticNKPIArchive(t))
if err != nil {
t.Fatalf("open archive: %v", err)
}
if got := archive.FileCount(); got != 1 {
t.Fatalf("file count=%d want=1", got)
}
file, ok := archive.FindFile("DIR\\TEST.ANI")
if !ok {
t.Fatal("find normalized archive path")
}
if file.Index != 0 || file.Path != "dir" || file.Name != "test.ani" || file.ArchivePath != "dir/test.ani" {
t.Fatalf("file=%+v", file)
}
files := archive.Files()
if len(files) != 1 || files[0] != file {
t.Fatalf("files=%+v want=%+v", files, file)
}
var paths []string
archive.RangePaths(func(archivePath string) bool {
paths = append(paths, archivePath)
return true
})
if len(paths) != 1 || paths[0] != "dir/test.ani" {
t.Fatalf("paths=%v", paths)
}
}
+113
View File
@@ -0,0 +1,113 @@
package pvf
import (
"encoding/binary"
"unicode/utf16"
)
func decryptGuard(buf []byte) {
if len(buf) < 28 {
return
}
for idx := 24; idx < 28; idx++ {
buf[idx] ^= 0x55
}
}
func decrypt(key string, buf []byte) {
decryptCore(key, buf, 0x269EC3)
}
func decrypt2(key string, buf []byte) {
decryptCore(key, buf, 0x269EC9)
}
func decryptProtected(key string, buf []byte) {
decryptProtectedCore(key, buf, 0x269EC3)
}
func decryptPString(key string, buf []byte) {
decryptProtectedCore(key, buf, 0x269EC9)
}
func decryptCore(key string, buf []byte, magic uint32) {
if key == "" || len(buf) == 0 {
return
}
k := []byte(key)
if len(k) < 4 {
return
}
size := len(buf)
tail := size
seed := uint32(0x76826701)*uint32(k[0]) +
0x1C1*(uint32(k[3])+0x1C1*(uint32(k[2])+0x1C1*uint32(k[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 := ((seed >> 16) & 0xFFFF) + (t1 & 0xFFFF0000)
off := idx << 2
value := binary.LittleEndian.Uint32(buf[off:off+4]) ^ xorKey
binary.LittleEndian.PutUint32(buf[off:off+4], value)
}
}
if tail > 0 {
t1 := 0x343FD*seed + magic
t2 := 0x343FD*t1 + magic
finalKey := (t1 & 0xFFFF0000) + ((t2 >> 16) & 0xFFFF)
var keyBytes [4]byte
binary.LittleEndian.PutUint32(keyBytes[:], finalKey)
start := size - tail
for idx := 0; idx < tail; idx++ {
buf[start+idx] ^= keyBytes[idx]
}
}
}
// decryptProtectedCore 复刻新版 DNF PVF 的 UTF-16 seed 密钥流。
// 它只用于 protected_nkpi 归档的 header、HASH、GRPI、字符串池和 body chunk 解密。
func decryptProtectedCore(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
value := binary.LittleEndian.Uint32(buf[off:off+4]) ^ xorKey
binary.LittleEndian.PutUint32(buf[off:off+4], value)
}
}
if tail > 0 {
t1 := 0x343FD*seed + magic
t2 := 0x343FD*t1 + magic
finalKey := (t1 & 0xFFFF0000) + (t2 >> 16)
var keyBytes [4]byte
binary.LittleEndian.PutUint32(keyBytes[:], finalKey)
start := size - tail
for idx := 0; idx < tail; idx++ {
buf[start+idx] ^= keyBytes[idx]
}
}
}
+138
View File
@@ -0,0 +1,138 @@
package pvf
import (
"bytes"
"compress/zlib"
"encoding/binary"
"fmt"
"io"
"math"
"strings"
"unicode/utf16"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
)
// DecodeScriptBytes 用本归档的字符串池解码一段 type-1 令牌流。
// 离线脚本改写工具用它校验「编码后的令牌流能否读回同一份脚本」。
func (a *Archive) DecodeScriptBytes(raw []byte) string {
if a == nil {
return ""
}
return a.decodeScript(raw)
}
func (a *Archive) decodeScript(raw []byte) string {
lineCount := len(raw) / 5
if lineCount == 0 {
return ""
}
var b strings.Builder
b.Grow(len(raw) * 2)
for idx := 0; idx < lineCount; idx++ {
off := idx * 5
tokenType := raw[off]
value := readInt32(raw[off+1 : off+5])
switch tokenType {
case 0:
fmt.Fprintf(&b, "%d ", value)
case 2:
fmt.Fprintf(&b, "%.2f ", math.Float32frombits(uint32(value)))
case 3:
b.WriteByte('\n')
b.WriteString(a.resolveString(value))
b.WriteByte('\n')
case 5:
b.WriteByte('\n')
b.WriteString("{5=``}")
case 6:
b.WriteByte('`')
b.WriteString(a.resolveString(value))
b.WriteString("` ")
case 7:
b.WriteByte('\n')
b.WriteString("{7=``}")
}
}
return b.String()
}
func (a *Archive) resolveString(magicOffset int) string {
if magicOffset < 0 {
return ""
}
if magicOffset&1 != 0 {
return readUTF16String(a.strW, (magicOffset>>1)*2)
}
return readUTF8String(a.strA, magicOffset>>1)
}
func readUTF8String(buf []byte, start int) string {
if start < 0 || start >= len(buf) {
return ""
}
end := bytes.IndexByte(buf[start:], 0)
if end < 0 {
return ""
}
rawBytes := buf[start : start+end]
if utf8.Valid(rawBytes) {
return string(rawBytes)
}
raw := string(rawBytes)
if decoded, err := simplifiedchinese.GB18030.NewDecoder().String(raw); err == nil {
return decoded
}
return raw
}
func readUTF16String(buf []byte, start int) string {
if start < 0 || start >= len(buf) {
return ""
}
end := start
for end+1 < len(buf) {
if buf[end] == 0 && buf[end+1] == 0 {
break
}
end += 2
}
if end <= start {
return ""
}
return decodeUTF16LE(buf[start:end])
}
func decodeUTF16LE(data []byte) string {
if len(data) < 2 {
return ""
}
units := make([]uint16, 0, len(data)/2)
for idx := 0; idx+1 < len(data); idx += 2 {
units = append(units, binary.LittleEndian.Uint16(data[idx:idx+2]))
}
return string(utf16.Decode(units))
}
func zlibBytes(data []byte) ([]byte, error) {
reader, err := zlib.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer reader.Close()
return io.ReadAll(reader)
}
func compressZlib(data []byte) ([]byte, error) {
var buf bytes.Buffer
writer := zlib.NewWriter(&buf)
if _, err := writer.Write(data); err != nil {
_ = writer.Close()
return nil, err
}
if err := writer.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
@@ -0,0 +1,171 @@
package pvf
import (
"bytes"
"encoding/binary"
"fmt"
"math"
"strings"
"unicode/utf16"
)
// 本文件补齐 companionpvf 需要的两个字符串池追加能力。
//
// 背景:支援兵的 AIC 覆盖要改写 `aicharacter/**.aic` 里的技能/按键引用,而
// 这些引用是字符串池下标。如果目标字符串在原始 Script.pvf 的池子里不存在
// (例如新的 key 路径),就必须**追加**到池子尾部,并返回新的魔法偏移。
// 追加只影响池子尾部,已有条目的偏移不变,因此文件表与 body 全部保持原样。
//
// 两个方法的语义与 Server-90 完全一致:
// - EnsureANSIString :往 strA 追加 NUL 结尾的 ANSI 串,返回 start<<1(偶数=ANSI)
// - EnsureUnicodeString:往 strW 追加 UTF-16LE 串,返回 start|1(奇数=UTF-16)
// 返回值是客户端/读取器共用的"魔法偏移"编码,奇偶性就是宽度选择器。
// ensureANSIStringIndex 缓存 ANSI 池里"已有字符串 -> 偏移"的映射,
// 避免每次追加都线性扫描整个池子。
func (a *Archive) ensureANSIStringOffsets() map[string]int {
if a.ansiOffsets != nil {
return a.ansiOffsets
}
a.ansiOffsets = make(map[string]int)
for start := 0; start < len(a.strA); {
endRel := bytes.IndexByte(a.strA[start:], 0)
if endRel < 0 {
break
}
end := start + endRel
stringValue := string(a.strA[start:end])
if _, exists := a.ansiOffsets[stringValue]; !exists {
a.ansiOffsets[stringValue] = start << 1
}
start = end + 1
}
return a.ansiOffsets
}
// EnsureANSIString 返回 value 在 ANSI 字符串池中的魔法偏移,必要时追加。
func (a *Archive) EnsureANSIString(value string) (int, error) {
if a == nil {
return 0, fmt.Errorf("%w: archive is nil", ErrInvalidArchive)
}
if a.format != FormatProtectedNKPI {
return 0, fmt.Errorf("%w: ANSI append requires %q, got %q", ErrInvalidArchive, FormatProtectedNKPI, a.format)
}
if strings.ContainsRune(value, 0) {
return 0, fmt.Errorf("%w: ANSI string must not contain NUL", ErrInvalidArchive)
}
offsets := a.ensureANSIStringOffsets()
if offset, ok := offsets[value]; ok {
return offset, nil
}
raw := []byte(value)
if len(a.strA) > math.MaxInt32-len(raw)-1 {
return 0, fmt.Errorf("%w: ANSI string pool too large", ErrInvalidArchive)
}
start := len(a.strA)
a.strA = append(a.strA, raw...)
a.strA = append(a.strA, 0)
offset := start << 1
offsets[value] = offset
return offset, nil
}
// RepackRawAndStringPools 同时重建"文件体"和"字符串池"两段,供离线补丁使用。
//
// 与 RepackRaw 的唯一区别在收尾:RepackRaw 直接复制原来的 name 段,而本方法
// 用当前的 a.strA/a.strW 重新编码 name 段。这一点是必需的 —— EnsureANSIString /
// EnsureUnicodeString 只是把新字符串追加到内存池尾部并返回新偏移,如果不把
// 池子写回归档,补丁后的引用就会指向不存在的数据(读出来是空串)。
//
// 只允许 protected NKPI:普通 NKPI 的 name 段布局不同,写回会破坏归档。
func (a *Archive) RepackRawAndStringPools(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.format != FormatProtectedNKPI {
return nil, fmt.Errorf("%w: raw+strings repack requires %q, got %q", ErrInvalidArchive, FormatProtectedNKPI, 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))
}
nameSection, err := a.encodeProtectedNameSection(layout, a.strA, a.strW)
if err != nil {
return nil, err
}
header := a.header
header.bodySize = len(body)
header.groupCount = len(newGroups)
header.nameSize = len(nameSection)
out := a.encodeHeader(header)
out = append(out, encodeFileTable(newItems)...)
out = append(out, a.data[layout.hashOffset:layout.hashOffset+layout.hashSize]...)
out = append(out, nameSection...)
out = append(out, a.encodeGroups(newGroups)...)
out = append(out, body...)
return out, nil
}
// EnsureUnicodeString 返回 value 在 UTF-16 字符串池中的魔法偏移,必要时追加。
func (a *Archive) EnsureUnicodeString(value string) (int, error) {
if a == nil || a.format != FormatProtectedNKPI || strings.ContainsRune(value, 0) {
return 0, fmt.Errorf("%w: Unicode append requires protected NKPI and a NUL-free string", ErrInvalidArchive)
}
units := utf16.Encode([]rune(value))
raw := make([]byte, (len(units)+1)*2)
for i, unit := range units {
binary.LittleEndian.PutUint16(raw[i*2:], unit)
}
for start := 0; start < len(a.strW); {
end := start
for end+1 < len(a.strW) && (a.strW[end] != 0 || a.strW[end+1] != 0) {
end += 2
}
if end+2 > len(a.strW) {
break
}
if bytes.Equal(a.strW[start:end+2], raw) {
return start | 1, nil
}
start = end + 2
}
if len(a.strW)%2 != 0 || len(a.strW) > math.MaxInt32-len(raw) {
return 0, fmt.Errorf("%w: invalid or oversized Unicode string pool", ErrInvalidArchive)
}
start := len(a.strW)
a.strW = append(a.strW, raw...)
return start | 1, nil
}
+39
View File
@@ -0,0 +1,39 @@
package pvf
import "sync"
// indexFlight coalesces concurrent first reads of one PVF file or body chunk.
// It is intentionally archive-local, so replacing the PVF archive (and its
// SHA-256 identity) also replaces every in-flight and cached value.
type indexFlight struct {
mu sync.Mutex
calls map[int]*indexFlightCall
}
type indexFlightCall struct {
done chan struct{}
value any
err error
}
func (f *indexFlight) do(key int, load func() (any, error)) (any, error) {
f.mu.Lock()
if f.calls == nil {
f.calls = make(map[int]*indexFlightCall)
}
if call, ok := f.calls[key]; ok {
f.mu.Unlock()
<-call.done
return call.value, call.err
}
call := &indexFlightCall{done: make(chan struct{})}
f.calls[key] = call
f.mu.Unlock()
call.value, call.err = load()
close(call.done)
f.mu.Lock()
delete(f.calls, key)
f.mu.Unlock()
return call.value, call.err
}
+279
View File
@@ -0,0 +1,279 @@
package pvf
import (
"encoding/binary"
"fmt"
)
type fileItem struct {
nameOffset int
pathOffset int
chunkIndex int
dataOffset int
dataSize int
dataType int
}
type groupItem struct {
compressedSize int
originalSize int
}
type pvfHeader struct {
plain [headerSize]byte
fileCount int
bodySize int
groupCount int
hashSize int
nameSize int
}
func (a *Archive) parse() error {
if len(a.data) < headerSize {
return fmt.Errorf("%w: header too short", ErrInvalidArchive)
}
if err := a.parseNKPI(); err == nil {
return nil
}
if err := a.parseProtectedNKPI(); err == nil {
return nil
}
return fmt.Errorf("%w: unsupported archive format", ErrInvalidArchive)
}
func (a *Archive) parseNKPI() error {
a.resetParseState()
header := append([]byte(nil), a.data[:headerSize]...)
decryptGuard(header)
decrypt("HeaD", header)
if binary.LittleEndian.Uint32(header[0:4]) != magicSignature {
return fmt.Errorf("%w: bad signature", ErrInvalidArchive)
}
pvfHeader := parseHeaderSizes(header)
if err := validateHeader(pvfHeader); err != nil {
return err
}
return a.parseNKPITables(pvfHeader, false)
}
func (a *Archive) parseProtectedNKPI() error {
a.resetParseState()
header := append([]byte(nil), a.data[:headerSize]...)
decryptProtected("hEAd", header)
if binary.LittleEndian.Uint32(header[0:4]) != magicSignature {
return fmt.Errorf("%w: bad protected signature", ErrInvalidArchive)
}
pvfHeader := parseHeaderSizes(header)
if err := validateHeader(pvfHeader); err != nil {
return err
}
return a.parseNKPITables(pvfHeader, true)
}
func (a *Archive) parseNKPITables(header pvfHeader, protected bool) error {
pos := headerSize
tableOffset := pos
tableSize, err := checkedMul(header.fileCount, fileItemSize)
if err != nil {
return err
}
pos += tableSize
pos += header.hashSize
nameOffset := pos
pos += header.nameSize
groupOffset := pos
groupSize, err := checkedMul(header.groupCount, groupItemSize)
if err != nil {
return err
}
pos += groupSize
if pos < 0 || pos > len(a.data) || header.bodySize > len(a.data)-pos {
return fmt.Errorf("%w: section sizes exceed archive", ErrInvalidArchive)
}
a.header = header
a.tableOff = tableOffset
a.bodyOff = pos
nameBytes := append([]byte(nil), a.data[nameOffset:nameOffset+header.nameSize]...)
if protected {
if err := a.buildPStringBuffers(nameBytes); err != nil {
return err
}
a.format = FormatProtectedNKPI
} else {
a.buildStringBuffers(nameBytes)
a.format = FormatNKPI
}
groupBytes := append([]byte(nil), a.data[groupOffset:groupOffset+groupSize]...)
if protected {
decryptProtected("grpi", groupBytes)
} else {
decrypt("GRPI", groupBytes)
}
if err := a.parseGroups(groupBytes, header.groupCount); err != nil {
return err
}
return a.parseFiles(tableOffset, header.fileCount)
}
func (a *Archive) resetParseState() {
a.format = ""
a.items = nil
a.groups = nil
a.header = pvfHeader{}
a.pathIdx = make(map[string]int)
a.bodyOff = 0
a.strA = nil
a.strW = nil
}
func parseHeaderSizes(header []byte) pvfHeader {
var plain [headerSize]byte
copy(plain[:], header)
return pvfHeader{
plain: plain,
fileCount: readInt32(header[24:28]),
bodySize: readInt32(header[32:36]),
groupCount: readInt32(header[36:40]),
hashSize: readInt32(header[40:44]),
nameSize: readInt32(header[44:48]),
}
}
func validateHeader(header pvfHeader) error {
if header.fileCount < 0 || header.bodySize < 0 || header.groupCount < 0 || header.hashSize < 0 || header.nameSize < 0 {
return fmt.Errorf("%w: negative header size", ErrInvalidArchive)
}
return nil
}
func (a *Archive) parseGroups(data []byte, count int) error {
a.groups = make([]groupItem, 0, count)
for idx := 0; idx < count; idx++ {
off := idx * groupItemSize
compressedSize := readInt32(data[off : off+4])
originalSize := readInt32(data[off+4 : off+8])
if compressedSize < 0 || originalSize < 0 {
return fmt.Errorf("%w: negative group size", ErrInvalidArchive)
}
a.groups = append(a.groups, groupItem{
compressedSize: compressedSize,
originalSize: originalSize,
})
}
return nil
}
func (a *Archive) parseFiles(offset, count int) error {
a.items = make([]fileItem, 0, count)
for idx := 0; idx < count; idx++ {
off := offset + idx*fileItemSize
if off+fileItemSize > len(a.data) {
return fmt.Errorf("%w: file table exceeds archive", ErrInvalidArchive)
}
item := fileItem{
nameOffset: readInt32(a.data[off : off+4]),
pathOffset: readInt32(a.data[off+4 : off+8]),
chunkIndex: readInt32(a.data[off+8 : off+12]),
dataOffset: readInt32(a.data[off+12 : off+16]),
dataSize: readInt32(a.data[off+16 : off+20]),
dataType: readInt32(a.data[off+20 : off+24]),
}
name := a.resolveString(item.nameOffset)
dir := a.resolveString(item.pathOffset)
archivePath := joinArchivePath(dir, name)
a.items = append(a.items, item)
if archivePath != "" {
key := pathKey(archivePath)
if _, exists := a.pathIdx[key]; !exists {
a.pathIdx[key] = idx
}
}
}
return nil
}
func (a *Archive) buildStringBuffers(nameBytes []byte) {
if len(nameBytes) < 16 {
return
}
idx := 8
a.strA = decryptStringBuffer(nameBytes, &idx, "sTrA", 0xAA74472E)
a.strW = decryptStringBuffer(nameBytes, &idx, "sTrW", 0x9A82F037)
}
func (a *Archive) buildPStringBuffers(nameBytes []byte) error {
if len(nameBytes) < 16 {
return nil
}
idx := 8
strA, err := decryptPStringBuffer(nameBytes, &idx, "StRa", 0xAA74472E)
if err != nil {
return fmt.Errorf("decode protected pvf strA: %w", err)
}
strW, err := decryptPStringBuffer(nameBytes, &idx, "StRw", 0x9A82F037)
if err != nil {
return fmt.Errorf("decode protected pvf strW: %w", err)
}
a.strA = strA
a.strW = strW
return nil
}
func decryptStringBuffer(data []byte, idx *int, key string, xorConst uint32) []byte {
if *idx+8 > len(data) {
return nil
}
cnt1 := binary.LittleEndian.Uint32(data[*idx : *idx+4])
*idx += 4
*idx += 4
encSize := int(cnt1 ^ xorConst)
if encSize <= 0 || *idx+encSize > len(data) {
return nil
}
encrypted := append([]byte(nil), data[*idx:*idx+encSize]...)
*idx += encSize
decrypt2(key, encrypted)
out, err := zlibBytes(encrypted)
if err != nil {
return nil
}
return out
}
func decryptPStringBuffer(data []byte, idx *int, key string, xorConst uint32) ([]byte, error) {
if *idx+8 > len(data) {
return nil, nil
}
encodedSize := binary.LittleEndian.Uint32(data[*idx : *idx+4])
encodedOriginalSize := binary.LittleEndian.Uint32(data[*idx+4 : *idx+8])
*idx += 8
encSize := int(encodedSize ^ xorConst)
if encSize <= 0 || *idx+encSize > len(data) {
return nil, fmt.Errorf("%w: invalid protected string buffer size %d", ErrInvalidArchive, encSize)
}
encrypted := append([]byte(nil), data[*idx:*idx+encSize]...)
*idx += encSize
decryptPString(key, encrypted)
out, err := zlibBytes(encrypted)
if err != nil {
return nil, err
}
if want := int(encodedOriginalSize ^ uint32(encSize)); want >= 0 && want != len(out) {
return nil, fmt.Errorf("%w: protected string buffer size mismatch: want %d got %d", ErrInvalidArchive, want, len(out))
}
return out, nil
}
func readInt32(data []byte) int {
return int(int32(binary.LittleEndian.Uint32(data)))
}
func checkedMul(left, right int) (int, error) {
maxInt := int(^uint(0) >> 1)
if left < 0 || right < 0 || (right != 0 && left > maxInt/right) {
return 0, fmt.Errorf("%w: section size overflow", ErrInvalidArchive)
}
return left * right, nil
}
@@ -0,0 +1,52 @@
package pvf
import (
"encoding/binary"
"fmt"
)
// PatchFileDataType 把文件表里某个已存在表项的 dataType 字段改写为 want,
// 并返回重建后的完整归档字节。
//
// 为什么可以只改 4 字节:文件表本身是明文(parseNKPITables 直接把 a.data 当表读),
// 表项布局 = nameOffset/pathOffset/chunkIndex/dataOffset/dataSize/dataType 六个
// int32(见 parse.go 的 parseFiles),dataType 在记录 +20 处。数据体、字符串池、
// 分组表、体偏移都不需要动,所以这是定点写,不会重排归档。
//
// 现场用途(2026-09-19):玩家用第三方工具新增 stackable/10000001/_test123.stk 时,
// 新文件被以 dataType=3 写入;本仓库 patch_string.go 的判据与客户端一致——.stk 这类
// 脚本文档必须是 type 1,于是客户端解析不了这个新道具(改现有道具不受影响,因为
// 原有文件仍是 type 1)。把该表项改回 1 即可让新道具在客户端可见。
func (a *Archive) PatchFileDataType(relativePath string, want int) ([]byte, error) {
if a == nil {
return nil, fmt.Errorf("%w: nil archive", ErrInvalidArchive)
}
key := pathKey(relativePath)
fileIndex, found := a.pathIdx[key]
if !found {
return nil, fmt.Errorf("%w: %s", ErrFileNotFound, relativePath)
}
off := a.tableOff + fileIndex*fileItemSize + 20
if a.tableOff <= 0 || off+4 > len(a.data) {
return nil, fmt.Errorf("%w: file table offset out of range", ErrInvalidArchive)
}
out := append([]byte(nil), a.data...)
binary.LittleEndian.PutUint32(out[off:off+4], uint32(want))
verified, err := OpenBytes(out)
if err != nil {
return nil, fmt.Errorf("reopen patched archive: %w", err)
}
for _, file := range verified.Files() {
if pathKey(file.ArchivePath) != key {
continue
}
if file.DataType != want {
return nil, fmt.Errorf("patch verification failed: %s dataType=%d want=%d",
relativePath, file.DataType, want)
}
return out, nil
}
return nil, fmt.Errorf("%w: %s", ErrFileNotFound, relativePath)
}
@@ -0,0 +1,218 @@
package pvf
import (
"bytes"
"encoding/binary"
"fmt"
"math"
)
// ScriptStringPatchReport 记录一次脚本字符串引用补丁的命中统计。
type ScriptStringPatchReport struct {
FilesChanged int `json:"files_changed"`
TokensChanged int `json:"tokens_changed"`
ChunksChanged int `json:"chunks_changed"`
}
// StringValueReplacement replaces an existing shared string-pool value in
// place. Old and New must have the same encoded width.
type StringValueReplacement struct {
Old string
New string
}
// RepackStringValues rewrites shared string-pool values without requiring the
// replacement values to already exist in the pool. It is intentionally
// limited to equal-width replacements so all existing references remain valid.
func (a *Archive) RepackStringValues(replacements []StringValueReplacement) ([]byte, error) {
if a == nil || len(replacements) == 0 {
return nil, fmt.Errorf("%w: invalid string value replacement", ErrInvalidArchive)
}
resolved := make([]StringReplacement, 0, len(replacements))
for _, replacement := range replacements {
if replacement.Old == "" || replacement.New == "" || replacement.Old == replacement.New {
return nil, fmt.Errorf("%w: invalid string value replacement", ErrInvalidArchive)
}
magic, ok := a.findStringMagic(replacement.Old)
if !ok {
return nil, fmt.Errorf("old string %q is absent from PVF string pool", replacement.Old)
}
resolved = append(resolved, StringReplacement{
MagicOffset: magic,
Old: replacement.Old,
New: replacement.New,
})
}
return a.RepackStrings(resolved)
}
// PatchScriptString 把选定 type-1 脚本中对共享字符串池某一值的引用改写为
// 池中另一个已存在的值。它只重写脚本 token 里的 4 字节池偏移,不扩建、不重排
// 字符串池,因此引用旧值的其他脚本保持不变(格式依据:type-1 脚本以 5 字节
// token 编码,token 类型 3/6 的负载为字符串池 magic 偏移,见 decode.go 的
// decodeScript;该写路径与参考项目 pvf/patch.go 的已验证行为一致)。
//
// oldValue 与 newValue 都必须已存在于 ANSI 字符串池(strA)中;UTF-16 池
// 字符串的引用改写尚未被参考实现证明,故不予承诺。返回值为重建后的完整 PVF
// 字节;函数会在返回前回开归档并逐文件验证新值生效、旧值消失。
func (a *Archive) PatchScriptString(paths []string, oldValue, newValue string) ([]byte, ScriptStringPatchReport, error) {
var report ScriptStringPatchReport
if a == nil || len(paths) == 0 || oldValue == "" || newValue == "" || oldValue == newValue {
return nil, report, fmt.Errorf("%w: invalid script string patch", ErrInvalidArchive)
}
oldMagic, ok := a.findStringMagic(oldValue)
if !ok {
return nil, report, fmt.Errorf("old string %q is absent from PVF string pool", oldValue)
}
newMagic, ok := a.findStringMagic(newValue)
if !ok {
return nil, report, fmt.Errorf("new string %q is absent from PVF string pool", newValue)
}
modifiedChunks := make(map[int][]byte)
seenPaths := make(map[string]struct{}, len(paths))
for _, relativePath := range paths {
key := pathKey(relativePath)
if _, duplicate := seenPaths[key]; duplicate {
continue
}
seenPaths[key] = struct{}{}
fileIndex, found := a.pathIdx[key]
if !found {
return nil, report, fmt.Errorf("%w: %s", ErrFileNotFound, relativePath)
}
item := a.items[fileIndex]
if item.dataType != 1 {
return nil, report, fmt.Errorf("file %s has data type %d, want script type 1", relativePath, item.dataType)
}
chunk, exists := modifiedChunks[item.chunkIndex]
if !exists {
plain, err := a.chunk(item.chunkIndex)
if err != nil {
return nil, report, err
}
// chunk 缓存被归档共享,改写前必须复制,避免污染运行期查询。
chunk = append([]byte(nil), plain...)
modifiedChunks[item.chunkIndex] = chunk
}
if item.dataOffset < 0 || item.dataSize < 0 || item.dataOffset+item.dataSize > len(chunk) {
return nil, report, fmt.Errorf("%w: file %s data range is invalid", ErrInvalidArchive, relativePath)
}
changed := replaceScriptStringMagic(chunk[item.dataOffset:item.dataOffset+item.dataSize], oldMagic, newMagic)
if changed == 0 {
return nil, report, fmt.Errorf("file %s does not reference %q", relativePath, oldValue)
}
report.FilesChanged++
report.TokensChanged += changed
}
report.ChunksChanged = len(modifiedChunks)
patched, err := a.rebuildWithChunks(modifiedChunks)
if err != nil {
return nil, report, err
}
verified, err := OpenBytes(patched)
if err != nil {
return nil, report, fmt.Errorf("reopen patched PVF: %w", err)
}
for relativePath := range seenPaths {
text, err := verified.ReadText(relativePath)
if err != nil {
return nil, report, fmt.Errorf("verify patched file %s: %w", relativePath, err)
}
if !bytes.Contains([]byte(text), []byte(newValue)) || bytes.Contains([]byte(text), []byte(oldValue)) {
return nil, report, fmt.Errorf("patched file %s failed string verification", relativePath)
}
}
return patched, report, nil
}
// findStringMagic 在 ANSI 字符串池中定位完整值(前后均以 NUL 结尾),返回
// 脚本 token 使用的 magic 偏移(池内字节偏移左移 1 位,偶数表示 ANSI 池,
// 见 decode.go 的 resolveString)。
func (a *Archive) findStringMagic(value string) (int, bool) {
needle := append([]byte(value), 0)
for offset := 0; offset < len(a.strA); {
index := bytes.Index(a.strA[offset:], needle)
if index < 0 {
break
}
index += offset
if index == 0 || a.strA[index-1] == 0 {
return index << 1, true
}
offset = index + 1
}
return 0, false
}
// replaceScriptStringMagic 逐 token 扫描脚本原始字节,把引用 oldMagic 的
// 字符串 token(类型 3 标签、类型 6 行内字符串)改写为 newMagic。只触碰
// 4 字节负载,脚本长度不变。
func replaceScriptStringMagic(raw []byte, oldMagic, newMagic int) int {
changed := 0
for offset := 0; offset+5 <= len(raw); offset += 5 {
if raw[offset] != 3 && raw[offset] != 6 {
continue
}
if readInt32(raw[offset+1:offset+5]) != oldMagic {
continue
}
binary.LittleEndian.PutUint32(raw[offset+1:offset+5], uint32(int32(newMagic)))
changed++
}
return changed
}
// rebuildWithChunks 用改写后的 chunk 重建归档:逐 chunk 重新 zlib 压缩并按
// 当前格式重新加密(bODy/BodY),重建 GRPI 组表与 header 的 bodySize;未改动
// 的 chunk 直接搬运原始密文,文件表、哈希段、字符串池保持字节级不变。
func (a *Archive) rebuildWithChunks(modified map[int][]byte) ([]byte, error) {
layout, err := a.layout()
if err != nil {
return nil, err
}
groupTableSize, err := checkedMul(len(a.groups), groupItemSize)
if err != nil {
return nil, err
}
groupOffset := layout.bodyOffset - groupTableSize
if groupOffset < headerSize || groupOffset > len(a.data) {
return nil, fmt.Errorf("%w: group table offset is invalid", ErrInvalidArchive)
}
body := make([]byte, 0, layout.bodySize)
groups := make([]groupItem, len(a.groups))
for index, group := range a.groups {
var encrypted []byte
if chunk, changed := modified[index]; changed {
// 引用改写是等长替换,chunk 解压尺寸必须与原尺寸一致。
if len(chunk) != group.originalSize {
return nil, fmt.Errorf("%w: modified chunk %d size changed", ErrInvalidArchive, index)
}
encrypted, err = a.encryptChunk(chunk)
if err != nil {
return nil, fmt.Errorf("encrypt modified chunk %d: %w", index, err)
}
} else {
encrypted, err = a.rawChunk(layout, index)
if err != nil {
return nil, err
}
}
body = append(body, encrypted...)
if len(body) > math.MaxInt32 {
return nil, fmt.Errorf("%w: rebuilt body is too large: %d", ErrInvalidArchive, len(body))
}
groups[index] = groupItem{compressedSize: len(body), originalSize: group.originalSize}
}
result := make([]byte, 0, groupOffset+groupTableSize+len(body))
result = append(result, a.data[:groupOffset]...)
result = append(result, a.encodeGroups(groups)...)
result = append(result, body...)
header := a.header
header.bodySize = len(body)
copy(result[:headerSize], a.encodeHeader(header))
return result, nil
}
@@ -0,0 +1,367 @@
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)
}
}
+36
View File
@@ -0,0 +1,36 @@
package pvf
import (
"path"
"strings"
)
func normalizePath(value string) string {
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
for strings.HasPrefix(value, "./") || strings.HasPrefix(value, "/") {
value = strings.TrimPrefix(value, "./")
value = strings.TrimPrefix(value, "/")
}
if value == "" {
return ""
}
clean := strings.TrimSuffix(path.Clean(value), "/")
if clean == "." {
return ""
}
return clean
}
func pathKey(value string) string {
return strings.ToLower(normalizePath(value))
}
func joinArchivePath(dir, name string) string {
if strings.TrimSpace(dir) == "" {
return normalizePath(name)
}
if strings.TrimSpace(name) == "" {
return normalizePath(dir)
}
return normalizePath(dir + "/" + name)
}
+115
View File
@@ -0,0 +1,115 @@
// Package pvf 提供 DNF 类 PVF 资源的启动期内存加载能力。
//
// 框架只负责把 PVF 文件作为不可变资源装入内存并暴露元信息,不在平台层解释
// DNF 玩法字段;解析、索引和业务校验应放在游戏项目层。
package pvf
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
"time"
)
const DefaultMaxBytes int64 = 1024 * 1024 * 1024
// NoMaxBytes disables the optional PVF file-size guard. The archive still
// requires a readable regular file and must pass the normal PVF parser.
const NoMaxBytes int64 = -1
const rfc3339Nano = time.RFC3339Nano
var (
ErrPathRequired = errors.New("pvf path is required")
ErrTooLarge = errors.New("pvf exceeds max bytes")
)
type Options struct {
Path string
MaxBytes int64
ChunkCacheBytes int64
TextCacheBytes int64
}
type Snapshot struct {
Path string `json:"path"`
Size int64 `json:"size"`
Checksum string `json:"checksum"`
LoadedAt time.Time `json:"loaded_at"`
}
type Bundle struct {
snapshot Snapshot
data []byte
chunkCacheBytes int64
textCacheBytes int64
}
func Load(options Options) (*Bundle, error) {
options.Path = strings.TrimSpace(options.Path)
if options.Path == "" {
return nil, ErrPathRequired
}
maxBytes := options.MaxBytes
if maxBytes == 0 {
maxBytes = DefaultMaxBytes
}
if maxBytes < NoMaxBytes {
return nil, fmt.Errorf("pvf max bytes must be positive or %d to disable the limit", NoMaxBytes)
}
info, err := os.Stat(options.Path)
if err != nil {
return nil, fmt.Errorf("stat pvf %q: %w", options.Path, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("pvf path %q is not a regular file", options.Path)
}
if maxBytes != NoMaxBytes && info.Size() > maxBytes {
return nil, fmt.Errorf("%w: got %d want <= %d", ErrTooLarge, info.Size(), maxBytes)
}
data, err := os.ReadFile(options.Path)
if err != nil {
return nil, fmt.Errorf("read pvf %q: %w", options.Path, err)
}
if maxBytes != NoMaxBytes && int64(len(data)) > maxBytes {
return nil, fmt.Errorf("%w: got %d want <= %d", ErrTooLarge, len(data), maxBytes)
}
sum := sha256.Sum256(data)
return &Bundle{
snapshot: Snapshot{
Path: options.Path,
Size: int64(len(data)),
Checksum: hex.EncodeToString(sum[:]),
LoadedAt: time.Now().UTC(),
},
data: data,
chunkCacheBytes: options.ChunkCacheBytes,
textCacheBytes: options.TextCacheBytes,
}, nil
}
func (b *Bundle) Snapshot() Snapshot {
if b == nil {
return Snapshot{}
}
return b.snapshot
}
func (b *Bundle) Bytes() []byte {
if b == nil {
return nil
}
out := make([]byte, len(b.data))
copy(out, b.data)
return out
}
func (b *Bundle) Reader() *bytes.Reader {
if b == nil {
return bytes.NewReader(nil)
}
return bytes.NewReader(b.data)
}
@@ -0,0 +1,42 @@
package pvf
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestDefaultMaxBytesIsOneGiB(t *testing.T) {
const want int64 = 1024 * 1024 * 1024
if DefaultMaxBytes != want {
t.Fatalf("DefaultMaxBytes = %d, want %d", DefaultMaxBytes, want)
}
}
func TestLoadAllowsExplicitlyDisabledSizeLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "Script.pvf")
if err := os.WriteFile(path, []byte("pvf"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Load(Options{Path: path, MaxBytes: 1}); !errors.Is(err, ErrTooLarge) {
t.Fatalf("limited load error = %v, want ErrTooLarge", err)
}
bundle, err := Load(Options{Path: path, MaxBytes: NoMaxBytes})
if err != nil {
t.Fatalf("unlimited load rejected: %v", err)
}
if got := bundle.Snapshot().Size; got != 3 {
t.Fatalf("loaded size = %d, want 3", got)
}
}
func TestLoadRejectsUnknownNegativeSizeLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "Script.pvf")
if err := os.WriteFile(path, []byte("pvf"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Load(Options{Path: path, MaxBytes: NoMaxBytes - 1}); err == nil {
t.Fatal("unknown negative size limit was accepted")
}
}
+97
View File
@@ -0,0 +1,97 @@
package pvf
import "fmt"
func (a *Archive) readTextIndex(idx int) (string, error) {
if cached, ok := a.texts.get(idx); ok {
return cached.(string), nil
}
value, err := a.textFlights.do(idx, func() (any, error) {
if cached, ok := a.texts.get(idx); ok {
return cached.(string), nil
}
raw, err := a.readRawIndex(idx)
if err != nil {
return "", err
}
var text string
switch a.items[idx].dataType {
case 1:
text = a.decodeScript(raw)
case 3:
text = decodeUTF16LE(raw)
default:
text = ""
}
a.texts.add(idx, text, int64(len(text)))
return text, nil
})
if err != nil {
return "", err
}
return value.(string), nil
}
func (a *Archive) readRawIndex(idx int) ([]byte, error) {
if idx < 0 || idx >= len(a.items) {
return nil, fmt.Errorf("%w: file index %d", ErrFileNotFound, idx)
}
item := a.items[idx]
chunk, err := a.chunk(item.chunkIndex)
if err != nil {
return nil, err
}
if item.dataOffset < 0 || item.dataSize < 0 || item.dataOffset+item.dataSize > len(chunk) {
return nil, fmt.Errorf("%w: file %d data range is invalid", ErrInvalidArchive, idx)
}
out := make([]byte, item.dataSize)
copy(out, chunk[item.dataOffset:item.dataOffset+item.dataSize])
return out, nil
}
func (a *Archive) chunk(idx int) ([]byte, error) {
if idx < 0 || idx >= len(a.groups) {
return nil, fmt.Errorf("%w: chunk %d is out of range", ErrInvalidArchive, idx)
}
if cached, ok := a.chunks.get(idx); ok {
return cached.([]byte), nil
}
value, err := a.chunkFlights.do(idx, func() (any, error) {
if cached, ok := a.chunks.get(idx); ok {
return cached.([]byte), nil
}
prev := 0
if idx > 0 {
prev = a.groups[idx-1].compressedSize
}
curr := a.groups[idx].compressedSize
if curr <= prev {
return nil, fmt.Errorf("%w: chunk %d compressed range is invalid", ErrInvalidArchive, idx)
}
start := a.bodyOff + prev
size := curr - prev
if start < 0 || size <= 0 || start+size > len(a.data) {
return nil, fmt.Errorf("%w: chunk %d exceeds archive", ErrInvalidArchive, idx)
}
encrypted := append([]byte(nil), a.data[start:start+size]...)
switch a.format {
case FormatProtectedNKPI:
decryptProtected("bODy", encrypted)
default:
decrypt("BodY", encrypted)
}
chunk, err := zlibBytes(encrypted)
if err != nil {
return nil, fmt.Errorf("%w: chunk %d decompress: %w", ErrInvalidArchive, idx, err)
}
if want := a.groups[idx].originalSize; want >= 0 && want != len(chunk) {
return nil, fmt.Errorf("%w: chunk %d original size mismatch: want %d got %d", ErrInvalidArchive, idx, want, len(chunk))
}
a.chunks.add(idx, chunk, int64(len(chunk)))
return chunk, nil
})
if err != nil {
return nil, err
}
return value.([]byte), nil
}
+498
View File
@@ -0,0 +1,498 @@
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
}