Files
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

643 lines
20 KiB
Go

// pvfshopgift publishes the historical 2017 National Day, 2018 Spring
// Festival, and 2014 SAO Cera packages already present in a runtime PVF.
// It only appends real item definitions to etc/cerashop.etc and redirects
// expired target-script date references to a future date already present in
// the shared string pool. The input archive is never modified in place.
package main
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"math"
"os"
"path/filepath"
"sort"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
platformpvf "longheng.io/server/internal/platform/pvf"
)
const (
ceraShopPath = "etc/cerashop.etc"
stackableListPath = "stackable/stackable.lst"
equipmentListPath = "equipment/equipment.lst"
futureExpiration = "2028-08-16 06:00:00"
expirationDateLayout = "2006-01-02 15:04:05"
packageRowWidth = 11
)
var cst = time.FixedZone("CST", 8*60*60)
type desiredItem struct {
ItemID uint32
Price uint32
Group string
}
type itemReference struct {
Path string
Kind string
}
type packageRow struct {
CommodityID uint32
ItemID uint32
Price uint32
NameMagic int32
JobCode int32
Group string
Name string
Path string
}
type datePatch struct {
Old string `json:"old"`
New string `json:"new"`
Files []string `json:"files"`
}
type report struct {
InputSHA256 string `json:"input_sha256"`
OutputSHA256 string `json:"output_sha256"`
Output string `json:"output"`
Format string `json:"format"`
OriginalRows int `json:"original_package_rows"`
AddedRows int `json:"added_package_rows"`
FirstCommodityID uint32 `json:"first_commodity_id"`
LastCommodityID uint32 `json:"last_commodity_id"`
AddedByGroup map[string]int `json:"added_by_group"`
DatePatches []datePatch `json:"date_patches,omitempty"`
DatePatchFiles int `json:"date_patch_files"`
DatePatchTokens int `json:"date_patch_tokens"`
DatePatchChunks int `json:"date_patch_chunks"`
VerifiedJobCodes map[string]int32 `json:"verified_job_codes"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "pvfshopgift:", err)
os.Exit(1)
}
}
func run() error {
input := flag.String("pvf", "", "input PVF path (never modified)")
output := flag.String("out", "", "output PVF path (must differ from -pvf)")
flag.Parse()
if strings.TrimSpace(*input) == "" || strings.TrimSpace(*output) == "" {
return fmt.Errorf("-pvf and -out are required")
}
inputAbs, err := filepath.Abs(*input)
if err != nil {
return err
}
outputAbs, err := filepath.Abs(*output)
if err != nil {
return err
}
if strings.EqualFold(inputAbs, outputAbs) {
return fmt.Errorf("output must differ from input; in-place patching is not supported")
}
inputBytes, err := os.ReadFile(inputAbs)
if err != nil {
return err
}
archive, err := platformpvf.OpenBytes(inputBytes)
if err != nil {
return err
}
refs, stackableIDs, err := loadItemReferences(archive)
if err != nil {
return err
}
desired, err := desiredItems(stackableIDs)
if err != nil {
return err
}
if len(desired) != 228 {
return fmt.Errorf("target item count=%d, want 228", len(desired))
}
dateGroups, err := collectExpiredTargetDates(archive, refs, desired)
if err != nil {
return err
}
result := report{
InputSHA256: checksum(inputBytes),
Output: outputAbs,
Format: string(archive.Format()),
AddedByGroup: make(map[string]int),
VerifiedJobCodes: make(map[string]int32),
}
for _, oldValue := range sortedKeys(dateGroups) {
paths := dateGroups[oldValue]
sort.Strings(paths)
patched, patchReport, patchErr := archive.PatchScriptString(paths, oldValue, futureExpiration)
if patchErr != nil {
return fmt.Errorf("patch expiration %q: %w", oldValue, patchErr)
}
archive, err = platformpvf.OpenBytes(patched)
if err != nil {
return fmt.Errorf("reopen date-patched PVF: %w", err)
}
result.DatePatches = append(result.DatePatches, datePatch{Old: oldValue, New: futureExpiration, Files: paths})
result.DatePatchFiles += patchReport.FilesChanged
result.DatePatchTokens += patchReport.TokensChanged
result.DatePatchChunks += patchReport.ChunksChanged
}
ceraRaw, err := archive.ReadRaw(ceraShopPath)
if err != nil {
return err
}
packageStart, packageEnd, err := findRawSection(archive, ceraRaw, "[package]", "[/package]")
if err != nil {
return err
}
interior := ceraRaw[(packageStart+1)*5 : packageEnd*5]
if len(interior)%(packageRowWidth*5) != 0 {
return fmt.Errorf("existing package section bytes=%d are not %d-token rows", len(interior), packageRowWidth)
}
result.OriginalRows = len(interior) / (packageRowWidth * 5)
existingItemIDs, existingCommodityIDs, err := parseExistingPackageRows(interior)
if err != nil {
return err
}
for _, item := range desired {
if _, exists := existingItemIDs[item.ItemID]; exists {
return fmt.Errorf("target item %d is already present in [package]", item.ItemID)
}
}
jobCodes, err := deriveJobCodes(archive, refs, interior)
if err != nil {
return err
}
for key, value := range jobCodes {
result.VerifiedJobCodes[key] = value
}
usedCeraValues := allRawIntegerValues(ceraRaw)
var maxPackageCommodity uint32
for commodityID := range existingCommodityIDs {
if commodityID > maxPackageCommodity {
maxPackageCommodity = commodityID
}
}
if maxPackageCommodity == 0 || maxPackageCommodity == math.MaxUint32 {
return fmt.Errorf("invalid maximum package commodity ID %d", maxPackageCommodity)
}
rows := make([]packageRow, 0, len(desired))
nextCommodityID := maxPackageCommodity + 1
for _, item := range desired {
ref, ok := refs[item.ItemID]
if !ok || ref.Kind != "stackable" {
return fmt.Errorf("target item %d is not a real stackable.lst entry", item.ItemID)
}
document, _, err := readDocument(archive, ref.Path)
if err != nil {
return err
}
stackableType, found := document.Text("stackable type")
if !found || !strings.EqualFold(strings.TrimSpace(stackableType), "[usable cera package]") {
return fmt.Errorf("target item %d path=%s stackable_type=%q", item.ItemID, ref.Path, stackableType)
}
packageData := document.Ints("package data")
if len(packageData) == 0 || len(packageData)%2 != 0 {
return fmt.Errorf("target item %d path=%s invalid package data", item.ItemID, ref.Path)
}
name, found := document.Text("name")
if !found || strings.TrimSpace(name) == "" {
return fmt.Errorf("target item %d path=%s missing name", item.ItemID, ref.Path)
}
nameMagic, err := sectionStringMagic(archive, ref.Path, "[name]")
if err != nil {
return err
}
jobs := document.Texts("suitable job")
if len(jobs) == 0 {
return fmt.Errorf("target item %d path=%s missing suitable job", item.ItemID, ref.Path)
}
jobKey := normalizeJob(jobs[0])
jobCode, found := jobCodes[jobKey]
if !found {
return fmt.Errorf("target item %d path=%s has unmapped job %q", item.ItemID, ref.Path, jobs[0])
}
for {
if nextCommodityID == 0 {
return fmt.Errorf("commodity ID space exhausted after %d", maxPackageCommodity)
}
if _, collision := usedCeraValues[nextCommodityID]; !collision {
break
}
nextCommodityID++
}
commodityID := nextCommodityID
usedCeraValues[commodityID] = struct{}{}
nextCommodityID++
rows = append(rows, packageRow{
CommodityID: commodityID,
ItemID: item.ItemID,
Price: item.Price,
NameMagic: nameMagic,
JobCode: jobCode,
Group: item.Group,
Name: name,
Path: ref.Path,
})
result.AddedByGroup[item.Group]++
}
result.AddedRows = len(rows)
result.FirstCommodityID = rows[0].CommodityID
result.LastCommodityID = rows[len(rows)-1].CommodityID
appendRaw := encodePackageRows(rows)
newCeraRaw := make([]byte, 0, len(ceraRaw)+len(appendRaw))
newCeraRaw = append(newCeraRaw, ceraRaw[:packageEnd*5]...)
newCeraRaw = append(newCeraRaw, appendRaw...)
newCeraRaw = append(newCeraRaw, ceraRaw[packageEnd*5:]...)
outputBytes, err := archive.RepackRaw([]platformpvf.RawReplacement{{Path: ceraShopPath, Data: newCeraRaw}})
if err != nil {
return err
}
verified, err := platformpvf.OpenBytes(outputBytes)
if err != nil {
return fmt.Errorf("reopen output PVF: %w", err)
}
if err := verifyOutput(verified, refs, rows, result.OriginalRows); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(outputAbs), 0o755); err != nil {
return err
}
if err := os.WriteFile(outputAbs, outputBytes, 0o644); err != nil {
return err
}
result.OutputSHA256 = checksum(outputBytes)
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(result)
}
func desiredItems(stackableIDs map[string]uint32) ([]desiredItem, error) {
items := make([]desiredItem, 0, 228)
appendDescending := func(first, last uint32, price uint32, group string) {
for id := last; ; id-- {
items = append(items, desiredItem{ItemID: id, Price: price, Group: group})
if id == first {
break
}
}
}
appendDescending(490701336, 490701387, 19900, "2017_national_day_dream")
appendDescending(490701388, 490701439, 33800, "2017_national_day_romance")
appendDescending(490701876, 490701927, 19900, "2018_spring_guide")
appendDescending(490701928, 490701979, 39900, "2018_spring_guardian")
for _, tier := range []struct {
Pattern string
Price uint32
Group string
}{
{Pattern: "cash/chn_20141111_sao_package/chn_2014_sao_trade_package_", Price: 23900, Group: "2014_sao"},
{Pattern: "cash/chn_20141111_sao_package/chn_2014_sao_1timefree_package_", Price: 25900, Group: "2014_sao_true"},
} {
matches := make([]uint32, 0, 10)
for path, id := range stackableIDs {
if strings.HasPrefix(strings.ToLower(path), tier.Pattern) {
matches = append(matches, id)
}
}
if len(matches) != 10 {
return nil, fmt.Errorf("SAO group %s matched %d stackable entries, want 10", tier.Group, len(matches))
}
sort.Slice(matches, func(left, right int) bool { return matches[left] > matches[right] })
for _, id := range matches {
items = append(items, desiredItem{ItemID: id, Price: tier.Price, Group: tier.Group})
}
}
return items, nil
}
func loadItemReferences(archive *platformpvf.Archive) (map[uint32]itemReference, map[string]uint32, error) {
refs := make(map[uint32]itemReference)
stackableIDs := make(map[string]uint32)
for _, spec := range []struct {
List string
Root string
Kind string
}{{stackableListPath, "stackable", "stackable"}, {equipmentListPath, "equipment", "equipment"}} {
text, err := archive.ReadText(spec.List)
if err != nil {
return nil, nil, err
}
document, err := dnfpvf.Parse(spec.List, text)
if err != nil {
return nil, nil, err
}
for _, entry := range dnfpvf.ParseList(document) {
if entry.ID <= 0 || entry.ID > math.MaxUint32 {
continue
}
clean := strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(entry.Path), "\\", "/"), "/")
if !strings.HasPrefix(strings.ToLower(clean), spec.Root+"/") {
clean = spec.Root + "/" + clean
}
id := uint32(entry.ID)
if _, exists := refs[id]; !exists {
refs[id] = itemReference{Path: clean, Kind: spec.Kind}
}
if spec.Kind == "stackable" {
stackableIDs[strings.ToLower(strings.TrimPrefix(clean, "stackable/"))] = id
}
}
}
return refs, stackableIDs, nil
}
func collectExpiredTargetDates(archive *platformpvf.Archive, refs map[uint32]itemReference, desired []desiredItem) (map[string][]string, error) {
now := time.Now().In(cst)
paths := make(map[string]struct{})
for _, item := range desired {
ref, ok := refs[item.ItemID]
if !ok {
return nil, fmt.Errorf("target item %d is absent from supported item lists", item.ItemID)
}
paths[ref.Path] = struct{}{}
// SAO sources have no static deadline. Audit their immediate rewards so
// historical inner boxes are not granted already expired.
if !strings.HasPrefix(item.Group, "2014_sao") {
continue
}
document, _, err := readDocument(archive, ref.Path)
if err != nil {
return nil, err
}
values := document.Ints("package data")
for offset := 0; offset+1 < len(values); offset += 2 {
if values[offset] <= 0 || values[offset] > math.MaxUint32 {
continue
}
if reward, exists := refs[uint32(values[offset])]; exists {
paths[reward.Path] = struct{}{}
}
}
}
grouped := make(map[string][]string)
for path := range paths {
document, _, err := readDocument(archive, path)
if err != nil {
return nil, err
}
raw, found := document.Text("expiration date")
if !found || strings.TrimSpace(raw) == "" {
continue
}
expires, err := time.ParseInLocation(expirationDateLayout, strings.TrimSpace(raw), cst)
if err != nil {
return nil, fmt.Errorf("parse expiration path=%s value=%q: %w", path, raw, err)
}
if now.Before(expires) {
continue
}
grouped[raw] = append(grouped[raw], path)
}
return grouped, nil
}
func deriveJobCodes(archive *platformpvf.Archive, refs map[uint32]itemReference, interior []byte) (map[string]int32, error) {
jobCodes := make(map[string]int32)
for offset := 0; offset < len(interior); offset += packageRowWidth * 5 {
itemID, ok := intToken(interior[offset+5 : offset+10])
if !ok || itemID < 490702380 || itemID > 490702491 {
continue
}
jobCode, ok := intToken(interior[offset+10*5 : offset+11*5])
if !ok {
return nil, fmt.Errorf("summer row item %d has invalid job code", itemID)
}
ref, found := refs[uint32(itemID)]
if !found {
return nil, fmt.Errorf("summer row item %d is absent from stackable.lst", itemID)
}
document, _, err := readDocument(archive, ref.Path)
if err != nil {
return nil, err
}
jobs := document.Texts("suitable job")
if len(jobs) == 0 {
return nil, fmt.Errorf("summer row item %d missing suitable job", itemID)
}
key := normalizeJob(jobs[0])
if previous, exists := jobCodes[key]; exists && previous != jobCode {
return nil, fmt.Errorf("job %s maps to both %d and %d", key, previous, jobCode)
}
jobCodes[key] = jobCode
}
if len(jobCodes) < 13 {
return nil, fmt.Errorf("derived only %d real job-code mappings from current summer rows", len(jobCodes))
}
return jobCodes, nil
}
func verifyOutput(archive *platformpvf.Archive, refs map[uint32]itemReference, rows []packageRow, originalRows int) error {
raw, err := archive.ReadRaw(ceraShopPath)
if err != nil {
return err
}
start, end, err := findRawSection(archive, raw, "[package]", "[/package]")
if err != nil {
return err
}
interior := raw[(start+1)*5 : end*5]
if len(interior)%(packageRowWidth*5) != 0 {
return fmt.Errorf("verified package section is malformed")
}
if got := len(interior) / (packageRowWidth * 5); got != originalRows+len(rows) {
return fmt.Errorf("verified package rows=%d, want %d", got, originalRows+len(rows))
}
byCommodity := make(map[uint32]packageRow)
for offset := 0; offset < len(interior); offset += packageRowWidth * 5 {
commodity, ok := intToken(interior[offset : offset+5])
if !ok || commodity <= 0 {
continue
}
item, itemOK := intToken(interior[offset+5 : offset+10])
price, priceOK := intToken(interior[offset+4*5 : offset+5*5])
job, jobOK := intToken(interior[offset+10*5 : offset+11*5])
if itemOK && priceOK && jobOK {
byCommodity[uint32(commodity)] = packageRow{ItemID: uint32(item), Price: uint32(price), JobCode: job}
}
}
now := time.Now().In(cst)
for _, want := range rows {
got, found := byCommodity[want.CommodityID]
if !found || got.ItemID != want.ItemID || got.Price != want.Price || got.JobCode != want.JobCode {
return fmt.Errorf("verify commodity %d got=%+v want_item=%d want_price=%d want_job=%d", want.CommodityID, got, want.ItemID, want.Price, want.JobCode)
}
ref := refs[want.ItemID]
document, _, err := readDocument(archive, ref.Path)
if err != nil {
return err
}
if values := document.Ints("package data"); len(values) == 0 || len(values)%2 != 0 {
return fmt.Errorf("verify item %d has invalid package data", want.ItemID)
}
if rawDate, found := document.Text("expiration date"); found {
expires, parseErr := time.ParseInLocation(expirationDateLayout, rawDate, cst)
if parseErr != nil || !now.Before(expires) {
return fmt.Errorf("verify item %d expiration=%q is not future", want.ItemID, rawDate)
}
}
}
return nil
}
func parseExistingPackageRows(interior []byte) (map[uint32]struct{}, map[uint32]struct{}, error) {
items := make(map[uint32]struct{})
commodities := make(map[uint32]struct{})
for offset := 0; offset < len(interior); offset += packageRowWidth * 5 {
commodityID, ok := intToken(interior[offset : offset+5])
if !ok || commodityID <= 0 {
return nil, nil, fmt.Errorf("existing package row %d has invalid commodity", offset/(packageRowWidth*5))
}
itemID, ok := intToken(interior[offset+5 : offset+10])
if !ok || itemID <= 0 {
return nil, nil, fmt.Errorf("existing package row %d has invalid item", offset/(packageRowWidth*5))
}
commodities[uint32(commodityID)] = struct{}{}
items[uint32(itemID)] = struct{}{}
}
return items, commodities, nil
}
func findRawSection(archive *platformpvf.Archive, raw []byte, open, close string) (int, int, error) {
start := -1
for index := 0; index+5 <= len(raw); index++ {
offset := index * 5
if raw[offset] != 3 {
continue
}
magic := int(int32(binary.LittleEndian.Uint32(raw[offset+1 : offset+5])))
value := archive.ResolveString(magic)
if value == open {
if start >= 0 {
return 0, 0, fmt.Errorf("duplicate section %s", open)
}
start = index
continue
}
if start >= 0 && value == close {
return start, index, nil
}
}
return 0, 0, fmt.Errorf("section %s...%s not found", open, close)
}
func sectionStringMagic(archive *platformpvf.Archive, path, section string) (int32, error) {
raw, err := archive.ReadRaw(path)
if err != nil {
return 0, err
}
found := false
for offset := 0; offset+5 <= len(raw); offset += 5 {
tokenType := raw[offset]
magic := int32(binary.LittleEndian.Uint32(raw[offset+1 : offset+5]))
if tokenType == 3 {
value := archive.ResolveString(int(magic))
if found {
return 0, fmt.Errorf("path=%s section=%s has no string token", path, section)
}
found = value == section
continue
}
if found && tokenType == 6 {
return magic, nil
}
}
return 0, fmt.Errorf("path=%s section=%s not found", path, section)
}
func encodePackageRows(rows []packageRow) []byte {
out := make([]byte, 0, len(rows)*packageRowWidth*5)
for _, row := range rows {
out = appendInt(out, int32(row.CommodityID))
out = appendInt(out, int32(row.ItemID))
out = appendInt(out, 0)
out = appendInt(out, 0)
out = appendInt(out, int32(row.Price))
out = appendToken(out, 6, row.NameMagic)
out = appendInt(out, 1)
out = appendInt(out, 0)
out = appendInt(out, -1)
out = appendInt(out, -1)
out = appendInt(out, row.JobCode)
}
return out
}
func appendInt(out []byte, value int32) []byte { return appendToken(out, 0, value) }
func appendToken(out []byte, tokenType byte, value int32) []byte {
var token [5]byte
token[0] = tokenType
binary.LittleEndian.PutUint32(token[1:], uint32(value))
return append(out, token[:]...)
}
func intToken(token []byte) (int32, bool) {
if len(token) != 5 || token[0] != 0 {
return 0, false
}
return int32(binary.LittleEndian.Uint32(token[1:])), true
}
func allRawIntegerValues(raw []byte) map[uint32]struct{} {
values := make(map[uint32]struct{})
for offset := 0; offset+5 <= len(raw); offset += 5 {
value, ok := intToken(raw[offset : offset+5])
if !ok || value <= 0 {
continue
}
values[uint32(value)] = struct{}{}
}
return values
}
func readDocument(archive *platformpvf.Archive, path string) (*dnfpvf.Document, string, error) {
text, err := archive.ReadText(path)
if err != nil {
return nil, "", fmt.Errorf("read %s: %w", path, err)
}
document, err := dnfpvf.Parse(path, text)
if err != nil {
return nil, "", fmt.Errorf("parse %s: %w", path, err)
}
return document, text, nil
}
func normalizeJob(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func checksum(data []byte) string {
sum := sha256.Sum256(data)
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func sortedKeys(values map[string][]string) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}