服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
352 lines
12 KiB
Go
352 lines
12 KiB
Go
// Command pvfaria removes the duplicate Aelia NPC 20462 from Hendon Myre
|
|
// while preserving the existing Aelia NPC 20449 and every unrelated PVF file.
|
|
// It always writes a separate candidate and never edits the active PVF.
|
|
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
const (
|
|
npcListPath = "npc/npc.lst"
|
|
itemShopListPath = "itemshop/itemshop.lst"
|
|
sourceShopPath = "itemshop/chn_masang_shop_2017.shp"
|
|
targetNPCPath = "npc/chn_event_lions10th_2.npc"
|
|
townMapPath = "map/cataclysm/town/hendonmyre/new_hendon_main.map"
|
|
)
|
|
|
|
type report struct {
|
|
InputPath string `json:"input_path"`
|
|
OutputPath string `json:"output_path"`
|
|
RemovedNPCID int `json:"removed_npc_id"`
|
|
PreservedNPCID int `json:"preserved_npc_id"`
|
|
PreservedX int `json:"preserved_x"`
|
|
PreservedY int `json:"preserved_y"`
|
|
RemovedShopID int `json:"removed_shop_id"`
|
|
SourceShopOwner int `json:"source_shop_owner"`
|
|
FileCount int `json:"file_count"`
|
|
InputSize int64 `json:"input_size"`
|
|
OutputSize int `json:"output_size"`
|
|
OutputSHA256 string `json:"output_sha256"`
|
|
}
|
|
|
|
func main() {
|
|
pvfPath := flag.String("pvf", `D:/DNF/runtime/data/dnf/Script.pvf`, "source Script.pvf")
|
|
outPath := flag.String("out", `D:/DNF/runtime/tmp/aria-npc-remove/Script-no-duplicate-aelia.pvf`, "candidate output path")
|
|
flag.Parse()
|
|
|
|
inputAbs, err := filepath.Abs(*pvfPath)
|
|
must(err)
|
|
outputAbs, err := filepath.Abs(*outPath)
|
|
must(err)
|
|
if strings.EqualFold(inputAbs, outputAbs) {
|
|
panic("output must differ from input")
|
|
}
|
|
|
|
archive, err := platformpvf.Open(inputAbs)
|
|
must(err)
|
|
fileCount := len(archive.Files())
|
|
preservedTargetNPC, err := archive.ReadRaw(targetNPCPath)
|
|
must(err)
|
|
|
|
npcListRaw, err := archive.ReadRaw(npcListPath)
|
|
must(err)
|
|
npcListPatched, err := removeListEntry(archive, npcListRaw, 20462, "chn_masang_2017.npc")
|
|
must(err)
|
|
|
|
itemShopListRaw, err := archive.ReadRaw(itemShopListPath)
|
|
must(err)
|
|
itemShopListPatched, err := removeListEntry(archive, itemShopListRaw, 101262, "chn_masang_shop_2017.shp")
|
|
must(err)
|
|
|
|
sourceShopRaw, err := archive.ReadRaw(sourceShopPath)
|
|
must(err)
|
|
sourceShopPatched, err := replaceSectionIntValue(archive, sourceShopRaw, "npc", 20462, 20325)
|
|
must(err)
|
|
|
|
townMapRaw, err := archive.ReadRaw(townMapPath)
|
|
must(err)
|
|
townMapPatched, err := removeTownNPCPlacement(archive, townMapRaw, 20462, "left", 2088, 156, 0)
|
|
must(err)
|
|
|
|
patched, err := archive.RepackRaw([]platformpvf.RawReplacement{
|
|
{Path: npcListPath, Data: npcListPatched},
|
|
{Path: itemShopListPath, Data: itemShopListPatched},
|
|
{Path: sourceShopPath, Data: sourceShopPatched},
|
|
{Path: townMapPath, Data: townMapPatched},
|
|
})
|
|
must(err)
|
|
|
|
verified, err := platformpvf.OpenBytes(patched)
|
|
must(err)
|
|
if len(verified.Files()) != fileCount {
|
|
panic(fmt.Errorf("file count changed: got=%d want=%d", len(verified.Files()), fileCount))
|
|
}
|
|
must(requireListEntryAbsent(verified, npcListPath, 20462))
|
|
must(requireListEntry(verified, npcListPath, 20449, "chn_event_lions10th_2.npc"))
|
|
must(requireListEntryAbsent(verified, itemShopListPath, 101262))
|
|
must(requireListEntry(verified, itemShopListPath, 400000022, "chn_10th_lions_2.shp"))
|
|
must(requireSectionIntValue(verified, sourceShopPath, "npc", 20325))
|
|
must(requireTownNPCPlacementAbsent(verified, townMapPath, 20462))
|
|
must(requireTownNPCPlacement(verified, townMapPath, 20449, "left", 1478, 354, 0))
|
|
must(requireTownNPCPlacement(verified, townMapPath, 159, "left", 1896, 156, 0))
|
|
must(requireTownNPCPlacement(verified, townMapPath, 45, "right", 2470, 156, 0))
|
|
verifiedTargetNPC, err := verified.ReadRaw(targetNPCPath)
|
|
must(err)
|
|
if string(verifiedTargetNPC) != string(preservedTargetNPC) {
|
|
panic(fmt.Errorf("preserved Aelia NPC %d content changed", 20449))
|
|
}
|
|
verifiedTargetText, err := verified.ReadText(targetNPCPath)
|
|
must(err)
|
|
for _, required := range []string{"[name]\n`艾莉亚`", "`[joust event]` 0", "`[item shop]` 400000022"} {
|
|
if !strings.Contains(verifiedTargetText, required) {
|
|
panic(fmt.Errorf("preserved Aelia NPC is missing %q", required))
|
|
}
|
|
}
|
|
|
|
must(os.MkdirAll(filepath.Dir(outputAbs), 0o755))
|
|
must(os.WriteFile(outputAbs, patched, 0o644))
|
|
inputInfo, err := os.Stat(inputAbs)
|
|
must(err)
|
|
sum := sha256.Sum256(patched)
|
|
result := report{
|
|
InputPath: inputAbs,
|
|
OutputPath: outputAbs,
|
|
RemovedNPCID: 20462,
|
|
PreservedNPCID: 20449,
|
|
PreservedX: 1478,
|
|
PreservedY: 354,
|
|
RemovedShopID: 101262,
|
|
SourceShopOwner: 20325,
|
|
FileCount: fileCount,
|
|
InputSize: inputInfo.Size(),
|
|
OutputSize: len(patched),
|
|
OutputSHA256: strings.ToUpper(hex.EncodeToString(sum[:])),
|
|
}
|
|
encoded, err := json.MarshalIndent(result, "", " ")
|
|
must(err)
|
|
fmt.Println(string(encoded))
|
|
}
|
|
|
|
func removeListEntry(archive *platformpvf.Archive, raw []byte, id int, wantPath string) ([]byte, error) {
|
|
if archive == nil || len(raw)%5 != 0 {
|
|
return nil, fmt.Errorf("list token stream is invalid: bytes=%d", len(raw))
|
|
}
|
|
out := make([]byte, 0, len(raw)-10)
|
|
matches := 0
|
|
for offset := 0; offset < len(raw); {
|
|
if offset+10 <= len(raw) && raw[offset] == 0 && scriptTokenInt(raw[offset:offset+5]) == id {
|
|
pathToken := raw[offset+5 : offset+10]
|
|
if pathToken[0] != 6 {
|
|
return nil, fmt.Errorf("list id %d is not followed by a string path", id)
|
|
}
|
|
gotPath := archive.ResolveString(scriptTokenInt(pathToken))
|
|
if !strings.EqualFold(gotPath, wantPath) {
|
|
return nil, fmt.Errorf("list id %d path=%q, want=%q", id, gotPath, wantPath)
|
|
}
|
|
matches++
|
|
offset += 10
|
|
continue
|
|
}
|
|
out = append(out, raw[offset:offset+5]...)
|
|
offset += 5
|
|
}
|
|
if matches != 1 {
|
|
return nil, fmt.Errorf("list id %d matches=%d, want=1", id, matches)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func removeTownNPCPlacement(archive *platformpvf.Archive, raw []byte, npcID int, direction string, x, y, z int) ([]byte, error) {
|
|
if archive == nil || len(raw)%5 != 0 {
|
|
return nil, fmt.Errorf("map token stream is invalid: bytes=%d", len(raw))
|
|
}
|
|
out := make([]byte, 0, len(raw)-25)
|
|
inNPCSection := false
|
|
matches := 0
|
|
for offset := 0; offset < len(raw); {
|
|
token := raw[offset : offset+5]
|
|
if token[0] == 3 {
|
|
inNPCSection = normalizedLabel(archive.ResolveString(scriptTokenInt(token))) == "npc"
|
|
}
|
|
if inNPCSection && offset+25 <= len(raw) && token[0] == 0 && scriptTokenInt(token) == npcID {
|
|
directionToken := raw[offset+5 : offset+10]
|
|
xToken := raw[offset+10 : offset+15]
|
|
yToken := raw[offset+15 : offset+20]
|
|
zToken := raw[offset+20 : offset+25]
|
|
if directionToken[0] != 6 || normalizedLabel(archive.ResolveString(scriptTokenInt(directionToken))) != normalizedLabel(direction) ||
|
|
xToken[0] != 0 || scriptTokenInt(xToken) != x || yToken[0] != 0 || scriptTokenInt(yToken) != y ||
|
|
zToken[0] != 0 || scriptTokenInt(zToken) != z {
|
|
return nil, fmt.Errorf("town NPC %d tuple differs from the expected duplicate placement", npcID)
|
|
}
|
|
matches++
|
|
offset += 25
|
|
continue
|
|
}
|
|
out = append(out, token...)
|
|
offset += 5
|
|
}
|
|
if matches != 1 {
|
|
return nil, fmt.Errorf("town NPC %d placement matches=%d, want=1", npcID, matches)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func replaceSectionIntValue(archive *platformpvf.Archive, raw []byte, section string, oldValue, newValue int) ([]byte, error) {
|
|
if archive == nil || len(raw)%5 != 0 {
|
|
return nil, fmt.Errorf("script token stream is invalid: bytes=%d", len(raw))
|
|
}
|
|
out := append([]byte(nil), raw...)
|
|
matches := 0
|
|
for offset := 0; offset+10 <= len(out); offset += 5 {
|
|
token := out[offset : offset+5]
|
|
if (token[0] != 3 && token[0] != 6) || normalizedLabel(archive.ResolveString(scriptTokenInt(token))) != normalizedLabel(section) {
|
|
continue
|
|
}
|
|
valueToken := out[offset+5 : offset+10]
|
|
if valueToken[0] != 0 || scriptTokenInt(valueToken) != oldValue {
|
|
return nil, fmt.Errorf("section [%s] value does not match %d", section, oldValue)
|
|
}
|
|
binary.LittleEndian.PutUint32(valueToken[1:5], uint32(int32(newValue)))
|
|
matches++
|
|
}
|
|
if matches != 1 {
|
|
return nil, fmt.Errorf("section [%s] matches=%d, want=1", section, matches)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func requireListEntry(archive *platformpvf.Archive, path string, id int, wantPath string) error {
|
|
raw, err := archive.ReadRaw(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
matches := 0
|
|
for offset := 0; offset+10 <= len(raw); offset += 5 {
|
|
if raw[offset] != 0 || scriptTokenInt(raw[offset:offset+5]) != id {
|
|
continue
|
|
}
|
|
pathToken := raw[offset+5 : offset+10]
|
|
if pathToken[0] != 6 || !strings.EqualFold(archive.ResolveString(scriptTokenInt(pathToken)), wantPath) {
|
|
return fmt.Errorf("list %s id %d does not map to %q", path, id, wantPath)
|
|
}
|
|
matches++
|
|
}
|
|
if matches != 1 {
|
|
return fmt.Errorf("list %s id %d matches=%d, want=1", path, id, matches)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requireListEntryAbsent(archive *platformpvf.Archive, path string, id int) error {
|
|
raw, err := archive.ReadRaw(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for offset := 0; offset+5 <= len(raw); offset += 5 {
|
|
if raw[offset] == 0 && scriptTokenInt(raw[offset:offset+5]) == id {
|
|
return fmt.Errorf("list %s still contains id %d", path, id)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requireSectionIntValue(archive *platformpvf.Archive, path, section string, want int) error {
|
|
raw, err := archive.ReadRaw(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
matches := 0
|
|
for offset := 0; offset+10 <= len(raw); offset += 5 {
|
|
token := raw[offset : offset+5]
|
|
if (token[0] != 3 && token[0] != 6) || normalizedLabel(archive.ResolveString(scriptTokenInt(token))) != normalizedLabel(section) {
|
|
continue
|
|
}
|
|
valueToken := raw[offset+5 : offset+10]
|
|
if valueToken[0] != 0 || scriptTokenInt(valueToken) != want {
|
|
return fmt.Errorf("section [%s] does not contain required integer %d", section, want)
|
|
}
|
|
matches++
|
|
}
|
|
if matches != 1 {
|
|
return fmt.Errorf("section [%s] matches=%d, want=1", section, matches)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requireTownNPCPlacement(archive *platformpvf.Archive, path string, npcID int, direction string, x, y, z int) error {
|
|
raw, err := archive.ReadRaw(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
inNPCSection := false
|
|
matches := 0
|
|
for offset := 0; offset+25 <= len(raw); offset += 5 {
|
|
token := raw[offset : offset+5]
|
|
if token[0] == 3 {
|
|
inNPCSection = normalizedLabel(archive.ResolveString(scriptTokenInt(token))) == "npc"
|
|
continue
|
|
}
|
|
if !inNPCSection || token[0] != 0 || scriptTokenInt(token) != npcID {
|
|
continue
|
|
}
|
|
directionToken := raw[offset+5 : offset+10]
|
|
xToken := raw[offset+10 : offset+15]
|
|
yToken := raw[offset+15 : offset+20]
|
|
zToken := raw[offset+20 : offset+25]
|
|
if directionToken[0] != 6 || normalizedLabel(archive.ResolveString(scriptTokenInt(directionToken))) != normalizedLabel(direction) ||
|
|
xToken[0] != 0 || scriptTokenInt(xToken) != x || yToken[0] != 0 || scriptTokenInt(yToken) != y ||
|
|
zToken[0] != 0 || scriptTokenInt(zToken) != z {
|
|
return fmt.Errorf("town NPC %d placement differs from required tuple", npcID)
|
|
}
|
|
matches++
|
|
}
|
|
if matches != 1 {
|
|
return fmt.Errorf("town NPC %d placement matches=%d, want=1", npcID, matches)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requireTownNPCPlacementAbsent(archive *platformpvf.Archive, path string, npcID int) error {
|
|
raw, err := archive.ReadRaw(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
inNPCSection := false
|
|
for offset := 0; offset+5 <= len(raw); offset += 5 {
|
|
token := raw[offset : offset+5]
|
|
if token[0] == 3 {
|
|
inNPCSection = normalizedLabel(archive.ResolveString(scriptTokenInt(token))) == "npc"
|
|
continue
|
|
}
|
|
if inNPCSection && token[0] == 0 && scriptTokenInt(token) == npcID {
|
|
return fmt.Errorf("town map %s still contains NPC %d", path, npcID)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizedLabel(value string) string {
|
|
return strings.ToLower(strings.Trim(strings.TrimSpace(value), "[]"))
|
|
}
|
|
|
|
func scriptTokenInt(token []byte) int {
|
|
return int(int32(binary.LittleEndian.Uint32(token[1:5])))
|
|
}
|
|
|
|
func must(err error) {
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|