服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
814 lines
23 KiB
Go
814 lines
23 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
pathpkg "path"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
|
|
"pvfine/internal/npk"
|
|
)
|
|
|
|
const imageIndexVersion = 2
|
|
|
|
// NPK packages are independent scan units. Keep the pool bounded so a large
|
|
// resource directory does not create one goroutine and file descriptor per
|
|
// package or overwhelm slower disks with unbounded random reads.
|
|
const maxImageScanWorkers = 8
|
|
|
|
var (
|
|
errImageDirectoryMissing = errors.New("NPK 目录不存在或不是目录")
|
|
imageCacheMaxEntries = 4096
|
|
imageCacheMaxBytes = 64 << 20
|
|
)
|
|
|
|
type imageManifest struct {
|
|
Relative string `json:"relative"`
|
|
Size int64 `json:"size"`
|
|
Modified int64 `json:"modified"`
|
|
}
|
|
|
|
type persistedImageRecord struct {
|
|
Path string `json:"path"`
|
|
NPK string `json:"npk"`
|
|
EntryOffset int64 `json:"entryOffset"`
|
|
EntrySize int64 `json:"entrySize"`
|
|
}
|
|
|
|
type persistedImageIndex struct {
|
|
Version int `json:"version"`
|
|
Directory string `json:"directory"`
|
|
Manifest []imageManifest `json:"manifest"`
|
|
Images []persistedImageRecord `json:"images"`
|
|
NPKFiles int `json:"npkFiles"`
|
|
IMGFiles int `json:"imgFiles"`
|
|
ImageCount int `json:"imageCount"`
|
|
Skipped int `json:"skipped"`
|
|
Duplicates int `json:"duplicates"`
|
|
}
|
|
|
|
type imageRecord struct {
|
|
path string
|
|
npkRelative string
|
|
offset int64
|
|
size int64
|
|
}
|
|
|
|
type imageSnapshot struct {
|
|
directory string
|
|
manifest []imageManifest
|
|
byPath map[string]*imageRecord
|
|
records []*imageRecord
|
|
npkFiles int
|
|
imgFiles int
|
|
imageCount int
|
|
skipped int
|
|
duplicates int
|
|
}
|
|
|
|
type imageCacheEntry struct {
|
|
data ImageData
|
|
bytes int
|
|
}
|
|
|
|
type imageScanResult struct {
|
|
index int
|
|
npkFiles int
|
|
imgFiles int
|
|
imageCount int
|
|
skipped int
|
|
records []*imageRecord
|
|
}
|
|
|
|
// ImageService owns the NPK/IMG metadata snapshot and on-demand PNG cache.
|
|
// It does not expose source NPK paths to the frontend.
|
|
type ImageService struct {
|
|
mu sync.RWMutex
|
|
settings *SettingsService
|
|
cachePath string // test override; production uses os.UserCacheDir()
|
|
|
|
directory string
|
|
snapshot *imageSnapshot
|
|
status ImageIndexStatus
|
|
cancel context.CancelFunc
|
|
generation uint64
|
|
|
|
cache map[string]imageCacheEntry
|
|
cacheOrder []string
|
|
cacheBytes int
|
|
|
|
// Shared by single-frame and batch requests; limits all active decoders.
|
|
decodeSlots chan struct{}
|
|
batchIO imageBatchIO // optional IO overrides for deterministic tests
|
|
}
|
|
|
|
func NewImageService(_ *core, settings *SettingsService) *ImageService {
|
|
return &ImageService{settings: settings, cache: make(map[string]imageCacheEntry), status: ImageIndexStatus{State: ImageIndexStateIdle, Stage: "idle"}}
|
|
}
|
|
|
|
// Initialize loads a valid persistent snapshot, or starts an asynchronous
|
|
// scan for the configured directory.
|
|
func (s *ImageService) Initialize() ImageIndexStatus {
|
|
if s.settings == nil {
|
|
return s.IndexStatus()
|
|
}
|
|
settings, err := s.settings.GetSettings()
|
|
if err != nil {
|
|
s.setError("读取 NPK 设置失败", err)
|
|
return s.IndexStatus()
|
|
}
|
|
directory, err := canonicalImageDirectory(settings.NPKDirectory)
|
|
if settings.NPKDirectory == "" {
|
|
s.mu.Lock()
|
|
if s.cancel != nil {
|
|
s.cancel()
|
|
s.cancel = nil
|
|
}
|
|
s.generation++
|
|
s.directory = ""
|
|
s.snapshot = nil
|
|
s.clearCacheLocked()
|
|
s.status = ImageIndexStatus{State: ImageIndexStateIdle, Stage: "idle", Generation: s.generation}
|
|
s.mu.Unlock()
|
|
return s.IndexStatus()
|
|
}
|
|
if err != nil {
|
|
s.setErrorForDirectory("NPK 目录不可用", settings.NPKDirectory, err)
|
|
return s.IndexStatus()
|
|
}
|
|
|
|
s.mu.Lock()
|
|
if s.directory == directory && (s.snapshot != nil || s.status.State == ImageIndexStateBuilding) {
|
|
status := s.status
|
|
s.mu.Unlock()
|
|
return status
|
|
}
|
|
s.directory = directory
|
|
s.snapshot = nil
|
|
s.clearCacheLocked()
|
|
if s.cancel != nil {
|
|
s.cancel()
|
|
}
|
|
s.generation++
|
|
generation := s.generation
|
|
s.mu.Unlock()
|
|
|
|
if snapshot, err := loadImageIndexCacheAt(directory, s.cachePath); err == nil {
|
|
s.mu.Lock()
|
|
if s.generation == generation && s.directory == directory {
|
|
s.snapshot = snapshot
|
|
s.status = readyImageStatus(snapshot, generation)
|
|
s.mu.Unlock()
|
|
emitEvent("image:index-ready", s.IndexStatus())
|
|
return s.IndexStatus()
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
s.startBuild(directory, generation)
|
|
return s.IndexStatus()
|
|
}
|
|
|
|
// SelectNPKDirectory opens the native directory chooser, persists the
|
|
// selected path and starts a new asynchronous index generation.
|
|
func (s *ImageService) SelectNPKDirectory() (ImageIndexStatus, error) {
|
|
app := application.Get()
|
|
if app == nil {
|
|
return s.IndexStatus(), errors.New("当前运行环境不支持目录选择")
|
|
}
|
|
path, err := app.Dialog.OpenFile().
|
|
CanChooseFiles(false).
|
|
CanChooseDirectories(true).
|
|
CanCreateDirectories(false).
|
|
SetTitle("选择 NPK 目录").
|
|
PromptForSingleSelection()
|
|
if err != nil {
|
|
return s.IndexStatus(), err
|
|
}
|
|
if strings.TrimSpace(path) == "" {
|
|
return s.IndexStatus(), nil
|
|
}
|
|
directory, err := canonicalImageDirectory(path)
|
|
if err != nil {
|
|
return s.IndexStatus(), err
|
|
}
|
|
if s.settings != nil {
|
|
if _, err := s.settings.UpdateNPKDirectory(directory); err != nil {
|
|
return s.IndexStatus(), err
|
|
}
|
|
}
|
|
s.switchDirectory(directory)
|
|
return s.IndexStatus(), nil
|
|
}
|
|
|
|
// RebuildIndex starts a manual rebuild. Existing image data remains available
|
|
// through the old immutable snapshot until the new one is ready.
|
|
func (s *ImageService) RebuildIndex() (ImageIndexStatus, error) {
|
|
s.mu.Lock()
|
|
directory := s.directory
|
|
if directory == "" {
|
|
s.mu.Unlock()
|
|
return s.status, nil
|
|
}
|
|
s.generation++
|
|
generation := s.generation
|
|
if s.cancel != nil {
|
|
s.cancel()
|
|
}
|
|
s.clearCacheLocked()
|
|
s.mu.Unlock()
|
|
s.startBuild(directory, generation)
|
|
return s.IndexStatus(), nil
|
|
}
|
|
|
|
// IndexStatus returns a copy of the current asynchronous scan state.
|
|
func (s *ImageService) IndexStatus() ImageIndexStatus {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.status
|
|
}
|
|
|
|
// GetImage decodes one indexed frame and encodes it as a PNG data URL.
|
|
// Missing configuration, unfinished indexes, missing paths and out-of-range
|
|
// frames intentionally return (nil, nil) so callers can render a fallback.
|
|
func (s *ImageService) GetImage(path string, index int32) (*ImageData, error) {
|
|
defer debugPhase("GetImage", fmt.Sprintf("path=%s frame=%d", path, index))()
|
|
results, err := s.getImages([]ImageReference{{Path: path, Index: index}}, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if results[0].Error != "" {
|
|
return nil, errors.New(results[0].Error)
|
|
}
|
|
return results[0].Data, nil
|
|
}
|
|
|
|
func (s *ImageService) switchDirectory(directory string) {
|
|
s.mu.Lock()
|
|
if s.cancel != nil {
|
|
s.cancel()
|
|
s.cancel = nil
|
|
}
|
|
s.directory = directory
|
|
s.snapshot = nil
|
|
s.clearCacheLocked()
|
|
s.generation++
|
|
generation := s.generation
|
|
s.status = ImageIndexStatus{State: ImageIndexStateBuilding, Stage: "scanning", Directory: directory, Generation: generation}
|
|
s.mu.Unlock()
|
|
emitEvent("image:index-progress", s.IndexStatus())
|
|
s.startBuild(directory, generation)
|
|
}
|
|
|
|
func (s *ImageService) startBuild(directory string, generation uint64) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.mu.Lock()
|
|
if s.generation != generation || s.directory != directory {
|
|
s.mu.Unlock()
|
|
cancel()
|
|
return
|
|
}
|
|
if s.cancel != nil {
|
|
s.cancel()
|
|
}
|
|
s.cancel = cancel
|
|
s.status = ImageIndexStatus{State: ImageIndexStateBuilding, Stage: "scanning", Directory: directory, Generation: generation}
|
|
s.mu.Unlock()
|
|
emitEvent("image:index-progress", s.IndexStatus())
|
|
go s.buildIndex(ctx, directory, generation, time.Now())
|
|
}
|
|
|
|
func (s *ImageService) buildIndex(ctx context.Context, directory string, generation uint64, startedAt time.Time) {
|
|
manifest, err := scanImageManifest(directory)
|
|
if err != nil {
|
|
s.failBuild(directory, generation, startedAt, err)
|
|
return
|
|
}
|
|
status := ImageIndexStatus{State: ImageIndexStateBuilding, Stage: "scanning", Directory: directory, Total: len(manifest), Generation: generation, BuildDurationMs: elapsedMilliseconds(startedAt)}
|
|
s.publishProgress(directory, generation, status)
|
|
|
|
byPath := make(map[string]*imageRecord)
|
|
records := make([]*imageRecord, 0)
|
|
duplicates := 0
|
|
skipped := 0
|
|
npkCount := 0
|
|
imgCount := 0
|
|
imageCount := 0
|
|
scanResults, err := scanImageNPKs(ctx, directory, manifest, 0, func(result imageScanResult, done int) {
|
|
npkCount += result.npkFiles
|
|
imgCount += result.imgFiles
|
|
imageCount += result.imageCount
|
|
skipped += result.skipped
|
|
status.Done = done
|
|
status.NPKFiles = npkCount
|
|
status.IMGFiles = imgCount
|
|
status.ImageCount = imageCount
|
|
status.Skipped = skipped
|
|
status.BuildDurationMs = elapsedMilliseconds(startedAt)
|
|
s.publishProgress(directory, generation, status)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) {
|
|
return
|
|
}
|
|
s.failBuild(directory, generation, startedAt, err)
|
|
return
|
|
}
|
|
for _, result := range scanResults {
|
|
for _, record := range result.records {
|
|
if _, exists := byPath[record.path]; exists {
|
|
duplicates++
|
|
continue
|
|
}
|
|
byPath[record.path] = record
|
|
for _, alias := range imageIndexAliases(record.path) {
|
|
if _, exists := byPath[alias]; !exists {
|
|
byPath[alias] = record
|
|
}
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
}
|
|
status.NPKFiles = len(manifest)
|
|
status.IMGFiles = imgCount
|
|
status.ImageCount = imageCount
|
|
status.Skipped = skipped
|
|
status.Duplicates = duplicates
|
|
status.Done = len(manifest)
|
|
status.BuildDurationMs = elapsedMilliseconds(startedAt)
|
|
s.publishProgress(directory, generation, status)
|
|
if len(records) == 0 {
|
|
s.failBuild(directory, generation, startedAt, fmt.Errorf("NPK 目录中没有可用的 IMG 文件"))
|
|
return
|
|
}
|
|
snapshot := &imageSnapshot{
|
|
directory: directory, manifest: manifest, byPath: byPath, records: records,
|
|
npkFiles: len(manifest), imgFiles: imgCount, imageCount: imageCount,
|
|
skipped: skipped, duplicates: duplicates,
|
|
}
|
|
if err := saveImageIndexCacheAt(snapshot, s.cachePath); err != nil {
|
|
// Cache persistence should not make otherwise valid image data unusable.
|
|
status.Error = "索引已生成,但缓存保存失败: " + err.Error()
|
|
}
|
|
s.mu.Lock()
|
|
if s.generation != generation || s.directory != directory || ctx.Err() != nil {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.snapshot = snapshot
|
|
s.cancel = nil
|
|
s.status = ImageIndexStatus{State: ImageIndexStateReady, Stage: "ready", Directory: directory, Done: len(manifest), Total: len(manifest), NPKFiles: len(manifest), IMGFiles: imgCount, ImageCount: imageCount, Skipped: skipped, Duplicates: duplicates, Generation: generation, BuildDurationMs: elapsedMilliseconds(startedAt)}
|
|
ready := s.status
|
|
s.mu.Unlock()
|
|
emitEvent("image:index-ready", ready)
|
|
}
|
|
|
|
func imageScanWorkerCount(fileCount int) int {
|
|
if fileCount <= 0 {
|
|
return 0
|
|
}
|
|
workers := runtime.GOMAXPROCS(0)
|
|
if workers < 2 {
|
|
workers = 2
|
|
}
|
|
if workers > maxImageScanWorkers {
|
|
workers = maxImageScanWorkers
|
|
}
|
|
if workers > fileCount {
|
|
workers = fileCount
|
|
}
|
|
return workers
|
|
}
|
|
|
|
func scanImageNPKs(ctx context.Context, directory string, manifest []imageManifest, workers int, onResult func(imageScanResult, int)) ([]imageScanResult, error) {
|
|
if len(manifest) == 0 {
|
|
return []imageScanResult{}, nil
|
|
}
|
|
if workers <= 0 {
|
|
workers = imageScanWorkerCount(len(manifest))
|
|
}
|
|
if workers > len(manifest) {
|
|
workers = len(manifest)
|
|
}
|
|
|
|
type scanJob int
|
|
jobs := make(chan scanJob)
|
|
results := make(chan imageScanResult)
|
|
var group sync.WaitGroup
|
|
group.Add(workers)
|
|
for index := 0; index < workers; index++ {
|
|
go func() {
|
|
defer group.Done()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case job, ok := <-jobs:
|
|
if !ok {
|
|
return
|
|
}
|
|
result := scanImageNPK(ctx, directory, int(job), manifest[int(job)])
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
select {
|
|
case results <- result:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
go func() {
|
|
defer close(jobs)
|
|
for index := range manifest {
|
|
select {
|
|
case jobs <- scanJob(index):
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
group.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
scanned := make([]imageScanResult, len(manifest))
|
|
completed := 0
|
|
for result := range results {
|
|
scanned[result.index] = result
|
|
completed++
|
|
if onResult != nil {
|
|
onResult(result, completed)
|
|
}
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if completed != len(manifest) {
|
|
return nil, fmt.Errorf("NPK 并发扫描未完成: %d/%d", completed, len(manifest))
|
|
}
|
|
return scanned, nil
|
|
}
|
|
|
|
func scanImageNPK(ctx context.Context, directory string, index int, item imageManifest) imageScanResult {
|
|
result := imageScanResult{index: index}
|
|
if ctx.Err() != nil {
|
|
return result
|
|
}
|
|
npkPath := filepath.Join(directory, filepath.FromSlash(item.Relative))
|
|
npkFile, err := npk.Open(npkPath)
|
|
if err != nil {
|
|
result.skipped++
|
|
return result
|
|
}
|
|
if ctx.Err() != nil {
|
|
return result
|
|
}
|
|
reader, err := os.Open(npkPath)
|
|
if err != nil {
|
|
result.skipped++
|
|
return result
|
|
}
|
|
defer reader.Close()
|
|
result.npkFiles = 1
|
|
for _, entry := range npkFile.Entries {
|
|
if ctx.Err() != nil {
|
|
return result
|
|
}
|
|
if !strings.EqualFold(filepath.Ext(entry.Name), ".img") {
|
|
continue
|
|
}
|
|
frameCount, parseErr := npk.ReadIMGFrameCount(reader, entry.Offset, entry.Size)
|
|
if parseErr != nil {
|
|
result.skipped++
|
|
continue
|
|
}
|
|
result.imgFiles++
|
|
result.imageCount += int(frameCount)
|
|
record := &imageRecord{path: normalizeImagePath(entry.Name), npkRelative: item.Relative, offset: entry.Offset, size: entry.Size}
|
|
if record.path == "" {
|
|
result.skipped++
|
|
continue
|
|
}
|
|
result.records = append(result.records, record)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *ImageService) publishProgress(directory string, generation uint64, status ImageIndexStatus) {
|
|
status.Directory = directory
|
|
status.Generation = generation
|
|
s.mu.Lock()
|
|
if s.generation != generation || s.directory != directory {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.status = status
|
|
s.mu.Unlock()
|
|
emitEvent("image:index-progress", status)
|
|
}
|
|
|
|
func (s *ImageService) failBuild(directory string, generation uint64, startedAt time.Time, err error) {
|
|
s.mu.Lock()
|
|
if s.generation != generation || s.directory != directory {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.cancel = nil
|
|
s.status = ImageIndexStatus{State: ImageIndexStateError, Stage: "error", Directory: directory, Error: err.Error(), Generation: generation, BuildDurationMs: elapsedMilliseconds(startedAt)}
|
|
status := s.status
|
|
s.mu.Unlock()
|
|
emitEvent("image:index-error", status)
|
|
}
|
|
|
|
func (s *ImageService) setError(stage string, err error) {
|
|
s.setErrorForDirectory(stage, "", err)
|
|
}
|
|
|
|
func (s *ImageService) setErrorForDirectory(stage, directory string, err error) {
|
|
s.mu.Lock()
|
|
s.status = ImageIndexStatus{State: ImageIndexStateError, Stage: stage, Directory: directory, Error: err.Error(), Generation: s.generation}
|
|
status := s.status
|
|
s.mu.Unlock()
|
|
emitEvent("image:index-error", status)
|
|
}
|
|
|
|
func (s *ImageService) clearCacheLocked() {
|
|
s.cache = make(map[string]imageCacheEntry)
|
|
s.cacheOrder = nil
|
|
s.cacheBytes = 0
|
|
}
|
|
|
|
func (s *ImageService) putCacheLocked(key string, data ImageData, size int) {
|
|
if size > imageCacheMaxBytes {
|
|
return
|
|
}
|
|
if s.cache == nil {
|
|
s.cache = make(map[string]imageCacheEntry)
|
|
}
|
|
if old, ok := s.cache[key]; ok {
|
|
s.cacheBytes -= old.bytes
|
|
}
|
|
s.cache[key] = imageCacheEntry{data: data, bytes: size}
|
|
s.cacheOrder = append(s.cacheOrder, key)
|
|
s.cacheBytes += size
|
|
for len(s.cacheOrder) > imageCacheMaxEntries || s.cacheBytes > imageCacheMaxBytes {
|
|
oldKey := s.cacheOrder[0]
|
|
s.cacheOrder = s.cacheOrder[1:]
|
|
old, ok := s.cache[oldKey]
|
|
if !ok {
|
|
continue
|
|
}
|
|
delete(s.cache, oldKey)
|
|
s.cacheBytes -= old.bytes
|
|
}
|
|
}
|
|
|
|
func readyImageStatus(snapshot *imageSnapshot, generation uint64) ImageIndexStatus {
|
|
return ImageIndexStatus{State: ImageIndexStateReady, Stage: "ready-cache", Directory: snapshot.directory, Done: len(snapshot.manifest), Total: len(snapshot.manifest), NPKFiles: snapshot.npkFiles, IMGFiles: snapshot.imgFiles, ImageCount: snapshot.imageCount, Skipped: snapshot.skipped, Duplicates: snapshot.duplicates, Generation: generation}
|
|
}
|
|
|
|
func canonicalImageDirectory(value string) (string, error) {
|
|
if strings.TrimSpace(value) == "" {
|
|
return "", nil
|
|
}
|
|
abs, err := filepath.Abs(filepath.Clean(value))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(abs)
|
|
if err != nil {
|
|
return "", errImageDirectoryMissing
|
|
}
|
|
info, err := os.Stat(resolved)
|
|
if err != nil {
|
|
return "", errImageDirectoryMissing
|
|
}
|
|
if !info.IsDir() {
|
|
return "", errImageDirectoryMissing
|
|
}
|
|
return filepath.Clean(resolved), nil
|
|
}
|
|
|
|
func scanImageManifest(directory string) ([]imageManifest, error) {
|
|
result := make([]imageManifest, 0)
|
|
err := filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entry.IsDir() {
|
|
return nil
|
|
}
|
|
if !entry.Type().IsRegular() || !strings.EqualFold(filepath.Ext(entry.Name()), ".npk") {
|
|
return nil
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
relative, err := filepath.Rel(directory, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result = append(result, imageManifest{Relative: filepath.ToSlash(relative), Size: info.Size(), Modified: info.ModTime().UnixNano()})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i].Relative < result[j].Relative })
|
|
return result, nil
|
|
}
|
|
|
|
func normalizeImagePath(value string) string {
|
|
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
|
value = strings.Trim(value, "/")
|
|
for strings.Contains(value, "//") {
|
|
value = strings.ReplaceAll(value, "//", "/")
|
|
}
|
|
value = pathpkg.Clean(value)
|
|
if value == "." || value == ".." || strings.HasPrefix(value, "../") {
|
|
return ""
|
|
}
|
|
return strings.ToLower(value)
|
|
}
|
|
|
|
func imageIndexAliases(value string) []string {
|
|
value = normalizeImagePath(value)
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
result := []string{value}
|
|
if strings.HasPrefix(value, "sprite/") {
|
|
result = append(result, strings.TrimPrefix(value, "sprite/"))
|
|
} else {
|
|
result = append(result, "sprite/"+value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func isImagePathWithin(root, candidate string) bool {
|
|
relative, err := filepath.Rel(root, candidate)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
|
|
}
|
|
|
|
func imageCachePath() (string, error) {
|
|
cache, err := os.UserCacheDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(cache, "pvfine", "npk-image-index.json"), nil
|
|
}
|
|
|
|
func loadImageIndexCache(directory string) (*imageSnapshot, error) {
|
|
return loadImageIndexCacheAt(directory, "")
|
|
}
|
|
|
|
func loadImageIndexCacheAt(directory, customPath string) (*imageSnapshot, error) {
|
|
cachePath := customPath
|
|
var err error
|
|
if cachePath == "" {
|
|
cachePath, err = imageCachePath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
release := holdCachePaths(cachePath, filepath.Dir(cachePath))
|
|
defer release()
|
|
file, err := os.Open(cachePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
// Our writer puts version first. Reject legacy caches before decoding their
|
|
// potentially huge frame tables or allocating a full-file byte buffer.
|
|
decoder := json.NewDecoder(file)
|
|
opening, err := decoder.Token()
|
|
if err != nil || opening != json.Delim('{') {
|
|
return nil, errors.New("无效图像索引缓存")
|
|
}
|
|
key, err := decoder.Token()
|
|
var version int
|
|
if err != nil || key != "version" || decoder.Decode(&version) != nil || version != imageIndexVersion {
|
|
return nil, errors.New("图像索引缓存版本不匹配")
|
|
}
|
|
if _, err := file.Seek(0, 0); err != nil {
|
|
return nil, err
|
|
}
|
|
var persisted persistedImageIndex
|
|
if err := json.NewDecoder(file).Decode(&persisted); err != nil || persisted.Version != imageIndexVersion || filepath.Clean(persisted.Directory) != filepath.Clean(directory) {
|
|
return nil, errors.New("图像索引缓存版本或目录不匹配")
|
|
}
|
|
manifest, err := scanImageManifest(directory)
|
|
if err != nil || !sameImageManifest(manifest, persisted.Manifest) {
|
|
return nil, errors.New("图像索引缓存已过期")
|
|
}
|
|
byPath := make(map[string]*imageRecord)
|
|
records := make([]*imageRecord, 0, len(persisted.Images))
|
|
for _, saved := range persisted.Images {
|
|
if saved.Path == "" || saved.NPK == "" {
|
|
continue
|
|
}
|
|
record := &imageRecord{path: saved.Path, npkRelative: saved.NPK, offset: saved.EntryOffset, size: saved.EntrySize}
|
|
records = append(records, record)
|
|
for _, alias := range imageIndexAliases(saved.Path) {
|
|
if _, exists := byPath[alias]; !exists {
|
|
byPath[alias] = record
|
|
}
|
|
}
|
|
}
|
|
if len(records) == 0 {
|
|
return nil, errors.New("图像索引缓存没有图片")
|
|
}
|
|
imgFiles := persisted.IMGFiles
|
|
imageCount := persisted.ImageCount
|
|
if imgFiles == 0 {
|
|
imgFiles = len(records)
|
|
}
|
|
npkFiles := persisted.NPKFiles
|
|
if npkFiles == 0 {
|
|
npkFiles = len(manifest)
|
|
}
|
|
return &imageSnapshot{directory: directory, manifest: manifest, byPath: byPath, records: records, npkFiles: npkFiles, imgFiles: imgFiles, imageCount: imageCount, skipped: persisted.Skipped, duplicates: persisted.Duplicates}, nil
|
|
}
|
|
|
|
func saveImageIndexCache(snapshot *imageSnapshot) error {
|
|
return saveImageIndexCacheAt(snapshot, "")
|
|
}
|
|
|
|
func saveImageIndexCacheAt(snapshot *imageSnapshot, customPath string) error {
|
|
cachePath := customPath
|
|
var err error
|
|
if cachePath == "" {
|
|
cachePath, err = imageCachePath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
release := holdCachePaths(cachePath, filepath.Dir(cachePath))
|
|
defer release()
|
|
manifest := append([]imageManifest(nil), snapshot.manifest...)
|
|
persisted := persistedImageIndex{Version: imageIndexVersion, Directory: snapshot.directory, Manifest: manifest, Images: make([]persistedImageRecord, 0, len(snapshot.records)), NPKFiles: snapshot.npkFiles, IMGFiles: snapshot.imgFiles, ImageCount: snapshot.imageCount, Skipped: snapshot.skipped, Duplicates: snapshot.duplicates}
|
|
for _, record := range snapshot.records {
|
|
persisted.Images = append(persisted.Images, persistedImageRecord{Path: record.path, NPK: record.npkRelative, EntryOffset: record.offset, EntrySize: record.size})
|
|
}
|
|
data, err := json.Marshal(persisted)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
temp, err := os.CreateTemp(filepath.Dir(cachePath), ".npk-image-index-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempPath := temp.Name()
|
|
defer os.Remove(tempPath)
|
|
if err := temp.Chmod(0o600); err != nil {
|
|
_ = temp.Close()
|
|
return err
|
|
}
|
|
if _, err := temp.Write(data); err != nil {
|
|
_ = temp.Close()
|
|
return err
|
|
}
|
|
if err := temp.Sync(); err != nil {
|
|
_ = temp.Close()
|
|
return err
|
|
}
|
|
if err := temp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tempPath, cachePath)
|
|
}
|
|
|
|
func sameImageManifest(a, b []imageManifest) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for index := range a {
|
|
if a[index] != b[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|