DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user