服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
217 lines
6.4 KiB
Go
217 lines
6.4 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"image/png"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
|
|
"pvfine/internal/npk"
|
|
)
|
|
|
|
const maxImageBatchSize = 128
|
|
const maxImageDecodeWorkers = 4
|
|
|
|
type imageBatchIO struct {
|
|
open func(string) (*os.File, error)
|
|
parse func(io.ReaderAt, int64, int64) (*npk.IMGFile, error)
|
|
}
|
|
|
|
type imageBatchJob struct {
|
|
key string
|
|
index int32
|
|
record *imageRecord
|
|
positions []int
|
|
}
|
|
|
|
type imageBatchGroup struct {
|
|
path string
|
|
jobs []*imageBatchJob
|
|
}
|
|
|
|
// GetImages groups a small visible-image batch by NPK and IMG. It opens each
|
|
// package once and parses each requested member's frame table once. Generation
|
|
// prevents delayed frontend requests from reading a different resource set.
|
|
func (s *ImageService) GetImages(references []ImageReference, generation uint64) ([]ImageBatchResult, error) {
|
|
defer debugPhase("GetImages", fmt.Sprintf("frames=%d", len(references)))()
|
|
return s.getImages(references, &generation)
|
|
}
|
|
|
|
func (s *ImageService) getImages(references []ImageReference, expectedGeneration *uint64) ([]ImageBatchResult, error) {
|
|
if len(references) > maxImageBatchSize {
|
|
return nil, errors.New("图像批量请求超过 128 帧")
|
|
}
|
|
results := make([]ImageBatchResult, len(references))
|
|
if len(references) == 0 {
|
|
return results, nil
|
|
}
|
|
s.mu.Lock()
|
|
snapshot, generation := s.snapshot, s.generation
|
|
if snapshot == nil || s.directory == "" || (expectedGeneration != nil && *expectedGeneration != generation) {
|
|
s.mu.Unlock()
|
|
return results, nil
|
|
}
|
|
if s.decodeSlots == nil {
|
|
s.decodeSlots = make(chan struct{}, maxImageDecodeWorkers)
|
|
}
|
|
slots, batchIO := s.decodeSlots, s.batchIO
|
|
groups := make([]*imageBatchGroup, 0)
|
|
byPackage := make(map[string]*imageBatchGroup)
|
|
byKey := make(map[string]*imageBatchJob)
|
|
for position, reference := range references {
|
|
path := normalizeImagePath(reference.Path)
|
|
if path == "" || reference.Index < 0 {
|
|
continue
|
|
}
|
|
record := snapshot.byPath[path]
|
|
if record == nil {
|
|
continue
|
|
}
|
|
key := fmt.Sprintf("%d\x00%s\x00%d", generation, record.path, reference.Index)
|
|
if cached, ok := s.cache[key]; ok {
|
|
data := cached.data
|
|
results[position].Data = &data
|
|
continue
|
|
}
|
|
if job := byKey[key]; job != nil {
|
|
job.positions = append(job.positions, position)
|
|
continue
|
|
}
|
|
npkPath := filepath.Join(snapshot.directory, filepath.FromSlash(record.npkRelative))
|
|
if !isImagePathWithin(snapshot.directory, npkPath) {
|
|
results[position].Error = "NPK 索引路径越界"
|
|
continue
|
|
}
|
|
group := byPackage[npkPath]
|
|
if group == nil {
|
|
group = &imageBatchGroup{path: npkPath}
|
|
byPackage[npkPath] = group
|
|
groups = append(groups, group)
|
|
}
|
|
job := &imageBatchJob{key: key, index: reference.Index, record: record, positions: []int{position}}
|
|
byKey[key] = job
|
|
group.jobs = append(group.jobs, job)
|
|
}
|
|
s.mu.Unlock()
|
|
if batchIO.open == nil {
|
|
batchIO.open = os.Open
|
|
}
|
|
if batchIO.parse == nil {
|
|
batchIO.parse = npk.ParseIMGAt
|
|
}
|
|
queue := make(chan *imageBatchGroup)
|
|
var workers sync.WaitGroup
|
|
for worker := 0; worker < min(len(groups), maxImageDecodeWorkers); worker++ {
|
|
workers.Add(1)
|
|
go func() {
|
|
defer workers.Done()
|
|
for group := range queue {
|
|
slots <- struct{}{}
|
|
s.readImageBatchGroup(group, snapshot, generation, batchIO, results)
|
|
<-slots
|
|
}
|
|
}()
|
|
}
|
|
for _, group := range groups {
|
|
queue <- group
|
|
}
|
|
close(queue)
|
|
workers.Wait()
|
|
if !s.imageSnapshotCurrent(snapshot, generation) {
|
|
return make([]ImageBatchResult, len(references)), nil
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func (s *ImageService) imageSnapshotCurrent(snapshot *imageSnapshot, generation uint64) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.snapshot == snapshot && s.generation == generation
|
|
}
|
|
|
|
func (s *ImageService) readImageBatchGroup(group *imageBatchGroup, snapshot *imageSnapshot, generation uint64, batchIO imageBatchIO, results []ImageBatchResult) {
|
|
if !s.imageSnapshotCurrent(snapshot, generation) {
|
|
return
|
|
}
|
|
file, err := batchIO.open(group.path)
|
|
if err != nil {
|
|
for _, job := range group.jobs {
|
|
setImageBatchResult(results, job, ImageBatchResult{Error: fmt.Sprintf("读取 NPK 文件失败: %v", err)})
|
|
}
|
|
return
|
|
}
|
|
defer file.Close()
|
|
// Adjacent jobs reuse a frame table; only one IMG table lives per worker.
|
|
sort.SliceStable(group.jobs, func(i, j int) bool {
|
|
if group.jobs[i].record.offset != group.jobs[j].record.offset {
|
|
return group.jobs[i].record.offset < group.jobs[j].record.offset
|
|
}
|
|
return group.jobs[i].record.path < group.jobs[j].record.path
|
|
})
|
|
var current *imageRecord
|
|
var img *npk.IMGFile
|
|
var parseErr error
|
|
for _, job := range group.jobs {
|
|
if !s.imageSnapshotCurrent(snapshot, generation) {
|
|
return
|
|
}
|
|
if current != job.record {
|
|
current = job.record
|
|
img = nil
|
|
img, parseErr = batchIO.parse(file, current.offset, current.size)
|
|
}
|
|
if parseErr != nil {
|
|
setImageBatchResult(results, job, ImageBatchResult{Error: fmt.Sprintf("读取 IMG 帧表失败: %v", parseErr)})
|
|
continue
|
|
}
|
|
if job.index >= int32(len(img.Images)) {
|
|
continue
|
|
}
|
|
data, size, err := decodeBatchImage(file, current, img, job.index)
|
|
if err != nil {
|
|
setImageBatchResult(results, job, ImageBatchResult{Error: err.Error()})
|
|
continue
|
|
}
|
|
s.mu.Lock()
|
|
if s.snapshot == snapshot && s.generation == generation {
|
|
s.putCacheLocked(job.key, *data, size)
|
|
setImageBatchResult(results, job, ImageBatchResult{Data: data})
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
func setImageBatchResult(results []ImageBatchResult, job *imageBatchJob, result ImageBatchResult) {
|
|
for _, position := range job.positions {
|
|
results[position] = result
|
|
}
|
|
}
|
|
|
|
func decodeBatchImage(reader io.ReaderAt, record *imageRecord, img *npk.IMGFile, index int32) (*ImageData, int, error) {
|
|
finishDecode := debugPhase("GetImages.read-and-decode", fmt.Sprintf("frame=%d", index))
|
|
decoded, err := npk.DecodeImage(reader, record.offset, record.size, img, index)
|
|
finishDecode()
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("解码 IMG 图片失败: %w", err)
|
|
}
|
|
var encoded bytes.Buffer
|
|
finishEncode := debugPhase("GetImages.png-encode", fmt.Sprintf("frame=%d", index))
|
|
if err := png.Encode(&encoded, decoded); err != nil {
|
|
finishEncode()
|
|
return nil, 0, fmt.Errorf("编码 PNG 失败: %w", err)
|
|
}
|
|
finishEncode()
|
|
bounds := decoded.Bounds()
|
|
data := &ImageData{
|
|
DataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes()),
|
|
Width: int32(bounds.Dx()), Height: int32(bounds.Dy()),
|
|
}
|
|
return data, encoded.Len(), nil
|
|
}
|