Files
90CNServer/pvf/internal/pvf/metadata.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

696 lines
21 KiB
Go

package pvf
import (
"encoding/binary"
"fmt"
"math"
"strconv"
"strings"
)
// ListPair is one id/path pair from a .lst script.
type ListPair struct {
ID string `json:"id"`
Path string `json:"path"`
}
// listRecord is one decoded id/path token pair together with its original
// tokens. Unchanged records are re-emitted verbatim so token encoding and
// string-pool offsets stay stable across an edit.
type listRecord struct {
id string
path string
idToken batchToken
pathToken batchToken
}
// ScriptImageReference is the raw PVF representation used by [icon] and
// [field image]. Services map it to their public ImageReference type.
type ScriptImageReference struct {
Path string `json:"path"`
Index int32 `json:"index"`
}
// StringTableReference identifies one placeholder used by a script metadata
// field. Services use it to invalidate only registered records whose display
// metadata actually depends on a changed localization file.
type StringTableReference struct {
Index int `json:"index"`
Key string `json:"key"`
}
// ScriptMetadata is the small metadata projection shared by name/search and
// image rendering. It intentionally does not decompile the full script.
type ScriptMetadata struct {
Name string `json:"name,omitempty"`
HasName bool `json:"-"`
Icon *ScriptImageReference `json:"icon,omitempty"`
FieldImage *ScriptImageReference `json:"fieldImage,omitempty"`
StringTableReferences []StringTableReference `json:"-"`
// Rarity is the raw value of the first [rarity] section, which the
// frontend maps to a display color (equipment and stackables share the
// same 0..6 scale). HasRarity reports whether the section exists at all.
Rarity int32 `json:"rarity"`
HasRarity bool `json:"-"`
// NameFallback reports that the name's `<table::key>` placeholder was
// answered by a language overlay because the archive's own localization has
// no text for that key, so a caller that displays the name can flag it.
NameFallback bool `json:"-"`
}
// RarityUnknown is the sentinel used by callers that project Rarity into a
// plain int32: it means the file declares no [rarity] section.
const RarityUnknown = -1
// RarityValue returns Rarity, or RarityUnknown when the script has no
// [rarity] section, so callers never mistake an absent value for "普通".
func (m ScriptMetadata) RarityValue() int32 {
if !m.HasRarity {
return RarityUnknown
}
return m.Rarity
}
// ScriptListPairs parses a TypeScript .lst payload as consecutive id/path
// token pairs. The .lst line layout is presentation-only, so token pairs are
// used instead of trying to recover source lines from the binary payload.
func (a *Archive) ScriptListPairs(i int32) ([]ListPair, error) {
if i < 0 || i >= int32(len(a.items)) {
return nil, ErrBadIndex
}
if a.items[i].typ != TypeScript {
return nil, fmt.Errorf("pvf: file %d is not a script", i)
}
raw, err := a.RawBytes(i)
if err != nil {
return nil, err
}
if len(raw)%5 != 0 {
return nil, fmt.Errorf("pvf: malformed script payload for file %d", i)
}
values := make([]scriptMetadataValue, 0, len(raw)/5)
invalid := false
for pos := 0; pos < len(raw); pos += 5 {
value, ok := a.scriptMetadataValue(raw[pos], int32(binary.LittleEndian.Uint32(raw[pos+1:])))
if !ok {
invalid = true
}
values = append(values, value)
}
if invalid || len(values)%2 != 0 {
return nil, fmt.Errorf("pvf: incomplete or unsupported .lst pair data for file %d", i)
}
pairs := make([]ListPair, 0, len(values)/2)
for pos := 0; pos+1 < len(values); pos += 2 {
id := strings.TrimSpace(values[pos].text)
path := strings.TrimSpace(values[pos+1].text)
if id == "" || path == "" {
continue
}
pairs = append(pairs, ListPair{ID: id, Path: path})
}
return pairs, nil
}
// RemoveListPairs removes matching id/path pairs from a TypeScript .lst
// payload while preserving the original token encoding of every remaining
// pair. Duplicate matching pairs are removed together.
func (a *Archive) RemoveListPairs(i int32, entries []ListPair) (int, error) {
if i < 0 || i >= int32(len(a.items)) {
return 0, ErrBadIndex
}
if a.items[i].typ != TypeScript {
return 0, fmt.Errorf("pvf: file %d is not a script", i)
}
if len(entries) == 0 {
return 0, nil
}
targets := make(map[string]struct{}, len(entries))
for _, entry := range entries {
id := strings.TrimSpace(entry.ID)
path := strings.TrimSpace(entry.Path)
if id == "" || path == "" {
continue
}
targets[id+"\x00"+path] = struct{}{}
}
if len(targets) == 0 {
return 0, nil
}
raw, err := a.RawBytes(i)
if err != nil {
return 0, err
}
if len(raw)%5 != 0 {
return 0, fmt.Errorf("pvf: malformed script payload for file %d", i)
}
values := make([]scriptMetadataValue, 0, len(raw)/5)
for pos := 0; pos < len(raw); pos += 5 {
value, ok := a.scriptMetadataValue(raw[pos], int32(binary.LittleEndian.Uint32(raw[pos+1:])))
if !ok {
return 0, fmt.Errorf("pvf: incomplete or unsupported .lst pair data for file %d", i)
}
values = append(values, value)
}
if len(values)%2 != 0 {
return 0, fmt.Errorf("pvf: incomplete or unsupported .lst pair data for file %d", i)
}
result := make([]byte, 0, len(raw))
removed := 0
for pos := 0; pos < len(values); pos += 2 {
id := strings.TrimSpace(values[pos].text)
path := strings.TrimSpace(values[pos+1].text)
if _, ok := targets[id+"\x00"+path]; ok && id != "" && path != "" {
removed++
continue
}
result = append(result, raw[pos*5:(pos+2)*5]...)
}
if removed == 0 {
return 0, nil
}
if err := a.SetRawBytes(i, result); err != nil {
return 0, err
}
return removed, nil
}
// decodeListRecords decodes a .lst payload into records. Every token must be
// a valid id/path member and the token count must be even.
func (a *Archive) decodeListRecords(i int32, raw []byte) ([]listRecord, error) {
if len(raw)%5 != 0 {
return nil, fmt.Errorf("pvf: malformed script payload for file %d", i)
}
tokens := make([]batchToken, 0, len(raw)/5)
values := make([]scriptMetadataValue, 0, len(raw)/5)
for pos := 0; pos < len(raw); pos += 5 {
value, ok := a.scriptMetadataValue(raw[pos], int32(binary.LittleEndian.Uint32(raw[pos+1:])))
if !ok {
return nil, fmt.Errorf("pvf: incomplete or unsupported .lst pair data for file %d", i)
}
tokens = append(tokens, batchToken{
typ: raw[pos],
value: int32(binary.LittleEndian.Uint32(raw[pos+1:])),
})
values = append(values, value)
}
if len(values)%2 != 0 {
return nil, fmt.Errorf("pvf: incomplete or unsupported .lst pair data for file %d", i)
}
records := make([]listRecord, 0, len(values)/2)
for pos := 0; pos+1 < len(values); pos += 2 {
records = append(records, listRecord{
id: strings.TrimSpace(values[pos].text),
path: strings.TrimSpace(values[pos+1].text),
idToken: tokens[pos],
pathToken: tokens[pos+1],
})
}
return records, nil
}
// listPathToken encodes a .lst entry path as the backtick-quoted string token
// used by real list files.
func (a *Archive) listPathToken(path string) batchToken {
return batchToken{typ: 6, value: a.StringOffset(path)}
}
// listIDToken encodes a list id. Numeric ids keep the integer token used by
// real .lst files; anything else falls back to a quoted string.
func (a *Archive) listIDToken(id string) batchToken {
if number, err := strconv.ParseInt(id, 10, 32); err == nil {
return batchToken{typ: 0, value: int32(number)}
}
return batchToken{typ: 6, value: a.StringOffset(id)}
}
func encodeListRecords(records []listRecord) []byte {
raw := make([]byte, 0, len(records)*10)
for _, record := range records {
raw = append(raw, encodeBatchTokens([]batchToken{record.idToken, record.pathToken})...)
}
return raw
}
// ListPairs returns the id/path pairs of a .lst payload in file order. When an
// id repeats, the first occurrence wins.
func (a *Archive) ListPairs(i int32) ([]ListPair, error) {
records, err := a.listRecordsAt(i)
if err != nil {
return nil, err
}
seen := make(map[string]struct{}, len(records))
pairs := make([]ListPair, 0, len(records))
for _, record := range records {
if record.id == "" || record.path == "" {
continue
}
if _, duplicate := seen[record.id]; duplicate {
continue
}
seen[record.id] = struct{}{}
pairs = append(pairs, ListPair{ID: record.id, Path: record.path})
}
return pairs, nil
}
func (a *Archive) listRecordsAt(i int32) ([]listRecord, error) {
if i < 0 || i >= int32(len(a.items)) {
return nil, ErrBadIndex
}
if a.items[i].typ != TypeScript {
return nil, fmt.Errorf("pvf: file %d is not a script", i)
}
raw, err := a.RawBytes(i)
if err != nil {
return nil, err
}
return a.decodeListRecords(i, raw)
}
// SetListPairs inserts or updates id/path pairs in a .lst payload. An existing
// id keeps its position and id-token encoding while its path is replaced; a
// new id is appended. Duplicate records for an id are collapsed into the first
// one, so an id addresses exactly one entry afterwards.
func (a *Archive) SetListPairs(i int32, pairs []ListPair) error {
if len(pairs) == 0 {
return nil
}
records, err := a.listRecordsAt(i)
if err != nil {
return err
}
// Match by trimmed id, mirroring how ListPairs reads the file. The first
// occurrence wins, so duplicates for the same id collapse into it.
firstByID := make(map[string]int, len(records))
for position, record := range records {
if _, exists := firstByID[record.id]; !exists {
firstByID[record.id] = position
}
}
requested := make(map[string]listRecord, len(pairs))
appended := make([]listRecord, 0, len(pairs))
for _, pair := range pairs {
id := strings.TrimSpace(pair.ID)
path := strings.TrimSpace(pair.Path)
if id == "" || path == "" {
return fmt.Errorf("pvf: 列表条目的 id 和路径不能为空")
}
record := listRecord{
id: id,
path: path,
idToken: a.listIDToken(id),
pathToken: a.listPathToken(path),
}
position, exists := firstByID[id]
if !exists {
firstByID[id] = len(records) + len(appended)
appended = append(appended, record)
continue
}
// An updated entry keeps the original id token so its encoding and
// string-pool offset are untouched.
record.idToken = records[position].idToken
requested[id] = record
}
updated := make([]listRecord, 0, len(records)+len(appended))
for position, record := range records {
// A duplicate of an id being set is dropped; the surviving copy is the
// first occurrence, with its replacement applied if there is one.
if _, isSet := requested[record.id]; isSet && firstByID[record.id] != position {
continue
}
if replacement, ok := requested[record.id]; ok && firstByID[record.id] == position {
updated = append(updated, replacement)
continue
}
updated = append(updated, record)
}
updated = append(updated, appended...)
return a.SetRawBytes(i, encodeListRecords(updated))
}
// SetListPair inserts or updates one id/path pair. See SetListPairs.
func (a *Archive) SetListPair(i int32, id, path string) error {
return a.SetListPairs(i, []ListPair{{ID: id, Path: path}})
}
// RemoveListIDs removes every record whose id matches, returning the number of
// removed records. Other records are re-emitted verbatim.
func (a *Archive) RemoveListIDs(i int32, ids []string) (int, error) {
targets := make(map[string]struct{}, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
targets[id] = struct{}{}
}
if len(targets) == 0 {
return 0, nil
}
records, err := a.listRecordsAt(i)
if err != nil {
return 0, err
}
kept := make([]listRecord, 0, len(records))
removed := 0
for _, record := range records {
if _, ok := targets[record.id]; ok && record.id != "" {
removed++
continue
}
kept = append(kept, record)
}
if removed == 0 {
return 0, nil
}
if err := a.SetRawBytes(i, encodeListRecords(kept)); err != nil {
return 0, err
}
return removed, nil
}
// ListID returns the id registered for path. It reports a malformed payload
// rather than treating it as "not found", so a lookup failure cannot be
// mistaken for an absent entry.
func (a *Archive) ListID(i int32, path string) (string, bool, error) {
records, err := a.listRecordsAt(i)
if err != nil {
return "", false, err
}
path = strings.TrimSpace(path)
if path == "" {
return "", false, nil
}
for _, record := range records {
if record.path == path && record.id != "" {
return record.id, true, nil
}
}
return "", false, nil
}
// ScriptMetadata extracts [name], [icon], [field image] and [rarity] in one
// raw token scan without formatting or materializing the full decompiled
// script.
//
// The name is display text: type 8/10 string-pool references are handled like
// the older 5/6/7 ones, and `<table::key>` placeholders (which is what the
// newer clients store instead of the text itself) are resolved through the
// archive's string tables so search and the explorer can match the text the
// player sees.
func (a *Archive) ScriptMetadata(i int32) (ScriptMetadata, error) {
if i < 0 || i >= int32(len(a.items)) {
return ScriptMetadata{}, ErrBadIndex
}
if a.items[i].typ != TypeScript {
return ScriptMetadata{}, fmt.Errorf("pvf: file %d is not a script", i)
}
raw, err := a.RawBytes(i)
if err != nil {
return ScriptMetadata{}, err
}
if len(raw)%5 != 0 {
return ScriptMetadata{}, fmt.Errorf("pvf: malformed script payload for file %d", i)
}
type metadataToken struct {
typ byte
value int32
text string
}
type metadataSection struct {
name string
kind string
depth int
values []metadataToken
}
// PVF scripts contain both paired sections and legacy, unpaired sections.
// First collect closing names so the stack follows the same semantic rule
// as decodeScript and ParseScriptView.
type sectionTag struct {
name string
closing, valid bool
}
// Resolve each tag offset once per extraction. This remains local so edits,
// pool replacement and concurrent readers cannot observe stale tag identities.
tags := make(map[int32]sectionTag)
pairedNames := make(map[string]bool)
for pos := 0; pos < len(raw); pos += 5 {
if raw[pos] != 3 {
continue
}
offset := int32(binary.LittleEndian.Uint32(raw[pos+1:]))
tag, exists := tags[offset]
if !exists {
tag.name, tag.closing, tag.valid = parseSectionTag(a.ResolveString(offset))
tags[offset] = tag
}
if tag.valid && tag.closing {
pairedNames[tag.name] = true
}
}
sections := make([]metadataSection, 0)
stack := make([]int, 0)
activeUnpaired := make(map[int]int)
closeUnpaired := func(depth int) {
delete(activeUnpaired, depth)
}
for pos := 0; pos < len(raw); pos += 5 {
typ := raw[pos]
value := int32(binary.LittleEndian.Uint32(raw[pos+1:]))
if typ == 3 {
tag := tags[value]
name, closing := tag.name, tag.closing
if tag.valid {
depth := len(stack)
closeUnpaired(depth)
if closing {
for position := len(stack) - 1; position >= 0; position-- {
if sections[stack[position]].name == name {
stack = stack[:position]
break
}
}
continue
}
sectionIndex := len(sections)
sections = append(sections, metadataSection{name: name, kind: strings.ToLower(strings.TrimSpace(name)), depth: depth})
if pairedNames[name] {
stack = append(stack, sectionIndex)
} else {
activeUnpaired[depth] = sectionIndex
}
continue
}
}
sectionIndex, ok := activeUnpaired[len(stack)]
if !ok && len(stack) > 0 {
sectionIndex = stack[len(stack)-1]
ok = true
}
if !ok {
continue
}
section := &sections[sectionIndex]
isString := typ == 3 || typ == 5 || typ == 6 || typ == 7 || typ == 8 || typ == 10
switch section.kind {
case "name":
if section.depth != 0 || len(section.values) != 0 || !isString {
continue
}
case "growtype name":
if section.depth != 0 || len(section.values) != 0 {
continue
}
case "rarity":
if len(section.values) != 0 {
continue
}
case "icon", "field image":
if len(section.values) == 2 {
continue // the first valid adjacent path/index pair is complete
}
default:
continue // no decoding or value allocation for unrelated sections
}
text := ""
if isString {
text = a.ResolveString(value)
}
token := metadataToken{typ: typ, value: value, text: text}
switch section.kind {
case "name":
if strings.TrimSpace(text) == "" {
continue
}
case "icon", "field image":
if len(section.values) == 1 && strings.TrimSpace(section.values[0].text) != "" && typ == 0 && value >= 0 {
section.values = append(section.values, token)
continue
}
// Only the previous token can form a pair with the next one.
if len(section.values) != 0 {
section.values[0] = token
continue
}
}
section.values = append(section.values, token)
}
metadata := ScriptMetadata{}
for _, section := range sections {
name := section.kind
switch name {
case "name":
// Entity names only come from top-level sections; a nested [name]
// belongs to an embedded sub-record, not to the file itself.
if section.depth != 0 {
continue
}
if metadata.HasName {
continue
}
for _, token := range section.values {
if (token.typ == 5 || token.typ == 6 || token.typ == 7 || token.typ == 8 || token.typ == 10 || token.typ == 3) && strings.TrimSpace(token.text) != "" {
metadata.Name = token.text
metadata.HasName = true
break
}
}
case "icon", "field image":
if len(section.values) < 2 {
continue
}
for index := 0; index+1 < len(section.values); index++ {
pathToken := section.values[index]
imageIndexToken := section.values[index+1]
if (pathToken.typ != 3 && pathToken.typ != 5 && pathToken.typ != 6 && pathToken.typ != 7 && pathToken.typ != 8 && pathToken.typ != 10) || strings.TrimSpace(pathToken.text) == "" || imageIndexToken.typ != 0 || imageIndexToken.value < 0 {
continue
}
reference := &ScriptImageReference{Path: strings.TrimSpace(pathToken.text), Index: imageIndexToken.value}
if name == "icon" && metadata.Icon == nil {
metadata.Icon = reference
}
if name == "field image" && metadata.FieldImage == nil {
metadata.FieldImage = reference
}
break
}
case "rarity":
// The preview reads the same field as "the first [rarity] token in
// document order", so the index uses that occurrence too. Both
// equipment and stackables store the value as an integer token, but
// a quoted number is also accepted.
if metadata.HasRarity || len(section.values) == 0 {
continue
}
value := section.values[0]
if value.typ == 0 {
metadata.Rarity, metadata.HasRarity = value.value, true
continue
}
if parsed, err := strconv.ParseInt(strings.TrimSpace(value.text), 10, 32); err == nil {
metadata.Rarity, metadata.HasRarity = int32(parsed), true
}
}
}
// Character lists identify base jobs, not the generic [name] of a character.
// Keep this in the shared metadata projection so every index and relation
// uses the same name and localization dependency tracking.
path := strings.ToLower(a.Path(i))
if strings.HasPrefix(path, "character/") && strings.HasSuffix(path, ".chr") {
for _, section := range sections {
if section.depth != 0 || !strings.EqualFold(section.name, "growtype name") || len(section.values) == 0 {
continue
}
if value := section.values[0].text; strings.TrimSpace(value) != "" {
metadata.Name, metadata.HasName = value, true
}
break
}
}
if metadata.HasName {
metadata.StringTableReferences = stringTableReferences(metadata.Name)
metadata.Name, metadata.NameFallback = a.resolvePlaceholders(metadata.Name, "")
}
if metadata.Icon != nil {
metadata.StringTableReferences = append(metadata.StringTableReferences, stringTableReferences(metadata.Icon.Path)...)
}
if metadata.FieldImage != nil {
metadata.StringTableReferences = append(metadata.StringTableReferences, stringTableReferences(metadata.FieldImage.Path)...)
}
return metadata, nil
}
func stringTableReferences(text string) []StringTableReference {
if !strings.Contains(text, "<") || !strings.Contains(text, "::") {
return nil
}
result := make([]StringTableReference, 0, 1)
seen := make(map[string]struct{})
for offset := 0; offset < len(text); {
start := strings.IndexByte(text[offset:], '<')
if start < 0 {
break
}
start += offset
end := strings.IndexByte(text[start:], '>')
if end < 0 {
break
}
end += start
if index, key, ok := parsePlaceholder(text[start : end+1]); ok {
identity := fmt.Sprintf("%d\x00%s", index, key)
if _, exists := seen[identity]; !exists {
seen[identity] = struct{}{}
result = append(result, StringTableReference{Index: index, Key: key})
}
}
offset = end + 1
}
return result
}
// ScriptName extracts the first direct string value from the [name] section
// without formatting or materializing the full decompiled script.
func (a *Archive) ScriptName(i int32) (string, bool, error) {
metadata, err := a.ScriptMetadata(i)
if err != nil {
return "", false, err
}
return metadata.Name, metadata.HasName, nil
}
type scriptMetadataValue struct {
text string
}
func (a *Archive) scriptMetadataValue(typ byte, value int32) (scriptMetadataValue, bool) {
switch typ {
case 0:
return scriptMetadataValue{text: strconv.FormatInt(int64(value), 10)}, true
case 2:
return scriptMetadataValue{text: strconv.FormatFloat(float64(math.Float32frombits(uint32(value))), 'g', -1, 32)}, true
case 3, 5, 6, 7, 8, 10:
return scriptMetadataValue{text: a.ResolveString(value)}, true
default:
return scriptMetadataValue{}, false
}
}