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