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)
|
||||
}
|
||||
Reference in new issue
Block a user