DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,39 @@
|
||||
package npk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
)
|
||||
|
||||
// IMG v5 sprite payloads may contain a complete DDS texture after zlib
|
||||
// decompression. Its 128-byte header is not part of the DXT block stream.
|
||||
// Raw sprite payloads (including older 8888 sprites) have no such wrapper.
|
||||
func decodeSpriteTexture(data []byte, sprite SpriteMeta) (image.Image, error) {
|
||||
if bytes.HasPrefix(data, []byte("DDS ")) {
|
||||
if len(data) < 128 || binary.LittleEndian.Uint32(data[4:8]) != 124 || binary.LittleEndian.Uint32(data[76:80]) != 32 {
|
||||
return nil, fmt.Errorf("%w: 无效 DDS 文件头", ErrBadImageData)
|
||||
}
|
||||
width, height := binary.LittleEndian.Uint32(data[16:20]), binary.LittleEndian.Uint32(data[12:16])
|
||||
if width != uint32(sprite.Width) || height != uint32(sprite.Height) {
|
||||
return nil, fmt.Errorf("%w: DDS 与 sprite 尺寸不匹配", ErrBadImageData)
|
||||
}
|
||||
var format int32
|
||||
switch string(data[84:88]) {
|
||||
case "DXT1":
|
||||
format = FormatDXT1
|
||||
case "DXT3":
|
||||
format = FormatDXT3
|
||||
case "DXT5":
|
||||
format = FormatDXT5
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: DDS 像素格式 %q", ErrUnsupported, data[84:88])
|
||||
}
|
||||
if binary.LittleEndian.Uint32(data[80:84])&4 == 0 || format != sprite.Format {
|
||||
return nil, fmt.Errorf("%w: DDS 与 sprite 像素格式不匹配", ErrBadImageData)
|
||||
}
|
||||
data = data[128:]
|
||||
}
|
||||
return decodePixels(data, sprite.Format, sprite.Width, sprite.Height, nil)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package npk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/binary"
|
||||
"image/color"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ddsTexture(format string, width, height uint32, blocks []byte) []byte {
|
||||
data := make([]byte, 128)
|
||||
copy(data, "DDS ")
|
||||
binary.LittleEndian.PutUint32(data[4:8], 124)
|
||||
binary.LittleEndian.PutUint32(data[12:16], height)
|
||||
binary.LittleEndian.PutUint32(data[16:20], width)
|
||||
binary.LittleEndian.PutUint32(data[76:80], 32)
|
||||
binary.LittleEndian.PutUint32(data[80:84], 4)
|
||||
copy(data[84:88], format)
|
||||
return append(data, blocks...)
|
||||
}
|
||||
|
||||
func TestSpriteDDSHeaderAndAtlasCrop(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
format int32
|
||||
blockSize, colors int
|
||||
}{{"DXT1", FormatDXT1, 8, 0}, {"DXT3", FormatDXT3, 16, 8}, {"DXT5", FormatDXT5, 16, 8}} {
|
||||
for _, wrapped := range []bool{false, true} {
|
||||
name := tc.name + "/raw"
|
||||
if wrapped {
|
||||
name = tc.name + "/dds"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// Two atlas blocks: red at x=0, green at x=4. The frame selects green.
|
||||
blocks := make([]byte, tc.blockSize*2)
|
||||
for i, c := range []uint16{0xf800, 0x07e0} {
|
||||
block := blocks[i*tc.blockSize : (i+1)*tc.blockSize]
|
||||
if tc.format == FormatDXT3 {
|
||||
for j := 0; j < 8; j++ {
|
||||
block[j] = 255
|
||||
}
|
||||
}
|
||||
if tc.format == FormatDXT5 {
|
||||
block[0] = 255
|
||||
}
|
||||
binary.LittleEndian.PutUint16(block[tc.colors:], c)
|
||||
}
|
||||
payload := blocks
|
||||
if wrapped {
|
||||
payload = ddsTexture(tc.name, 8, 4, blocks)
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
writer := zlib.NewWriter(&compressed)
|
||||
_, _ = writer.Write(payload)
|
||||
_ = writer.Close()
|
||||
prefix := []byte{}
|
||||
putInt32(&prefix, 1, int32(compressed.Len()), 0) // sprite count, data size, palette count
|
||||
putInt32(&prefix, 1, tc.format, 0, int32(compressed.Len()), int32(len(payload)), 8, 4)
|
||||
frame := imageHeader(tc.format, ExtraSprite, 4, 4, 0)
|
||||
putInt32(&frame, 0, 0, 4, 0, 8, 4, 0)
|
||||
data := makeIMG(5, append(prefix, frame...), compressed.Bytes(), int32(len(frame)))
|
||||
parsed, err := ParseIMGAt(bytes.NewReader(data), 0, int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := DecodeImage(bytes.NewReader(data), 0, int64(len(data)), parsed, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Bounds().Dx() != 4 || decoded.Bounds().Dy() != 4 {
|
||||
t.Fatal(decoded.Bounds())
|
||||
}
|
||||
for y := 0; y < 4; y++ {
|
||||
for x := 0; x < 4; x++ {
|
||||
got := color.NRGBAModel.Convert(decoded.At(x, y)).(color.NRGBA)
|
||||
if got != (color.NRGBA{G: 255, A: 255}) {
|
||||
t.Fatalf("裁剪到相邻图标或文件头:(%d,%d)=%+v", x, y, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpriteDDSInvalidHeaders(t *testing.T) {
|
||||
valid := ddsTexture("DXT1", 4, 4, make([]byte, 8))
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
sprite SpriteMeta
|
||||
}{
|
||||
{"truncated", []byte("DDS "), SpriteMeta{Format: FormatDXT1, Width: 4, Height: 4}},
|
||||
{"dimensions", valid, SpriteMeta{Format: FormatDXT1, Width: 8, Height: 4}},
|
||||
{"format", valid, SpriteMeta{Format: FormatDXT5, Width: 4, Height: 4}},
|
||||
{"unsupported", ddsTexture("DX10", 4, 4, nil), SpriteMeta{Format: FormatDXT1, Width: 4, Height: 4}},
|
||||
{"pixels", valid[:128], SpriteMeta{Format: FormatDXT1, Width: 4, Height: 4}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := decodeSpriteTexture(tc.data, tc.sprite); err == nil {
|
||||
t.Fatal("应拒绝无效DDS")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package npk
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
)
|
||||
|
||||
func decodeDXT(data []byte, format int32, width, height int) (image.Image, error) {
|
||||
blockBytes := 8
|
||||
if format == FormatDXT5 {
|
||||
blockBytes = 16
|
||||
} else if format == FormatDXT3 {
|
||||
blockBytes = 16
|
||||
}
|
||||
blocksWide := (width + 3) / 4
|
||||
blocksHigh := (height + 3) / 4
|
||||
needed := blocksWide * blocksHigh * blockBytes
|
||||
if needed < 0 || needed > len(data) {
|
||||
return nil, fmt.Errorf("%w: DXT 数据过短", ErrBadImageData)
|
||||
}
|
||||
out := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
for blockY := 0; blockY < blocksHigh; blockY++ {
|
||||
for blockX := 0; blockX < blocksWide; blockX++ {
|
||||
offset := (blockY*blocksWide + blockX) * blockBytes
|
||||
var pixels [16]Color
|
||||
var err error
|
||||
switch format {
|
||||
case FormatDXT1:
|
||||
pixels, err = decodeDXT1Block(data[offset : offset+8])
|
||||
case FormatDXT3:
|
||||
pixels, err = decodeDXT3Block(data[offset : offset+16])
|
||||
case FormatDXT5:
|
||||
pixels, err = decodeDXT5Block(data[offset : offset+16])
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: DXT format=%d", ErrUnsupported, format)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for row := 0; row < 4; row++ {
|
||||
for col := 0; col < 4; col++ {
|
||||
x := blockX*4 + col
|
||||
y := blockY*4 + row
|
||||
if x < width && y < height {
|
||||
out.SetNRGBA(x, y, colorNRGBA(pixels[row*4+col]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func decodeDXT1Block(data []byte) ([16]Color, error) {
|
||||
var result [16]Color
|
||||
if len(data) < 8 {
|
||||
return result, ErrBadImageData
|
||||
}
|
||||
c0 := binary.LittleEndian.Uint16(data[0:2])
|
||||
c1 := binary.LittleEndian.Uint16(data[2:4])
|
||||
colors := dxtColors(c0, c1, c0 > c1)
|
||||
indices := binary.LittleEndian.Uint32(data[4:8])
|
||||
for index := 0; index < 16; index++ {
|
||||
code := (indices >> (2 * index)) & 3
|
||||
result[index] = colors[code]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeDXT3Block(data []byte) ([16]Color, error) {
|
||||
var result [16]Color
|
||||
if len(data) < 16 {
|
||||
return result, ErrBadImageData
|
||||
}
|
||||
colors := dxtColors(binary.LittleEndian.Uint16(data[8:10]), binary.LittleEndian.Uint16(data[10:12]), true)
|
||||
indices := binary.LittleEndian.Uint32(data[12:16])
|
||||
for index := 0; index < 16; index++ {
|
||||
code := (indices >> (2 * index)) & 3
|
||||
alphaByte := data[index/2]
|
||||
alpha := alphaByte >> 4
|
||||
if index%2 == 0 {
|
||||
alpha = alphaByte & 0x0f
|
||||
}
|
||||
pixel := colors[code]
|
||||
pixel[3] = alpha * 17
|
||||
result[index] = pixel
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeDXT5Block(data []byte) ([16]Color, error) {
|
||||
var result [16]Color
|
||||
if len(data) < 16 {
|
||||
return result, ErrBadImageData
|
||||
}
|
||||
alpha := [8]uint8{data[0], data[1]}
|
||||
if alpha[0] > alpha[1] {
|
||||
for index := 2; index < 8; index++ {
|
||||
alpha[index] = uint8(((8-index)*int(alpha[0]) + (index-1)*int(alpha[1])) / 7)
|
||||
}
|
||||
} else {
|
||||
for index := 2; index < 6; index++ {
|
||||
alpha[index] = uint8(((6-index)*int(alpha[0]) + (index-1)*int(alpha[1])) / 5)
|
||||
}
|
||||
alpha[6], alpha[7] = 0, 255
|
||||
}
|
||||
alphaBits := uint64(0)
|
||||
for index := 0; index < 6; index++ {
|
||||
alphaBits |= uint64(data[2+index]) << (8 * index)
|
||||
}
|
||||
colors := dxtColors(binary.LittleEndian.Uint16(data[8:10]), binary.LittleEndian.Uint16(data[10:12]), true)
|
||||
indices := binary.LittleEndian.Uint32(data[12:16])
|
||||
for index := 0; index < 16; index++ {
|
||||
colorIndex := (indices >> (2 * index)) & 3
|
||||
alphaIndex := (alphaBits >> (3 * index)) & 7
|
||||
pixel := colors[colorIndex]
|
||||
pixel[3] = alpha[alphaIndex]
|
||||
result[index] = pixel
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func dxtColors(c0, c1 uint16, four bool) [4]Color {
|
||||
colors := [4]Color{rgb565(c0), rgb565(c1), {}, {}}
|
||||
if four {
|
||||
colors[2] = Color{
|
||||
uint8((2*int(colors[0][0]) + int(colors[1][0])) / 3),
|
||||
uint8((2*int(colors[0][1]) + int(colors[1][1])) / 3),
|
||||
uint8((2*int(colors[0][2]) + int(colors[1][2])) / 3),
|
||||
255,
|
||||
}
|
||||
colors[3] = Color{
|
||||
uint8((int(colors[0][0]) + 2*int(colors[1][0])) / 3),
|
||||
uint8((int(colors[0][1]) + 2*int(colors[1][1])) / 3),
|
||||
uint8((int(colors[0][2]) + 2*int(colors[1][2])) / 3),
|
||||
255,
|
||||
}
|
||||
} else {
|
||||
colors[2] = Color{
|
||||
uint8((int(colors[0][0]) + int(colors[1][0])) / 2),
|
||||
uint8((int(colors[0][1]) + int(colors[1][1])) / 2),
|
||||
uint8((int(colors[0][2]) + int(colors[1][2])) / 2),
|
||||
255,
|
||||
}
|
||||
colors[3] = Color{}
|
||||
}
|
||||
return colors
|
||||
}
|
||||
|
||||
func rgb565(value uint16) Color {
|
||||
r := uint8((value >> 11) & 0x1f)
|
||||
g := uint8((value >> 5) & 0x3f)
|
||||
b := uint8(value & 0x1f)
|
||||
return Color{(r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2), 255}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package npk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/binary"
|
||||
"image/color"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func putInt32(data *[]byte, values ...int32) {
|
||||
for _, value := range values {
|
||||
var raw [4]byte
|
||||
binary.LittleEndian.PutUint32(raw[:], uint32(value))
|
||||
*data = append(*data, raw[:]...)
|
||||
}
|
||||
}
|
||||
|
||||
func makeIMG(version int32, headers, payload []byte, imagesSize int32) []byte {
|
||||
return makeIMGCount(version, headers, payload, imagesSize, 1)
|
||||
}
|
||||
|
||||
func makeIMGCount(version int32, headers, payload []byte, imagesSize, count int32) []byte {
|
||||
data := make([]byte, 0, 32+len(headers)+len(payload)+128)
|
||||
if version == 1 {
|
||||
data = append(data, []byte(imgMagicOld)...)
|
||||
data = append(data, 0)
|
||||
data = append(data, 0, 0)
|
||||
putInt32(&data, 0, version, count)
|
||||
} else {
|
||||
data = append(data, []byte(imgMagic)...)
|
||||
data = append(data, 0)
|
||||
putInt32(&data, imagesSize, 0, version, count)
|
||||
}
|
||||
data = append(data, headers...)
|
||||
data = append(data, payload...)
|
||||
return data
|
||||
}
|
||||
|
||||
func imageHeader(format, extra, width, height, size int32) []byte {
|
||||
data := make([]byte, 0, 36)
|
||||
putInt32(&data, format, extra, width, height, size, 0, 0, width, height)
|
||||
return data
|
||||
}
|
||||
|
||||
func onePixelIMG(format int32, raw []byte) []byte {
|
||||
header := imageHeader(format, ExtraNone, 1, 1, int32(len(raw)))
|
||||
return makeIMG(2, header, raw, int32(len(header)))
|
||||
}
|
||||
|
||||
func decodeOnePixel(t *testing.T, data []byte) color.NRGBA {
|
||||
t.Helper()
|
||||
parsed, err := ParseIMGAt(bytes.NewReader(data), 0, int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := DecodeImage(bytes.NewReader(data), 0, int64(len(data)), parsed, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pixel, ok := decoded.At(0, 0).(color.NRGBA)
|
||||
if !ok {
|
||||
return color.NRGBAModel.Convert(decoded.At(0, 0)).(color.NRGBA)
|
||||
}
|
||||
return pixel
|
||||
}
|
||||
|
||||
func TestIMGRawFormatsAndZlib(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want color.NRGBA
|
||||
}{
|
||||
{name: "1555", data: onePixelIMG(Format1555, []byte{0, 0xfc}), want: color.NRGBA{R: 255, A: 255}},
|
||||
{name: "4444", data: onePixelIMG(Format4444, []byte{0, 0xff}), want: color.NRGBA{R: 240, A: 240}},
|
||||
{name: "8888", data: onePixelIMG(Format8888, []byte{3, 2, 1, 255}), want: color.NRGBA{R: 1, G: 2, B: 3, A: 255}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := decodeOnePixel(t, test.data); got != test.want {
|
||||
t.Fatalf("pixel = %#v, want %#v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
writer := zlib.NewWriter(&compressed)
|
||||
_, _ = writer.Write([]byte{3, 2, 1, 255})
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
header := imageHeader(Format8888, ExtraZlib, 1, 1, int32(compressed.Len()))
|
||||
if got := decodeOnePixel(t, makeIMG(2, header, compressed.Bytes(), int32(len(header)))); got != (color.NRGBA{R: 1, G: 2, B: 3, A: 255}) {
|
||||
t.Fatalf("zlib pixel = %#v", got)
|
||||
}
|
||||
if got := decodeOnePixel(t, makeIMG(1, imageHeader(Format8888, ExtraNone, 1, 1, 4), []byte{3, 2, 1, 255}, 0)); got != (color.NRGBA{R: 1, G: 2, B: 3, A: 255}) {
|
||||
t.Fatalf("v1 pixel = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMGLinkPaletteDXTAndSprite(t *testing.T) {
|
||||
ordinary := imageHeader(Format8888, ExtraNone, 1, 1, 4)
|
||||
link := make([]byte, 0, 8)
|
||||
putInt32(&link, FormatLink, 0)
|
||||
linkIMG := makeIMGCount(2, append(append([]byte{}, ordinary...), link...), []byte{3, 2, 1, 255}, int32(len(ordinary)+len(link)), 2)
|
||||
parsed, err := ParseIMGAt(bytes.NewReader(linkIMG), 0, int64(len(linkIMG)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linked, err := DecodeImage(bytes.NewReader(linkIMG), 0, int64(len(linkIMG)), parsed, 1)
|
||||
if err != nil || linked.Bounds().Dx() != 1 {
|
||||
t.Fatalf("link decode = %v, %v", linked, err)
|
||||
}
|
||||
|
||||
palette := make([]byte, 0, 16)
|
||||
putInt32(&palette, 2)
|
||||
palette = append(palette, 1, 2, 3, 255)
|
||||
palette = append(palette, 9, 8, 7, 255)
|
||||
var paletteCompressed bytes.Buffer
|
||||
paletteWriter := zlib.NewWriter(&paletteCompressed)
|
||||
_, _ = paletteWriter.Write([]byte{1})
|
||||
_ = paletteWriter.Close()
|
||||
paletteHeader := imageHeader(Format8888, ExtraZlib, 1, 1, int32(paletteCompressed.Len()))
|
||||
paletteIMG := make([]byte, 0)
|
||||
paletteIMG = append(paletteIMG, []byte(imgMagic)...)
|
||||
paletteIMG = append(paletteIMG, 0)
|
||||
putInt32(&paletteIMG, int32(len(paletteHeader)), 0, 4, 1)
|
||||
paletteIMG = append(paletteIMG, palette...)
|
||||
paletteIMG = append(paletteIMG, paletteHeader...)
|
||||
paletteIMG = append(paletteIMG, paletteCompressed.Bytes()...)
|
||||
paletteParsed, err := ParseIMGAt(bytes.NewReader(paletteIMG), 0, int64(len(paletteIMG)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paletteDecoded, err := DecodeImage(bytes.NewReader(paletteIMG), 0, int64(len(paletteIMG)), paletteParsed, 0)
|
||||
if err != nil || paletteDecoded.At(0, 0) != (color.NRGBA{R: 9, G: 8, B: 7, A: 255}) {
|
||||
t.Fatalf("palette decode = %v, %v", paletteDecoded, err)
|
||||
}
|
||||
|
||||
dxtHeader := imageHeader(FormatDXT1, ExtraNone, 4, 4, 8)
|
||||
dxt := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint16(dxt[0:], 0xf800)
|
||||
binary.LittleEndian.PutUint16(dxt[2:], 0x0000)
|
||||
var dxtCompressed bytes.Buffer
|
||||
dxtWriter := zlib.NewWriter(&dxtCompressed)
|
||||
_, _ = dxtWriter.Write(dxt)
|
||||
_ = dxtWriter.Close()
|
||||
dxtHeader = imageHeader(FormatDXT1, ExtraZlib, 4, 4, int32(dxtCompressed.Len()))
|
||||
dxtIMG := makeIMG(2, dxtHeader, dxtCompressed.Bytes(), int32(len(dxtHeader)))
|
||||
if got := decodeOnePixel(t, dxtIMG); got != (color.NRGBA{R: 255, A: 255}) {
|
||||
t.Fatalf("DXT decode = %#v", got)
|
||||
}
|
||||
for _, format := range []int32{FormatDXT3, FormatDXT5} {
|
||||
block := make([]byte, 16)
|
||||
for index := 0; index < 8; index++ {
|
||||
block[index] = 0xff
|
||||
}
|
||||
binary.LittleEndian.PutUint16(block[8:], 0xf800)
|
||||
binary.LittleEndian.PutUint16(block[10:], 0)
|
||||
var compressed bytes.Buffer
|
||||
writer := zlib.NewWriter(&compressed)
|
||||
_, _ = writer.Write(block)
|
||||
_ = writer.Close()
|
||||
header := imageHeader(format, ExtraZlib, 4, 4, int32(compressed.Len()))
|
||||
if got := decodeOnePixel(t, makeIMG(2, header, compressed.Bytes(), int32(len(header)))); got != (color.NRGBA{R: 255, A: 255}) {
|
||||
t.Fatalf("DXT%d decode = %#v", format, got)
|
||||
}
|
||||
}
|
||||
|
||||
spriteRaw := []byte{0, 0, 255, 255, 255, 0, 0, 255}
|
||||
var spriteCompressed bytes.Buffer
|
||||
spriteWriter := zlib.NewWriter(&spriteCompressed)
|
||||
_, _ = spriteWriter.Write(spriteRaw)
|
||||
_ = spriteWriter.Close()
|
||||
sprite := make([]byte, 0)
|
||||
putInt32(&sprite, 0, Format8888, 0, int32(spriteCompressed.Len()), 8, 2, 1)
|
||||
spriteImage := imageHeader(Format8888, ExtraSprite, 2, 1, 0)
|
||||
putInt32(&spriteImage, 0, 0, 0, 0, 2, 1, 1)
|
||||
spriteIMG := make([]byte, 0)
|
||||
spriteIMG = append(spriteIMG, []byte(imgMagic)...)
|
||||
spriteIMG = append(spriteIMG, 0)
|
||||
putInt32(&spriteIMG, int32(len(spriteImage)), 0, 5, 1)
|
||||
putInt32(&spriteIMG, 1, int32(spriteCompressed.Len()))
|
||||
putInt32(&spriteIMG, 0)
|
||||
spriteIMG = append(spriteIMG, sprite...)
|
||||
spriteIMG = append(spriteIMG, spriteImage...)
|
||||
spriteIMG = append(spriteIMG, spriteCompressed.Bytes()...)
|
||||
spriteParsed, err := ParseIMGAt(bytes.NewReader(spriteIMG), 0, int64(len(spriteIMG)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spriteDecoded, err := DecodeImage(bytes.NewReader(spriteIMG), 0, int64(len(spriteIMG)), spriteParsed, 0)
|
||||
if err != nil || spriteDecoded.Bounds().Dx() != 1 || spriteDecoded.Bounds().Dy() != 2 {
|
||||
t.Fatalf("sprite decode = %v, %v", spriteDecoded, err)
|
||||
}
|
||||
|
||||
cycleHeaders := make([]byte, 0, 16)
|
||||
putInt32(&cycleHeaders, FormatLink, 1, FormatLink, 0)
|
||||
cycleIMG := makeIMGCount(2, cycleHeaders, nil, int32(len(cycleHeaders)), 2)
|
||||
cycleParsed, err := ParseIMGAt(bytes.NewReader(cycleIMG), 0, int64(len(cycleIMG)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := DecodeImage(bytes.NewReader(cycleIMG), 0, int64(len(cycleIMG)), cycleParsed, 0); err == nil {
|
||||
t.Fatal("link cycle should fail")
|
||||
}
|
||||
|
||||
var v6Compressed bytes.Buffer
|
||||
v6Writer := zlib.NewWriter(&v6Compressed)
|
||||
_, _ = v6Writer.Write([]byte{1})
|
||||
_ = v6Writer.Close()
|
||||
v6Header := imageHeader(Format8888, ExtraZlib, 1, 1, int32(v6Compressed.Len()))
|
||||
v6IMG := make([]byte, 0)
|
||||
v6IMG = append(v6IMG, []byte(imgMagic)...)
|
||||
v6IMG = append(v6IMG, 0)
|
||||
putInt32(&v6IMG, int32(len(v6Header)), 0, 6, 1)
|
||||
// The v6 color board is a count followed by RGBA colors.
|
||||
putInt32(&v6IMG, 1, 2)
|
||||
v6IMG = append(v6IMG, 1, 2, 3, 255, 2, 3, 4, 255)
|
||||
v6IMG = append(v6IMG, v6Header...)
|
||||
v6IMG = append(v6IMG, v6Compressed.Bytes()...)
|
||||
v6Parsed, err := ParseIMGAt(bytes.NewReader(v6IMG), 0, int64(len(v6IMG)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v6Decoded, err := DecodeImage(bytes.NewReader(v6IMG), 0, int64(len(v6IMG)), v6Parsed, 0)
|
||||
if err != nil || v6Decoded.At(0, 0) != (color.NRGBA{R: 2, G: 3, B: 4, A: 255}) {
|
||||
t.Fatalf("v6 decode = %v, %v", v6Decoded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNPKFilenameXORAndBadFile(t *testing.T) {
|
||||
name := "sprite/item/test.img"
|
||||
data := make([]byte, 0, 284)
|
||||
data = append(data, []byte(npkMagic)...)
|
||||
data = append(data, 0)
|
||||
putInt32(&data, 1)
|
||||
putInt32(&data, 284, 0)
|
||||
encrypted := make([]byte, 256)
|
||||
plain := []byte(name)
|
||||
for index := range encrypted {
|
||||
value := byte(0)
|
||||
if index < len(plain) {
|
||||
value = plain[index]
|
||||
}
|
||||
encrypted[index] = value ^ npkFilenameKey[index]
|
||||
}
|
||||
data = append(data, encrypted...)
|
||||
parsed, err := Open(writeTempBytes(t, data))
|
||||
if err != nil || len(parsed.Entries) != 1 || parsed.Entries[0].Name != name {
|
||||
t.Fatalf("NPK = %#v, %v", parsed, err)
|
||||
}
|
||||
if _, err := Open(writeTempBytes(t, []byte("bad"))); err == nil {
|
||||
t.Fatal("bad NPK should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTempBytes(t *testing.T, data []byte) string {
|
||||
t.Helper()
|
||||
path := t.TempDir() + "/test.npk"
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestReadIMGFrameCountWithoutFrameTable(t *testing.T) {
|
||||
// A v2 fixed header claiming a large table must be counted without reading
|
||||
// or allocating that table. Full validation is deferred to image requests.
|
||||
data := append([]byte("Neople Img File"), 0)
|
||||
for _, value := range []int32{0, 0, 2, 1000000} {
|
||||
var raw [4]byte
|
||||
binary.LittleEndian.PutUint32(raw[:], uint32(value))
|
||||
data = append(data, raw[:]...)
|
||||
}
|
||||
count, err := ReadIMGFrameCount(bytes.NewReader(data), 0, int64(len(data)))
|
||||
if err != nil || count != 1000000 {
|
||||
t.Fatalf("header frame count = %d, %v", count, err)
|
||||
}
|
||||
if _, err := ReadIMGFrameCount(bytes.NewReader(data[:20]), 0, 20); err == nil {
|
||||
t.Fatal("truncated header should fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
// Package npk contains the read-only NPK/IMG decoder used by the icon index.
|
||||
//
|
||||
// The indexer only parses headers and keeps offsets. Pixel data is read and
|
||||
// decoded by DecodeImage when a caller actually asks for one frame.
|
||||
package npk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/korean"
|
||||
)
|
||||
|
||||
const (
|
||||
npkMagic = "NeoplePack_Bill"
|
||||
imgMagic = "Neople Img File"
|
||||
imgMagicOld = "Neople Image File"
|
||||
maxNPKEntries = 4_000_000
|
||||
maxIMGImages = 2_000_000
|
||||
maxDimension = 16_384
|
||||
maxPixels = 268_000_000
|
||||
)
|
||||
|
||||
const (
|
||||
Format1555 int32 = 14
|
||||
Format4444 int32 = 15
|
||||
Format8888 int32 = 16
|
||||
FormatLink int32 = 17
|
||||
FormatDXT1 int32 = 18
|
||||
FormatDXT3 int32 = 19
|
||||
FormatDXT5 int32 = 20
|
||||
)
|
||||
|
||||
const (
|
||||
ExtraNone int32 = 5
|
||||
ExtraZlib int32 = 6
|
||||
ExtraSprite int32 = 7
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotNPK = errors.New("不是有效的 NPK 文件")
|
||||
ErrNotIMG = errors.New("不是有效的 IMG 文件")
|
||||
ErrUnsupported = errors.New("不支持的 IMG 版本或图片格式")
|
||||
ErrBadImageData = errors.New("IMG 图片数据损坏")
|
||||
)
|
||||
|
||||
var npkFilenameKey = []byte("puchikon@neople dungeon and fighter " + strings.Repeat("DNF", 73) + "\x00")
|
||||
|
||||
// Entry is an NPK member. Offset and Size are absolute byte positions in the
|
||||
// NPK file, as stored by the format.
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
Offset int64 `json:"offset"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// NPK is the cheap, metadata-only representation of an NPK file.
|
||||
type NPK struct {
|
||||
Path string `json:"-"`
|
||||
Size int64 `json:"size"`
|
||||
Entries []Entry `json:"entries"`
|
||||
}
|
||||
|
||||
// Open parses the NPK header and member table without loading member data.
|
||||
func Open(path string) (*NPK, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Size() < 20 {
|
||||
return nil, ErrNotNPK
|
||||
}
|
||||
magic, pos, err := readCStringAt(file, 0, 16)
|
||||
if err != nil || magic != npkMagic {
|
||||
return nil, ErrNotNPK
|
||||
}
|
||||
var count int32
|
||||
if err := readAt(file, pos, &count); err != nil || count < 0 || count > maxNPKEntries {
|
||||
return nil, fmt.Errorf("%w: 无效文件数量", ErrNotNPK)
|
||||
}
|
||||
pos += 4
|
||||
if int64(count)*264 > info.Size()-pos {
|
||||
return nil, fmt.Errorf("%w: 文件表越界", ErrNotNPK)
|
||||
}
|
||||
entries := make([]Entry, 0, count)
|
||||
for index := int32(0); index < count; index++ {
|
||||
var raw [8]byte
|
||||
if _, err := file.ReadAt(raw[:], pos); err != nil {
|
||||
return nil, fmt.Errorf("%w: 读取文件表失败: %v", ErrNotNPK, err)
|
||||
}
|
||||
offset := int64(int32(binary.LittleEndian.Uint32(raw[0:])))
|
||||
size := int64(int32(binary.LittleEndian.Uint32(raw[4:])))
|
||||
if offset < 0 || size < 0 || offset > info.Size() || size > info.Size()-offset {
|
||||
return nil, fmt.Errorf("%w: 第 %d 个文件的偏移越界", ErrNotNPK, index)
|
||||
}
|
||||
var encrypted [256]byte
|
||||
if _, err := file.ReadAt(encrypted[:], pos+8); err != nil {
|
||||
return nil, fmt.Errorf("%w: 读取文件名失败: %v", ErrNotNPK, err)
|
||||
}
|
||||
entries = append(entries, Entry{Name: decodeNPKName(encrypted[:]), Offset: offset, Size: size})
|
||||
pos += 264
|
||||
}
|
||||
return &NPK{Path: path, Size: info.Size(), Entries: entries}, nil
|
||||
}
|
||||
|
||||
func decodeNPKName(encrypted []byte) string {
|
||||
decoded := make([]byte, len(encrypted))
|
||||
for index, value := range encrypted {
|
||||
decoded[index] = value ^ npkFilenameKey[index%len(npkFilenameKey)]
|
||||
}
|
||||
if zero := bytes.IndexByte(decoded, 0); zero >= 0 {
|
||||
decoded = decoded[:zero]
|
||||
}
|
||||
name, err := korean.EUCKR.NewDecoder().Bytes(decoded)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(string(decoded))
|
||||
}
|
||||
return strings.TrimSpace(string(name))
|
||||
}
|
||||
|
||||
// ImageMeta describes one IMG frame. DataOffset and Sprite.DataOffset are
|
||||
// relative to the beginning of the IMG member in its NPK file.
|
||||
type ImageMeta struct {
|
||||
Format int32 `json:"format"`
|
||||
Extra int32 `json:"extra"`
|
||||
Width int32 `json:"width"`
|
||||
Height int32 `json:"height"`
|
||||
HeaderSize int32 `json:"headerSize"`
|
||||
DataSize int32 `json:"dataSize"`
|
||||
X int32 `json:"x"`
|
||||
Y int32 `json:"y"`
|
||||
MemoryWidth int32 `json:"memoryWidth"`
|
||||
MemoryHeight int32 `json:"memoryHeight"`
|
||||
DataOffset int64 `json:"dataOffset"`
|
||||
LinkIndex int32 `json:"linkIndex,omitempty"`
|
||||
SpriteIndex int32 `json:"spriteIndex,omitempty"`
|
||||
Left int32 `json:"left,omitempty"`
|
||||
Top int32 `json:"top,omitempty"`
|
||||
Right int32 `json:"right,omitempty"`
|
||||
Bottom int32 `json:"bottom,omitempty"`
|
||||
Rotate int32 `json:"rotate,omitempty"`
|
||||
}
|
||||
|
||||
type Color [4]uint8
|
||||
|
||||
type SpriteMeta struct {
|
||||
Keep int32 `json:"keep"`
|
||||
Format int32 `json:"format"`
|
||||
Index int32 `json:"index"`
|
||||
DataSize int32 `json:"dataSize"`
|
||||
RawSize int32 `json:"rawSize"`
|
||||
Width int32 `json:"width"`
|
||||
Height int32 `json:"height"`
|
||||
DataOffset int64 `json:"dataOffset"`
|
||||
}
|
||||
|
||||
// IMGFile is a parsed IMG metadata snapshot. It is safe to JSON-marshal and
|
||||
// persist as part of the image index.
|
||||
type IMGFile struct {
|
||||
Version int32 `json:"version"`
|
||||
ImagesSize int64 `json:"imagesSize"`
|
||||
DataStart int64 `json:"dataStart"`
|
||||
Images []ImageMeta `json:"images"`
|
||||
Palettes [][]Color `json:"palettes,omitempty"`
|
||||
Sprites []SpriteMeta `json:"sprites,omitempty"`
|
||||
}
|
||||
|
||||
type imgCursor struct {
|
||||
r io.ReaderAt
|
||||
base int64
|
||||
size int64
|
||||
pos int64
|
||||
}
|
||||
|
||||
func (c *imgCursor) readBytes(count int) ([]byte, error) {
|
||||
if count < 0 || int64(count) > c.size-c.pos {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
data := make([]byte, count)
|
||||
if _, err := c.r.ReadAt(data, c.base+c.pos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.pos += int64(count)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *imgCursor) readInt32() (int32, error) {
|
||||
data, err := c.readBytes(4)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int32(binary.LittleEndian.Uint32(data)), nil
|
||||
}
|
||||
|
||||
func (c *imgCursor) readInt16() (int16, error) {
|
||||
data, err := c.readBytes(2)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int16(binary.LittleEndian.Uint16(data)), nil
|
||||
}
|
||||
|
||||
func (c *imgCursor) skip(count int64) error {
|
||||
if count < 0 || count > c.size-c.pos {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
c.pos += count
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadIMGFrameCount reads the fixed IMG header without reading frame tables,
|
||||
// palettes or sprite metadata. Indexing only needs this for status statistics.
|
||||
func ReadIMGFrameCount(r io.ReaderAt, offset, size int64) (int32, error) {
|
||||
if offset < 0 || size < 32 {
|
||||
return 0, ErrNotIMG
|
||||
}
|
||||
// Both supported magic layouts have a 32-byte fixed header. Read once
|
||||
// rather than issuing a file read for each magic byte and integer.
|
||||
var header [32]byte
|
||||
if _, err := r.ReadAt(header[:], offset); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_, _, count, err := readIMGHeader(&imgCursor{r: bytes.NewReader(header[:]), size: 32})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func readIMGHeader(c *imgCursor) (int32, int32, int32, error) {
|
||||
magic, err := readCursorCString(c, 18)
|
||||
if err != nil {
|
||||
return 0, 0, 0, ErrNotIMG
|
||||
}
|
||||
newFormat := magic == imgMagic
|
||||
oldFormat := magic == imgMagicOld
|
||||
if !newFormat && !oldFormat {
|
||||
return 0, 0, 0, ErrNotIMG
|
||||
}
|
||||
var imagesSize int32
|
||||
if newFormat {
|
||||
imagesSize, err = c.readInt32()
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
} else if _, err := c.readInt16(); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if imagesSize < 0 {
|
||||
return 0, 0, 0, fmt.Errorf("%w: images_size 为负数", ErrNotIMG)
|
||||
}
|
||||
_, err = c.readInt32() // keep
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
version, err := c.readInt32()
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
count, err := c.readInt32()
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if count < 0 || count > maxIMGImages {
|
||||
return 0, 0, 0, fmt.Errorf("%w: 无效图片数量", ErrNotIMG)
|
||||
}
|
||||
if version != 1 && version != 2 && version != 4 && version != 5 && version != 6 {
|
||||
return 0, 0, 0, fmt.Errorf("%w: v%d", ErrUnsupported, version)
|
||||
}
|
||||
return imagesSize, version, count, nil
|
||||
}
|
||||
|
||||
// ParseIMGAt parses only an IMG header/table from an NPK member.
|
||||
func ParseIMGAt(r io.ReaderAt, offset, size int64) (*IMGFile, error) {
|
||||
if offset < 0 || size < 0 {
|
||||
return nil, ErrNotIMG
|
||||
}
|
||||
c := &imgCursor{r: r, base: offset, size: size}
|
||||
imagesSize, version, count, err := readIMGHeader(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &IMGFile{Version: version, ImagesSize: int64(imagesSize), Images: make([]ImageMeta, 0, count)}
|
||||
if version == 5 {
|
||||
if err := parseV5Prefix(c, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if version == 4 {
|
||||
palette, err := readPalette(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Palettes = [][]Color{palette}
|
||||
} else if version == 6 {
|
||||
count, err := c.readInt32()
|
||||
if err != nil || count < 0 || count > 1_000_000 {
|
||||
return nil, fmt.Errorf("%w: 无效调色板数量", ErrNotIMG)
|
||||
}
|
||||
result.Palettes = make([][]Color, 0, count)
|
||||
for index := int32(0); index < count; index++ {
|
||||
palette, err := readPalette(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Palettes = append(result.Palettes, palette)
|
||||
}
|
||||
}
|
||||
|
||||
if version == 1 {
|
||||
for index := int32(0); index < count; index++ {
|
||||
meta, err := readImageHeader(c, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta.Extra == ExtraSprite {
|
||||
values, err := readInts(c, 7)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta.SpriteIndex, meta.Left, meta.Top, meta.Right, meta.Bottom, meta.Rotate = values[1], values[2], values[3], values[4], values[5], values[6]
|
||||
meta.DataSize = 0
|
||||
}
|
||||
meta.DataOffset = c.pos
|
||||
if err := validateImageMeta(meta, size); err != nil {
|
||||
return nil, fmt.Errorf("图片 %d: %w", index, err)
|
||||
}
|
||||
result.Images = append(result.Images, meta)
|
||||
if meta.Format != FormatLink && meta.Extra != ExtraSprite {
|
||||
if err := c.skip(int64(meta.DataSize)); err != nil {
|
||||
return nil, fmt.Errorf("图片 %d 数据越界: %w", index, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
result.DataStart = 32
|
||||
resolveLinks(result.Images)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// v2/v4/v5/v6 keep all ordinary image headers together. The data starts
|
||||
// after every extra header (and after the sprite table in v5). Real v4
|
||||
// files place the palette before the image headers, so using the cursor is
|
||||
// important; images_size alone would point into the palette.
|
||||
for index := int32(0); index < count; index++ {
|
||||
meta, err := readImageHeader(c, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta.Extra == ExtraSprite {
|
||||
values, err := readInts(c, 7)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta.SpriteIndex, meta.Left, meta.Top, meta.Right, meta.Bottom, meta.Rotate = values[1], values[2], values[3], values[4], values[5], values[6]
|
||||
meta.DataSize = 0
|
||||
}
|
||||
if err := validateImageMeta(meta, size); err != nil {
|
||||
return nil, fmt.Errorf("图片 %d: %w", index, err)
|
||||
}
|
||||
result.Images = append(result.Images, meta)
|
||||
}
|
||||
dataStart := c.pos
|
||||
if version == 2 && imagesSize > 0 {
|
||||
// For ordinary v2 files this is equivalent to the cursor position and
|
||||
// catches malformed headers whose declared table is shorter.
|
||||
declared := int64(32) + int64(imagesSize)
|
||||
if declared >= dataStart && declared <= size {
|
||||
dataStart = declared
|
||||
}
|
||||
}
|
||||
result.DataStart = dataStart
|
||||
cursor := dataStart
|
||||
if version == 5 {
|
||||
for index := range result.Sprites {
|
||||
result.Sprites[index].DataOffset = cursor
|
||||
cursor += int64(result.Sprites[index].DataSize)
|
||||
if cursor > size {
|
||||
return nil, fmt.Errorf("sprite %d 数据越界", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
for index := range result.Images {
|
||||
meta := &result.Images[index]
|
||||
if meta.Format == FormatLink || meta.Extra == ExtraSprite {
|
||||
continue
|
||||
}
|
||||
meta.DataOffset = cursor
|
||||
cursor += int64(meta.DataSize)
|
||||
if cursor > size {
|
||||
return nil, fmt.Errorf("图片 %d 数据越界", index)
|
||||
}
|
||||
}
|
||||
resolveLinks(result.Images)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseV5Prefix(c *imgCursor, result *IMGFile) error {
|
||||
spriteCount, _ := c.readInt32()
|
||||
if _, err := c.readInt32(); err != nil || spriteCount < 0 || spriteCount > maxIMGImages {
|
||||
return fmt.Errorf("%w: 无效 sprite 数量", ErrNotIMG)
|
||||
}
|
||||
palette, err := readPalette(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.Palettes = [][]Color{palette}
|
||||
result.Sprites = make([]SpriteMeta, 0, spriteCount)
|
||||
for index := int32(0); index < spriteCount; index++ {
|
||||
values, err := readInts(c, 7)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if values[3] < 0 || values[4] < 0 || values[5] < 0 || values[6] < 0 {
|
||||
return fmt.Errorf("%w: sprite %d 参数为负数", ErrNotIMG, index)
|
||||
}
|
||||
result.Sprites = append(result.Sprites, SpriteMeta{
|
||||
Keep: values[0], Format: values[1], Index: values[2], DataSize: values[3], RawSize: values[4], Width: values[5], Height: values[6],
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPalette(c *imgCursor) ([]Color, error) {
|
||||
count, err := c.readInt32()
|
||||
if err != nil || count < 0 || count > 1_000_000 {
|
||||
return nil, fmt.Errorf("%w: 无效颜色数量", ErrNotIMG)
|
||||
}
|
||||
data, err := c.readBytes(int(count) * 4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
palette := make([]Color, count)
|
||||
for index := range palette {
|
||||
copy(palette[index][:], data[index*4:index*4+4])
|
||||
}
|
||||
return palette, nil
|
||||
}
|
||||
|
||||
func readImageHeader(c *imgCursor, version int32) (ImageMeta, error) {
|
||||
format, err := c.readInt32()
|
||||
if err != nil {
|
||||
return ImageMeta{}, err
|
||||
}
|
||||
if format == FormatLink {
|
||||
link, err := c.readInt32()
|
||||
return ImageMeta{Format: format, Extra: ExtraNone, LinkIndex: link, Width: 0, Height: 0}, err
|
||||
}
|
||||
extra, err := c.readInt32()
|
||||
if err != nil {
|
||||
return ImageMeta{}, err
|
||||
}
|
||||
values, err := readInts(c, 7)
|
||||
if err != nil {
|
||||
return ImageMeta{}, err
|
||||
}
|
||||
if extra != ExtraNone && extra != ExtraZlib && extra != ExtraSprite {
|
||||
return ImageMeta{}, fmt.Errorf("%w: extra=%d", ErrUnsupported, extra)
|
||||
}
|
||||
meta := ImageMeta{Format: format, Extra: extra, Width: values[0], Height: values[1], HeaderSize: values[2], DataSize: values[2], X: values[3], Y: values[4], MemoryWidth: values[5], MemoryHeight: values[6]}
|
||||
if version == 1 || version == 2 {
|
||||
if extra == ExtraNone {
|
||||
meta.DataSize = meta.Width * meta.Height * pixelSize(format)
|
||||
}
|
||||
}
|
||||
if extra == ExtraSprite {
|
||||
// The image's seven sprite fields follow in the caller because the
|
||||
// regular image header does not contain them.
|
||||
meta.DataSize = 0
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func readInts(c *imgCursor, count int) ([]int32, error) {
|
||||
values := make([]int32, count)
|
||||
for index := range values {
|
||||
value, err := c.readInt32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[index] = value
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func pixelSize(format int32) int32 {
|
||||
switch format {
|
||||
case Format1555, Format4444, FormatDXT1, FormatDXT3:
|
||||
return 2
|
||||
case Format8888, FormatDXT5:
|
||||
return 4
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func validateImageMeta(meta ImageMeta, imgSize int64) error {
|
||||
if meta.Format != Format1555 && meta.Format != Format4444 && meta.Format != Format8888 && meta.Format != FormatLink && meta.Format != FormatDXT1 && meta.Format != FormatDXT3 && meta.Format != FormatDXT5 {
|
||||
return fmt.Errorf("%w: format=%d", ErrUnsupported, meta.Format)
|
||||
}
|
||||
if meta.Format == FormatLink {
|
||||
if meta.LinkIndex < 0 {
|
||||
return fmt.Errorf("%w: link 索引为负数", ErrBadImageData)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if meta.Width <= 0 || meta.Height <= 0 || meta.Width > maxDimension || meta.Height > maxDimension || int64(meta.Width)*int64(meta.Height) > maxPixels {
|
||||
return fmt.Errorf("%w: 尺寸 %dx%d", ErrBadImageData, meta.Width, meta.Height)
|
||||
}
|
||||
if meta.DataSize < 0 || int64(meta.DataSize) > imgSize {
|
||||
return fmt.Errorf("%w: 数据长度 %d", ErrBadImageData, meta.DataSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveLinks(images []ImageMeta) {
|
||||
for index := range images {
|
||||
if images[index].Format == FormatLink && images[index].LinkIndex >= 0 && images[index].LinkIndex < int32(len(images)) {
|
||||
// Dimensions are useful to callers during indexing. Pixel decoding
|
||||
// still resolves links recursively and detects cycles.
|
||||
seen := map[int32]struct{}{int32(index): {}}
|
||||
current := images[index].LinkIndex
|
||||
for current >= 0 && current < int32(len(images)) && images[current].Format == FormatLink {
|
||||
if _, ok := seen[current]; ok {
|
||||
break
|
||||
}
|
||||
seen[current] = struct{}{}
|
||||
current = images[current].LinkIndex
|
||||
}
|
||||
if current >= 0 && current < int32(len(images)) && images[current].Format != FormatLink {
|
||||
images[index].Width = images[current].Width
|
||||
images[index].Height = images[current].Height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readCursorCString(c *imgCursor, max int) (string, error) {
|
||||
data := make([]byte, 0, max)
|
||||
for index := 0; index < max; index++ {
|
||||
value, err := c.readBytes(1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if value[0] == 0 {
|
||||
return string(data), nil
|
||||
}
|
||||
data = append(data, value[0])
|
||||
}
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
func readCStringAt(r io.ReaderAt, offset int64, max int) (string, int64, error) {
|
||||
data := make([]byte, max)
|
||||
n, err := r.ReadAt(data, offset)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", 0, err
|
||||
}
|
||||
data = data[:n]
|
||||
if zero := bytes.IndexByte(data, 0); zero >= 0 {
|
||||
return string(data[:zero]), offset + int64(zero) + 1, nil
|
||||
}
|
||||
return "", 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
func readAt(r io.ReaderAt, offset int64, value *int32) error {
|
||||
var data [4]byte
|
||||
if _, err := r.ReadAt(data[:], offset); err != nil {
|
||||
return err
|
||||
}
|
||||
*value = int32(binary.LittleEndian.Uint32(data[:]))
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecodeImage reads the requested image data from an NPK member and returns a
|
||||
// Go image. It is deliberately separate from ParseIMGAt so scans never load
|
||||
// every pixel into memory.
|
||||
func DecodeImage(r io.ReaderAt, imgOffset, imgSize int64, file *IMGFile, index int32) (image.Image, error) {
|
||||
if file == nil || index < 0 || index >= int32(len(file.Images)) {
|
||||
return nil, fmt.Errorf("图片索引越界: %d", index)
|
||||
}
|
||||
return decodeImage(r, imgOffset, imgSize, file, index, map[int32]struct{}{})
|
||||
}
|
||||
|
||||
func decodeImage(r io.ReaderAt, imgOffset, imgSize int64, file *IMGFile, index int32, visiting map[int32]struct{}) (image.Image, error) {
|
||||
if _, ok := visiting[index]; ok {
|
||||
return nil, fmt.Errorf("%w: 图片 link 环", ErrBadImageData)
|
||||
}
|
||||
visiting[index] = struct{}{}
|
||||
defer delete(visiting, index)
|
||||
meta := file.Images[index]
|
||||
if meta.Format == FormatLink {
|
||||
if meta.LinkIndex < 0 || meta.LinkIndex >= int32(len(file.Images)) {
|
||||
return nil, fmt.Errorf("%w: link 索引 %d", ErrBadImageData, meta.LinkIndex)
|
||||
}
|
||||
return decodeImage(r, imgOffset, imgSize, file, meta.LinkIndex, visiting)
|
||||
}
|
||||
if meta.Extra == ExtraSprite {
|
||||
if meta.SpriteIndex < 0 || meta.SpriteIndex >= int32(len(file.Sprites)) {
|
||||
return nil, fmt.Errorf("%w: sprite 索引 %d", ErrBadImageData, meta.SpriteIndex)
|
||||
}
|
||||
sprite := file.Sprites[meta.SpriteIndex]
|
||||
compressed, err := readRange(r, imgOffset+sprite.DataOffset, int64(sprite.DataSize), imgOffset, imgSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := decompressZlib(compressed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoded, err := decodeSpriteTexture(raw, sprite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cropRotate(decoded, meta.Left, meta.Top, meta.Right, meta.Bottom, meta.Rotate)
|
||||
}
|
||||
data, err := readRange(r, imgOffset+meta.DataOffset, int64(meta.DataSize), imgOffset, imgSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta.Extra == ExtraZlib {
|
||||
data, err = decompressZlib(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var palette []Color
|
||||
if len(file.Palettes) > 0 && (meta.Extra == ExtraZlib || file.Version == 6) {
|
||||
palette = file.Palettes[0]
|
||||
}
|
||||
return decodePixels(data, meta.Format, meta.Width, meta.Height, palette)
|
||||
}
|
||||
|
||||
func readRange(r io.ReaderAt, offset, size, base, memberSize int64) ([]byte, error) {
|
||||
if size < 0 || offset < base || offset-base > memberSize || size > memberSize-(offset-base) {
|
||||
return nil, fmt.Errorf("%w: 数据偏移越界", ErrBadImageData)
|
||||
}
|
||||
data := make([]byte, size)
|
||||
if _, err := r.ReadAt(data, offset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func decompressZlib(data []byte) ([]byte, error) {
|
||||
try := func(input []byte) ([]byte, error) {
|
||||
reader, err := zlib.NewReader(bytes.NewReader(input))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
return io.ReadAll(io.LimitReader(reader, maxZlibReader))
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, ErrBadImageData
|
||||
}
|
||||
if result, err := try(data); err == nil {
|
||||
return result, nil
|
||||
}
|
||||
last := bytes.LastIndexByte(data, 0x78)
|
||||
if last >= 0 {
|
||||
if result, err := try(data[last:]); err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if last+2 <= len(data) {
|
||||
if result, err := try(append(append([]byte(nil), data[last:last+2]...), data...)); err == nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: zlib 解压失败", ErrBadImageData)
|
||||
}
|
||||
|
||||
const maxZlibReader = 256 << 20
|
||||
|
||||
func decodePixels(data []byte, format int32, width, height int32, palette []Color) (image.Image, error) {
|
||||
if width <= 0 || height <= 0 || width > maxDimension || height > maxDimension || int64(width)*int64(height) > maxPixels {
|
||||
return nil, fmt.Errorf("%w: 尺寸 %dx%d", ErrBadImageData, width, height)
|
||||
}
|
||||
if len(palette) > 0 {
|
||||
if int64(width)*int64(height) > int64(len(data)) {
|
||||
return nil, fmt.Errorf("%w: 调色板索引数据过短", ErrBadImageData)
|
||||
}
|
||||
out := image.NewNRGBA(image.Rect(0, 0, int(width), int(height)))
|
||||
for y := 0; y < int(height); y++ {
|
||||
for x := 0; x < int(width); x++ {
|
||||
index := data[y*int(width)+x]
|
||||
if int(index) >= len(palette) {
|
||||
return nil, fmt.Errorf("%w: 调色板索引 %d", ErrBadImageData, index)
|
||||
}
|
||||
out.SetNRGBA(x, y, colorNRGBA(palette[index]))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if format == FormatDXT1 || format == FormatDXT3 || format == FormatDXT5 {
|
||||
return decodeDXT(data, format, int(width), int(height))
|
||||
}
|
||||
pixelBytes := pixelSize(format)
|
||||
if pixelBytes == 0 || int64(width)*int64(height)*int64(pixelBytes) > int64(len(data)) {
|
||||
return nil, fmt.Errorf("%w: 原始像素数据过短", ErrBadImageData)
|
||||
}
|
||||
out := image.NewNRGBA(image.Rect(0, 0, int(width), int(height)))
|
||||
for y := 0; y < int(height); y++ {
|
||||
for x := 0; x < int(width); x++ {
|
||||
base := (y*int(width) + x) * int(pixelBytes)
|
||||
var pixel Color
|
||||
switch format {
|
||||
case Format1555:
|
||||
pixel = decode1555(data[base], data[base+1])
|
||||
case Format4444:
|
||||
pixel = decode4444(data[base], data[base+1])
|
||||
case Format8888:
|
||||
pixel = Color{data[base+2], data[base+1], data[base], data[base+3]}
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: format=%d", ErrUnsupported, format)
|
||||
}
|
||||
out.SetNRGBA(x, y, colorNRGBA(pixel))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func colorNRGBA(value Color) color.NRGBA {
|
||||
return color.NRGBA{R: value[0], G: value[1], B: value[2], A: value[3]}
|
||||
}
|
||||
|
||||
func decode1555(first, second byte) Color {
|
||||
b := first & 0x1f
|
||||
b = (b << 3) | (b >> 2)
|
||||
g := (first >> 5) | ((second & 3) << 3)
|
||||
g = (g << 3) | (g >> 2)
|
||||
r := (second >> 2) & 0x1f
|
||||
r = (r << 3) | (r >> 2)
|
||||
a := uint8(0)
|
||||
if second>>7 != 0 {
|
||||
a = 0xff
|
||||
}
|
||||
return Color{r, g, b, a}
|
||||
}
|
||||
|
||||
func decode4444(first, second byte) Color {
|
||||
return Color{(second & 0x0f) << 4, first & 0xf0, (first & 0x0f) << 4, second & 0xf0}
|
||||
}
|
||||
|
||||
func cropRotate(src image.Image, left, top, right, bottom, rotate int32) (image.Image, error) {
|
||||
bounds := src.Bounds()
|
||||
if left < 0 || top < 0 || right <= left || bottom <= top || right > int32(bounds.Dx()) || bottom > int32(bounds.Dy()) {
|
||||
return nil, fmt.Errorf("%w: sprite 裁剪框 [%d,%d,%d,%d]", ErrBadImageData, left, top, right, bottom)
|
||||
}
|
||||
cropped := image.NewNRGBA(image.Rect(0, 0, int(right-left), int(bottom-top)))
|
||||
for y := int32(0); y < bottom-top; y++ {
|
||||
for x := int32(0); x < right-left; x++ {
|
||||
cropped.Set(int(x), int(y), src.At(int(left+x), int(top+y)))
|
||||
}
|
||||
}
|
||||
if rotate != 1 {
|
||||
return cropped, nil
|
||||
}
|
||||
rotated := image.NewNRGBA(image.Rect(0, 0, cropped.Bounds().Dy(), cropped.Bounds().Dx()))
|
||||
for y := 0; y < cropped.Bounds().Dy(); y++ {
|
||||
for x := 0; x < cropped.Bounds().Dx(); x++ {
|
||||
rotated.Set(y, cropped.Bounds().Dx()-1-x, cropped.At(x, y))
|
||||
}
|
||||
}
|
||||
return rotated, nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package npk
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRealImagePacks2Metadata(t *testing.T) {
|
||||
root := os.Getenv("NPK_TESTDIR")
|
||||
if root == "" {
|
||||
t.Skip("NPK_TESTDIR 未设置")
|
||||
}
|
||||
root = resolveTestPath(root)
|
||||
if resolved, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = resolved
|
||||
}
|
||||
manifest, err := collectTestNPKs(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(manifest) != 120 {
|
||||
t.Fatalf("NPK count = %d, want 120", len(manifest))
|
||||
}
|
||||
imgFiles, imageCount := 0, 0
|
||||
for _, path := range manifest {
|
||||
npkFile, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range npkFile.Entries {
|
||||
if !strings.EqualFold(filepath.Ext(entry.Name), ".img") {
|
||||
continue
|
||||
}
|
||||
parsed, err := ParseIMGAt(reader, entry.Offset, entry.Size)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s:%s: %v", filepath.Base(path), entry.Name, err)
|
||||
}
|
||||
imgFiles++
|
||||
imageCount += len(parsed.Images)
|
||||
}
|
||||
_ = reader.Close()
|
||||
}
|
||||
if imgFiles != 1692 || imageCount != 145816 {
|
||||
t.Fatalf("IMG/image count = %d/%d, want 1692/145816", imgFiles, imageCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealImagePacks2DecodeSamples(t *testing.T) {
|
||||
root := os.Getenv("NPK_TESTDIR")
|
||||
if root == "" {
|
||||
t.Skip("NPK_TESTDIR 未设置")
|
||||
}
|
||||
root = resolveTestPath(root)
|
||||
if resolved, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = resolved
|
||||
}
|
||||
targets := map[string]int32{
|
||||
"sprite/item/new_equipment/08_necklace/necklace.img": 69,
|
||||
"sprite/item/fieldimage.img": 6,
|
||||
"sprite/item/title/2017_battlesuit.img": 0,
|
||||
}
|
||||
found := make(map[string]bool)
|
||||
paths, err := collectTestNPKs(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range paths {
|
||||
npkFile, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range npkFile.Entries {
|
||||
key := strings.ToLower(filepath.ToSlash(entry.Name))
|
||||
index, ok := targets[key]
|
||||
if !ok || found[key] {
|
||||
continue
|
||||
}
|
||||
img, err := ParseIMGAt(reader, entry.Offset, entry.Size)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := DecodeImage(reader, entry.Offset, entry.Size, img, index)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %s: %v", entry.Name, err)
|
||||
}
|
||||
if decoded.Bounds().Dx() <= 0 || decoded.Bounds().Dy() <= 0 {
|
||||
t.Fatalf("decode %s returned empty image", entry.Name)
|
||||
}
|
||||
found[key] = true
|
||||
}
|
||||
_ = reader.Close()
|
||||
}
|
||||
for target := range targets {
|
||||
if !found[target] {
|
||||
t.Fatalf("sample %s not found", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTestPath(value string) string {
|
||||
if filepath.IsAbs(value) {
|
||||
return value
|
||||
}
|
||||
candidate := value
|
||||
for index := 0; index < 4; index++ {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
absolute, _ := filepath.Abs(candidate)
|
||||
return absolute
|
||||
}
|
||||
candidate = filepath.Join("..", candidate)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func collectTestNPKs(root string) ([]string, error) {
|
||||
paths := make([]string, 0)
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && strings.EqualFold(filepath.Ext(path), ".npk") {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range paths {
|
||||
for other := index + 1; other < len(paths); other++ {
|
||||
if paths[other] < paths[index] {
|
||||
paths[index], paths[other] = paths[other], paths[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
Reference in new issue
Block a user