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

1653 lines
51 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"log"
"path"
"sort"
"strings"
"time"
"pvfine/internal/pvf"
pvfversion "pvfine/internal/version"
)
const (
IndexStateIdle = "idle"
IndexStateBuilding = "building"
IndexStateReady = "ready"
IndexStateError = "error"
SearchCategoryFile = "file"
SearchCategoryEquipment = "equipment"
SearchCategoryStackable = "stackable"
SearchCategorySkill = "skill"
)
const (
itemShopListPath = "itemshop/itemshop.lst"
npcListPath = "npc/npc.lst"
)
// IndexStatus is the current state of the semantic search index.
type IndexStatus struct {
State string `json:"state"`
Stage string `json:"stage"`
Done int `json:"done"`
Total int `json:"total"`
Skipped int `json:"skipped"`
Error string `json:"error"`
// Refreshing means an older ready snapshot is still serving queries while
// a newer candidate is being built in the background.
Refreshing bool `json:"refreshing"`
RefreshError string `json:"refreshError"`
CacheHit bool `json:"cacheHit"`
OpenDurationMs float64 `json:"openDurationMs"`
BuildDurationMs float64 `json:"buildDurationMs"`
StringTableIndex *pvf.StringTableIndexStats `json:"stringTableIndex,omitempty"`
}
// SearchHit is one searchable file/list record.
type SearchHit struct {
Name string `json:"name"`
ID string `json:"id"`
Path string `json:"path"`
Category string `json:"category"`
Size int32 `json:"size"`
DataType int32 `json:"dataType"`
FileIndex int32 `json:"fileIndex"`
Rarity int32 `json:"rarity"`
ChangeKind string `json:"changeKind,omitempty"`
Annotations []TreeAnnotation `json:"annotations,omitempty"`
PathAnnotations map[string][]TreeAnnotation `json:"pathAnnotations,omitempty"`
Icon *ImageReference `json:"icon,omitempty"`
FieldImage *ImageReference `json:"fieldImage,omitempty"`
}
// TreeTag is one list mapping displayed after a file name in the explorer.
type TreeTag struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
// Rarity is the target file's [rarity] value, or pvf.RarityUnknown when the
// file declares none. The explorer colors the tag with it.
Rarity int32 `json:"rarity"`
}
type searchRecord struct {
hit SearchHit
lowerName string
lowerID string
lowerPath string
}
type indexedMetadata struct {
name string
id string
category string
listPath string
path string
fileIndex int32
size int32
dataType int32
rarity int32
icon *ImageReference
fieldImage *ImageReference
}
type fileVisuals struct {
icon *ImageReference
fieldImage *ImageReference
// rarity is the file's [rarity] value, or pvf.RarityUnknown when the file
// declares none. Empty visuals must carry RarityUnknown, never 0.
rarity int32
}
// visualsFromMetadata projects a script's display metadata onto the per-file
// visual snapshot shared by the explorer, the search results and the stale
// checks of the mutation classifier.
func visualsFromMetadata(metadata pvf.ScriptMetadata) fileVisuals {
return fileVisuals{
icon: imageReferenceFromPVF(metadata.Icon),
fieldImage: imageReferenceFromPVF(metadata.FieldImage),
rarity: metadata.RarityValue(),
}
}
type searchableListSpec struct {
listPath string
category string
}
func (c *core) searchableListSpecs() []searchableListSpec {
c.mu.RLock()
defer c.mu.RUnlock()
return c.searchableListSpecsLocked()
}
func (c *core) searchableListSpecsLocked() []searchableListSpec {
specs := make([]searchableListSpec, 0, 16)
seen := make(map[string]struct{})
appendSpec := func(spec searchableListSpec) {
key := strings.ToLower(spec.category + "\x00" + spec.listPath)
if spec.listPath == "" {
return
}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
specs = append(specs, spec)
}
engine := c.annotationEngine
if engine != nil {
for name, relation := range engine.Document().Relations {
category := relationSearchCategory(name)
kind := relation.Kind
if kind == "" {
kind = "list"
}
if kind == "list" {
appendSpec(searchableListSpec{listPath: relation.ListPath, category: category})
continue
}
if kind != "contextual" {
continue
}
for _, listPath := range relation.ContextPaths {
appendSpec(searchableListSpec{listPath: listPath, category: category})
}
}
}
sort.SliceStable(specs, func(i, j int) bool {
if specs[i].category != specs[j].category {
return specs[i].category < specs[j].category
}
return specs[i].listPath < specs[j].listPath
})
return specs
}
func relationSearchCategory(name string) string {
switch strings.ToLower(strings.TrimSpace(name)) {
case "装备", "equipment":
return SearchCategoryEquipment
case "道具", "stackable":
return SearchCategoryStackable
case "技能", "skill":
return SearchCategorySkill
default:
return "relation:" + strings.TrimSpace(name)
}
}
// startSearchIndex starts or queues a metadata indexing generation for the
// current archive. The path/tree index is already available when this runs.
func (c *core) startSearchIndex() {
c.startSearchIndexWithOptions(false)
}
func (c *core) startSearchIndexForced() {
c.startSearchIndexWithOptions(true)
}
func (c *core) startSearchIndexWithOptions(force bool) {
lockStartedAt := time.Now()
c.mu.Lock()
if waited := time.Since(lockStartedAt).Round(time.Millisecond); waited > 0 {
developmentLog("[pvfine:index] waited for core lock: elapsed=%s", waited)
}
if c.archive == nil {
c.mu.Unlock()
developmentLog("[pvfine:index] index request ignored: no archive")
return
}
dirty := c.indexDirty != nil && len(c.indexDirty) > 0
if c.diskIndex != nil {
dirty = c.diskIndex.dirty
}
developmentLog("[pvfine:index] index request: force=%t files=%d disk=%t ready=%t dirty=%t", force, c.archive.FileCount(), c.diskIndex != nil, c.indexStatus.State == IndexStateReady, dirty)
if c.indexCancel != nil {
// Do not cancel an in-flight build for ordinary archive mutations.
// The mutation classifier has already accumulated the newest dirty
// files/list relations; let this candidate finish and run one follow-up
// build from that accumulated state. Cancelling here can race with the
// publisher and lose the later mutation.
c.indexRefreshPending = true
c.indexRefreshPendingForce = c.indexRefreshPendingForce || force
c.mu.Unlock()
return
}
// A completion path clears these before asking for the queued build. Clear
// them here as well so an external/manual request consumes any stale queue.
c.indexRefreshPending = false
c.indexRefreshPendingForce = false
c.indexGen++
gen := c.indexGen
a := c.archive
startedAt := time.Now()
if c.diskIndex != nil {
index := c.diskIndex
if index.ready {
index.semanticInitialized = true
var fingerprint string
if index.db.QueryRow(`SELECT value FROM meta WHERE key='specs'`).Scan(&fingerprint) != nil || fingerprint != searchIndexSpecFingerprint(c.searchableListSpecsLocked()) {
index.ready, index.dirty = false, true
}
}
hasSnapshot := index.ready && !force && !index.dirty
index.ready = hasSnapshot
ctx, cancel := context.WithCancel(context.Background())
c.indexCancel = cancel
c.indexGen++
gen := c.indexGen
c.indexStartedAt = startedAt
if hasSnapshot {
developmentLog("[pvfine:index] cache hit: files=%d elapsed=%s", a.FileCount(), time.Since(startedAt).Round(time.Millisecond))
c.indexStatus = IndexStatus{State: IndexStateReady, Stage: "ready-cache", Done: int(a.FileCount()), Total: int(a.FileCount()), CacheHit: true, OpenDurationMs: c.indexStatus.OpenDurationMs}
status := c.indexStatus
c.indexCancel = nil
c.mu.Unlock()
cancel()
emitEvent("archive:index-ready", status)
return
}
index.dirty = true
c.indexStatus = IndexStatus{State: IndexStateBuilding, Stage: "sqlite", OpenDurationMs: c.indexStatus.OpenDurationMs}
status := c.indexStatus
c.mu.Unlock()
finishTask, ok := c.archiveTasks.tryBegin()
if !ok {
cancel()
return
}
if !c.indexCurrent(a, gen, ctx) {
finishTask()
return
}
emitEvent("archive:index-progress", status)
go func() {
defer finishTask()
c.buildSearchIndexSQLite(ctx, gen, a, index, startedAt, force)
}()
return
}
openDurationMs := c.indexStatus.OpenDurationMs
hasSnapshot := c.indexStatus.State == IndexStateReady && c.searchRecords != nil
pendingDirty := c.indexDirty
cacheEligible := a.SourcePath() != "" && !a.Modified()
delta := !force && hasSnapshot && (c.searchIndexDeltaPending || len(c.searchIndexListPending) > 0)
listIndexes := make([]int32, 0, len(c.searchIndexListPending))
for index := range c.searchIndexListPending {
listIndexes = append(listIndexes, index)
}
c.searchIndexDeltaPending = false
c.searchIndexListPending = nil
ctx, cancel := context.WithCancel(context.Background())
c.indexCancel = cancel
if !hasSnapshot || pendingDirty == nil {
c.indexDirty = make(map[int32]struct{})
}
c.indexStartedAt = startedAt
state := IndexStateBuilding
stage := "preparing"
if hasSnapshot {
state = IndexStateReady
stage = "refreshing"
}
c.indexStatus = IndexStatus{
State: state,
Stage: stage,
OpenDurationMs: openDurationMs,
Refreshing: hasSnapshot,
}
status := c.indexStatus
c.mu.Unlock()
finishTask, ok := c.archiveTasks.tryBegin()
if !ok {
cancel()
return
}
if !c.indexCurrent(a, gen, ctx) {
finishTask()
return
}
emitEvent("archive:index-progress", status)
if delta {
go func() {
defer finishTask()
c.buildSearchIndexDelta(ctx, gen, a, startedAt, listIndexes)
}()
return
}
go func() {
defer finishTask()
c.buildSearchIndex(ctx, gen, a, startedAt, force, cacheEligible)
}()
}
func (c *core) buildSearchIndexSQLite(ctx context.Context, gen uint64, a *pvf.Archive, index *sqliteArchiveIndex, startedAt time.Time, force bool) {
specs := c.searchableListSpecs()
developmentLog("[pvfine:index] semantic build started: force=%t generation=%d files=%d specs=%d", force, gen, a.FileCount(), len(specs))
var total, skipped int
var err error
c.mu.RLock()
delta := !force && index.semanticInitialized
epoch := index.semanticEpoch
revision := c.batchRevision
c.mu.RUnlock()
if delta {
total, skipped, err = index.buildSemanticDelta(ctx, c, a, specs, gen)
} else {
total, skipped, err = index.buildSemantic(ctx, c, a, specs, gen)
}
if errors.Is(err, errIndexDeltaStale) {
c.mu.Lock()
if c.indexIsCurrentLocked(a, gen) {
retryForce := force || c.indexRefreshPendingForce
c.indexCancel = nil
c.mu.Unlock()
c.startSearchIndexWithOptions(retryForce)
} else {
c.mu.Unlock()
}
return
}
if err != nil {
if ctx.Err() != nil {
developmentLog("[pvfine:index] semantic build cancelled: generation=%d elapsed=%s", gen, time.Since(startedAt).Round(time.Millisecond))
return
}
log.Printf("[pvfine:index] semantic build failed: generation=%d elapsed=%s error=%v", gen, time.Since(startedAt).Round(time.Millisecond), err)
c.mu.Lock()
if c.indexIsCurrentLocked(a, gen) {
pendingRefresh := c.indexRefreshPending
pendingForce := c.indexRefreshPendingForce
c.indexStatus = IndexStatus{State: IndexStateError, Stage: "error", Error: err.Error(), OpenDurationMs: c.indexStatus.OpenDurationMs, BuildDurationMs: elapsedMilliseconds(startedAt)}
c.indexCancel = nil
c.indexRefreshPending = false
c.indexRefreshPendingForce = false
status := c.indexStatus
c.mu.Unlock()
if pendingRefresh {
// The failed candidate is obsolete because a newer mutation is
// already queued. Retry the queued build directly so observers do
// not mistake the transient failure for the final index state.
c.startSearchIndexWithOptions(pendingForce)
return
}
emitEvent("archive:index-error", status)
return
}
c.mu.Unlock()
return
}
pendingRefresh := false
pendingForce := false
c.mu.Lock()
if !c.indexIsCurrentLocked(a, gen) || c.diskIndex != index || ctx.Err() != nil {
c.mu.Unlock()
developmentLog("[pvfine:index] semantic build discarded: generation=%d elapsed=%s", gen, time.Since(startedAt).Round(time.Millisecond))
return
}
pendingRefresh = c.indexRefreshPending || index.semanticPublishedEpoch != index.semanticEpoch || index.semanticPublishedRevision != c.batchRevision
pendingForce = c.indexRefreshPendingForce
index.semanticInitialized = true
if !delta && index.semanticEpoch == epoch && c.batchRevision == revision {
index.pendingPaths, index.pendingIDs = nil, nil
index.pendingStructural = false
}
index.ready = true
if !pendingRefresh {
index.dirty = false
}
stage := "ready-sqlite"
if pendingRefresh {
stage = "refreshing"
}
state := IndexStateReady
if pendingRefresh {
state = IndexStateBuilding
index.ready = false
}
c.indexStatus = IndexStatus{State: state, Stage: stage, Done: total, Total: total, Skipped: skipped, Refreshing: pendingRefresh, OpenDurationMs: c.indexStatus.OpenDurationMs, BuildDurationMs: elapsedMilliseconds(startedAt)}
c.indexCancel = nil
c.indexRefreshPending = false
c.indexRefreshPendingForce = false
status := c.indexStatus
c.mu.Unlock()
developmentLog("[pvfine:index] semantic build finished: generation=%d records=%d skipped=%d elapsed=%s", gen, total, skipped, time.Since(startedAt).Round(time.Millisecond))
emitEvent("archive:index-ready", status)
if pendingRefresh {
c.startSearchIndexWithOptions(pendingForce)
} else if !delta {
// Full builds now publish into private session storage. Preserve the
// completed clean cache for the next open as well.
c.persistCurrentSQLiteIndexAsync()
}
}
func (c *core) buildSearchIndex(ctx context.Context, gen uint64, a *pvf.Archive, startedAt time.Time, force, cacheEligible bool) {
defer func() {
if recovered := recover(); recovered != nil {
c.failSearchIndex(a, gen, ctx, startedAt, fmt.Errorf("搜索索引构建失败: %v", recovered))
}
}()
paths, ok := c.snapshotPaths(a, gen, ctx)
if !ok {
return
}
specs := c.searchableListSpecs()
if !force && cacheEligible {
c.mu.RLock()
cached, err := loadSearchIndexCache(a, specs, c.searchIndexCachePath)
c.mu.RUnlock()
if err == nil {
metadata := cached.Metadata
byFile := make(map[int32][]int)
visuals := make(map[int32]fileVisuals)
for index := range metadata {
value := &metadata[index]
byFile[value.fileIndex] = append(byFile[value.fileIndex], index)
visuals[value.fileIndex] = fileVisuals{
icon: cloneImageReference(value.icon),
fieldImage: cloneImageReference(value.fieldImage),
rarity: value.rarity,
}
}
records, recordsByFile := buildSearchRecords(paths, metadata, byFile)
treeTagsByFile := buildTreeTags(records, recordsByFile)
if c.publishSearchCandidate(a, gen, ctx, startedAt, records, recordsByFile, metadata, treeTagsByFile, visuals, cached.Total, cached.Skipped, cached.SpecsFingerprint, false, true) {
return
}
return
}
}
refs := make([]indexedMetadata, 0)
skipped := 0
for _, spec := range specs {
if !c.indexCurrent(a, gen, ctx) {
return
}
listIndex, ok := c.findArchiveList(a, gen, ctx, spec.listPath)
if !ok {
continue
}
pairs, err := c.readListPairs(a, gen, ctx, listIndex)
if err != nil {
skipped++
continue
}
for _, pair := range pairs {
target, ok := c.findListTarget(a, gen, ctx, spec.listPath, pair.Path)
if !ok {
skipped++
continue
}
refs = append(refs, indexedMetadata{
id: pair.ID,
category: spec.category,
listPath: spec.listPath,
path: target,
})
}
}
npcNames := make(map[string]string)
for _, spec := range specs {
if !sameSearchPath(spec.listPath, itemShopListPath) {
continue
}
var ok bool
npcNames, ok = c.buildNPCNameIndex(a, gen, ctx)
if !ok {
return
}
break
}
if !c.publishIndexStatus(a, gen, ctx, IndexStatus{
State: IndexStateBuilding,
Stage: "metadata",
Total: len(refs),
Skipped: skipped,
}) {
return
}
metadata := make([]indexedMetadata, 0, len(refs))
byFile := make(map[int32][]int)
metadataByIndex := make(map[int32]pvf.ScriptMetadata)
lastProgress := time.Now()
for i, ref := range refs {
if !c.indexCurrent(a, gen, ctx) {
return
}
fileIndex, ok := c.findArchiveEntry(a, gen, ctx, ref.path)
if !ok {
skipped++
c.publishIndexProgress(a, gen, ctx, i+1, len(refs), skipped, &lastProgress)
continue
}
f, canonicalPath, ok := c.readFileMetadata(a, gen, ctx, fileIndex)
if !ok {
return
}
scriptMetadata, metadataCached := metadataByIndex[fileIndex]
var metadataErr error
if !metadataCached {
scriptMetadata, metadataErr = c.readIndexedMetadata(a, gen, ctx, fileIndex, ref.listPath, npcNames)
if metadataErr == nil {
metadataByIndex[fileIndex] = scriptMetadata
}
}
if metadataErr != nil {
// The id/path mapping remains useful even when the target is not
// a parseable script or has malformed content.
scriptMetadata = pvf.ScriptMetadata{}
}
ref.name = scriptMetadata.Name
ref.icon = imageReferenceFromPVF(scriptMetadata.Icon)
ref.fieldImage = imageReferenceFromPVF(scriptMetadata.FieldImage)
ref.rarity = scriptMetadata.RarityValue()
ref.fileIndex = fileIndex
ref.path = canonicalPath
ref.size = f.DataSize
ref.dataType = f.DataType
byFile[fileIndex] = append(byFile[fileIndex], len(metadata))
metadata = append(metadata, ref)
c.publishIndexProgress(a, gen, ctx, i+1, len(refs), skipped, &lastProgress)
}
records, recordsByFile := buildSearchRecords(paths, metadata, byFile)
treeTagsByFile := buildTreeTags(records, recordsByFile)
visualsByFile := make(map[int32]fileVisuals, len(metadataByIndex))
for fileIndex, scriptMetadata := range metadataByIndex {
visualsByFile[fileIndex] = visualsFromMetadata(scriptMetadata)
}
if !c.publishSearchCandidate(a, gen, ctx, startedAt, records, recordsByFile, metadata, treeTagsByFile, visualsByFile, len(refs), skipped, searchIndexSpecFingerprint(specs), cacheEligible, false) {
return
}
}
func (c *core) buildSearchIndexDelta(ctx context.Context, gen uint64, a *pvf.Archive, startedAt time.Time, listIndexes []int32) {
paths, ok := c.snapshotPaths(a, gen, ctx)
if !ok {
return
}
c.mu.RLock()
metadata := cloneIndexedMetadata(c.searchMetadata)
oldSkipped := c.indexStatus.Skipped
dirtyIndexes := make([]int32, 0, len(c.indexDirty))
for index := range c.indexDirty {
dirtyIndexes = append(dirtyIndexes, index)
}
c.mu.RUnlock()
specs := c.searchableListSpecs()
affectedLists := make(map[int32]struct{}, len(listIndexes))
for _, listIndex := range listIndexes {
affectedLists[listIndex] = struct{}{}
}
// A structural delta keeps existing list registrations and only rebinds
// their paths/file indexes against the new file table. A list delta replaces
// the rows for the affected relation specs below.
if len(affectedLists) == 0 {
filtered := make([]indexedMetadata, 0, len(metadata))
for _, value := range metadata {
if !c.indexCurrent(a, gen, ctx) {
return
}
fileIndex, found := c.findArchiveEntry(a, gen, ctx, value.path)
if !found {
continue
}
file, canonicalPath, found := c.readFileMetadata(a, gen, ctx, fileIndex)
if !found {
continue
}
value.fileIndex = fileIndex
value.path = canonicalPath
value.size = file.DataSize
value.dataType = file.DataType
filtered = append(filtered, value)
}
metadata = filtered
} else {
kept := make([]indexedMetadata, 0, len(metadata))
for _, value := range metadata {
listIndex, found := c.findArchiveList(a, gen, ctx, value.listPath)
if found {
if _, affected := affectedLists[listIndex]; affected {
continue
}
}
fileIndex, found := c.findArchiveEntry(a, gen, ctx, value.path)
if !found {
continue
}
file, canonicalPath, found := c.readFileMetadata(a, gen, ctx, fileIndex)
if !found {
continue
}
value.fileIndex = fileIndex
value.path = canonicalPath
value.size = file.DataSize
value.dataType = file.DataType
kept = append(kept, value)
}
metadata = kept
npcNames := map[string]string(nil)
for _, spec := range specs {
listIndex, found := c.findArchiveList(a, gen, ctx, spec.listPath)
if !found {
continue
}
if _, affected := affectedLists[listIndex]; !affected {
continue
}
if sameSearchPath(spec.listPath, itemShopListPath) {
var namesOK bool
npcNames, namesOK = c.buildNPCNameIndex(a, gen, ctx)
if !namesOK {
return
}
}
pairs, err := c.readListPairs(a, gen, ctx, listIndex)
if err != nil {
oldSkipped++
continue
}
metadataByPath := make(map[string]pvf.ScriptMetadata)
for _, pair := range pairs {
targetPath, targetIndex, file, found := c.findListTargetFile(a, gen, ctx, spec.listPath, pair.Path)
if !found {
oldSkipped++
continue
}
scriptMetadata, cached := metadataByPath[targetPath]
if !cached {
var metadataErr error
scriptMetadata, metadataErr = c.readIndexedMetadata(a, gen, ctx, targetIndex, spec.listPath, npcNames)
if metadataErr != nil {
scriptMetadata = pvf.ScriptMetadata{}
}
metadataByPath[targetPath] = scriptMetadata
}
metadata = append(metadata, indexedMetadata{
name: scriptMetadata.Name,
id: pair.ID,
category: spec.category,
listPath: spec.listPath,
path: targetPath,
fileIndex: targetIndex,
size: file.DataSize,
dataType: file.DataType,
rarity: scriptMetadata.RarityValue(),
icon: imageReferenceFromPVF(scriptMetadata.Icon),
fieldImage: imageReferenceFromPVF(scriptMetadata.FieldImage),
})
}
}
}
// Payload-only edits keep the record set but need fresh display metadata.
// Read each changed target once and apply it to every list registration of
// that target. The expensive work is intentionally outside the request that
// staged the edit.
for _, dirtyIndex := range dirtyIndexes {
file, canonicalPath, ok := c.readFileMetadata(a, gen, ctx, dirtyIndex)
if !ok {
continue
}
listPath := ""
for _, value := range metadata {
if value.fileIndex == dirtyIndex || value.path == canonicalPath {
listPath = value.listPath
break
}
}
if listPath == "" {
for index := range paths {
if paths[index].idx == dirtyIndex {
paths[index].size = file.DataSize
paths[index].typ = file.DataType
}
}
continue
}
scriptMetadata, err := c.readIndexedMetadata(a, gen, ctx, dirtyIndex, listPath, nil)
if err != nil {
scriptMetadata = pvf.ScriptMetadata{}
}
visuals := visualsFromMetadata(scriptMetadata)
for index := range metadata {
if metadata[index].fileIndex != dirtyIndex && metadata[index].path != canonicalPath {
continue
}
metadata[index].path = canonicalPath
metadata[index].fileIndex = dirtyIndex
metadata[index].size = file.DataSize
metadata[index].dataType = file.DataType
metadata[index].name = scriptMetadata.Name
metadata[index].rarity = visuals.rarity
metadata[index].icon = cloneImageReference(visuals.icon)
metadata[index].fieldImage = cloneImageReference(visuals.fieldImage)
}
for index := range paths {
if paths[index].idx == dirtyIndex {
paths[index].size = file.DataSize
paths[index].typ = file.DataType
}
}
}
byFile := make(map[int32][]int)
visuals := make(map[int32]fileVisuals)
for index := range metadata {
value := &metadata[index]
byFile[value.fileIndex] = append(byFile[value.fileIndex], index)
visuals[value.fileIndex] = fileVisuals{
icon: cloneImageReference(value.icon),
fieldImage: cloneImageReference(value.fieldImage),
rarity: value.rarity,
}
}
records, recordsByFile := buildSearchRecords(paths, metadata, byFile)
treeTagsByFile := buildTreeTags(records, recordsByFile)
total := len(metadata) + oldSkipped
if !c.publishSearchCandidate(a, gen, ctx, startedAt, records, recordsByFile, metadata, treeTagsByFile, visuals, total, oldSkipped, searchIndexSpecFingerprint(specs), false, false) {
return
}
}
func cloneIndexedMetadata(values []indexedMetadata) []indexedMetadata {
if len(values) == 0 {
return nil
}
result := make([]indexedMetadata, len(values))
for index, value := range values {
result[index] = value
result[index].icon = cloneImageReference(value.icon)
result[index].fieldImage = cloneImageReference(value.fieldImage)
}
return result
}
// publishSearchCandidate atomically swaps a completed candidate into the
// currently active snapshot. A ready snapshot remains queryable until this
// point, so a slow refresh never turns the normal UI flow into a loading gate.
func (c *core) publishSearchCandidate(
a *pvf.Archive,
gen uint64,
ctx context.Context,
startedAt time.Time,
records []searchRecord,
recordsByFile map[int32][]int,
metadata []indexedMetadata,
treeTagsByFile map[int32][]TreeTag,
visualsByFile map[int32]fileVisuals,
total, skipped int,
specsFingerprint string,
cacheEligible bool,
cacheHit bool,
) bool {
// Reconcile payloads before taking the publish lock. Parsing script
// metadata can touch decompression and string-table caches; doing that
// while holding core.mu used to make the resource tree wait for the whole
// reconciliation pass.
c.mu.RLock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
c.mu.RUnlock()
return false
}
dirtyIndexes := make([]int32, 0, len(c.indexDirty))
for fileIndex := range c.indexDirty {
dirtyIndexes = append(dirtyIndexes, fileIndex)
}
c.mu.RUnlock()
for _, fileIndex := range dirtyIndexes {
c.mu.RLock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil || fileIndex < 0 || fileIndex >= a.FileCount() {
c.mu.RUnlock()
return false
}
scriptMetadata, err := readIndexedMetadataFromArchive(a, fileIndex, a.Path(fileIndex), nil)
fileSize := a.File(fileIndex).DataSize
fileType := a.File(fileIndex).DataType
c.mu.RUnlock()
if err != nil {
scriptMetadata = pvf.ScriptMetadata{}
}
visuals := visualsFromMetadata(scriptMetadata)
visualsByFile[fileIndex] = visuals
for metadataIndex := range metadata {
if metadata[metadataIndex].fileIndex != fileIndex {
continue
}
metadata[metadataIndex].name = scriptMetadata.Name
metadata[metadataIndex].size = fileSize
metadata[metadataIndex].dataType = fileType
metadata[metadataIndex].rarity = visuals.rarity
metadata[metadataIndex].icon = cloneImageReference(visuals.icon)
metadata[metadataIndex].fieldImage = cloneImageReference(visuals.fieldImage)
}
for _, recordIndex := range recordsByFile[fileIndex] {
if recordIndex < 0 || recordIndex >= len(records) {
continue
}
record := &records[recordIndex]
record.hit.Size = fileSize
record.hit.DataType = fileType
if record.hit.Category == SearchCategoryFile {
continue
}
record.hit.Name = scriptMetadata.Name
record.lowerName = strings.ToLower(scriptMetadata.Name)
record.hit.Rarity = visuals.rarity
record.hit.Icon = cloneImageReference(visuals.icon)
record.hit.FieldImage = cloneImageReference(visuals.fieldImage)
}
}
c.mu.Lock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
c.mu.Unlock()
return false
}
c.searchRecords = records
c.searchByFile = recordsByFile
c.searchMetadata = cloneIndexedMetadata(metadata)
c.searchSpecFingerprint = specsFingerprint
c.treeTagsByFile = treeTagsByFile
c.visualsByFile = visualsByFile
pendingRefresh := c.indexRefreshPending
pendingForce := c.indexRefreshPendingForce
openDurationMs := c.indexStatus.OpenDurationMs
c.indexStatus = IndexStatus{
State: IndexStateReady,
Stage: "ready",
Done: total,
Total: total,
Skipped: skipped,
CacheHit: cacheHit,
OpenDurationMs: openDurationMs,
BuildDurationMs: elapsedMilliseconds(startedAt),
Refreshing: pendingRefresh,
}
if cacheHit {
c.indexStatus.Stage = "ready-cache"
}
if pendingRefresh {
// Mutations that arrived after this build captured its dirty set belong
// to the queued build. Keeping the map here is intentionally conservative
// and may rebuild one already reconciled file, but can never drop the
// newest content.
c.indexStatus.Stage = "refreshing"
} else {
c.indexDirty = make(map[int32]struct{})
}
c.indexCancel = nil
c.indexRefreshPending = false
c.indexRefreshPendingForce = false
status := c.indexStatus
c.mu.Unlock()
emitEvent("archive:index-ready", status)
if pendingRefresh {
c.startSearchIndexWithOptions(pendingForce)
} else if cacheEligible && !cacheHit {
c.persistSearchIndexCacheAsync(a, gen, metadata, total, skipped)
}
return true
}
func (c *core) failSearchIndex(a *pvf.Archive, gen uint64, ctx context.Context, startedAt time.Time, err error) {
if err == nil || ctx.Err() != nil {
return
}
c.mu.Lock()
if !c.indexIsCurrentLocked(a, gen) {
c.mu.Unlock()
return
}
pendingRefresh := c.indexRefreshPending
pendingForce := c.indexRefreshPendingForce
if c.indexStatus.State == IndexStateReady && c.searchRecords != nil {
c.indexStatus.Refreshing = false
c.indexStatus.Stage = "refresh-error"
c.indexStatus.RefreshError = err.Error()
c.indexStatus.BuildDurationMs = elapsedMilliseconds(startedAt)
} else {
c.indexStatus = IndexStatus{
State: IndexStateError,
Stage: "error",
Error: err.Error(),
OpenDurationMs: c.indexStatus.OpenDurationMs,
BuildDurationMs: elapsedMilliseconds(startedAt),
}
}
c.indexCancel = nil
c.indexRefreshPending = false
c.indexRefreshPendingForce = false
status := c.indexStatus
c.mu.Unlock()
if pendingRefresh {
// See the SQLite path above: a queued newer mutation should be retried
// without exposing the failed intermediate candidate as final state.
c.startSearchIndexWithOptions(pendingForce)
return
}
emitEvent("archive:index-error", status)
}
// setText updates an archive script and synchronizes its semantic search name
// when the file is already represented by the completed index.
func (c *core) setText(index int32, text string) (bool, string, error) {
c.mu.Lock()
if c.archive == nil {
c.mu.Unlock()
return false, "", ErrNoArchive
}
if err := c.ensureVersionReadyLocked(); err != nil {
c.mu.Unlock()
return false, "", err
}
if index < 0 || index >= c.archive.FileCount() {
err := fmt.Errorf("文件索引越界: %d", index)
c.mu.Unlock()
return false, "", err
}
path := c.archive.Path(index)
a := c.archive
mutationCheckpoint := a.MutationCheckpoint()
var before pvfversion.ContentSnapshot
if c.versionRepo != nil {
var err error
before, err = pvfversion.ContentSnapshotFromArchive(c.archive, []string{path})
if err != nil {
c.mu.Unlock()
return false, "", err
}
}
if err := c.archive.SetText(index, text); err != nil {
c.mu.Unlock()
return false, "", err
}
summary := a.MutationsSince(mutationCheckpoint)
if len(summary.Files) == 0 {
a.ClearMutations()
c.mu.Unlock()
return false, "", nil
}
if c.versionRepo != nil {
after, err := pvfversion.ContentSnapshotFromArchive(c.archive, []string{path})
if err != nil {
c.mu.Unlock()
return false, "", err
}
if err := c.recordVersionMutationLocked("编辑文件", before, after); err != nil {
c.mu.Unlock()
return false, "", err
}
}
c.batchRevision++
c.batchPlan = nil
c.invalidateScriptLocked()
versioned := c.versionRepo != nil
if c.editorText == nil {
c.editorText = make(map[int32]string)
}
c.editorText[index] = text
c.annotationRelations = make(map[string]map[string]*relationTarget)
c.editorAnnotation = editorAnnotationCache{}
c.markAdvancedSearchStaleLocked(c.archive)
if c.diskIndex != nil {
if err := c.diskIndex.refreshFileMetadata(a, map[int32]struct{}{index: {}}); err != nil {
a.ClearMutations()
c.mu.Unlock()
return false, "", err
}
} else {
refreshArchiveIndexMetadataLocked(c, map[int32]struct{}{index: {}})
}
delete(c.visualsByFile, index)
a.ClearMutations()
c.mu.Unlock()
if err := c.scheduleArchiveMutations(a, summary); err != nil {
return false, "", err
}
emitEvent("archive:advanced-search-stale", map[string]any{"fileIndex": index})
if versioned {
emitVersionState(c, "edited")
}
return false, "", nil
}
// setPlaceholderText rewrites the string-table text behind one `<table::key>`
// placeholder and refreshes the name of the file that carries it, so the
// explorer and the search results pick the new text up immediately.
//
// The script that holds the placeholder is untouched: only the `.str` payload
// changes, which is where this client keeps the display text.
func (c *core) setPlaceholderText(index int32, tableIndex int32, key, text string) error {
c.mu.Lock()
if c.archive == nil {
c.mu.Unlock()
return ErrNoArchive
}
if err := c.ensureVersionReadyLocked(); err != nil {
c.mu.Unlock()
return err
}
if index < 0 || index >= c.archive.FileCount() {
err := fmt.Errorf("文件索引越界: %d", index)
c.mu.Unlock()
return err
}
a := c.archive
mutationCheckpoint := a.MutationCheckpoint()
tableFileIndex, ok := c.archive.StringTableEntryIndex(int(tableIndex), key)
if !ok {
c.mu.Unlock()
return fmt.Errorf("找不到字符串表条目 <%d::%s>", tableIndex, key)
}
tablePath := c.archive.Path(tableFileIndex)
var before pvfversion.ContentSnapshot
if c.versionRepo != nil {
var err error
before, err = pvfversion.ContentSnapshotFromArchive(c.archive, []string{tablePath})
if err != nil {
c.mu.Unlock()
return err
}
}
if err := c.archive.SetStringTableEntryAt(tableFileIndex, key, text); err != nil {
c.mu.Unlock()
return err
}
summary := a.MutationsSince(mutationCheckpoint)
if len(summary.Files) == 0 {
a.ClearMutations()
c.mu.Unlock()
return nil
}
if c.versionRepo != nil {
after, err := pvfversion.ContentSnapshotFromArchive(c.archive, []string{tablePath})
if err != nil {
c.mu.Unlock()
return err
}
if err := c.recordVersionMutationLocked("编辑字符串表", before, after); err != nil {
c.mu.Unlock()
return err
}
}
c.batchRevision++
c.batchPlan = nil
c.invalidateScriptLocked()
versioned := c.versionRepo != nil
c.annotationRelations = make(map[string]map[string]*relationTarget)
c.editorAnnotation = editorAnnotationCache{}
c.markAdvancedSearchStaleLocked(c.archive)
delete(c.visualsByFile, index)
if c.diskIndex == nil {
refreshArchiveIndexMetadataLocked(c, map[int32]struct{}{tableFileIndex: {}})
}
a.ClearMutations()
c.mu.Unlock()
if err := c.scheduleArchiveMutations(a, summary); err != nil {
return err
}
emitEvent("archive:advanced-search-stale", map[string]any{"fileIndex": index})
if versioned {
emitVersionState(c, "edited")
}
return nil
}
// refreshIndexedRecordsLocked re-reads one file's name and images from the
// archive and updates its search records and tree tags. It reports whether
// anything changed. The caller must hold c.mu and must already have deleted any
// stale entry from c.visualsByFile.
func (c *core) refreshIndexedRecordsLocked(index int32, previousVisuals fileVisuals, hadPreviousVisuals bool) (bool, string, fileVisuals) {
recordIndexes := c.searchByFile[index]
metadata, err := readIndexedMetadataFromArchive(c.archive, index, c.archive.Path(index), nil)
if err != nil {
metadata = pvf.ScriptMetadata{}
}
name := metadata.Name
visuals := visualsFromMetadata(metadata)
c.visualsByFile[index] = visuals
updated := false
for _, recordIndex := range recordIndexes {
record := &c.searchRecords[recordIndex]
if record.hit.Category != SearchCategoryFile {
record.hit.Name = name
record.lowerName = strings.ToLower(name)
}
if !imageReferencesEqual(record.hit.Icon, visuals.icon) || !imageReferencesEqual(record.hit.FieldImage, visuals.fieldImage) {
updated = true
}
record.hit.Icon = cloneImageReference(visuals.icon)
record.hit.FieldImage = cloneImageReference(visuals.fieldImage)
if record.hit.Category != SearchCategoryFile {
record.hit.Rarity = visuals.rarity
updated = true
}
}
c.treeTagsByFile[index] = treeTagsForRecords(c.searchRecords, recordIndexes)
if !hadPreviousVisuals || !imageReferencesEqual(previousVisuals.icon, visuals.icon) || !imageReferencesEqual(previousVisuals.fieldImage, visuals.fieldImage) {
if visuals.icon != nil || visuals.fieldImage != nil || hadPreviousVisuals {
updated = true
}
}
if isNPCEntryPath(c.archive.Path(index)) && c.refreshItemShopNamesLocked() {
updated = true
}
return updated, name, visuals
}
func (c *core) publishIndexProgress(a *pvf.Archive, gen uint64, ctx context.Context, done, total, skipped int, last *time.Time) {
if done != total && done%1000 != 0 && time.Since(*last) < 100*time.Millisecond {
return
}
status := IndexStatus{
State: IndexStateBuilding,
Stage: "metadata",
Done: done,
Total: total,
Skipped: skipped,
}
if c.publishIndexStatus(a, gen, ctx, status) {
*last = time.Now()
}
}
func (c *core) publishIndexStatus(a *pvf.Archive, gen uint64, ctx context.Context, status IndexStatus) bool {
c.mu.Lock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
c.mu.Unlock()
return false
}
if c.indexStatus.State == IndexStateReady && c.indexStatus.Refreshing {
// Keep the old snapshot queryable while a candidate reports progress.
status.State = IndexStateReady
status.Refreshing = true
}
status.OpenDurationMs = c.indexStatus.OpenDurationMs
status.BuildDurationMs = elapsedMilliseconds(c.indexStartedAt)
c.indexStatus = status
c.mu.Unlock()
emitEvent("archive:index-progress", status)
return true
}
func (c *core) indexCurrent(a *pvf.Archive, gen uint64, ctx context.Context) bool {
if ctx.Err() != nil {
return false
}
c.mu.RLock()
defer c.mu.RUnlock()
return c.indexIsCurrentLocked(a, gen)
}
func (c *core) indexIsCurrentLocked(a *pvf.Archive, gen uint64) bool {
return c.archive == a && c.indexGen == gen &&
(c.indexStatus.State == IndexStateBuilding ||
(c.indexStatus.State == IndexStateReady && c.indexStatus.Refreshing))
}
func (c *core) readListPairs(a *pvf.Archive, gen uint64, ctx context.Context, index int32) ([]pvf.ListPair, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
return nil, context.Canceled
}
return a.ScriptListPairs(index)
}
func (c *core) findArchiveEntry(a *pvf.Archive, gen uint64, ctx context.Context, name string) (int32, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
return 0, false
}
return a.Find(name)
}
// findArchiveList resolves a `.lst` relation path, tolerating the list layout
// difference between client generations (see Archive.FindList).
func (c *core) findArchiveList(a *pvf.Archive, gen uint64, ctx context.Context, listPath string) (int32, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
return 0, false
}
return a.FindList(listPath)
}
func (c *core) findListTarget(a *pvf.Archive, gen uint64, ctx context.Context, listPath, relativePath string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
return "", false
}
targetPath, _, ok := findListTargetInArchive(a, listPath, relativePath)
return targetPath, ok
}
func (c *core) findListTargetFile(a *pvf.Archive, gen uint64, ctx context.Context, listPath, relativePath string) (string, int32, pvf.File, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
return "", 0, pvf.File{}, false
}
targetPath, index, ok := findListTargetInArchive(a, listPath, relativePath)
if !ok {
return "", 0, pvf.File{}, false
}
return targetPath, index, a.File(index), true
}
func (c *core) readFileMetadata(a *pvf.Archive, gen uint64, ctx context.Context, index int32) (pvf.File, string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
return pvf.File{}, "", false
}
return a.File(index), a.Path(index), true
}
func (c *core) readIndexedMetadata(a *pvf.Archive, gen uint64, ctx context.Context, index int32, listPath string, npcNames map[string]string) (pvf.ScriptMetadata, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
return pvf.ScriptMetadata{}, context.Canceled
}
return readIndexedMetadataFromArchive(a, index, listPath, npcNames)
}
func (c *core) readIndexedName(a *pvf.Archive, gen uint64, ctx context.Context, index int32, listPath string, npcNames map[string]string) (string, bool, error) {
metadata, err := c.readIndexedMetadata(a, gen, ctx, index, listPath, npcNames)
return metadata.Name, metadata.HasName, err
}
func (c *core) buildNPCNameIndex(a *pvf.Archive, gen uint64, ctx context.Context) (map[string]string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
return nil, false
}
return buildNPCNameIndexFromArchive(a), true
}
func readIndexedNameFromArchive(a *pvf.Archive, index int32, listPath string, npcNames map[string]string) (string, bool, error) {
metadata, err := readIndexedMetadataFromArchive(a, index, listPath, npcNames)
return metadata.Name, metadata.HasName, err
}
// markedName is the name shown by the explorer, the search results and the
// editor's tree tags. It is the placeholder-resolved text, flagged when this
// client's own localization has no text for the key and only a language overlay
// could answer, so a Korean name is never mistaken for the client's own text.
func markedName(metadata pvf.ScriptMetadata) string {
if metadata.Name == "" || !metadata.NameFallback {
return metadata.Name
}
return metadata.Name + untranslatedMark
}
func readIndexedMetadataFromArchive(a *pvf.Archive, index int32, listPath string, npcNames map[string]string) (pvf.ScriptMetadata, error) {
metadata, err := a.ScriptMetadata(index)
if err != nil {
return pvf.ScriptMetadata{}, err
}
metadata.Name = markedName(metadata)
if metadata.HasName && strings.TrimSpace(metadata.Name) != "" {
return metadata, nil
}
if !isItemShopEntry(listPath, a.Path(index)) {
return metadata, nil
}
if npcNames == nil {
npcNames = buildNPCNameIndexFromArchive(a)
}
text, err := a.Text(index)
if err != nil {
return metadata, err
}
npcID := firstSectionValue(text, "npc")
if npcID == "" {
return metadata, nil
}
name, ok := npcNames[npcID]
if ok && strings.TrimSpace(name) != "" {
metadata.Name = name
metadata.HasName = true
}
return metadata, nil
}
func imageReferenceFromPVF(reference *pvf.ScriptImageReference) *ImageReference {
if reference == nil || strings.TrimSpace(reference.Path) == "" || reference.Index < 0 {
return nil
}
return &ImageReference{Path: reference.Path, Index: reference.Index}
}
func cloneImageReference(reference *ImageReference) *ImageReference {
if reference == nil {
return nil
}
copy := *reference
return &copy
}
func imageReferencesEqual(left, right *ImageReference) bool {
if left == nil || right == nil {
return left == right
}
return left.Path == right.Path && left.Index == right.Index
}
func (c *core) fileVisualsLocked(index int32) fileVisuals {
if c.diskIndex != nil {
return c.diskIndex.visuals(index)
}
if c.visualsByFile == nil {
c.visualsByFile = make(map[int32]fileVisuals)
}
if visuals, ok := c.visualsByFile[index]; ok {
return fileVisuals{
icon: cloneImageReference(visuals.icon),
fieldImage: cloneImageReference(visuals.fieldImage),
rarity: visuals.rarity,
}
}
if c.archive == nil || index < 0 || index >= c.archive.FileCount() || c.archive.File(index).DataType != pvf.TypeScript {
return fileVisuals{rarity: pvf.RarityUnknown}
}
metadata, err := c.archive.ScriptMetadata(index)
if err != nil {
return fileVisuals{rarity: pvf.RarityUnknown}
}
visuals := visualsFromMetadata(metadata)
c.visualsByFile[index] = visuals
return fileVisuals{
icon: cloneImageReference(visuals.icon),
fieldImage: cloneImageReference(visuals.fieldImage),
rarity: visuals.rarity,
}
}
func buildNPCNameIndexFromArchive(a *pvf.Archive) map[string]string {
result := make(map[string]string)
listIndex, ok := a.FindList(npcListPath)
if !ok {
return result
}
pairs, err := a.ScriptListPairs(listIndex)
if err != nil {
return result
}
for _, pair := range pairs {
_, targetIndex, ok := findListTargetInArchive(a, npcListPath, pair.Path)
if !ok {
continue
}
metadata, err := a.ScriptMetadata(targetIndex)
if err != nil || !metadata.HasName || strings.TrimSpace(metadata.Name) == "" {
continue
}
if _, exists := result[pair.ID]; !exists {
result[pair.ID] = markedName(metadata)
}
}
return result
}
func isItemShopEntry(listPath, entryPath string) bool {
if sameSearchPath(listPath, itemShopListPath) {
return true
}
entryPath = normalizeSearchPath(entryPath)
return strings.HasPrefix(entryPath, "itemshop/") && strings.EqualFold(path.Ext(entryPath), ".shp")
}
func isNPCEntryPath(entryPath string) bool {
entryPath = normalizeSearchPath(entryPath)
return entryPath != npcListPath && strings.HasPrefix(entryPath, "npc/")
}
func (c *core) refreshItemShopNamesLocked() bool {
npcNames := buildNPCNameIndexFromArchive(c.archive)
changed := false
affectedFiles := make(map[int32]struct{})
for index := range c.searchRecords {
record := &c.searchRecords[index]
if record.hit.Category == SearchCategoryFile || !isItemShopEntry("", record.hit.Path) {
continue
}
name, _, err := readIndexedNameFromArchive(c.archive, record.hit.FileIndex, itemShopListPath, npcNames)
if err != nil {
name = ""
}
if record.hit.Name == name {
continue
}
record.hit.Name = name
record.lowerName = strings.ToLower(name)
affectedFiles[record.hit.FileIndex] = struct{}{}
changed = true
}
for fileIndex := range affectedFiles {
c.treeTagsByFile[fileIndex] = treeTagsForRecords(c.searchRecords, c.searchByFile[fileIndex])
}
return changed
}
func sameSearchPath(left, right string) bool {
return normalizeSearchPath(left) == normalizeSearchPath(right)
}
func normalizeSearchPath(value string) string {
return strings.ToLower(strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/"))
}
func (c *core) snapshotPaths(a *pvf.Archive, gen uint64, ctx context.Context) ([]pathEntry, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
return nil, false
}
paths := make([]pathEntry, len(c.sortedPaths))
copy(paths, c.sortedPaths)
return paths, true
}
func buildSearchRecords(paths []pathEntry, metadata []indexedMetadata, metadataByFile map[int32][]int) ([]searchRecord, map[int32][]int) {
if paths == nil {
paths = make([]pathEntry, 0, len(metadata))
seen := make(map[int32]bool)
for _, entry := range metadata {
if seen[entry.fileIndex] {
continue
}
seen[entry.fileIndex] = true
paths = append(paths, pathEntry{
path: entry.path,
lower: strings.ToLower(entry.path),
idx: entry.fileIndex,
size: entry.size,
typ: entry.dataType,
})
}
sort.Slice(paths, func(i, j int) bool { return paths[i].path < paths[j].path })
}
records := make([]searchRecord, 0, len(paths)+len(metadata))
recordsByFile := make(map[int32][]int)
for _, p := range paths {
metadataIndexes := metadataByFile[p.idx]
if len(metadataIndexes) == 0 {
appendSearchRecord(&records, &recordsByFile, SearchHit{
Name: pathBase(p.path),
Path: p.path,
Category: SearchCategoryFile,
Size: p.size,
DataType: p.typ,
FileIndex: p.idx,
Rarity: pvf.RarityUnknown,
ChangeKind: p.changeKind,
})
continue
}
for _, metadataIndex := range metadataIndexes {
entry := metadata[metadataIndex]
appendSearchRecord(&records, &recordsByFile, SearchHit{
Name: entry.name,
ID: entry.id,
Path: entry.path,
Category: entry.category,
Size: entry.size,
DataType: entry.dataType,
FileIndex: entry.fileIndex,
Rarity: entry.rarity,
ChangeKind: p.changeKind,
Icon: cloneImageReference(entry.icon),
FieldImage: cloneImageReference(entry.fieldImage),
})
}
}
return records, recordsByFile
}
func appendSearchRecord(records *[]searchRecord, recordsByFile *map[int32][]int, hit SearchHit) {
record := searchRecord{
hit: hit,
lowerName: strings.ToLower(hit.Name),
lowerID: strings.ToLower(hit.ID),
lowerPath: strings.ToLower(hit.Path),
}
*records = append(*records, record)
index := len(*records) - 1
(*recordsByFile)[hit.FileIndex] = append((*recordsByFile)[hit.FileIndex], index)
}
func buildTreeTags(records []searchRecord, recordsByFile map[int32][]int) map[int32][]TreeTag {
tagsByFile := make(map[int32][]TreeTag)
for fileIndex, recordIndexes := range recordsByFile {
tags := treeTagsForRecords(records, recordIndexes)
if len(tags) > 0 {
tagsByFile[fileIndex] = tags
}
}
return tagsByFile
}
func treeTagsForRecords(records []searchRecord, recordIndexes []int) []TreeTag {
var tags []TreeTag
for _, recordIndex := range recordIndexes {
if recordIndex < 0 || recordIndex >= len(records) {
continue
}
hit := records[recordIndex].hit
if hit.Category == SearchCategoryFile {
continue
}
tags = append(tags, TreeTag{
ID: hit.ID,
Name: hit.Name,
Category: hit.Category,
Rarity: hit.Rarity,
})
}
return tags
}
func cloneTreeTags(tags []TreeTag) []TreeTag {
if len(tags) == 0 {
return nil
}
cloned := make([]TreeTag, len(tags))
copy(cloned, tags)
return cloned
}
func resolveListPath(listPath, relative string) (string, bool) {
relative = strings.TrimSpace(strings.ReplaceAll(relative, "\\", "/"))
if relative == "" || strings.HasPrefix(relative, "/") {
return "", false
}
resolved := path.Clean(path.Join(path.Dir(listPath), relative))
if resolved == "." || resolved == ".." || strings.HasPrefix(resolved, "../") {
return "", false
}
return resolved, true
}
func findListTargetInArchive(a *pvf.Archive, listPath, relative string) (string, int32, bool) {
candidates, ok := listPathCandidates(listPath, relative)
if !ok {
return "", 0, false
}
for _, candidate := range candidates {
index, ok := a.Find(candidate)
if ok {
return a.Path(index), index, true
}
}
return "", 0, false
}
// listPathCandidates returns the archive paths a `.lst` entry may denote. The
// 90US layout stores entry paths relative to the list's own directory
// (`equipment/equipment.lst` + `character/a.equ`), while the 110US layout
// stores archive-root-relative paths (`list/equipment.lst` +
// `equipment/character/a.equ`). Both readings are tried, each also with the
// `(r)` override file name the client falls back to.
func listPathCandidates(listPath, relative string) ([]string, bool) {
targetPath, ok := resolveListPath(listPath, relative)
if !ok {
return nil, false
}
candidates := []string{targetPath}
appendOverride := func(candidate string) {
base := path.Base(candidate)
if strings.HasPrefix(strings.ToLower(base), "(r)") {
return
}
candidates = append(candidates, path.Join(path.Dir(candidate), "(r)"+base))
}
appendOverride(targetPath)
root := strings.Trim(strings.ReplaceAll(strings.TrimSpace(relative), "\\", "/"), "/")
if root != "" && !strings.Contains(root, "..") {
if cleaned := path.Clean(root); cleaned != "." && cleaned != targetPath {
candidates = append(candidates, cleaned)
appendOverride(cleaned)
}
}
return candidates, true
}
func pathBase(p string) string {
if slash := strings.LastIndexByte(p, '/'); slash >= 0 {
return p[slash+1:]
}
return p
}