Files
90CNServer/pvf/internal/pvf/archive.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

709 lines
22 KiB
Go

package pvf
import (
"bytes"
"encoding/binary"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"pvfine/internal/buildmode"
"pvfine/internal/rendering"
)
// Header is the decrypted 48-byte archive header (packed, little-endian).
type Header struct {
Signature uint32
Guid [20]byte
FileCount int32
Padding int32
BodySize int32
GroupCount int32
HashTableSize int32
NameTableSize int32
}
type fileItem struct {
identity uint64 // process-local identity, preserved by clones and removal
nameOff, pathOff int32 // magic offsets into the string pools
chunk int32 // body chunk index
off, size int32 // location of the file payload inside the decompressed chunk
typ int32 // TypeScript / TypeUnicode
}
var nextFileIdentity atomic.Uint64
// FileIdentity identifies a live entry independently of its compact file index.
// It survives CloneForBatch and RemoveFiles, but is never serialized to PVF.
func (a *Archive) FileIdentity(index int32) uint64 {
if index < 0 || index >= int32(len(a.items)) {
return 0
}
return a.items[index].identity
}
type removedFileSpan struct {
off, size int32
}
// MutationKind identifies one effective archive entry mutation. The mutation
// journal is intentionally small: services use it to decide which derived
// indexes are affected after a compound operation has committed.
type MutationKind string
const (
MutationModified MutationKind = "modified"
MutationAdded MutationKind = "added"
MutationRemoved MutationKind = "removed"
)
// FileMutation is one effective file-level change. Path is captured before a
// structural edit can renumber the remaining entries.
type FileMutation struct {
Index int32
Identity uint64 // captured before structural edits can compact the file table
Path string
Kind MutationKind
}
// MutationSummary describes changes recorded after a mutation checkpoint.
type MutationSummary struct {
Files []FileMutation
Structural bool
}
type groupItem struct{ compSize, origSize int32 }
// Archive is a parsed PVF container. It is not safe for concurrent
// modification, but Chunk decompression is cached and read-only use of
// accessors from multiple goroutines is fine.
type Archive struct {
cacheMu sync.Mutex
data []byte // original file bytes (nil for archives built from scratch)
contentDigest string // cached hash of immutable data; cacheMu protects it
hdr Header
guard bool
keys keySet // per-section LCG seeds (standard or recovered)
// format separates container transport from client content conventions.
// pageKeys retains the per-page state needed to write the container back.
format formatProfile
pageKeys []byte
act60 *act60Archive // immutable 60ACT transport metadata
act60Sources map[uint64]string // edited ACT60 sources, keyed by stable file identity
tw *twArchive // immutable TW transport and index metadata
sourcePath string // file the archive was opened from
textEncoding string // optional native text / narrow pool codec override
tableOff, hashOff, nameOff, grpiOff, bodyOff int
hashSize, nameSize, grpiSize int
items []fileItem
groups []groupItem // decrypted GRPI
strA, strW []byte // decompressed string pools (UTF-8 / UTF-16LE)
strAIdx, strWIdx map[string]int32
poolsDirty bool // pools gained appended strings since parse
resolveCache map[int32]string
resolveCacheOrder []int32
resolveCacheBytes int64
chunkCache map[int32][]byte
chunkCacheMeta map[int32]chunkCacheEntry
chunkCacheBytes int64
chunkCacheClock uint64
overlay map[int32][]byte // index -> replacement payload
pathIndex map[string]int32
compactPaths []uint64 // immutable sorted fingerprints for large archives
structuralDirty bool // file entries were added or removed since the last save
removedSpans map[int32][]removedFileSpan
mutations []FileMutation // effective changes since the last checkpoint
// scriptRenderer controls the user-facing decompiled layout. The
// canonical renderer is kept stable so version content hashes do not
// depend on presentation-only configuration.
scriptRenderer *rendering.Engine
canonicalScriptRenderer *rendering.Engine
// tables caches the lazily loaded string tables used to resolve
// `<index::key>` placeholders in item names.
tables struct {
mu sync.Mutex
state *stringTableState
stats StringTableIndexStats
observer func(StringTableIndexEvent)
}
}
// MutationCheckpoint returns the current position in the archive mutation
// journal. The journal is local to one Archive and is not part of the packed
// file format.
func (a *Archive) MutationCheckpoint() int {
if a == nil {
return 0
}
return len(a.mutations)
}
// MutationsSince returns effective file changes recorded after checkpoint.
// Callers may safely retain the returned slices.
func (a *Archive) MutationsSince(checkpoint int) MutationSummary {
if a == nil {
return MutationSummary{}
}
if checkpoint < 0 {
checkpoint = 0
}
if checkpoint > len(a.mutations) {
checkpoint = len(a.mutations)
}
result := MutationSummary{
Files: make([]FileMutation, len(a.mutations)-checkpoint),
}
copy(result.Files, a.mutations[checkpoint:])
for _, mutation := range result.Files {
if mutation.Kind == MutationAdded || mutation.Kind == MutationRemoved {
result.Structural = true
break
}
}
return result
}
// ClearMutations forgets journal entries that have already been consumed by
// the owning service. It does not alter archive content.
func (a *Archive) ClearMutations() {
if a != nil {
a.mutations = nil
}
}
// PendingEdits lists the entries that currently differ from the packed
// baseline: replacement payloads held in the overlay and entries added since
// the archive was parsed. Removed entries cannot be reported because their
// paths are gone from the file table. Callers must not mutate the archive
// while iterating; the returned slice is detached.
func (a *Archive) PendingEdits() []FileMutation {
if a == nil {
return nil
}
result := make([]FileMutation, 0, len(a.overlay))
for i := range a.items {
index := int32(i)
if a.items[i].chunk < 0 {
result = append(result, FileMutation{Index: index, Identity: a.FileIdentity(index), Path: a.Path(index), Kind: MutationAdded})
continue
}
if _, ok := a.overlay[index]; ok {
result = append(result, FileMutation{Index: index, Identity: a.FileIdentity(index), Path: a.Path(index), Kind: MutationModified})
}
}
return result
}
func (a *Archive) recordMutation(index int32, path string, kind MutationKind) {
if a == nil || path == "" {
return
}
// A ready projection must not disagree with direct placeholder lookups.
// Ordinary text/raw edits and structural changes share this journal entry
// point, including late localization files and mapping-list replacements.
if strings.HasSuffix(strings.ToLower(path), ".str") || IsStringTableMappingPath(path) {
a.InvalidateStringTables()
}
a.mutations = append(a.mutations, FileMutation{Index: index, Identity: a.FileIdentity(index), Path: path, Kind: kind})
}
type chunkCacheEntry struct {
bytes int64
used uint64
}
const defaultChunkCacheLimit = int64(64 << 20)
const defaultResolveCacheLimit = int64(16 << 20)
// Open reads and parses the archive at path. Paged containers may
// keep their per-page keys in sibling "sk.dat" and "DFO.exe" / "DNF.exe" files.
// Complete bundled 110US / 100CN parameters are tried first. Other revisions
// can supply matching sk.dat and client executables next to the archive.
func Open(path string) (*Archive, error) {
return OpenWithSidecars(path, "")
}
// OpenWithSidecars reads and parses the archive at path while resolving
// container key sidecars from sidecarDir instead of the file's own directory.
// Backups of paged archives live outside the client folder, so their page
// keys have to be borrowed from the directory of the file they came from.
// An empty sidecarDir behaves like Open.
func OpenWithSidecars(path, sidecarDir string) (*Archive, error) {
return OpenWithTextEncoding(path, sidecarDir, "auto")
}
// OpenWithTextEncoding applies the selected codec during the initial parse.
// sidecarDir has the same meaning as in OpenWithSidecars.
func OpenWithTextEncoding(path, sidecarDir, encoding string) (*Archive, error) {
if _, err := textCodec(encoding); err != nil {
return nil, err
}
if encoding == "auto" {
encoding = ""
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if sidecarDir == "" {
sidecarDir = filepath.Dir(path)
}
a, err := parseOwned(data, sidecarDir, true, encoding)
if err != nil {
return nil, err
}
a.sourcePath = path
return a, nil
}
// Parse parses an archive from memory. The byte slice is retained; treat it
// as read-only afterwards. Paged parsing uses the bundled 110US key table; an
// archive-specific sidecar can be supplied by using Open.
func Parse(data []byte) (*Archive, error) {
return parse(data, "")
}
func parse(data []byte, sidecarDir string) (*Archive, error) {
return parseOwned(data, sidecarDir, false)
}
func parseOwned(data []byte, sidecarDir string, owned bool, codecs ...string) (*Archive, error) {
if tree, body, count, legacy, ok := probeACT60(data); ok {
return parseACT60(data, tree, body, count, legacy, codecs...)
}
if bytes.HasPrefix(data, []byte(act60Signature)) {
return nil, fmt.Errorf("%w: ACT60 directory header or checksum", ErrInvalidSection)
}
if isTW(data) {
return parseTW(data, codecs...)
}
detected, err := detectArchive(data, sidecarDir, owned)
if err != nil {
return nil, err
}
return parseDetected(detected, codecs...)
}
// parseDetected is the shared logical-container parser, independent of file
// discovery and physical page unlocking.
func parseDetected(detected detectedArchive, codecs ...string) (*Archive, error) {
data := detected.data
renderer := defaultScriptRenderer()
a := &Archive{
data: data,
resolveCache: map[int32]string{},
chunkCache: map[int32][]byte{},
chunkCacheMeta: map[int32]chunkCacheEntry{},
overlay: map[int32][]byte{},
pathIndex: map[string]int32{},
removedSpans: make(map[int32][]removedFileSpan),
scriptRenderer: renderer,
canonicalScriptRenderer: renderer,
}
if len(codecs) > 0 {
a.textEncoding = codecs[0]
}
a.hdr, a.guard, a.keys = detected.hdr, detected.guard, detected.keys
a.format, a.pageKeys = detected.format, detected.pageKeys
// Section layout.
pos := headerSize
a.tableOff = pos
pos += int(a.hdr.FileCount) * 0x18
a.hashOff = pos
a.hashSize = int(a.hdr.HashTableSize)
pos += a.hashSize
a.nameOff = pos
a.nameSize = int(a.hdr.NameTableSize)
pos += a.nameSize
a.grpiOff = pos
a.grpiSize = int(a.hdr.GroupCount) * 8
pos += a.grpiSize
a.bodyOff = pos
declaredEnd := pos + int(a.hdr.BodySize)
if declaredEnd > len(data) {
return nil, ErrOverflow
}
// File table (plaintext).
a.items = make([]fileItem, a.hdr.FileCount)
for i := range a.items {
it := &a.items[i]
it.identity = nextFileIdentity.Add(1)
base := a.tableOff + i*0x18
it.nameOff = int32(binary.LittleEndian.Uint32(data[base:]))
it.pathOff = int32(binary.LittleEndian.Uint32(data[base+4:]))
it.chunk = int32(binary.LittleEndian.Uint32(data[base+8:]))
it.off = int32(binary.LittleEndian.Uint32(data[base+12:]))
it.size = int32(binary.LittleEndian.Uint32(data[base+16:]))
it.typ = int32(binary.LittleEndian.Uint32(data[base+20:]))
}
// GRPI (cumulative compressed chunk sizes).
if a.grpiSize > 0 {
grpiRaw := data[a.grpiOff : a.grpiOff+a.grpiSize]
grpi := make([]byte, a.grpiSize)
copy(grpi, grpiRaw)
cryptSeed(a.keys.grpi.seed, a.keys.grpi.magic, grpi)
if !a.grpiLooksSane(grpi) {
// Non-standard seed: recover it from the BodySize anchor and the
// monotonicity invariants of the cumulative size table.
if key, ok := recoverGRPISeed(grpiRaw, int(a.hdr.GroupCount), a.hdr.BodySize); ok {
a.keys.grpi = key
copy(grpi, grpiRaw)
cryptSeed(key.seed, key.magic, grpi)
} else {
return nil, fmt.Errorf("%w: GRPI cannot be decoded", ErrInvalidSection)
}
}
a.groups = make([]groupItem, a.hdr.GroupCount)
for i := range a.groups {
a.groups[i].compSize = int32(binary.LittleEndian.Uint32(grpi[i*8:]))
a.groups[i].origSize = int32(binary.LittleEndian.Uint32(grpi[i*8+4:]))
}
}
// String pools.
if err := a.parseNameTable(data[a.nameOff : a.nameOff+a.nameSize]); err != nil {
return nil, err
}
// Hash section seed: known for the standard key set and the alternate
// variant family. When it is not (an unidentified variant, the Paged110
// containers), solve it from the section itself now that the string pools
// are available to validate candidate offsets.
if !a.keys.hashKnown && a.hashSize > 0 {
if key, ok := a.recoverHashSeed(data[a.hashOff:a.hashOff+a.hashSize], int(a.hdr.FileCount)); ok {
a.keys.hash = key
a.keys.hashKnown = true
}
}
// Body seed: for variants the standard key fails, and chunk 0's zlib
// header plus GRPI's original size let it be recovered from the data.
if !a.bodyKeyWorks() {
if key, ok := recoverZlibSeed(a.firstChunkSpan(), a.firstChunkOrigSize()); ok {
a.keys.body = key
a.keys.bodyRecovered = true
} else {
return nil, fmt.Errorf("%w: body cannot be decoded", ErrInvalidSection)
}
}
// Path index (case-insensitive, mirrors the reference GM tool).
a.rebuildPathIndex()
return a, nil
}
// New returns an empty archive ready for AddFile + SaveTo.
func New() *Archive {
renderer := defaultScriptRenderer()
return &Archive{
hdr: Header{Signature: MagicSignature},
keys: standardKeys(),
format: standardProfile,
strA: []byte{0},
strW: []byte{0, 0},
poolsDirty: true,
resolveCache: map[int32]string{},
chunkCache: map[int32][]byte{},
chunkCacheMeta: map[int32]chunkCacheEntry{},
overlay: map[int32][]byte{},
pathIndex: map[string]int32{},
removedSpans: make(map[int32][]removedFileSpan),
scriptRenderer: renderer,
canonicalScriptRenderer: renderer,
}
}
func defaultScriptRenderer() *rendering.Engine {
engine, _ := rendering.LoadDefault()
return engine
}
func decodeHeader(b [headerSize]byte) Header {
return Header{
Signature: binary.LittleEndian.Uint32(b[0:]),
FileCount: int32(binary.LittleEndian.Uint32(b[24:])),
Padding: int32(binary.LittleEndian.Uint32(b[28:])),
BodySize: int32(binary.LittleEndian.Uint32(b[32:])),
GroupCount: int32(binary.LittleEndian.Uint32(b[36:])),
HashTableSize: int32(binary.LittleEndian.Uint32(b[40:])),
NameTableSize: int32(binary.LittleEndian.Uint32(b[44:])),
}
}
// Header returns the (decrypted) archive header.
func (a *Archive) Header() Header { return a.hdr }
// UsesGuard reports whether the archive header uses the 0x55 guard variant.
func (a *Archive) UsesGuard() bool { return a.guard }
// IsPaged reports whether the archive uses page guards, independently of its
// client version.
func (a *Archive) IsPaged() bool { return a.format.container == pagedContainer }
// IsPaged110 is the legacy name for the shared page-guard capability.
// Deprecated: use IsPaged for capabilities or ClientVersion for client identity.
func (a *Archive) IsPaged110() bool { return a.IsPaged() }
// FileCount returns the number of file entries.
func (a *Archive) FileCount() int32 { return int32(len(a.items)) }
// File describes one entry.
type File struct {
Index int32
Name, Path string // resolved from the string pools; Path is the directory part
ChunkIndex int32
DataOffset int32
DataSize int32
DataType int32
}
// File returns entry i.
func (a *Archive) File(i int32) File {
it := &a.items[i]
size := it.size
if payload, ok := a.overlay[i]; ok {
size = int32(len(payload))
}
a.cacheMu.Lock()
name := a.resolveStringLocked(it.nameOff)
path := a.resolveStringLocked(it.pathOff)
a.cacheMu.Unlock()
return File{
Index: i,
Name: name,
Path: path,
ChunkIndex: it.chunk,
DataOffset: it.off,
DataSize: size,
DataType: it.typ,
}
}
// FullPath returns the canonical "dir/name" path of entry i.
func (a *Archive) FullPath(i int32) string {
it := &a.items[i]
a.cacheMu.Lock()
dir := a.resolveStringLocked(it.pathOff)
name := a.resolveStringLocked(it.nameOff)
a.cacheMu.Unlock()
return joinPath(dir, name)
}
// Path is an alias of FullPath.
func (a *Archive) Path(i int32) string { return a.FullPath(i) }
// Find looks up an entry by "dir/name" path (case-insensitive).
func (a *Archive) Find(path string) (int32, bool) {
return a.findNormalizedPath(normalizePath(path))
}
// FindList looks up a `.lst` index across client layouts. The 90US clients keep
// a list next to the files it indexes (`equipment/equipment.lst`), while the
// 110US clients collect every list under `list/` (`list/equipment.lst`) and
// store archive-root-relative entry paths inside it. Both layouts are tried in
// that order, so a configured 90US path also resolves on a newer archive.
func (a *Archive) FindList(path string) (int32, bool) {
for _, candidate := range listLookupCandidates(path) {
if i, ok := a.Find(candidate); ok {
return i, true
}
}
return 0, false
}
func listLookupCandidates(path string) []string {
trimmed := strings.Trim(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"), "/")
if trimmed == "" {
return nil
}
candidates := []string{trimmed}
base := trimmed
if slash := strings.LastIndexByte(base, '/'); slash >= 0 {
base = base[slash+1:]
}
if base == "" || strings.EqualFold(base, trimmed) {
return candidates
}
candidates = append(candidates, "list/"+base)
// A configured `list/x.lst` also names the 90US location `<stem>/x.lst`,
// e.g. `list/equipment.lst` -> `equipment/equipment.lst`.
if dir := trimmed[:strings.LastIndexByte(trimmed, '/')]; strings.EqualFold(dir, "list") {
stem := strings.TrimSuffix(base, pathExt(base))
if stem != "" {
candidates = append(candidates, stem+"/"+base)
}
}
return candidates
}
func pathExt(name string) string {
if dot := strings.LastIndexByte(name, '.'); dot > 0 {
return name[dot:]
}
return ""
}
// Chunk returns decompressed chunk ci, caching the result.
func (a *Archive) Chunk(ci int32) ([]byte, error) {
var started time.Time
if buildmode.Development {
started = time.Now()
}
a.cacheMu.Lock()
defer a.cacheMu.Unlock()
cached := false
bytes := 0
if buildmode.Development {
wait := time.Since(started)
defer func() {
if elapsed := time.Since(started); elapsed >= 50*time.Millisecond {
log.Printf("[DEBUG] pvf.Chunk chunk=%d cache-hit=%t bytes=%d cache-lock-wait=%s work=%s total=%s",
ci, cached, bytes, wait, elapsed-wait, elapsed)
}
}()
}
if ch, ok := a.chunkCache[ci]; ok {
cached, bytes = true, len(ch)
a.chunkCacheClock++
entry := a.chunkCacheMeta[ci]
entry.used = a.chunkCacheClock
a.chunkCacheMeta[ci] = entry
return ch, nil
}
raw, err := a.decompressChunk(ci)
bytes = len(raw)
if err != nil {
return nil, err
}
if raw == nil {
return nil, nil
}
if a.chunkCacheMeta == nil {
a.chunkCacheMeta = make(map[int32]chunkCacheEntry)
}
a.chunkCacheClock++
if int64(len(raw)) <= defaultChunkCacheLimit {
for a.chunkCacheBytes+int64(len(raw)) > defaultChunkCacheLimit && len(a.chunkCache) > 0 {
var oldest int32
var oldestUsed uint64
first := true
for index, entry := range a.chunkCacheMeta {
if first || entry.used < oldestUsed {
oldest, oldestUsed, first = index, entry.used, false
}
}
if first {
break
}
delete(a.chunkCache, oldest)
if entry, ok := a.chunkCacheMeta[oldest]; ok {
a.chunkCacheBytes -= entry.bytes
}
delete(a.chunkCacheMeta, oldest)
}
a.chunkCache[ci] = raw
a.chunkCacheMeta[ci] = chunkCacheEntry{bytes: int64(len(raw)), used: a.chunkCacheClock}
a.chunkCacheBytes += int64(len(raw))
}
return raw, nil
}
// chunkSpan returns the raw (still encrypted) body byte range of chunk ci.
func (a *Archive) chunkSpan(ci int32) ([]byte, bool) {
if ci < 0 || ci >= int32(len(a.groups)) {
return nil, false
}
prev := int64(0)
if ci > 0 {
prev = int64(a.groups[ci-1].compSize)
}
start := a.bodyOff + int(prev)
end := a.bodyOff + int(a.groups[ci].compSize)
if start < a.bodyOff || start > end || end > len(a.data) {
return nil, false
}
return a.data[start:end], true
}
// normalizePath mirrors the reference implementation's path normalization.
func normalizePath(p string) string {
p = strings.ReplaceAll(p, "\\", "/")
p = strings.TrimSpace(p)
for strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/") {
if strings.HasPrefix(p, "./") {
p = p[2:]
} else {
p = p[1:]
}
}
return strings.ToLower(strings.TrimRight(p, "/"))
}
// joinPath joins raw pool strings for display. No normalization: entries
// may legitimately contain odd whitespace (the reference implementation
// preserves it and only normalizes lookup keys).
func joinPath(dir, name string) string {
switch {
case dir == "":
return name
case name == "":
return dir
default:
return dir + "/" + name
}
}
// ArchiveInfoView is a UI-facing snapshot of the archive state. Paged110 is a
// legacy wire field for the shared paged capability, not a client version.
type ArchiveInfoView struct {
Path string `json:"path"`
FileCount int32 `json:"fileCount"`
GroupCount int32 `json:"groupCount"`
BodySize int32 `json:"bodySize"`
ModifiedCount int `json:"modifiedCount"`
UsesGuard bool `json:"usesGuard"`
Paged110 bool `json:"paged110"`
Format string `json:"format"`
ContentRules ContentRules `json:"contentRules"`
WriteCapabilities WriteCapabilities `json:"writeCapabilities"`
TextEncoding string `json:"textEncoding"`
TextEncodingLabel string `json:"textEncodingLabel"`
}
// Info returns the current state snapshot.
func (a *Archive) Info() ArchiveInfoView {
return ArchiveInfoView{
TextEncoding: a.TextEncoding(),
TextEncodingLabel: a.TextEncodingLabel(),
Path: a.sourcePath,
FileCount: int32(len(a.items)),
GroupCount: a.hdr.GroupCount,
BodySize: a.hdr.BodySize,
ModifiedCount: a.ModifiedCount(),
UsesGuard: a.guard,
Paged110: a.IsPaged(),
Format: a.Format(),
ContentRules: a.ContentRules(),
WriteCapabilities: a.WriteCapabilities(),
}
}