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