Files
90CNServer/server/cmd/scratch/skillnpkpathaudit/main.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

557 lines
18 KiB
Go

// skillnpkpathaudit closes the loop between PVF skill/ANI IMG references and
// the internal IMG indexes of a selected set of NPK archives.
package main
import (
"bufio"
"encoding/binary"
"encoding/csv"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
platformpvf "longheng.io/server/internal/platform/pvf"
)
const npkMagic = "NeoplePack_Bill\x00"
var (
npkNameKey = []byte("puchikon@neople dungeon and fighter " +
"DNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNFDNF\x00")
imagePair = regexp.MustCompile("(?i)`([^`]+\\.img)`\\s+(-?\\d+)")
)
type npkEntry struct {
Path string
Offset uint32
Length uint32
Frames uint32
}
type documentRefs struct {
Path string
Refs []imageRef
}
type imageRef struct {
Path string
Frame int
}
type packageResult struct {
Name string `json:"name"`
IMGEntries int `json:"img_entries"`
ReferencedIMGPaths int `json:"referenced_img_paths"`
ReferencingPVFANIs int `json:"referencing_pvf_anis"`
DirectSkillIconRefs int `json:"direct_skill_icon_refs"`
}
type unmatchedRef struct {
Document string `json:"document"`
IMG string `json:"img"`
Frame int `json:"frame"`
OwnerNPK string `json:"owner_npk,omitempty"`
}
type frameFailure struct {
Document string `json:"document"`
IMG string `json:"img"`
Frame int `json:"frame"`
Frames uint32 `json:"available_frames"`
}
type report struct {
SelectedPackages int `json:"selected_packages"`
SelectedUniqueIMGPaths int `json:"selected_unique_img_paths"`
SelectedDuplicateIMGPaths int `json:"selected_duplicate_img_paths"`
SelectedNPKReadFailures []string `json:"selected_npk_read_failures,omitempty"`
PVFAnimationDocumentsScanned int `json:"pvf_animation_documents_scanned"`
RelevantPVFAnimationDocs int `json:"relevant_pvf_animation_documents"`
RelevantPVFIMGReferences int `json:"relevant_pvf_img_references"`
SelectedPVFIMGReferences int `json:"selected_pvf_img_references"`
ExternalPVFIMGReferences int `json:"external_pvf_img_references"`
MissingPVFIMGReferences int `json:"missing_pvf_img_references"`
OutOfRangeFrameReferences int `json:"out_of_range_frame_references"`
ExclusiveSkills int `json:"exclusive_skills"`
ExclusiveSkillIconReferences int `json:"exclusive_skill_icon_references"`
MatchedSkillIconReferences int `json:"matched_skill_icon_references"`
ExternalSkillIconReferences int `json:"external_skill_icon_references"`
MissingSkillIconReferences int `json:"missing_skill_icon_references"`
Packages []packageResult `json:"packages"`
PackagesWithoutPVFReference []string `json:"packages_without_pvf_reference,omitempty"`
ExternalReferences []unmatchedRef `json:"external_references,omitempty"`
MissingReferences []unmatchedRef `json:"missing_references,omitempty"`
FrameFailures []frameFailure `json:"frame_failures,omitempty"`
MissingSkillIcons []unmatchedRef `json:"missing_skill_icons,omitempty"`
}
func main() {
pvfPath := flag.String("pvf", "", "Script.pvf path")
manifestPath := flag.String("manifest", "", "selected package CSV containing a filename column")
selectedDir := flag.String("selected-dir", "", "directory containing selected NPKs")
allNPKDir := flag.String("all-npk-dir", "", "complete source ImagePacks2 directory")
skillSpecs := flag.String("skills", "", "comma-separated archive-list:branch pairs")
aniPrefixes := flag.String("ani-prefixes", "", "comma-separated PVF animation path prefixes")
flag.Parse()
for name, value := range map[string]string{
"-pvf": *pvfPath, "-manifest": *manifestPath, "-selected-dir": *selectedDir,
"-all-npk-dir": *allNPKDir, "-skills": *skillSpecs, "-ani-prefixes": *aniPrefixes,
} {
if strings.TrimSpace(value) == "" {
fmt.Fprintf(os.Stderr, "%s is required\n", name)
os.Exit(2)
}
}
selectedNames, err := readManifest(*manifestPath)
check(err)
selectedOwners := make(map[string][]string)
selectedEntries := make(map[string]npkEntry)
packageEntries := make(map[string]int)
selectedFailures := make([]string, 0)
for _, name := range selectedNames {
entries, readErr := readNPK(filepath.Join(*selectedDir, name), true)
if readErr != nil {
selectedFailures = append(selectedFailures, fmt.Sprintf("%s: %v", name, readErr))
continue
}
packageEntries[name] = len(entries)
for _, entry := range entries {
key := normalizeIMG(entry.Path)
selectedOwners[key] = appendUnique(selectedOwners[key], name)
if _, exists := selectedEntries[key]; !exists {
selectedEntries[key] = entry
}
}
}
allOwners := make(map[string]string)
allFiles, err := filepath.Glob(filepath.Join(*allNPKDir, "*.NPK"))
check(err)
for _, filePath := range allFiles {
entries, readErr := readNPK(filePath, false)
if readErr != nil {
continue
}
name := filepath.Base(filePath)
for _, entry := range entries {
key := normalizeIMG(entry.Path)
if _, exists := allOwners[key]; !exists {
allOwners[key] = name
}
}
}
archive, err := platformpvf.Open(*pvfPath)
check(err)
prefixes := splitNormalized(*aniPrefixes)
documents := make([]documentRefs, 0, 4096)
for _, file := range archive.Files() {
archivePath := normalize(file.ArchivePath)
if !hasAnyPrefix(archivePath, prefixes) || !(strings.HasSuffix(archivePath, ".ani") || strings.HasSuffix(archivePath, ".ani.als")) {
continue
}
text, readErr := archive.ReadText(file.ArchivePath)
if readErr != nil {
continue
}
refs := parseImageRefs(text)
if len(refs) > 0 {
documents = append(documents, documentRefs{Path: archivePath, Refs: refs})
}
}
// Generic icon/cut-in/grow-type containers must not make every class ANI a
// seed. A relevant ANI is seeded by a profession-specific effect archive.
seedPaths := make(map[string]struct{})
for imagePath, owners := range selectedOwners {
for _, owner := range owners {
lower := strings.ToLower(owner)
if lower == "sprite_character_swordman_effect.npk" || lower == "sprite_character_mage_effect.npk" ||
strings.Contains(lower, "effect_cutin") || strings.Contains(lower, "equipment_growtype") {
continue
}
seedPaths[imagePath] = struct{}{}
}
}
packageReferencedPaths := make(map[string]map[string]struct{})
packageDocuments := make(map[string]map[string]struct{})
external := make([]unmatchedRef, 0)
missing := make([]unmatchedRef, 0)
frameFailures := make([]frameFailure, 0)
relevantDocuments := 0
relevantRefs := 0
selectedRefs := 0
for _, document := range documents {
relevant := false
for _, ref := range document.Refs {
if _, ok := seedPaths[ref.Path]; ok {
relevant = true
break
}
}
if !relevant {
continue
}
relevantDocuments++
for _, ref := range document.Refs {
relevantRefs++
if owners := selectedOwners[ref.Path]; len(owners) > 0 {
selectedRefs++
entry := selectedEntries[ref.Path]
if ref.Frame >= 0 && uint32(ref.Frame) >= entry.Frames {
frameFailures = append(frameFailures, frameFailure{Document: document.Path, IMG: ref.Path, Frame: ref.Frame, Frames: entry.Frames})
}
for _, owner := range owners {
if packageReferencedPaths[owner] == nil {
packageReferencedPaths[owner] = make(map[string]struct{})
packageDocuments[owner] = make(map[string]struct{})
}
packageReferencedPaths[owner][ref.Path] = struct{}{}
packageDocuments[owner][document.Path] = struct{}{}
}
continue
}
owner := allOwners[ref.Path]
row := unmatchedRef{Document: document.Path, IMG: ref.Path, Frame: ref.Frame, OwnerNPK: owner}
if owner == "" {
missing = append(missing, row)
} else {
external = append(external, row)
}
}
}
exclusiveSkills, iconRefs, matchedIcons, externalIcons, missingIcons := auditSkills(archive, *skillSpecs, selectedOwners, allOwners, packageReferencedPaths)
packages := make([]packageResult, 0, len(selectedNames))
withoutRefs := make([]string, 0)
for _, name := range selectedNames {
row := packageResult{
Name: name, IMGEntries: packageEntries[name],
ReferencedIMGPaths: len(packageReferencedPaths[name]), ReferencingPVFANIs: len(packageDocuments[name]),
}
for _, ref := range matchedIcons {
for _, owner := range selectedOwners[ref.IMG] {
if owner == name {
row.DirectSkillIconRefs++
}
}
}
if row.ReferencedIMGPaths == 0 && row.DirectSkillIconRefs == 0 {
withoutRefs = append(withoutRefs, name)
}
packages = append(packages, row)
}
sort.Slice(packages, func(i, j int) bool { return packages[i].Name < packages[j].Name })
sort.Strings(withoutRefs)
report := report{
SelectedPackages: len(selectedNames), SelectedUniqueIMGPaths: len(selectedOwners),
SelectedDuplicateIMGPaths: duplicateCount(selectedOwners), SelectedNPKReadFailures: selectedFailures,
PVFAnimationDocumentsScanned: len(documents), RelevantPVFAnimationDocs: relevantDocuments,
RelevantPVFIMGReferences: relevantRefs, SelectedPVFIMGReferences: selectedRefs,
ExternalPVFIMGReferences: len(external), MissingPVFIMGReferences: len(missing),
OutOfRangeFrameReferences: len(frameFailures), ExclusiveSkills: exclusiveSkills,
ExclusiveSkillIconReferences: iconRefs, MatchedSkillIconReferences: len(matchedIcons),
ExternalSkillIconReferences: len(externalIcons), MissingSkillIconReferences: len(missingIcons),
Packages: packages, PackagesWithoutPVFReference: withoutRefs,
ExternalReferences: uniqueUnmatched(external), MissingReferences: uniqueUnmatched(missing),
FrameFailures: frameFailures, MissingSkillIcons: uniqueUnmatched(missingIcons),
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
check(encoder.Encode(report))
}
func auditSkills(archive *platformpvf.Archive, specs string, selected map[string][]string, all map[string]string, packageRefs map[string]map[string]struct{}) (int, int, []unmatchedRef, []unmatchedRef, []unmatchedRef) {
exclusiveCount := 0
iconCount := 0
matched := make([]unmatchedRef, 0)
external := make([]unmatchedRef, 0)
missing := make([]unmatchedRef, 0)
for _, rawSpec := range strings.Split(specs, ",") {
parts := strings.Split(strings.TrimSpace(rawSpec), ":")
if len(parts) != 2 {
panic(fmt.Errorf("invalid skill spec %q", rawSpec))
}
branch, err := strconv.Atoi(parts[1])
check(err)
listPath := normalize(parts[0])
listText, err := archive.ReadText(listPath)
check(err)
listDoc, err := dnfpvf.Parse(listPath, listText)
check(err)
for _, item := range dnfpvf.ParseList(listDoc) {
docPath := resolve(archive, listPath, item.Path)
if docPath == "" {
continue
}
text, readErr := archive.ReadText(docPath)
if readErr != nil {
continue
}
doc, parseErr := dnfpvf.Parse(docPath, text)
if parseErr != nil || !exclusiveToBranch(doc, branch) {
continue
}
exclusiveCount++
for _, ref := range parseImageRefs(text) {
iconCount++
row := unmatchedRef{Document: docPath, IMG: ref.Path, Frame: ref.Frame, OwnerNPK: all[ref.Path]}
if owners := selected[ref.Path]; len(owners) > 0 {
matched = append(matched, row)
for _, owner := range owners {
if packageRefs[owner] == nil {
packageRefs[owner] = make(map[string]struct{})
}
packageRefs[owner][ref.Path] = struct{}{}
}
} else if row.OwnerNPK != "" {
external = append(external, row)
} else {
missing = append(missing, row)
}
}
}
}
return exclusiveCount, iconCount, matched, external, missing
}
func exclusiveToBranch(doc *dnfpvf.Document, wanted int) bool {
primary := doc.Ints("growtype maximum level")
second := doc.Ints("second growtype maximum level")
fitness := doc.Ints("skill fitness growtype")
if !enabled(primary, second, fitness, wanted) {
return false
}
branches := len(primary)
if half := len(second) / 2; half > branches {
branches = half
}
for branch := 0; branch < branches; branch++ {
if branch != wanted && enabled(primary, second, fitness, branch) {
return false
}
}
return true
}
func enabled(primary, second, fitness []int64, branch int) bool {
if branch < len(primary) && primary[branch] > 0 {
return true
}
first := branch * 2
if first < len(second) && second[first] > 0 || first+1 < len(second) && second[first+1] > 0 {
return true
}
for _, value := range fitness {
if int(value) == branch {
return true
}
}
return false
}
func parseImageRefs(text string) []imageRef {
refs := make([]imageRef, 0)
for _, match := range imagePair.FindAllStringSubmatch(text, -1) {
frame, _ := strconv.Atoi(match[2])
refs = append(refs, imageRef{Path: normalizeIMG(match[1]), Frame: frame})
}
return refs
}
func readManifest(filePath string) ([]string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
reader := csv.NewReader(file)
rows, err := reader.ReadAll()
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("empty manifest")
}
filenameColumn := -1
for index, name := range rows[0] {
if strings.EqualFold(strings.TrimSpace(name), "filename") {
filenameColumn = index
break
}
}
if filenameColumn < 0 {
return nil, fmt.Errorf("manifest has no filename column")
}
names := make([]string, 0, len(rows)-1)
for _, row := range rows[1:] {
if filenameColumn < len(row) && strings.TrimSpace(row[filenameColumn]) != "" {
names = append(names, strings.TrimSpace(row[filenameColumn]))
}
}
return names, nil
}
func readNPK(filePath string, metadata bool) ([]npkEntry, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
header := make([]byte, 20)
if _, err := io.ReadFull(file, header); err != nil {
return nil, err
}
if string(header[:16]) != npkMagic {
return nil, fmt.Errorf("invalid magic")
}
count := binary.LittleEndian.Uint32(header[16:20])
if count > 1_000_000 {
return nil, fmt.Errorf("unreasonable IMG count %d", count)
}
entries := make([]npkEntry, 0, count)
for index := uint32(0); index < count; index++ {
row := make([]byte, 264)
if _, err := io.ReadFull(file, row); err != nil {
return nil, err
}
nameBytes := append([]byte(nil), row[8:]...)
for position := range nameBytes {
nameBytes[position] ^= npkNameKey[position]
}
name := string(nameBytes)
if zero := strings.IndexByte(name, 0); zero >= 0 {
name = name[:zero]
}
entries = append(entries, npkEntry{Path: strings.TrimSpace(name), Offset: binary.LittleEndian.Uint32(row[:4]), Length: binary.LittleEndian.Uint32(row[4:8])})
}
if metadata {
for index := range entries {
entries[index].Frames, err = readFrames(file, entries[index])
if err != nil {
return nil, fmt.Errorf("%s: %w", entries[index].Path, err)
}
}
}
return entries, nil
}
func readFrames(file *os.File, entry npkEntry) (uint32, error) {
if _, err := file.Seek(int64(entry.Offset), io.SeekStart); err != nil {
return 0, err
}
reader := bufio.NewReader(file)
magic, err := reader.ReadString(0)
if err != nil {
return 0, err
}
var metadata [16]byte
if _, err := io.ReadFull(reader, metadata[:]); err != nil {
return 0, err
}
if strings.HasPrefix(magic, "Neople Image File") {
return binary.LittleEndian.Uint32(metadata[10:14]), nil
}
if strings.HasPrefix(magic, "Neople Img File") {
return binary.LittleEndian.Uint32(metadata[12:16]), nil
}
return 0, fmt.Errorf("unsupported IMG magic %q", strings.TrimSuffix(magic, "\x00"))
}
func resolve(archive *platformpvf.Archive, listPath, reference string) string {
reference = strings.TrimSpace(strings.Trim(reference, "`\"'"))
for _, candidate := range []string{reference, path.Join(path.Dir(listPath), reference)} {
if file, ok := archive.FindFile(candidate); ok {
return file.ArchivePath
}
}
return ""
}
func uniqueUnmatched(rows []unmatchedRef) []unmatchedRef {
seen := make(map[string]struct{})
result := make([]unmatchedRef, 0, len(rows))
for _, row := range rows {
key := row.Document + "\x00" + row.IMG + "\x00" + strconv.Itoa(row.Frame)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, row)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Document != result[j].Document {
return result[i].Document < result[j].Document
}
if result[i].IMG != result[j].IMG {
return result[i].IMG < result[j].IMG
}
return result[i].Frame < result[j].Frame
})
return result
}
func duplicateCount(owners map[string][]string) int {
count := 0
for _, names := range owners {
if len(names) > 1 {
count++
}
}
return count
}
func appendUnique(values []string, value string) []string {
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
func splitNormalized(value string) []string {
parts := make([]string, 0)
for _, item := range strings.Split(value, ",") {
if item = normalize(item); item != "" {
parts = append(parts, item)
}
}
return parts
}
func hasAnyPrefix(value string, prefixes []string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
}
func normalizeIMG(value string) string {
value = normalize(strings.Trim(value, "`\"'"))
return strings.TrimPrefix(value, "sprite/")
}
func normalize(value string) string {
return strings.ToLower(strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/"))
}
func check(err error) {
if err != nil {
panic(err)
}
}