Adding modding folder with a hacked build.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package bitmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
const (
|
||||
animationEndTag = 0x0C
|
||||
animationEndTagData = 0x01
|
||||
|
||||
animationEntryTag = 0x04
|
||||
|
||||
errEndTagDataWrong ss1.StringError = "end tag data wrong"
|
||||
)
|
||||
|
||||
type unknownEntryTagError struct {
|
||||
tag byte
|
||||
}
|
||||
|
||||
func (err unknownEntryTagError) Error() string {
|
||||
return fmt.Sprintf("unknown entry tag 0x%02X", int(err.tag))
|
||||
}
|
||||
|
||||
// Animation describes a sequence of bitmaps to form a small movie.
|
||||
type Animation struct {
|
||||
Width int16
|
||||
Height int16
|
||||
ResourceID resource.ID
|
||||
IntroFlag uint16
|
||||
Entries []AnimationEntry
|
||||
}
|
||||
|
||||
// AnimationEntry describes one part of an animation with common frame time.
|
||||
type AnimationEntry struct {
|
||||
FirstFrame byte
|
||||
LastFrame byte
|
||||
FrameTime int16
|
||||
}
|
||||
|
||||
// ReadAnimation extracts an animation from a serialized stream.
|
||||
func ReadAnimation(reader io.Reader) (anim Animation, err error) {
|
||||
decoder := serial.NewDecoder(reader)
|
||||
codeAnimationBase(decoder, &anim)
|
||||
done := false
|
||||
err = decoder.FirstError()
|
||||
for !done && (err == nil) {
|
||||
var tag byte
|
||||
decoder.Code(&tag)
|
||||
err = decoder.FirstError()
|
||||
switch tag {
|
||||
case animationEntryTag:
|
||||
var entry AnimationEntry
|
||||
decoder.Code(&entry)
|
||||
anim.Entries = append(anim.Entries, entry)
|
||||
err = decoder.FirstError()
|
||||
case animationEndTag:
|
||||
var endTagData byte
|
||||
decoder.Code(&endTagData)
|
||||
if endTagData != animationEndTagData {
|
||||
err = errEndTagDataWrong
|
||||
}
|
||||
done = true
|
||||
default:
|
||||
if err == nil {
|
||||
err = unknownEntryTagError{tag: tag}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// WriteAnimation serializes an animation to a writer.
|
||||
func WriteAnimation(writer io.Writer, anim Animation) error {
|
||||
encoder := serial.NewEncoder(writer)
|
||||
codeAnimationBase(encoder, &anim)
|
||||
for _, entry := range anim.Entries {
|
||||
encoder.Code([]byte{animationEntryTag})
|
||||
encoder.Code(&entry) // nolint: scopelint
|
||||
}
|
||||
encoder.Code([]byte{animationEndTag, animationEndTagData})
|
||||
|
||||
return encoder.FirstError()
|
||||
}
|
||||
|
||||
func codeAnimationBase(coder serial.Coder, anim *Animation) {
|
||||
coder.Code(&anim.Width)
|
||||
coder.Code(&anim.Height)
|
||||
coder.Code(&anim.ResourceID)
|
||||
var unknown [6]byte
|
||||
coder.Code(&unknown)
|
||||
coder.Code(&anim.IntroFlag)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package bitmap
|
||||
|
||||
import (
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
)
|
||||
|
||||
// AnimationCache retrieves animations from a localizer and keeps them decoded until they are invalidated.
|
||||
type AnimationCache struct {
|
||||
localizer resource.Localizer
|
||||
|
||||
animations map[resource.Key]Animation
|
||||
}
|
||||
|
||||
// NewAnimationCache returns a new instance.
|
||||
func NewAnimationCache(localizer resource.Localizer) *AnimationCache {
|
||||
cache := &AnimationCache{
|
||||
localizer: localizer,
|
||||
animations: make(map[resource.Key]Animation),
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
// InvalidateResources lets the cache remove any animations from resources that are specified in the given slice.
|
||||
func (cache *AnimationCache) InvalidateResources(ids []resource.ID) {
|
||||
for _, id := range ids {
|
||||
for key := range cache.animations {
|
||||
if key.ID == id {
|
||||
delete(cache.animations, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animation tries to look up given animation.
|
||||
func (cache *AnimationCache) Animation(key resource.Key) (anim Animation, err error) {
|
||||
anim, existing := cache.animations[key]
|
||||
if existing {
|
||||
return anim, nil
|
||||
}
|
||||
selector := cache.localizer.LocalizedResources(key.Lang)
|
||||
view, err := selector.Select(key.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if (view.ContentType() != resource.Animation) || (view.BlockCount() != 1) {
|
||||
return anim, resource.ErrWrongType(key, resource.Animation)
|
||||
}
|
||||
reader, err := view.Block(0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
anim, err = ReadAnimation(reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cache.animations[key] = anim
|
||||
return anim, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package bitmap_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
)
|
||||
|
||||
const (
|
||||
baseAnimationResourceID = 0x0A4C
|
||||
)
|
||||
|
||||
type AnimationCacheSuite struct {
|
||||
suite.Suite
|
||||
|
||||
localizedResources resource.LocalizedResourcesList
|
||||
|
||||
instance *bitmap.AnimationCache
|
||||
}
|
||||
|
||||
func TestAnimationCacheSuite(t *testing.T) {
|
||||
suite.Run(t, new(AnimationCacheSuite))
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) SetupTest() {
|
||||
suite.instance = nil
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) TestAnimationReturnsValueIfOK() {
|
||||
anim := suite.someAnimation(0)
|
||||
suite.givenAnInstance()
|
||||
suite.whenResourcesAre(
|
||||
suite.someLocalizedResources(
|
||||
suite.storing(0, anim)))
|
||||
suite.thenAnimationShouldReturn(anim, 0)
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) TestAnimationReturnsErrorIfResourceNotExisting() {
|
||||
suite.givenAnInstance()
|
||||
suite.whenResourcesAre()
|
||||
suite.thenAnimationShouldReturnError(0)
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) TestTextTriesToReloadWhenCacheInvalidated() {
|
||||
suite.givenAnInstance()
|
||||
suite.givenResourcesAre(
|
||||
suite.someLocalizedResources(
|
||||
suite.storing(0, suite.someAnimation(0))))
|
||||
suite.givenAnimationWasRetrieved(0)
|
||||
suite.givenResourcesAre()
|
||||
suite.whenCacheResourcesAreInvalidated(baseAnimationResourceID)
|
||||
suite.thenAnimationShouldReturnError(0)
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) TestInvalidationConsidersOnlyAffectedIDs() {
|
||||
suite.givenAnInstance()
|
||||
suite.givenResourcesAre(
|
||||
suite.someLocalizedResources(
|
||||
suite.storing(0, suite.someAnimation(0)), suite.storing(1, suite.someAnimation(1))))
|
||||
suite.givenAnimationWasRetrieved(0)
|
||||
suite.givenResourcesAre()
|
||||
suite.whenCacheResourcesAreInvalidated(0x02BD)
|
||||
suite.thenAnimationShouldReturn(suite.someAnimation(0), 0)
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) TestAnimationReturnsErrorIfResourceIsNotAnAnimation() {
|
||||
suite.givenAnInstance()
|
||||
suite.whenResourcesAre(suite.someLocalizedResources(suite.storingNonAnimation(0)))
|
||||
suite.thenAnimationShouldReturnError(0)
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) givenAnInstance() {
|
||||
suite.instance = bitmap.NewAnimationCache(suite)
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) givenResourcesAre(resources ...resource.LocalizedResources) {
|
||||
suite.localizedResources = resources
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) givenAnimationWasRetrieved(index int) {
|
||||
_, err := suite.instance.Animation(suite.keyed(index))
|
||||
require.Nil(suite.T(), err, "No error expected setting up")
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) whenResourcesAre(resources ...resource.LocalizedResources) {
|
||||
suite.localizedResources = resources
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) whenCacheResourcesAreInvalidated(ids ...resource.ID) {
|
||||
suite.instance.InvalidateResources(ids)
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) thenAnimationShouldReturn(expected bitmap.Animation, index int) {
|
||||
result, err := suite.instance.Animation(suite.keyed(index))
|
||||
require.Nil(suite.T(), err, "No error expected")
|
||||
assert.Equal(suite.T(), expected, result, "Animations don't match")
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) thenAnimationShouldReturnError(index int) {
|
||||
_, err := suite.instance.Animation(suite.keyed(index))
|
||||
require.NotNil(suite.T(), err, "Error expected")
|
||||
}
|
||||
|
||||
func (suite AnimationCacheSuite) keyed(index int) resource.Key {
|
||||
return resource.KeyOf(baseAnimationResourceID, resource.LangAny, index)
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) someLocalizedResources(modifiers ...func(*resource.Store)) resource.LocalizedResources {
|
||||
var store resource.Store
|
||||
for _, modifier := range modifiers {
|
||||
modifier(&store)
|
||||
}
|
||||
return resource.LocalizedResources{
|
||||
ID: "unnamed",
|
||||
Language: resource.LangAny,
|
||||
Viewer: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) storing(id int, anim bitmap.Animation) func(*resource.Store) {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
_ = bitmap.WriteAnimation(buf, anim)
|
||||
return func(store *resource.Store) {
|
||||
_ = store.Put(resource.ID(baseAnimationResourceID).Plus(id), resource.Resource{
|
||||
Properties: resource.Properties{
|
||||
ContentType: resource.Animation,
|
||||
Compound: true,
|
||||
},
|
||||
Blocks: resource.BlocksFrom([][]byte{buf.Bytes()}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) someAnimation(seed uint8) bitmap.Animation {
|
||||
anim := bitmap.Animation{
|
||||
Width: int16(seed) * 4,
|
||||
Height: int16(seed) * 3,
|
||||
IntroFlag: uint16(seed % 2),
|
||||
ResourceID: resource.ID(seed),
|
||||
Entries: make([]bitmap.AnimationEntry, seed+1),
|
||||
}
|
||||
|
||||
for i := 0; i < len(anim.Entries); i++ {
|
||||
anim.Entries[i] = bitmap.AnimationEntry{
|
||||
FirstFrame: byte(i),
|
||||
LastFrame: byte(i),
|
||||
FrameTime: 100,
|
||||
}
|
||||
}
|
||||
return anim
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) storingNonAnimation(id int) func(*resource.Store) {
|
||||
return func(store *resource.Store) {
|
||||
_ = store.Put(resource.ID(baseAnimationResourceID).Plus(id), resource.Resource{
|
||||
Properties: resource.Properties{
|
||||
ContentType: resource.Sound,
|
||||
Compound: true,
|
||||
},
|
||||
Blocks: resource.BlocksFrom([][]byte{{}}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *AnimationCacheSuite) LocalizedResources(lang resource.Language) resource.Selector {
|
||||
return resource.Selector{
|
||||
From: suite.localizedResources,
|
||||
Lang: lang,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package bitmap_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
)
|
||||
|
||||
func TestReadAnimationErrorsLength(t *testing.T) {
|
||||
for i := 0; i < 20; i++ {
|
||||
data := make([]byte, i)
|
||||
_, err := bitmap.ReadAnimation(bytes.NewReader(data))
|
||||
assert.Error(t, err, fmt.Sprintf("Error expected for %d bytes", i))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAnimationErrorsTags(t *testing.T) {
|
||||
tt := []struct {
|
||||
info string
|
||||
data []byte
|
||||
}{
|
||||
{
|
||||
info: "end tag data missing",
|
||||
data: []byte{
|
||||
0x00, 0x00,
|
||||
0x02, 0x02,
|
||||
0x04, 0x04,
|
||||
0x06, 0x06, 0x06, 0x06, 0x06, 0x06,
|
||||
0x0C, 0x0C,
|
||||
0x0C,
|
||||
},
|
||||
},
|
||||
{
|
||||
info: "end tag data wrong",
|
||||
data: []byte{
|
||||
0x00, 0x00,
|
||||
0x02, 0x02,
|
||||
0x04, 0x04,
|
||||
0x06, 0x06, 0x06, 0x06, 0x06, 0x06,
|
||||
0x0C, 0x0C,
|
||||
0x0C, 0x00,
|
||||
},
|
||||
},
|
||||
{
|
||||
info: "unknown tag",
|
||||
data: []byte{
|
||||
0x00, 0x00,
|
||||
0x02, 0x02,
|
||||
0x04, 0x04,
|
||||
0x06, 0x06, 0x06, 0x06, 0x06, 0x06,
|
||||
0x0C, 0x0C,
|
||||
0x0B,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tt {
|
||||
_, err := bitmap.ReadAnimation(bytes.NewReader(tc.data))
|
||||
assert.Error(t, err, fmt.Sprintf("Error expected for %s", tc.info))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWriteAnimationEntries(t *testing.T) {
|
||||
tt := []struct {
|
||||
info string
|
||||
data []byte
|
||||
expected bitmap.Animation
|
||||
}{
|
||||
{
|
||||
info: "empty list of entries",
|
||||
data: []byte{
|
||||
0x12, 0x34,
|
||||
0x56, 0x78,
|
||||
0xAA, 0xBB,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xCC, 0xDD,
|
||||
0x0C, 0x01,
|
||||
},
|
||||
expected: bitmap.Animation{
|
||||
Width: 0x3412,
|
||||
Height: 0x7856,
|
||||
ResourceID: resource.ID(0xBBAA),
|
||||
IntroFlag: 0xDDCC,
|
||||
Entries: nil,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
info: "two entries",
|
||||
data: []byte{
|
||||
0x12, 0x34,
|
||||
0x56, 0x78,
|
||||
0xAA, 0xBB,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xCC, 0xDD,
|
||||
0x04, 0x00, 0x0A, 0x10, 0x20,
|
||||
0x04, 0x0B, 0x10, 0x30, 0x40,
|
||||
0x0C, 0x01,
|
||||
},
|
||||
expected: bitmap.Animation{
|
||||
Width: 0x3412,
|
||||
Height: 0x7856,
|
||||
ResourceID: resource.ID(0xBBAA),
|
||||
IntroFlag: 0xDDCC,
|
||||
Entries: []bitmap.AnimationEntry{
|
||||
{FirstFrame: 0x00, LastFrame: 0x0A, FrameTime: 0x2010},
|
||||
{FirstFrame: 0x0B, LastFrame: 0x10, FrameTime: 0x4030},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tt {
|
||||
td := tc
|
||||
t.Run(tc.info, func(t *testing.T) {
|
||||
anim, err := bitmap.ReadAnimation(bytes.NewReader(td.data))
|
||||
require.Nil(t, err, "No error expected")
|
||||
assert.Equal(t, td.expected, anim, "Animation data not as expected after reading")
|
||||
|
||||
output := bytes.NewBuffer(nil)
|
||||
err = bitmap.WriteAnimation(output, anim)
|
||||
require.Nil(t, err, "No error expected")
|
||||
assert.Equal(t, td.data, output.Bytes(), "Data mismatch after re-encoding")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package bitmap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1"
|
||||
"github.com/inkyblackness/hacked/ss1/serial/rle"
|
||||
)
|
||||
|
||||
const (
|
||||
errReaderIsNil ss1.StringError = "reader is nil"
|
||||
errStrideNotValid ss1.StringError = "stride not equal to width for compressed bitmap"
|
||||
errDataCouldNotBeRead ss1.StringError = "data could not be read"
|
||||
errPaletteCouldNotBeRead ss1.StringError = "palette could not be read"
|
||||
)
|
||||
|
||||
// Bitmap describes a palette based image.
|
||||
type Bitmap struct {
|
||||
Header Header
|
||||
Pixels []byte
|
||||
Palette *Palette
|
||||
}
|
||||
|
||||
// Decode tries to read a bitmap from given reader.
|
||||
// Should the bitmap be a compressed bitmap, then a reference image with pixel data all 0x00 is assumed.
|
||||
func Decode(reader io.Reader) (*Bitmap, error) {
|
||||
return DecodeReferenced(reader, func(width, height int16) ([]byte, error) {
|
||||
return make([]byte, int(width)*int(height)), nil
|
||||
})
|
||||
}
|
||||
|
||||
// DecodeReferenced tries to read a bitmap from given reader.
|
||||
// If the serialized bitmap describes a compressed bitmap, then the pixels from the reference are used as a basis for the result.
|
||||
// The returned byte array from the provider will be used as pixel buffer for the new bitmap.
|
||||
func DecodeReferenced(reader io.Reader, provider func(width, height int16) ([]byte, error)) (*Bitmap, error) {
|
||||
if reader == nil {
|
||||
return nil, errReaderIsNil
|
||||
}
|
||||
|
||||
var bmp Bitmap
|
||||
|
||||
err := binary.Read(reader, binary.LittleEndian, &bmp.Header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bmp.Header.Type == TypeCompressed8Bit {
|
||||
if bmp.Header.Stride != uint16(bmp.Header.Width) {
|
||||
return nil, errStrideNotValid
|
||||
}
|
||||
bmp.Pixels, err = provider(bmp.Header.Width, bmp.Header.Height)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = rle.Decompress(reader, bmp.Pixels)
|
||||
} else {
|
||||
bmp.Pixels = make([]byte, int(bmp.Header.Height)*int(bmp.Header.Stride))
|
||||
_, err = reader.Read(bmp.Pixels)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errDataCouldNotBeRead
|
||||
}
|
||||
|
||||
if bmp.Header.PaletteOffset != 0 {
|
||||
paletteFlag := uint32(0)
|
||||
_ = binary.Read(reader, binary.LittleEndian, &paletteFlag)
|
||||
bmp.Palette = new(Palette)
|
||||
err = binary.Read(reader, binary.LittleEndian, bmp.Palette)
|
||||
if err != nil {
|
||||
return nil, errPaletteCouldNotBeRead
|
||||
}
|
||||
}
|
||||
|
||||
return &bmp, nil
|
||||
}
|
||||
|
||||
// Encode writes the bitmap to a byte array and returns it.
|
||||
// Compressed bitmaps will be compressed with a reference image with pixel data all 0x00.
|
||||
func Encode(bmp *Bitmap, offsetBase int) []byte {
|
||||
rawData := bmp.Pixels
|
||||
if bmp.Header.Type == TypeCompressed8Bit {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
_ = rle.Compress(buf, rawData, nil)
|
||||
rawData = buf.Bytes()
|
||||
}
|
||||
header := bmp.Header
|
||||
if bmp.Palette != nil {
|
||||
header.PaletteOffset = int32(offsetBase + HeaderSize + len(rawData))
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
_ = binary.Write(buf, binary.LittleEndian, &header)
|
||||
_ = binary.Write(buf, binary.LittleEndian, rawData)
|
||||
if bmp.Palette != nil {
|
||||
_ = binary.Write(buf, binary.LittleEndian, paletteMarker)
|
||||
_ = binary.Write(buf, binary.LittleEndian, bmp.Palette)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package bitmap_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDecodeReturnsErrorOnNilSource(t *testing.T) {
|
||||
_, err := bitmap.Decode(nil)
|
||||
|
||||
assert.Error(t, err, "error expected")
|
||||
}
|
||||
|
||||
func TestDecodeOfUncompressedDataReturnsBitmap(t *testing.T) {
|
||||
data := getTestData(bitmap.TypeFlat8Bit, []byte{0xAA}, false)
|
||||
bmp, err := bitmap.Decode(bytes.NewReader(data))
|
||||
|
||||
require.Nil(t, err, "no error expected")
|
||||
require.NotNil(t, bmp, "bitmap expected")
|
||||
assert.Equal(t, int16(1), bmp.Header.Width)
|
||||
assert.Equal(t, int16(1), bmp.Header.Height)
|
||||
assert.Equal(t, []byte{0xAA}, bmp.Pixels)
|
||||
}
|
||||
|
||||
func TestDecodeOfCompressedDataReturnsBitmap(t *testing.T) {
|
||||
data := getTestData(bitmap.TypeCompressed8Bit, []byte{0x00, 0x01, 0xBB, 0x80, 0x00, 0x00}, false)
|
||||
bmp, err := bitmap.Decode(bytes.NewReader(data))
|
||||
|
||||
require.Nil(t, err, "no error expected")
|
||||
require.NotNil(t, bmp, "bitmap expected")
|
||||
assert.Equal(t, int16(1), bmp.Header.Width)
|
||||
assert.Equal(t, int16(1), bmp.Header.Height)
|
||||
assert.Equal(t, []byte{0xBB}, bmp.Pixels)
|
||||
}
|
||||
|
||||
func TestDecodeWithPrivatePaletteReturnsPalette(t *testing.T) {
|
||||
data := getTestData(bitmap.TypeFlat8Bit, []byte{0xAA}, true)
|
||||
bmp, err := bitmap.Decode(bytes.NewReader(data))
|
||||
|
||||
require.Nil(t, err, "no error expected")
|
||||
require.NotNil(t, bmp, "bitmap expected")
|
||||
assert.NotNil(t, bmp.Palette, "palette expected")
|
||||
}
|
||||
|
||||
func TestWriteUncompressedWithoutPalette(t *testing.T) {
|
||||
sourceData := getTestData(bitmap.TypeFlat8Bit, []byte{0xAA}, false)
|
||||
bmp, _ := bitmap.Decode(bytes.NewReader(sourceData))
|
||||
|
||||
result := bitmap.Encode(bmp, 0)
|
||||
assert.Equal(t, sourceData, result)
|
||||
}
|
||||
|
||||
func TestWriteCompressedWithoutPalette(t *testing.T) {
|
||||
sourceData := getTestData(bitmap.TypeCompressed8Bit, []byte{0x01, 0xBB, 0x80, 0x00, 0x00}, false)
|
||||
bmp, _ := bitmap.Decode(bytes.NewReader(sourceData))
|
||||
|
||||
result := bitmap.Encode(bmp, 0)
|
||||
assert.Equal(t, sourceData, result)
|
||||
}
|
||||
|
||||
func TestWriteUncompressedWithPalette(t *testing.T) {
|
||||
sourceData := getTestData(bitmap.TypeFlat8Bit, []byte{0xCC}, true)
|
||||
bmp, _ := bitmap.Decode(bytes.NewReader(sourceData))
|
||||
|
||||
result := bitmap.Encode(bmp, 0)
|
||||
assert.Equal(t, sourceData, result)
|
||||
}
|
||||
|
||||
func TestWriteCompressedWithPalette(t *testing.T) {
|
||||
sourceData := getTestData(bitmap.TypeCompressed8Bit, []byte{0x01, 0xEE, 0x80, 0x00, 0x00}, true)
|
||||
bmp, _ := bitmap.Decode(bytes.NewReader(sourceData))
|
||||
|
||||
result := bitmap.Encode(bmp, 0)
|
||||
assert.Equal(t, sourceData, result)
|
||||
}
|
||||
|
||||
func TestWriteWithForceTransparency(t *testing.T) {
|
||||
sourceData := getTestData(bitmap.TypeFlat8Bit, []byte{0xAA}, false)
|
||||
sourceData[6] = 1
|
||||
bmp, _ := bitmap.Decode(bytes.NewReader(sourceData))
|
||||
|
||||
result := bitmap.Encode(bmp, 0)
|
||||
assert.Equal(t, sourceData, result)
|
||||
}
|
||||
|
||||
func getTestData(bmpType bitmap.Type, data []byte, withPalette bool) []byte {
|
||||
var header bitmap.Header
|
||||
buf := bytes.NewBuffer(nil)
|
||||
|
||||
header.Type = bmpType
|
||||
header.Width = 1
|
||||
header.Stride = 1
|
||||
header.Height = 1
|
||||
header.WidthFactor = 1
|
||||
header.HeightFactor = 1
|
||||
if withPalette {
|
||||
header.PaletteOffset = int32(binary.Size(header) + len(data))
|
||||
}
|
||||
_ = binary.Write(buf, binary.LittleEndian, &header)
|
||||
buf.Write(data)
|
||||
if withPalette {
|
||||
var pal bitmap.Palette
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(0x01000000))
|
||||
_ = binary.Write(buf, binary.LittleEndian, pal)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package bitmap
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
)
|
||||
|
||||
// d64 is the reference white point.
|
||||
var d65 = [3]float64{0.95047, 1.00000, 1.08883}
|
||||
|
||||
func labF(t float64) float64 {
|
||||
if t > 6.0/29.0*6.0/29.0*6.0/29.0 {
|
||||
return math.Cbrt(t)
|
||||
}
|
||||
return t/3.0*29.0/6.0*29.0/6.0 + 4.0/29.0
|
||||
}
|
||||
|
||||
func square(value float64) float64 {
|
||||
return value * value
|
||||
}
|
||||
|
||||
type labEntry struct {
|
||||
l float64
|
||||
a float64
|
||||
b float64
|
||||
}
|
||||
|
||||
func labEntryFromColor(clr color.Color) labEntry {
|
||||
rLinear, gLinear, bLinear, _ := clr.RGBA()
|
||||
r, g, b := float64(rLinear)/float64(0xFFFF), float64(gLinear)/float64(0xFFFF), float64(bLinear)/float64(0xFFFF)
|
||||
x := 0.4124564*r + 0.3575761*g + 0.1804375*b
|
||||
y := 0.2126729*r + 0.7151522*g + 0.0721750*b
|
||||
z := 0.0193339*r + 0.1191920*g + 0.9503041*b
|
||||
whiteRef := d65
|
||||
fy := labF(y / whiteRef[1])
|
||||
entry := labEntry{
|
||||
l: 1.16*fy - 0.16,
|
||||
a: 5.0 * (labF(x/whiteRef[0]) - fy),
|
||||
b: 2.0 * (fy - labF(z/whiteRef[2]))}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func (entry labEntry) distanceTo(other labEntry) float64 {
|
||||
return math.Sqrt(square(entry.l-other.l) + square(entry.a-other.a) + square(entry.b-other.b))
|
||||
}
|
||||
|
||||
// Bitmapper creates bitmap images from generic images.
|
||||
type Bitmapper struct {
|
||||
pal []labEntry
|
||||
}
|
||||
|
||||
// NewBitmapper returns a new bitmapper instance based on the given palette.
|
||||
func NewBitmapper(palette *Palette) *Bitmapper {
|
||||
bitmapper := &Bitmapper{}
|
||||
|
||||
for _, clr := range palette {
|
||||
bitmapper.pal = append(bitmapper.pal, labEntryFromColor(clr.Color(0xFF)))
|
||||
}
|
||||
|
||||
return bitmapper
|
||||
}
|
||||
|
||||
// Map maps the provided image to a bitmap based on the internal palette.
|
||||
func (bitmapper *Bitmapper) Map(img image.Image) Bitmap {
|
||||
var bmp Bitmap
|
||||
bounds := img.Bounds()
|
||||
|
||||
bmp.Header.Width = int16(math.Max(0, math.Min(float64(bounds.Dx()), math.MaxInt16)))
|
||||
bmp.Header.Height = int16(math.Max(0, math.Min(float64(bounds.Dy()), math.MaxInt16)))
|
||||
bmp.Pixels = make([]byte, int(bmp.Header.Width)*int(bmp.Header.Height))
|
||||
for row := 0; row < int(bmp.Header.Height); row++ {
|
||||
for column := 0; column < int(bmp.Header.Width); column++ {
|
||||
bmp.Pixels[row*int(bmp.Header.Width)+column] = bitmapper.MapColor(img.At(column, row))
|
||||
}
|
||||
}
|
||||
|
||||
return bmp
|
||||
}
|
||||
|
||||
// MapColor maps the provided color to the nearest index in the palette.
|
||||
func (bitmapper *Bitmapper) MapColor(clr color.Color) (palIndex byte) {
|
||||
_, _, _, a := clr.RGBA() // nolint:dogsled
|
||||
indexWithin := func(index, from, to int) bool {
|
||||
return (index >= from) && (index <= to)
|
||||
}
|
||||
|
||||
if a > 0 {
|
||||
clrEntry := labEntryFromColor(clr)
|
||||
palDistance := 1000.0
|
||||
|
||||
for colorIndex, palEntry := range bitmapper.pal {
|
||||
isRegularColor := indexWithin(colorIndex, 0x01, 0x02) || indexWithin(colorIndex, 0x08, 0x0A) || indexWithin(colorIndex, 0x20, 0xFF)
|
||||
if isRegularColor {
|
||||
distance := palEntry.distanceTo(clrEntry)
|
||||
if distance < palDistance {
|
||||
palDistance = distance
|
||||
palIndex = byte(colorIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package bitmap
|
||||
|
||||
const (
|
||||
paletteMarker uint32 = 0x01000000
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
package bitmap
|
||||
|
||||
// Type describes the data layout of a bitmap.
|
||||
type Type byte
|
||||
|
||||
// Type constants are listed below.
|
||||
const (
|
||||
// TypeFlat8Bit bitmaps are 8-bit paletted bitmaps that have their pixel stored in a flat layout.
|
||||
TypeFlat8Bit Type = 2
|
||||
// TypeCompressed8Bit bitmaps are 8-bit paletted bitmaps that have their pixel compressed in storage.
|
||||
// Compression is using run-length-encoding (RLE); See package rle.
|
||||
TypeCompressed8Bit Type = 4
|
||||
)
|
||||
|
||||
// Flag adds further properties.
|
||||
type Flag uint16
|
||||
|
||||
// Flag constants are listed below.
|
||||
const (
|
||||
// FlagTransparent is set for bitmaps that shall treat palette index 0x00 as fully transparent.
|
||||
FlagTransparent Flag = 0x0001
|
||||
)
|
||||
|
||||
// HeaderSize is the size of the Header structure, in bytes.
|
||||
const HeaderSize = 28
|
||||
|
||||
// Area is a placeholder for either a rectangle, or an anchoring point (first two entries).
|
||||
type Area [4]int16
|
||||
|
||||
// Header contains the meta information for a bitmap.
|
||||
type Header struct {
|
||||
_ [4]byte
|
||||
Type Type
|
||||
_ byte
|
||||
Flags Flag
|
||||
Width int16
|
||||
Height int16
|
||||
Stride uint16
|
||||
WidthFactor byte
|
||||
HeightFactor byte
|
||||
Area Area
|
||||
PaletteOffset int32
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package bitmap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/color"
|
||||
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
)
|
||||
|
||||
// RGB is describing Red, Green, and Blue intensities with 8-bit resolution.
|
||||
type RGB struct {
|
||||
Red uint8
|
||||
Green uint8
|
||||
Blue uint8
|
||||
}
|
||||
|
||||
// Color returns the RGB data as a regular Color entry.
|
||||
func (col RGB) Color(alpha byte) color.Color {
|
||||
return color.RGBA{
|
||||
R: col.Red,
|
||||
G: col.Green,
|
||||
B: col.Blue,
|
||||
A: alpha,
|
||||
}
|
||||
}
|
||||
|
||||
// PaletteSize indicates how many colors a palette can hold with a byte index.
|
||||
const PaletteSize = 256
|
||||
|
||||
// Palette describes a list of colors to be used by bitmaps.
|
||||
// It is an array of 256 RGB values.
|
||||
type Palette [PaletteSize]RGB
|
||||
|
||||
// ColorPalette returns a palette usable for the image packages.
|
||||
func (pal Palette) ColorPalette(firstIndexTransparent bool) color.Palette {
|
||||
result := make(color.Palette, len(pal))
|
||||
for index, col := range pal {
|
||||
alpha := byte(0xFF)
|
||||
if (index == 0) && firstIndexTransparent {
|
||||
alpha = 0x00
|
||||
}
|
||||
result[index] = col.Color(alpha)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IndexClosestTo returns the index into this palette that matches the given color the closest.
|
||||
// This search excludes the provided indices.
|
||||
func (pal Palette) IndexClosestTo(rgb RGB, excluding []byte) byte {
|
||||
targetColor, _ := colorful.MakeColor(rgb.Color(0xFF))
|
||||
closestDist := float64(-1)
|
||||
closestIndex := byte(0)
|
||||
for index, other := range pal {
|
||||
if bytes.IndexByte(excluding, byte(index)) >= 0 {
|
||||
continue
|
||||
}
|
||||
otherColor, _ := colorful.MakeColor(other.Color(0xFF))
|
||||
dist := targetColor.DistanceLab(otherColor)
|
||||
if (closestDist < 0) || (dist < closestDist) {
|
||||
closestDist = dist
|
||||
closestIndex = byte(index)
|
||||
}
|
||||
}
|
||||
return closestIndex
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package bitmap
|
||||
|
||||
import (
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
// PaletteCache retrieves palettes from a localizer and keeps them decoded until they are invalidated.
|
||||
type PaletteCache struct {
|
||||
localizer resource.Localizer
|
||||
|
||||
defaultPalette Palette
|
||||
palettes map[resource.Key]Palette
|
||||
}
|
||||
|
||||
// NewPaletteCache returns a new instance.
|
||||
func NewPaletteCache(localizer resource.Localizer) *PaletteCache {
|
||||
cache := &PaletteCache{
|
||||
localizer: localizer,
|
||||
palettes: make(map[resource.Key]Palette),
|
||||
}
|
||||
for i := 0; i < len(cache.defaultPalette); i++ {
|
||||
col := &cache.defaultPalette[i]
|
||||
col.Red = uint8(i)
|
||||
col.Green = uint8(i)
|
||||
col.Blue = uint8(i)
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
// InvalidateResources lets the cache remove any palettes from resources that are specified in the given slice.
|
||||
func (cache *PaletteCache) InvalidateResources(ids []resource.ID) {
|
||||
for _, id := range ids {
|
||||
for key := range cache.palettes {
|
||||
if key.ID == id {
|
||||
delete(cache.palettes, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Palette tries to look up given palette.
|
||||
func (cache *PaletteCache) Palette(key resource.Key) (pal Palette, err error) {
|
||||
pal, existing := cache.palettes[key]
|
||||
if existing {
|
||||
return pal, nil
|
||||
}
|
||||
selector := cache.localizer.LocalizedResources(key.Lang)
|
||||
view, err := selector.Select(key.ID)
|
||||
if err != nil {
|
||||
return cache.defaultPalette, err
|
||||
}
|
||||
if (view.ContentType() != resource.Palette) || (view.BlockCount() != 1) {
|
||||
return cache.defaultPalette, resource.ErrWrongType(key, resource.Palette)
|
||||
}
|
||||
reader, err := view.Block(0)
|
||||
if err != nil {
|
||||
return cache.defaultPalette, err
|
||||
}
|
||||
decoder := serial.NewDecoder(reader)
|
||||
decoder.Code(&pal)
|
||||
if decoder.FirstError() != nil {
|
||||
return cache.defaultPalette, decoder.FirstError()
|
||||
}
|
||||
cache.palettes[key] = pal
|
||||
return pal, nil
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package bitmap_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
const (
|
||||
basePaletteResourceID = 0x02BC
|
||||
)
|
||||
|
||||
type PaletteCacheSuite struct {
|
||||
suite.Suite
|
||||
|
||||
localizedResources resource.LocalizedResourcesList
|
||||
|
||||
instance *bitmap.PaletteCache
|
||||
}
|
||||
|
||||
func TestPaletteCacheSuite(t *testing.T) {
|
||||
suite.Run(t, new(PaletteCacheSuite))
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) SetupTest() {
|
||||
suite.instance = nil
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) TestPaletteReturnsValueIfOK() {
|
||||
pal := suite.somePalette(0)
|
||||
suite.givenAnInstance()
|
||||
suite.whenResourcesAre(
|
||||
suite.someLocalizedResources(
|
||||
suite.storing(0, pal)))
|
||||
suite.thenPaletteShouldReturn(pal, 0)
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) TestPaletteReturnsErrorIfResourceNotExisting() {
|
||||
suite.givenAnInstance()
|
||||
suite.whenResourcesAre()
|
||||
suite.thenPaletteShouldReturnError(0)
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) TestTextTriesToReloadWhenCacheInvalidated() {
|
||||
suite.givenAnInstance()
|
||||
suite.givenResourcesAre(
|
||||
suite.someLocalizedResources(
|
||||
suite.storing(0, suite.somePalette(0))))
|
||||
suite.givenPaletteWasRetrieved(0)
|
||||
suite.givenResourcesAre()
|
||||
suite.whenCacheResourcesAreInvalidated(basePaletteResourceID)
|
||||
suite.thenPaletteShouldReturnError(0)
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) TestInvalidationConsidersOnlyAffectedIDs() {
|
||||
suite.givenAnInstance()
|
||||
suite.givenResourcesAre(
|
||||
suite.someLocalizedResources(
|
||||
suite.storing(0, suite.somePalette(0)), suite.storing(1, suite.somePalette(1))))
|
||||
suite.givenPaletteWasRetrieved(0)
|
||||
suite.givenResourcesAre()
|
||||
suite.whenCacheResourcesAreInvalidated(0x02BD)
|
||||
suite.thenPaletteShouldReturn(suite.somePalette(0), 0)
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) TestPaletteReturnsErrorIfResourceIsNotAPalette() {
|
||||
suite.givenAnInstance()
|
||||
suite.whenResourcesAre(suite.someLocalizedResources(suite.storingNonPalette(0)))
|
||||
suite.thenPaletteShouldReturnError(0)
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) givenAnInstance() {
|
||||
suite.instance = bitmap.NewPaletteCache(suite)
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) givenResourcesAre(resources ...resource.LocalizedResources) {
|
||||
suite.localizedResources = resources
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) givenPaletteWasRetrieved(index int) {
|
||||
_, err := suite.instance.Palette(suite.keyed(index))
|
||||
require.Nil(suite.T(), err, "No error expected setting up")
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) whenResourcesAre(resources ...resource.LocalizedResources) {
|
||||
suite.localizedResources = resources
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) whenCacheResourcesAreInvalidated(ids ...resource.ID) {
|
||||
suite.instance.InvalidateResources(ids)
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) thenPaletteShouldReturn(expected bitmap.Palette, index int) {
|
||||
result, err := suite.instance.Palette(suite.keyed(index))
|
||||
require.Nil(suite.T(), err, "No error expected")
|
||||
assert.Equal(suite.T(), expected, result, "Palettes don't match")
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) thenPaletteShouldReturnError(index int) {
|
||||
_, err := suite.instance.Palette(suite.keyed(index))
|
||||
require.NotNil(suite.T(), err, "Error expected")
|
||||
}
|
||||
|
||||
func (suite PaletteCacheSuite) keyed(index int) resource.Key {
|
||||
return resource.KeyOf(basePaletteResourceID, resource.LangAny, index)
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) someLocalizedResources(modifiers ...func(*resource.Store)) resource.LocalizedResources {
|
||||
var store resource.Store
|
||||
for _, modifier := range modifiers {
|
||||
modifier(&store)
|
||||
}
|
||||
return resource.LocalizedResources{
|
||||
ID: "unnamed",
|
||||
Language: resource.LangAny,
|
||||
Viewer: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) storing(id int, palette bitmap.Palette) func(*resource.Store) {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
encoder := serial.NewEncoder(buf)
|
||||
encoder.Code(palette)
|
||||
return func(store *resource.Store) {
|
||||
_ = store.Put(resource.ID(basePaletteResourceID).Plus(id), resource.Resource{
|
||||
Properties: resource.Properties{
|
||||
ContentType: resource.Palette,
|
||||
Compound: false,
|
||||
},
|
||||
Blocks: resource.BlocksFrom([][]byte{buf.Bytes()}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) somePalette(seed uint8) bitmap.Palette {
|
||||
var pal bitmap.Palette
|
||||
for i := 0; i < len(pal); i++ {
|
||||
pal[i].Red = seed + uint8(i)
|
||||
pal[i].Green = seed + uint8(255-i)
|
||||
pal[i].Blue = seed + uint8(i*2)
|
||||
}
|
||||
return pal
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) storingNonPalette(id int) func(*resource.Store) {
|
||||
return func(store *resource.Store) {
|
||||
_ = store.Put(resource.ID(basePaletteResourceID).Plus(id), resource.Resource{
|
||||
Properties: resource.Properties{
|
||||
ContentType: resource.Sound,
|
||||
Compound: false,
|
||||
},
|
||||
Blocks: resource.BlocksFrom([][]byte{{}}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *PaletteCacheSuite) LocalizedResources(lang resource.Language) resource.Selector {
|
||||
return resource.Selector{
|
||||
From: suite.localizedResources,
|
||||
Lang: lang,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user