DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+39
View File
@@ -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)
}
+106
View File
@@ -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")
}
})
}
}
+156
View File
@@ -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}
}
+283
View File
@@ -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")
}
}
+775
View File
@@ -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
}
+147
View File
@@ -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
}