Adding modding folder with a hacked build.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
package resource
|
||||
|
||||
import "io"
|
||||
|
||||
// BlockProvider are capable of returning block data of a resource.
|
||||
type BlockProvider interface {
|
||||
// BlockCount returns the number of available blocks in the resource.
|
||||
// Simple resources will always have exactly one block.
|
||||
BlockCount() int
|
||||
|
||||
// Block returns the reader for the identified block.
|
||||
// Each call returns a new reader instance.
|
||||
// Data provided by this reader is always uncompressed.
|
||||
Block(index int) (io.Reader, error)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Block is one set of bytes stored in a resource.
|
||||
// The interpretation of the bytes is dependent on the content type of the resource.
|
||||
type Block []byte
|
||||
|
||||
// Blocks is a list of blocks in memory.
|
||||
type Blocks struct {
|
||||
data [][]byte
|
||||
}
|
||||
|
||||
// BlocksFrom returns a blocks instance from given data.
|
||||
func BlocksFrom(data [][]byte) Blocks {
|
||||
return Blocks{data: data}
|
||||
}
|
||||
|
||||
// BlockCount returns the number of available blocks.
|
||||
func (blocks Blocks) BlockCount() int {
|
||||
return len(blocks.data)
|
||||
}
|
||||
|
||||
// BlockRaw returns the raw byte slice stored in the identified block.
|
||||
func (blocks Blocks) BlockRaw(index int) ([]byte, error) {
|
||||
available := len(blocks.data)
|
||||
if (index < 0) || (index >= available) {
|
||||
return nil, ErrBlockNotFound(index, available)
|
||||
}
|
||||
return blocks.data[index], nil
|
||||
}
|
||||
|
||||
// Block returns the reader for the identified block.
|
||||
// Each call returns a new reader instance.
|
||||
func (blocks Blocks) Block(index int) (io.Reader, error) {
|
||||
raw, err := blocks.BlockRaw(index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.NewBuffer(raw), nil
|
||||
}
|
||||
|
||||
// Set the data of all the blocks.
|
||||
func (blocks *Blocks) Set(data [][]byte) {
|
||||
blocks.data = data
|
||||
}
|
||||
|
||||
// SetBlock sets the data of the identified block.
|
||||
func (blocks *Blocks) SetBlock(index int, data []byte) {
|
||||
if index < 0 {
|
||||
return
|
||||
}
|
||||
if index >= len(blocks.data) {
|
||||
newData := make([][]byte, index+1)
|
||||
copy(newData, blocks.data)
|
||||
blocks.data = newData
|
||||
}
|
||||
blocks.data[index] = data
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package resource_test
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBlockCountReturnsZeroForNilInstance(t *testing.T) {
|
||||
var blocks resource.Blocks
|
||||
assert.Equal(t, 0, blocks.BlockCount())
|
||||
}
|
||||
|
||||
func TestBlockCountReturnsLengthOfArray(t *testing.T) {
|
||||
blocks := resource.BlocksFrom(make([][]byte, 3))
|
||||
assert.Equal(t, 3, blocks.BlockCount())
|
||||
}
|
||||
|
||||
func TestBlockReturnsArrayEntries(t *testing.T) {
|
||||
blocks := resource.BlocksFrom([][]byte{{0x01}, {0x02, 0x03}})
|
||||
verifyBlock := func(index int) {
|
||||
reader, err := blocks.Block(index)
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, reader)
|
||||
}
|
||||
verifyBlock(0)
|
||||
verifyBlock(1)
|
||||
}
|
||||
|
||||
func TestBlockReturnsErrorForInvalidIndex(t *testing.T) {
|
||||
blocks := resource.BlocksFrom([][]byte{{0x01}, {0x02, 0x03}})
|
||||
verifyError := func(index int) {
|
||||
_, err := blocks.Block(index)
|
||||
assert.NotNil(t, err, "Error expected for index ", index)
|
||||
}
|
||||
verifyError(-1)
|
||||
verifyError(2)
|
||||
verifyError(3)
|
||||
}
|
||||
|
||||
func TestBlockSetting(t *testing.T) {
|
||||
var blocks resource.Blocks
|
||||
|
||||
blocks.Set(make([][]byte, 3))
|
||||
assert.Equal(t, 3, blocks.BlockCount(), "block count should have been set")
|
||||
|
||||
blocks.SetBlock(1, []byte{0x01, 0x02})
|
||||
block, err := blocks.Block(1)
|
||||
assert.Nil(t, err, "block should be known")
|
||||
storedData, err := ioutil.ReadAll(block)
|
||||
assert.Nil(t, err, "data should be read")
|
||||
assert.Equal(t, []byte{0x01, 0x02}, storedData, "stored data mismatch")
|
||||
|
||||
blocks.SetBlock(5, []byte{0xAA})
|
||||
assert.Equal(t, 6, blocks.BlockCount(), "block count should have been updated")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5" // nolint: gas
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type resourceHash []byte
|
||||
type resourceHashes map[ID]resourceHash
|
||||
type resourceHashSnapshot map[Language]resourceHashes
|
||||
|
||||
// ModificationCallback is a callback function to notify a change in resources.
|
||||
type ModificationCallback func(modifiedIDs []ID, failedIDs []ID)
|
||||
|
||||
// Localizer provides selectors to resources for specific languages.
|
||||
type Localizer interface {
|
||||
LocalizedResources(lang Language) Selector
|
||||
}
|
||||
|
||||
// ChangeNotifier is a utility that assists in detecting changes in modifying resources.
|
||||
// A callback is called for any resource identifier that refers to resources that are different after a modification.
|
||||
//
|
||||
// Use this utility in combination to resource lists where resources can be overwritten by other entries.
|
||||
// Changes in order, or content, affects how a resource is resolved.
|
||||
type ChangeNotifier struct {
|
||||
Localizer Localizer
|
||||
Callback ModificationCallback
|
||||
}
|
||||
|
||||
// ModifyAndNotify must be called with a range of affected IDs that will change during the call of the modifier.
|
||||
// A hash snapshot is taken before and after the modifier, considering the affected IDs.
|
||||
// Any change is then reported to the callback, listing all IDs that have different hashes.
|
||||
//
|
||||
// Hashing the resources considers all languages, as well as the meta-information of the resources.
|
||||
func (notifier ChangeNotifier) ModifyAndNotify(modifier func(), affectedIDs ...[]ID) {
|
||||
var flattenedIDs []ID
|
||||
for _, idList := range affectedIDs {
|
||||
flattenedIDs = append(flattenedIDs, idList...)
|
||||
}
|
||||
oldHashes, _ := notifier.hashAll(flattenedIDs)
|
||||
modifier()
|
||||
newHashes, failedIDs := notifier.hashAll(flattenedIDs)
|
||||
modifiedResourceIDs := notifier.modifiedIDs(oldHashes, newHashes)
|
||||
notifier.Callback(modifiedResourceIDs, failedIDs)
|
||||
}
|
||||
|
||||
func (notifier ChangeNotifier) hashAll(ids []ID) (hashes resourceHashSnapshot, failedIDs []ID) {
|
||||
var failedMap IDMarkerMap
|
||||
hashes = make(resourceHashSnapshot)
|
||||
var selectMutex sync.Mutex
|
||||
var mapMutex sync.Mutex
|
||||
for _, lang := range Languages() {
|
||||
hashByResourceID := make(resourceHashes)
|
||||
hashes[lang] = hashByResourceID
|
||||
selector := notifier.Localizer.LocalizedResources(lang)
|
||||
selectorFunc := func(id ID) (View, error) {
|
||||
selectMutex.Lock()
|
||||
defer selectMutex.Unlock()
|
||||
return selector.Select(id)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, id := range ids {
|
||||
wg.Add(1)
|
||||
go func(id ID) {
|
||||
defer wg.Done()
|
||||
hash, hashErr := notifier.hashResource(selectorFunc, id)
|
||||
mapMutex.Lock()
|
||||
defer mapMutex.Unlock()
|
||||
if hashErr == nil {
|
||||
hashByResourceID[id] = hash
|
||||
} else {
|
||||
failedMap.Add(id)
|
||||
}
|
||||
}(id)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
failedIDs = failedMap.ToList()
|
||||
return
|
||||
}
|
||||
|
||||
func (notifier ChangeNotifier) hashResource(selector func(ID) (View, error), id ID) (resourceHash, error) {
|
||||
view, viewErr := selector(id)
|
||||
if viewErr != nil {
|
||||
return nil, viewErr
|
||||
}
|
||||
hasher := md5.New() // nolint: gas
|
||||
blocks := view.BlockCount()
|
||||
for index := 0; index < blocks; index++ {
|
||||
blockReader, blockErr := view.Block(index)
|
||||
if blockErr != nil {
|
||||
return nil, blockErr
|
||||
}
|
||||
written, hashErr := io.Copy(hasher, blockReader)
|
||||
if hashErr != nil {
|
||||
return nil, hashErr
|
||||
}
|
||||
binary.Write(hasher, binary.LittleEndian, &written) // nolint: errcheck
|
||||
}
|
||||
binary.Write(hasher, binary.LittleEndian, int64(blocks)) // nolint: errcheck
|
||||
binary.Write(hasher, binary.LittleEndian, view.Compound()) // nolint: errcheck
|
||||
binary.Write(hasher, binary.LittleEndian, view.ContentType()) // nolint: errcheck
|
||||
binary.Write(hasher, binary.LittleEndian, view.Compressed()) // nolint: errcheck
|
||||
|
||||
return hasher.Sum(nil), nil
|
||||
}
|
||||
|
||||
func (notifier ChangeNotifier) modifiedIDs(oldHashes resourceHashSnapshot, newHashes resourceHashSnapshot) []ID {
|
||||
var modifiedIDs IDMarkerMap
|
||||
for _, lang := range Languages() {
|
||||
oldHashesByResourceID := oldHashes[lang]
|
||||
newHashesByResourceID := newHashes[lang]
|
||||
|
||||
for newID, newHash := range newHashesByResourceID {
|
||||
oldHash, oldExisting := oldHashesByResourceID[newID]
|
||||
if !oldExisting || !bytes.Equal(newHash, oldHash) {
|
||||
modifiedIDs.Add(newID)
|
||||
}
|
||||
}
|
||||
for oldID := range oldHashesByResourceID {
|
||||
if _, newExisting := newHashesByResourceID[oldID]; !newExisting {
|
||||
modifiedIDs.Add(oldID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return modifiedIDs.ToList()
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package resource_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type ChangeNotifierSuite struct {
|
||||
suite.Suite
|
||||
|
||||
lastModifiedIDs []resource.ID
|
||||
lastFailedIDs []resource.ID
|
||||
|
||||
localizedResources resource.LocalizedResourcesList
|
||||
|
||||
instance resource.ChangeNotifier
|
||||
}
|
||||
|
||||
func TestResourceChangeNotifierSuite(t *testing.T) {
|
||||
suite.Run(t, new(ChangeNotifierSuite))
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) SetupTest() {
|
||||
suite.lastModifiedIDs = nil
|
||||
suite.lastFailedIDs = nil
|
||||
|
||||
suite.instance.Localizer = suite
|
||||
suite.instance.Callback = suite.onResourcesModified
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) TestNoIDsAreNotifiedIfDataNotChanged() {
|
||||
suite.givenListIsMadeOf(
|
||||
suite.someLocalizedResources(resource.LangGerman,
|
||||
suite.storing(0x1000, []byte{0x01})))
|
||||
suite.whenResourcesAreModified(func() {}, 0x1000)
|
||||
suite.thenModifiedIDsShouldBe()
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) TestIDsAreNotifiedOfNewData() {
|
||||
suite.givenListIsMadeOf(
|
||||
suite.someLocalizedResources(resource.LangGerman,
|
||||
suite.storing(0x1000, []byte{0x01})))
|
||||
suite.whenResourcesAreModified(func() {
|
||||
suite.extendingListWith(
|
||||
suite.someLocalizedResources(resource.LangGerman,
|
||||
suite.storing(0x2000, []byte{0x01})))
|
||||
}, 0x2000)
|
||||
suite.thenModifiedIDsShouldBe(0x2000)
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) TestIDsAreNotifiedOfRemovedData() {
|
||||
suite.givenListIsMadeOf(
|
||||
suite.someLocalizedResources(resource.LangGerman,
|
||||
suite.storing(0x1000, []byte{0x01})))
|
||||
suite.whenResourcesAreModified(func() {
|
||||
suite.listIsMadeOf()
|
||||
}, 0x1000)
|
||||
suite.thenModifiedIDsShouldBe(0x1000)
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) TestIDsAreNotifiedOfChangedData() {
|
||||
suite.givenListIsMadeOf(
|
||||
suite.someLocalizedResources(resource.LangAny,
|
||||
suite.storing(0x1000, []byte{0x01})))
|
||||
suite.whenResourcesAreModified(func() {
|
||||
suite.listIsMadeOf(
|
||||
suite.someLocalizedResources(resource.LangAny,
|
||||
suite.storing(0x1000, []byte{0x02})))
|
||||
}, 0x1000)
|
||||
suite.thenModifiedIDsShouldBe(0x1000)
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) givenListIsMadeOf(resources ...resource.LocalizedResources) {
|
||||
suite.localizedResources = resources
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) extendingListWith(resources ...resource.LocalizedResources) {
|
||||
suite.localizedResources = append(suite.localizedResources, resources...)
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) listIsMadeOf(resources ...resource.LocalizedResources) {
|
||||
suite.localizedResources = resources
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) whenResourcesAreModified(modifier func(), affectedIDs ...int) {
|
||||
convertedIDs := make([]resource.ID, len(affectedIDs))
|
||||
for i, id := range affectedIDs {
|
||||
convertedIDs[i] = resource.ID(id)
|
||||
}
|
||||
suite.instance.ModifyAndNotify(modifier, convertedIDs)
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) thenModifiedIDsShouldBe(expected ...int) {
|
||||
convertedIDs := make([]resource.ID, len(expected))
|
||||
for i, id := range expected {
|
||||
convertedIDs[i] = resource.ID(id)
|
||||
}
|
||||
|
||||
assert.Equal(suite.T(), convertedIDs, suite.lastModifiedIDs, "IDs don't match")
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) someLocalizedResources(lang resource.Language, modifiers ...func(*resource.Store)) resource.LocalizedResources {
|
||||
var store resource.Store
|
||||
for _, modifier := range modifiers {
|
||||
modifier(&store)
|
||||
}
|
||||
return resource.LocalizedResources{
|
||||
ID: "unnamed",
|
||||
Language: lang,
|
||||
Viewer: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) storing(id int, data ...[]byte) func(*resource.Store) {
|
||||
return func(store *resource.Store) {
|
||||
_ = store.Put(resource.ID(id), resource.Resource{
|
||||
Blocks: resource.BlocksFrom(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) LocalizedResources(lang resource.Language) resource.Selector {
|
||||
return resource.Selector{
|
||||
From: suite.localizedResources,
|
||||
Lang: lang,
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ChangeNotifierSuite) onResourcesModified(modifiedIDs []resource.ID, failedIDs []resource.ID) {
|
||||
suite.lastModifiedIDs = modifiedIDs
|
||||
suite.lastFailedIDs = failedIDs
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package resource
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ContentType identifies how resource data shall be interpreted.
|
||||
type ContentType byte
|
||||
|
||||
var contentTypeNames = map[ContentType]string{
|
||||
Palette: "Palette",
|
||||
Text: "Text",
|
||||
Bitmap: "Bitmap",
|
||||
Font: "Font",
|
||||
Animation: "Animation",
|
||||
Sound: "Sound",
|
||||
Geometry: "Geometry",
|
||||
Movie: "Movie",
|
||||
Archive: "Archive",
|
||||
}
|
||||
|
||||
// String returns the textual representation of the type.
|
||||
func (t ContentType) String() string {
|
||||
s, existing := contentTypeNames[t]
|
||||
if existing {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("Unknown%02X", int(t))
|
||||
}
|
||||
|
||||
const (
|
||||
// Palette refers to color tables.
|
||||
Palette = ContentType(0x00)
|
||||
// Text refers to texts.
|
||||
Text = ContentType(0x01)
|
||||
// Bitmap refers to images.
|
||||
Bitmap = ContentType(0x02)
|
||||
// Font refers to font descriptions.
|
||||
Font = ContentType(0x03)
|
||||
// Animation refers to graphical animations.
|
||||
Animation = ContentType(0x04)
|
||||
// Sound refers to audio samples.
|
||||
Sound = ContentType(0x07)
|
||||
// Geometry refers to 3D models.
|
||||
Geometry = ContentType(0x0F)
|
||||
// Movie refers to audio logs and cutscenes.
|
||||
Movie = ContentType(0x11)
|
||||
// Archive refers to archive data.
|
||||
Archive = ContentType(0x30)
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
package resource_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
)
|
||||
|
||||
func TestContentTypeString(t *testing.T) {
|
||||
tt := []struct {
|
||||
contentType resource.ContentType
|
||||
expected string
|
||||
}{
|
||||
{resource.Palette, "Palette"},
|
||||
{resource.Text, "Text"},
|
||||
{resource.Bitmap, "Bitmap"},
|
||||
{resource.Font, "Font"},
|
||||
{resource.Animation, "Animation"},
|
||||
{resource.Sound, "Sound"},
|
||||
{resource.Geometry, "Geometry"},
|
||||
{resource.Movie, "Movie"},
|
||||
{resource.Archive, "Archive"},
|
||||
{resource.ContentType(254), "UnknownFE"},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
result := tc.contentType.String()
|
||||
assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.contentType)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package resource
|
||||
|
||||
import "fmt"
|
||||
|
||||
// NotFoundError indicates the unknown ID.
|
||||
type NotFoundError struct {
|
||||
ID ID
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (err NotFoundError) Error() string {
|
||||
return fmt.Sprintf("resource with ID %v does not exist", err.ID)
|
||||
}
|
||||
|
||||
// ErrNotFound returns an error specifying the given ID doesn't
|
||||
// have an associated resource.
|
||||
func ErrNotFound(id ID) error {
|
||||
return NotFoundError{ID: id}
|
||||
}
|
||||
|
||||
// BlockNotFoundError indicates the unknown block index.
|
||||
type BlockNotFoundError struct {
|
||||
Index int
|
||||
Available int
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (err BlockNotFoundError) Error() string {
|
||||
return fmt.Sprintf("block index wrong: %v/%v", err.Index, err.Available)
|
||||
}
|
||||
|
||||
// ErrBlockNotFound returns an error specifying the given index does not identify a valid block.
|
||||
func ErrBlockNotFound(index, available int) error {
|
||||
return BlockNotFoundError{Index: index, Available: available}
|
||||
}
|
||||
|
||||
// WrongTypeError indicates a resource of unexpected type.
|
||||
type WrongTypeError struct {
|
||||
Key Key
|
||||
Expected ContentType
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (err WrongTypeError) Error() string {
|
||||
return fmt.Sprintf("resource %v does not have expected type %v", err.Key, err.Expected)
|
||||
}
|
||||
|
||||
// ErrWrongType returns an error specifying a resource was of unexpected type.
|
||||
func ErrWrongType(key Key, expected ContentType) error {
|
||||
return WrongTypeError{Key: key, Expected: expected}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ID represents an integer key of resources.
|
||||
type ID uint16
|
||||
|
||||
// Value returns the numerical value of the identifier.
|
||||
func (id ID) Value() uint16 {
|
||||
return uint16(id)
|
||||
}
|
||||
|
||||
// Plus adds the given offset and returns the resulting ID.
|
||||
func (id ID) Plus(offset int) ID {
|
||||
return ID(int(id) + offset)
|
||||
}
|
||||
|
||||
// String returns the textual representation.
|
||||
func (id ID) String() string {
|
||||
return fmt.Sprintf("%04X", uint16(id))
|
||||
}
|
||||
|
||||
// IDMarkerMap is used to collect IDs.
|
||||
type IDMarkerMap struct {
|
||||
ids map[ID]struct{}
|
||||
}
|
||||
|
||||
// Add adds the given ID to the map.
|
||||
func (marker *IDMarkerMap) Add(id ID) {
|
||||
if marker.ids == nil {
|
||||
marker.ids = make(map[ID]struct{})
|
||||
}
|
||||
marker.ids[id] = struct{}{}
|
||||
}
|
||||
|
||||
// ToList converts the map to a de-duplicated list.
|
||||
func (marker IDMarkerMap) ToList() []ID {
|
||||
result := make([]ID, 0, len(marker.ids))
|
||||
for id := range marker.ids {
|
||||
result = append(result, id)
|
||||
}
|
||||
sort.Slice(result, func(a, b int) bool { return result[a] < result[b] })
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package resource_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIDValueReturnsOwnValue(t *testing.T) {
|
||||
assert.Equal(t, uint16(0), resource.ID(0).Value())
|
||||
assert.Equal(t, uint16(123), resource.ID(123).Value())
|
||||
assert.Equal(t, uint16(math.MaxUint16), resource.ID(math.MaxUint16).Value(), "maximum of uint16 should be supported")
|
||||
}
|
||||
|
||||
func TestIDImplementsStringer(t *testing.T) {
|
||||
assert.Equal(t, "0FA0", fmt.Sprintf("%v", resource.ID(0x0FA0)))
|
||||
}
|
||||
|
||||
func TestIDPlusReturnsOffsetValue(t *testing.T) {
|
||||
tt := []struct {
|
||||
base resource.ID
|
||||
offset int
|
||||
expected resource.ID
|
||||
}{
|
||||
{0, 0, 0},
|
||||
{10, 0, 10},
|
||||
{10, 2, 12},
|
||||
{10, -2, 8},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
result := tc.base.Plus(tc.offset)
|
||||
assert.Equal(t, tc.expected, result, "ID mismatch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package resource
|
||||
|
||||
// Key identifies a particular resource out of a combination of a resource ID, a language, and a block index.
|
||||
//
|
||||
// Keys are used for retrieving particular content that is stored in resources.
|
||||
// Not all resources contain only one atomic entity.
|
||||
type Key struct {
|
||||
// ID identifies the resource.
|
||||
ID ID
|
||||
// Lang localizes the resource, should the actual content be localized.
|
||||
Lang Language
|
||||
// Index specifies the exact entry within a resource.
|
||||
Index int
|
||||
}
|
||||
|
||||
// KeyOf is a factory function for creating a Key instance.
|
||||
// It is done to avoid getting warnings when trying to do a similar construction via Key{id, lang, index}.
|
||||
func KeyOf(id ID, lang Language, index int) Key {
|
||||
return Key{ID: id, Lang: lang, Index: index}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package resource_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestKeyOfReturnsKey(t *testing.T) {
|
||||
assert.Equal(t, resource.Key{ID: 0x1000, Lang: resource.LangGerman, Index: 123}, resource.KeyOf(0x1000, resource.LangGerman, 123))
|
||||
assert.Equal(t, resource.Key{ID: 0xA000, Lang: resource.LangAny, Index: 0}, resource.KeyOf(0xA000, resource.LangAny, 0))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package resource
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Language defines the human language of a resource.
|
||||
type Language byte
|
||||
|
||||
const (
|
||||
// LangAny identifies language agnostic resources.
|
||||
LangAny Language = 0xFF
|
||||
// LangDefault identifies the default language, typically English - unless modded.
|
||||
LangDefault Language = 0
|
||||
// LangFrench identifies the French language.
|
||||
LangFrench Language = 1
|
||||
// LangGerman identifies the German language.
|
||||
LangGerman Language = 2
|
||||
|
||||
// LanguageCount specifies how many languages are supported.
|
||||
LanguageCount = 3
|
||||
)
|
||||
|
||||
func (lang Language) String() string {
|
||||
switch lang {
|
||||
case LangAny:
|
||||
return "Any"
|
||||
case LangDefault:
|
||||
return "Default"
|
||||
case LangFrench:
|
||||
return "French"
|
||||
case LangGerman:
|
||||
return "German"
|
||||
default:
|
||||
return fmt.Sprintf("Unknown%02X", int(lang))
|
||||
}
|
||||
}
|
||||
|
||||
// Languages returns a slice of all human languages. Does not include "Any" selector.
|
||||
func Languages() []Language {
|
||||
return []Language{LangDefault, LangFrench, LangGerman}
|
||||
}
|
||||
|
||||
// Includes returns true if the language includes the provided one.
|
||||
// This is not symmetrical. While "Any" includes "German", "German" does not include "Any".
|
||||
func (lang Language) Includes(other Language) bool {
|
||||
return (lang == LangAny) || (lang == other)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package resource_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
)
|
||||
|
||||
func TestLanguageIncludes(t *testing.T) {
|
||||
tt := []struct {
|
||||
primary resource.Language
|
||||
secondary resource.Language
|
||||
expected bool
|
||||
}{
|
||||
{resource.LangAny, resource.LangAny, true},
|
||||
{resource.LangAny, resource.LangGerman, true},
|
||||
{resource.LangFrench, resource.LangAny, false},
|
||||
{resource.LangGerman, resource.LangFrench, false},
|
||||
{resource.LangFrench, resource.LangFrench, true},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
td := tc
|
||||
t.Run(fmt.Sprintf("Expecting %v including %v should be %v", td.primary, td.secondary, td.expected), func(t *testing.T) {
|
||||
result := td.primary.Includes(td.secondary)
|
||||
assert.Equal(t, td.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageString(t *testing.T) {
|
||||
tt := []struct {
|
||||
lang resource.Language
|
||||
expected string
|
||||
}{
|
||||
{resource.LangAny, "Any"},
|
||||
{resource.LangDefault, "Default"},
|
||||
{resource.LangFrench, "French"},
|
||||
{resource.LangGerman, "German"},
|
||||
{resource.Language(0x40), "Unknown40"},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
td := tc
|
||||
t.Run(td.expected, func(t *testing.T) {
|
||||
result := td.lang.String()
|
||||
assert.Equal(t, td.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguages(t *testing.T) {
|
||||
result := resource.Languages()
|
||||
assert.Equal(t, 3, len(result))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package resource
|
||||
|
||||
// List is a helper type for slices of resources.
|
||||
// Although the length of the list is immutable, the content and the fields are not.
|
||||
// Meaning, modifying the cells of one list may affect the cells of others.
|
||||
type List []View
|
||||
|
||||
// With returns a new list with the provided resource appended at the end.
|
||||
func (list List) With(res View) List {
|
||||
return append(list, res)
|
||||
}
|
||||
|
||||
// Joined returns a new list which is the combination of this list and the provided one.
|
||||
func (list List) Joined(other List) List {
|
||||
otherLen := len(other)
|
||||
if otherLen == 0 {
|
||||
return list
|
||||
}
|
||||
listLen := len(list)
|
||||
newList := make([]View, listLen+otherLen)
|
||||
copy(newList[:listLen], list)
|
||||
copy(newList[listLen:], other)
|
||||
return newList
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
)
|
||||
|
||||
type listMerger struct {
|
||||
list List
|
||||
}
|
||||
|
||||
// Compound indicates whether the resource is a compound one, which can contain zero, one, or more blocks.
|
||||
// Simple resources always contain exactly one block.
|
||||
func (view listMerger) Compound() bool {
|
||||
return view.list[0].Compound()
|
||||
}
|
||||
|
||||
// ContentType specifies how the data in the resource shall be interpreted.
|
||||
func (view listMerger) ContentType() ContentType {
|
||||
return view.list[0].ContentType()
|
||||
}
|
||||
|
||||
// Compressed indicates whether the data should be compressed when serialized.
|
||||
func (view listMerger) Compressed() bool {
|
||||
return view.list[0].Compressed()
|
||||
}
|
||||
|
||||
// BlockCount returns the maximum count of all resources.
|
||||
func (view listMerger) BlockCount() (max int) {
|
||||
for _, layer := range view.list {
|
||||
count := layer.BlockCount()
|
||||
if max < count {
|
||||
max = count
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Block returns a reader for the identified block in the resource.
|
||||
// The view returns the block from the first resource that has a non-empty block.
|
||||
// The resources are checked from last-to-first.
|
||||
func (view listMerger) Block(index int) (reader io.Reader, err error) {
|
||||
blockCount := view.BlockCount()
|
||||
if (index < 0) || (index >= blockCount) {
|
||||
return nil, ErrBlockNotFound(index, blockCount)
|
||||
}
|
||||
|
||||
for layer := len(view.list) - 1; (layer >= 0) && (reader == nil); layer-- {
|
||||
tempReader, tempErr := view.list[layer].Block(index)
|
||||
if tempErr == nil {
|
||||
var buf [1]byte
|
||||
read, _ := tempReader.Read(buf[:])
|
||||
if read == 1 {
|
||||
reader, err = view.list[layer].Block(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reader == nil) && (err == nil) {
|
||||
reader = bytes.NewBuffer(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package resource_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func someResource(key byte) resource.View {
|
||||
return resource.Resource{
|
||||
Blocks: resource.BlocksFrom([][]byte{{key}}),
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWith(t *testing.T) {
|
||||
t.Run("nil list", func(t *testing.T) {
|
||||
var base resource.List
|
||||
result := base.With(someResource(0x01))
|
||||
assert.Equal(t, resource.List([]resource.View{someResource(0x01)}), result)
|
||||
})
|
||||
|
||||
t.Run("append", func(t *testing.T) {
|
||||
base := resource.List([]resource.View{someResource(0x01)})
|
||||
result := base.With(someResource(0x03))
|
||||
assert.Equal(t, resource.List([]resource.View{someResource(0x01), someResource(0x03)}), result)
|
||||
})
|
||||
|
||||
t.Run("immutable source", func(t *testing.T) {
|
||||
raw := make([]resource.View, 1, 2)
|
||||
raw[0] = someResource(0x02)
|
||||
base := resource.List(raw)
|
||||
result := base.With(someResource(0x04))
|
||||
assert.Equal(t, 2, len(result))
|
||||
assert.Equal(t, resource.List([]resource.View{someResource(0x02)}), base)
|
||||
})
|
||||
}
|
||||
|
||||
func TestListJoined(t *testing.T) {
|
||||
var empty resource.List
|
||||
t.Run("two lists", func(t *testing.T) {
|
||||
base := empty.With(someResource(0x01)).With(someResource(0x02))
|
||||
other := empty.With(someResource(0x03)).With(someResource(0x04))
|
||||
result := base.Joined(other)
|
||||
assert.Equal(t, resource.List([]resource.View{someResource(0x01), someResource(0x02), someResource(0x03), someResource(0x04)}), result)
|
||||
})
|
||||
t.Run("nil with nil is nil", func(t *testing.T) {
|
||||
result := empty.Joined(empty)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package resource
|
||||
|
||||
// LocalizedResources associates a language with a resource viewer under a specific identifier.
|
||||
type LocalizedResources struct {
|
||||
// ID is the identifier of the viewer. This could be a filename for instance.
|
||||
ID string
|
||||
// Language specifies for which language the viewer has resources.
|
||||
Language Language
|
||||
// Viewer is the actual container of the resources.
|
||||
Viewer Viewer
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package resource
|
||||
|
||||
// LocalizedResourcesList is a collection of localized resources.
|
||||
// it exists to provide a typical implementation of a Selector.
|
||||
type LocalizedResourcesList []LocalizedResources
|
||||
|
||||
// Filter returns all resources that match the given parameters.
|
||||
func (list LocalizedResourcesList) Filter(lang Language, id ID) List {
|
||||
var result List
|
||||
for _, localized := range list {
|
||||
if localized.Language.Includes(lang) {
|
||||
if res, err := localized.Viewer.View(id); err == nil {
|
||||
result = result.With(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package resource
|
||||
|
||||
// Properties describe the meta information about a resource.
|
||||
type Properties struct {
|
||||
// Compound tells whether the resource should be serialized with a directory.
|
||||
// Compound resources can have zero, one, or more blocks.
|
||||
// Simple resources always have exactly one block.
|
||||
Compound bool
|
||||
|
||||
// ContentType describes how the block data shall be interpreted.
|
||||
ContentType ContentType
|
||||
|
||||
// Compressed tells whether the data shall be serialized in compressed form.
|
||||
Compressed bool
|
||||
}
|
||||
|
||||
// Resource provides meta information as well as access to its contained blocks.
|
||||
type Resource struct {
|
||||
Properties
|
||||
Blocks
|
||||
}
|
||||
|
||||
// Compound tells whether the resource should be serialized with a directory.
|
||||
// Compound resources can have zero, one, or more blocks.
|
||||
// Simple resources always have exactly one block.
|
||||
func (res Resource) Compound() bool {
|
||||
return res.Properties.Compound
|
||||
}
|
||||
|
||||
// ContentType describes how the block data shall be interpreted.
|
||||
func (res Resource) ContentType() ContentType {
|
||||
return res.Properties.ContentType
|
||||
}
|
||||
|
||||
// Compressed tells whether the data shall be serialized in compressed form.
|
||||
func (res Resource) Compressed() bool {
|
||||
return res.Properties.Compressed
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package resource_test
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
)
|
||||
|
||||
func verifyBlockContent(t *testing.T, provider resource.BlockProvider, index int, expected []byte) {
|
||||
t.Helper()
|
||||
reader, err := provider.Block(index)
|
||||
assert.Nil(t, err, "No error expected for index %d", index)
|
||||
assert.NotNil(t, reader, "Reader expected for index %d", index)
|
||||
if reader != nil {
|
||||
data, dataErr := ioutil.ReadAll(reader)
|
||||
assert.Nil(t, dataErr, "No error expected reading data from index %d", index)
|
||||
assert.Equal(t, expected, data, "Proper data expected from index %d", index)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyBlockError(t *testing.T, provider resource.BlockProvider, index int) {
|
||||
t.Helper()
|
||||
_, err := provider.Block(index)
|
||||
assert.NotNil(t, err, "Error expected for index %d", index)
|
||||
}
|
||||
|
||||
func TestResourceRefersToBlockProviderByDefault(t *testing.T) {
|
||||
res := &resource.Resource{Blocks: resource.BlocksFrom([][]byte{{0x01}, {0x02, 0x02}})}
|
||||
|
||||
assert.Equal(t, 2, res.BlockCount())
|
||||
verifyBlockContent(t, res, 0, []byte{0x01})
|
||||
verifyBlockContent(t, res, 1, []byte{0x02, 0x02})
|
||||
}
|
||||
|
||||
func TestResourceBlockReturnsErrorOnInvalidIndex(t *testing.T) {
|
||||
res := &resource.Resource{Blocks: resource.BlocksFrom(nil)}
|
||||
|
||||
verifyBlockError(t, res, -1)
|
||||
verifyBlockError(t, res, 0)
|
||||
verifyBlockError(t, res, 1)
|
||||
verifyBlockError(t, res, 2)
|
||||
}
|
||||
|
||||
func TestResourceBlockReturnsErrorForDefaultObject(t *testing.T) {
|
||||
var res resource.Resource
|
||||
|
||||
assert.Equal(t, 0, res.BlockCount())
|
||||
verifyBlockError(t, res, -1)
|
||||
verifyBlockError(t, res, 0)
|
||||
verifyBlockError(t, res, 1)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package resource
|
||||
|
||||
// Filter filters for language and id to produce a list of matching resources.
|
||||
type Filter interface {
|
||||
Filter(lang Language, id ID) List
|
||||
}
|
||||
|
||||
// Selector provides a merged view of resources according to a language.
|
||||
type Selector struct {
|
||||
// Lang specifies the language to filter by.
|
||||
Lang Language
|
||||
|
||||
// From specifies from where the resources shall be taken.
|
||||
From Filter
|
||||
|
||||
// As defines how the found resources should be viewed in case more than one matches.
|
||||
// By default, the last resource will be used.
|
||||
As ViewStrategy
|
||||
}
|
||||
|
||||
// Select provides a collected view on one resource.
|
||||
func (merger Selector) Select(id ID) (view View, err error) {
|
||||
list := merger.From.Filter(merger.Lang, id)
|
||||
if len(list) == 0 {
|
||||
return nil, ErrNotFound(id)
|
||||
}
|
||||
if (merger.As == nil) || !merger.As.IsCompoundList(id) {
|
||||
view = list[len(list)-1]
|
||||
} else {
|
||||
view = &listMerger{list: list}
|
||||
}
|
||||
|
||||
return view, nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package resource_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type ResourceSelectorSuite struct {
|
||||
suite.Suite
|
||||
|
||||
resources []resource.View
|
||||
view resource.View
|
||||
|
||||
isCompoundList bool
|
||||
viewStrategy resource.ViewStrategy
|
||||
}
|
||||
|
||||
func TestResourceSelectorSuite(t *testing.T) {
|
||||
suite.Run(t, new(ResourceSelectorSuite))
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) SetupTest() {
|
||||
suite.resources = nil
|
||||
suite.view = nil
|
||||
suite.isCompoundList = false
|
||||
suite.viewStrategy = nil
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) TestBlockReturnsData() {
|
||||
suite.givenResource(resource.BlocksFrom([][]byte{{0x01}}))
|
||||
suite.whenInstanceIsCreated()
|
||||
suite.thenResourceBlockShouldBe(0, []byte{0x01})
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) TestBlockReturnsDataFromLastEntryByDefault() {
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{0xAA}})
|
||||
})
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{0xBB}})
|
||||
})
|
||||
suite.whenInstanceIsCreated()
|
||||
suite.thenResourceBlockShouldBe(0, []byte{0xBB})
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) TestBlockReturnsDataFromLastEntryForCompoundNonListResources() {
|
||||
suite.givenViewStrategyIsSet()
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Properties.Compound = true
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{0xAA}})
|
||||
})
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Properties.Compound = true
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{0xBB}, {0xCC}})
|
||||
})
|
||||
suite.whenInstanceIsCreated()
|
||||
suite.thenResourceBlockCountShouldBe(2)
|
||||
suite.thenResourceBlockShouldBe(0, []byte{0xBB})
|
||||
suite.thenResourceBlockShouldBe(1, []byte{0xCC})
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) TestBlockReturnsDataFromLastNonEmptyEntryIfACompoundList() {
|
||||
suite.givenResourceIsACompoundList()
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Properties.Compound = true
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{0xAA}})
|
||||
})
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Properties.Compound = true
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{}})
|
||||
})
|
||||
suite.whenInstanceIsCreated()
|
||||
suite.thenResourceBlockShouldBe(0, []byte{0xAA})
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) TestBlockCountOfACompoundListReturnsHighestCount() {
|
||||
suite.givenResourceIsACompoundList()
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Properties.Compound = true
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{}, {}, {0x11}})
|
||||
})
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Properties.Compound = true
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{}, {0x22}})
|
||||
})
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Properties.Compound = true
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{}, {}, {}, {0x33}})
|
||||
})
|
||||
suite.whenInstanceIsCreated()
|
||||
suite.thenResourceBlockCountShouldBe(4)
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) TestMetaOfCompoundListIsThatOfFirst() {
|
||||
suite.givenResourceIsACompoundList()
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Properties.Compound = true
|
||||
res.Properties.Compressed = true
|
||||
res.Properties.ContentType = resource.Movie
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{}, {}, {0x11}})
|
||||
})
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Properties.Compound = false
|
||||
res.Properties.Compressed = false
|
||||
res.Properties.ContentType = resource.Text
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{}, {0x22}})
|
||||
})
|
||||
suite.whenInstanceIsCreated()
|
||||
suite.thenResourceShouldHaveMeta(true, resource.Movie, true)
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) TestMetaOfNonCompoundListIsThatOfLast() {
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Properties.Compound = true
|
||||
res.Properties.Compressed = true
|
||||
res.Properties.ContentType = resource.Movie
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{}, {}, {0x11}})
|
||||
})
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Properties.Compound = false
|
||||
res.Properties.Compressed = false
|
||||
res.Properties.ContentType = resource.Text
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{}, {0x22}})
|
||||
})
|
||||
suite.whenInstanceIsCreated()
|
||||
suite.thenResourceShouldHaveMeta(false, resource.Text, false)
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) TestBlockReturnsErrorForIndexOutOfRange() {
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Blocks = resource.BlocksFrom(nil)
|
||||
})
|
||||
suite.whenInstanceIsCreated()
|
||||
suite.thenBlockShouldReturnErrorFor(0)
|
||||
suite.thenBlockShouldReturnErrorFor(-1)
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) TestBlockReturnsEmptyReaderIfAllLayersAreEmpty() {
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Blocks = resource.BlocksFrom([][]byte{{}})
|
||||
})
|
||||
suite.whenInstanceIsCreated()
|
||||
suite.thenBlockShouldReturnFor(0, []byte{})
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) givenResourceIsACompoundList() {
|
||||
suite.isCompoundList = true
|
||||
suite.viewStrategy = suite
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) givenViewStrategyIsSet() {
|
||||
suite.viewStrategy = suite
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) givenResource(blocks resource.Blocks) {
|
||||
suite.givenSpecificResource(func(res *resource.Resource) {
|
||||
res.Blocks = blocks
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) givenSpecificResource(modifier func(*resource.Resource)) {
|
||||
res := resource.Resource{}
|
||||
modifier(&res)
|
||||
suite.resources = append(suite.resources, res)
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) whenInstanceIsCreated() {
|
||||
selector := resource.Selector{
|
||||
Lang: resource.LangAny,
|
||||
From: suite,
|
||||
As: suite.viewStrategy,
|
||||
}
|
||||
|
||||
var err error
|
||||
suite.view, err = selector.Select(resource.ID(1000))
|
||||
require.Nil(suite.T(), err, "No error expected creating view for %v", 1000)
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) thenResourceBlockCountShouldBe(expected int) {
|
||||
assert.Equal(suite.T(), expected, suite.view.BlockCount(), "Proper block count expected")
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) thenResourceBlockShouldBe(block int, expected []byte) {
|
||||
reader, err := suite.view.Block(block)
|
||||
require.Nil(suite.T(), err, fmt.Sprintf("No error expected retrieving block %v", block))
|
||||
data, readErr := ioutil.ReadAll(reader)
|
||||
require.Nil(suite.T(), readErr, fmt.Sprintf("No error expected reading block %v", block))
|
||||
assert.Equal(suite.T(), expected, data, "Block data expected")
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) thenResourceShouldHaveMeta(compound bool, contentType resource.ContentType, compressed bool) {
|
||||
assert.Equal(suite.T(), compound, suite.view.Compound(), "Compound state is wrong")
|
||||
assert.Equal(suite.T(), contentType, suite.view.ContentType(), "Content type is wrong")
|
||||
assert.Equal(suite.T(), compressed, suite.view.Compressed(), "Compression is wrong")
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) thenBlockShouldReturnErrorFor(index int) {
|
||||
_, err := suite.view.Block(index)
|
||||
assert.NotNil(suite.T(), err, "Error expected")
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) thenBlockShouldReturnFor(index int, expected []byte) {
|
||||
reader, readerErr := suite.view.Block(index)
|
||||
require.Nil(suite.T(), readerErr, "No error expected retrieving block")
|
||||
require.NotNil(suite.T(), reader, "Reader expected")
|
||||
data, dataErr := ioutil.ReadAll(reader)
|
||||
require.Nil(suite.T(), dataErr, "No error expected reading block")
|
||||
assert.Equal(suite.T(), expected, data, "Data mismatch")
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) IsCompoundList(id resource.ID) bool {
|
||||
assert.Equal(suite.T(), resource.ID(1000), id, "Unknown resource queried")
|
||||
return suite.isCompoundList
|
||||
}
|
||||
|
||||
func (suite *ResourceSelectorSuite) Filter(lang resource.Language, id resource.ID) resource.List {
|
||||
return suite.resources
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package resource
|
||||
|
||||
import "io/ioutil"
|
||||
|
||||
// Store holds a set of resources. This set can be modified.
|
||||
type Store struct {
|
||||
ids []ID
|
||||
resources map[ID]*Resource
|
||||
}
|
||||
|
||||
// IDs returns a list of available IDs this store currently contains.
|
||||
func (store Store) IDs() []ID {
|
||||
ids := make([]ID, 0, len(store.resources))
|
||||
for _, id := range store.ids {
|
||||
if _, existing := store.resources[id]; existing {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Resource returns reference to the contained resource for given identifier.
|
||||
func (store Store) Resource(id ID) (*Resource, error) {
|
||||
res, existing := store.resources[id]
|
||||
if !existing {
|
||||
return nil, ErrNotFound(id)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// View returns a read-only view on the resource for given identifier.
|
||||
func (store Store) View(id ID) (View, error) {
|
||||
return store.Resource(id)
|
||||
}
|
||||
|
||||
// Del removes the resource with given identifier from the store.
|
||||
func (store *Store) Del(id ID) bool {
|
||||
_, existing := store.resources[id]
|
||||
if existing {
|
||||
delete(store.resources, id)
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
// Put (re-)assigns an identifier with data. If no resource with given ID exists,
|
||||
// then it is created. Existing resources are overwritten with the provided data.
|
||||
func (store *Store) Put(id ID, view View) error {
|
||||
data := make([][]byte, view.BlockCount())
|
||||
for index := 0; index < len(data); index++ {
|
||||
reader, err := view.Block(index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data[index], err = ioutil.ReadAll(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
res := &Resource{
|
||||
Properties: Properties{
|
||||
Compound: view.Compound(),
|
||||
ContentType: view.ContentType(),
|
||||
Compressed: view.Compressed(),
|
||||
},
|
||||
Blocks: BlocksFrom(data),
|
||||
}
|
||||
if store.resources == nil {
|
||||
store.resources = make(map[ID]*Resource)
|
||||
}
|
||||
store.resources[id] = res
|
||||
if store.findIDIndex(id) < 0 {
|
||||
store.ids = append(store.ids, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store Store) findIDIndex(id ID) int {
|
||||
for index := 0; index < len(store.ids); index++ {
|
||||
if store.ids[index] == id {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package resource_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type StoreSuite struct {
|
||||
suite.Suite
|
||||
|
||||
store resource.Store
|
||||
resourceCounter int
|
||||
}
|
||||
|
||||
func TestStoreSuite(t *testing.T) {
|
||||
suite.Run(t, new(StoreSuite))
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) SetupTest() {
|
||||
suite.store = resource.Store{}
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) TestNewInstanceIsEmpty() {
|
||||
suite.whenInstanceIsCreated()
|
||||
suite.thenIDsShouldBeEmpty()
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) TestResourceReturnsErrorForUnknownResource() {
|
||||
suite.whenInstanceIsCreated()
|
||||
suite.thenViewShouldReturnErrorFor(resource.ID(10))
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) TestDelWillHaveStoreIgnorePreviousEntry() {
|
||||
suite.givenAnInstance()
|
||||
suite.givenStoredResource(resource.ID(1), suite.aResource())
|
||||
suite.givenStoredResource(resource.ID(2), suite.aResource())
|
||||
suite.whenResourceIsDeleted(resource.ID(2))
|
||||
suite.thenIDsShouldBe([]resource.ID{resource.ID(1)})
|
||||
suite.thenViewShouldReturnErrorFor(resource.ID(2))
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) TestDelWillHaveStoreReportFewerIDs() {
|
||||
suite.givenAnInstance()
|
||||
suite.givenStoredResource(resource.ID(1), suite.aResource())
|
||||
suite.givenStoredResource(resource.ID(2), suite.aResource())
|
||||
suite.givenStoredResource(resource.ID(3), suite.aResource())
|
||||
suite.givenStoredResource(resource.ID(4), suite.aResource())
|
||||
suite.whenResourceIsDeleted(resource.ID(2))
|
||||
suite.thenIDsShouldBe([]resource.ID{resource.ID(1), resource.ID(3), resource.ID(4)})
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) TestPutOverridesPreviousResources() {
|
||||
suite.givenAnInstance()
|
||||
suite.givenStoredResource(resource.ID(2), suite.aResource())
|
||||
suite.givenStoredResource(resource.ID(1), suite.aResource())
|
||||
newRes := suite.aResource()
|
||||
suite.whenResourceIsPut(resource.ID(2), newRes)
|
||||
suite.thenIDsShouldBe([]resource.ID{resource.ID(2), resource.ID(1)})
|
||||
suite.thenReturnedViewShouldBe(resource.ID(2), newRes)
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) TestDelWillHaveStoreIgnoreResourceEvenIfPutMultipleTimes() {
|
||||
suite.givenAnInstance()
|
||||
suite.givenStoredResource(resource.ID(1), suite.aResource())
|
||||
suite.givenStoredResource(resource.ID(2), suite.aResource())
|
||||
suite.givenStoredResource(resource.ID(2), suite.aResource())
|
||||
suite.whenResourceIsDeleted(resource.ID(2))
|
||||
suite.thenIDsShouldBe([]resource.ID{resource.ID(1)})
|
||||
suite.thenViewShouldReturnErrorFor(resource.ID(2))
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) TestPutAddsNewResourcesAtEnd() {
|
||||
suite.givenAnInstance()
|
||||
suite.givenStoredResource(resource.ID(2), suite.aResource())
|
||||
suite.givenStoredResource(resource.ID(1), suite.aResource())
|
||||
newRes := suite.aResource()
|
||||
suite.whenResourceIsPut(resource.ID(3), newRes)
|
||||
suite.thenIDsShouldBe([]resource.ID{resource.ID(2), resource.ID(1), resource.ID(3)})
|
||||
suite.thenReturnedViewShouldBe(resource.ID(3), newRes)
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) TestPutRestoresIDAtOldPosition() {
|
||||
suite.givenAnInstance()
|
||||
suite.givenStoredResource(resource.ID(2), suite.aResource())
|
||||
suite.givenStoredResource(resource.ID(1), suite.aResource())
|
||||
suite.givenResourceWasDeleted(resource.ID(2))
|
||||
newRes := suite.aResource()
|
||||
suite.whenResourceIsPut(resource.ID(2), newRes)
|
||||
suite.thenIDsShouldBe([]resource.ID{resource.ID(2), resource.ID(1)})
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) givenAnInstance() {
|
||||
suite.whenInstanceIsCreated()
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) givenStoredResource(id resource.ID, res *resource.Resource) {
|
||||
err := suite.store.Put(id, res)
|
||||
require.Nil(suite.T(), err, "No error expected storing resource")
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) givenResourceWasDeleted(id resource.ID) {
|
||||
suite.whenResourceIsDeleted(id)
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) whenInstanceIsCreated() {
|
||||
suite.store = resource.Store{}
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) whenResourceIsDeleted(id resource.ID) {
|
||||
suite.store.Del(id)
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) whenResourceIsPut(id resource.ID, res *resource.Resource) {
|
||||
err := suite.store.Put(id, res)
|
||||
require.Nil(suite.T(), err, "No error expected putting resource")
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) thenIDsShouldBeEmpty() {
|
||||
assert.Equal(suite.T(), 0, len(suite.store.IDs()))
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) thenIDsShouldBe(expected []resource.ID) {
|
||||
assert.Equal(suite.T(), expected, suite.store.IDs())
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) thenReturnedViewShouldBe(id resource.ID, expected *resource.Resource) {
|
||||
res, err := suite.store.View(id)
|
||||
assert.Nil(suite.T(), err, "No error expected for ID %v", id)
|
||||
assert.Equal(suite.T(), expected, res, "Different res returned for ID %v", id)
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) thenViewShouldReturnErrorFor(id resource.ID) {
|
||||
_, err := suite.store.View(id)
|
||||
assert.Error(suite.T(), err, "Error expected for ID %v ", id) // nolint: vet
|
||||
}
|
||||
|
||||
func (suite *StoreSuite) aResource() *resource.Resource {
|
||||
suite.resourceCounter++
|
||||
return &resource.Resource{Blocks: resource.BlocksFrom([][]byte{{byte(suite.resourceCounter)}})}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// View is a read-only view on a selected resource.
|
||||
type View interface {
|
||||
|
||||
// Compound tells whether the resource should be serialized with a directory.
|
||||
// Compound resources can have zero, one, or more blocks.
|
||||
// Simple resources always have exactly one block.
|
||||
Compound() bool
|
||||
|
||||
// ContentType describes how the block data shall be interpreted.
|
||||
ContentType() ContentType
|
||||
|
||||
// Compressed tells whether the data shall be serialized in compressed form.
|
||||
Compressed() bool
|
||||
|
||||
// BlockCount returns the number of available blocks in the resource.
|
||||
// Simple resources will always have exactly one block.
|
||||
BlockCount() int
|
||||
|
||||
// Block returns the reader for the identified block.
|
||||
// Each call returns a new reader instance.
|
||||
// Data provided by this reader is always uncompressed.
|
||||
Block(index int) (io.Reader, error)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package resource
|
||||
|
||||
// ViewStrategy defines how selected resources shall be viewed.
|
||||
type ViewStrategy interface {
|
||||
// IsCompoundList returns true for compound resources where each contained block is a separate entity.
|
||||
// Separate entities are those that can be replaced without affecting others.
|
||||
// Examples are the small game textures and the list of object names.
|
||||
IsCompoundList(id ID) bool
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package resource
|
||||
|
||||
// Viewer provides resources from some storage.
|
||||
type Viewer interface {
|
||||
// IDs returns a list of available IDs this viewer can provide.
|
||||
IDs() []ID
|
||||
|
||||
// View returns a read-only view to a resource for the given identifier.
|
||||
View(id ID) (View, error)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
Package resource provides interfaces and primitives for low-level handling of resources.
|
||||
*/
|
||||
package resource
|
||||
@@ -0,0 +1,22 @@
|
||||
package lgres
|
||||
|
||||
import "io"
|
||||
|
||||
type blockFunc func(index int) (io.Reader, error)
|
||||
|
||||
type blockReader struct {
|
||||
blockCount int
|
||||
blockFunc blockFunc
|
||||
}
|
||||
|
||||
// BlockCount returns the number of available blocks.
|
||||
func (reader *blockReader) BlockCount() int {
|
||||
return reader.blockCount
|
||||
}
|
||||
|
||||
// Block returns the reader for the identified block.
|
||||
// Each call returns a new reader instance.
|
||||
// Data provided by this reader is always uncompressed.
|
||||
func (reader *blockReader) Block(index int) (io.Reader, error) {
|
||||
return reader.blockFunc(index)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package lgres
|
||||
|
||||
import "io"
|
||||
|
||||
// BlockWriter is for writing data of a single block in a resource.
|
||||
type BlockWriter struct {
|
||||
target io.Writer
|
||||
finisher func()
|
||||
bytesWritten uint32
|
||||
}
|
||||
|
||||
func (writer *BlockWriter) finish() (length uint32) {
|
||||
writer.finisher()
|
||||
writer.finisher = nil
|
||||
writer.target = nil
|
||||
return writer.bytesWritten
|
||||
}
|
||||
|
||||
// Write stores the given data in the block and follows the Writer interface.
|
||||
func (writer *BlockWriter) Write(data []byte) (written int, err error) {
|
||||
written, err = writer.target.Write(data)
|
||||
writer.bytesWritten += uint32(written)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package lgres
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
// CompoundResourceWriter writes a resource with zero, one, or more blocks.
|
||||
// Multiple blocks can be created and then written concurrently. Only when the
|
||||
// resource is finished, the blocks are finalized.
|
||||
type CompoundResourceWriter struct {
|
||||
target *serial.PositioningEncoder
|
||||
|
||||
compressed bool
|
||||
dataPaddingSize int
|
||||
blockStores []*serial.ByteStore
|
||||
blockWriter []*BlockWriter
|
||||
}
|
||||
|
||||
// CreateBlock provides a new, dedicated writer for a new block.
|
||||
func (writer *CompoundResourceWriter) CreateBlock() *BlockWriter {
|
||||
store := serial.NewByteStore()
|
||||
blockWriter := &BlockWriter{target: serial.NewEncoder(store), finisher: func() {}}
|
||||
writer.blockStores = append(writer.blockStores, store)
|
||||
writer.blockWriter = append(writer.blockWriter, blockWriter)
|
||||
return blockWriter
|
||||
}
|
||||
|
||||
func (writer *CompoundResourceWriter) finish() (length uint32) {
|
||||
var unpackedSize uint32
|
||||
blockCount := len(writer.blockStores)
|
||||
writer.target.Code(uint16(blockCount))
|
||||
offset := 2 + (blockCount+1)*4 + writer.dataPaddingSize
|
||||
for index, store := range writer.blockStores {
|
||||
unpackedSize += writer.blockWriter[index].finish()
|
||||
writer.target.Code(uint32(offset))
|
||||
offset += len(store.Data())
|
||||
}
|
||||
writer.target.Code(uint32(offset))
|
||||
unpackedSize += writer.target.CurPos() + uint32(writer.dataPaddingSize)
|
||||
|
||||
writer.writeBlocks()
|
||||
|
||||
return unpackedSize
|
||||
}
|
||||
|
||||
func (writer *CompoundResourceWriter) writeBlocks() {
|
||||
var targetWriter io.Writer = writer.target
|
||||
targetFinisher := func() {}
|
||||
if writer.compressed {
|
||||
compressor := compression.NewCompressor(targetWriter)
|
||||
targetWriter = compressor
|
||||
targetFinisher = func() { compressor.Close() } // nolint: errcheck
|
||||
}
|
||||
for i := 0; i < writer.dataPaddingSize; i++ {
|
||||
targetWriter.Write([]byte{0x00}) // nolint: errcheck
|
||||
}
|
||||
for _, store := range writer.blockStores {
|
||||
targetWriter.Write(store.Data()) // nolint: errcheck
|
||||
}
|
||||
targetFinisher()
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package lgres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
// Reader provides methods to extract resource data from a serialized form.
|
||||
// Resources may be accessed out of sequence due to the nature of the underlying io.ReaderAt.
|
||||
type Reader struct {
|
||||
source io.ReaderAt
|
||||
firstResourceOffset uint32
|
||||
directoryOffset uint32
|
||||
directory []resourceDirectoryEntry
|
||||
|
||||
cache map[uint16]resource.View
|
||||
}
|
||||
|
||||
const (
|
||||
// ErrSourceNil is returned if the source is not valid.
|
||||
ErrSourceNil ss1.StringError = "source is nil"
|
||||
|
||||
// ErrFormatMismatch is returned if the format is unknown.
|
||||
ErrFormatMismatch ss1.StringError = "format mismatch"
|
||||
)
|
||||
|
||||
// ReaderFrom accesses the provided source and creates a new Reader instance
|
||||
// from it.
|
||||
// Should the provided decoder not follow the resource file format, an error
|
||||
// is returned.
|
||||
func ReaderFrom(source io.ReaderAt) (reader *Reader, err error) {
|
||||
if source == nil {
|
||||
return nil, ErrSourceNil
|
||||
}
|
||||
|
||||
var dirOffset uint32
|
||||
dirOffset, err = readAndVerifyHeader(io.NewSectionReader(source, 0, format.ResourceDirectoryFileOffsetPos+4))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firstResourceOffset, directory, err := readDirectoryAt(dirOffset, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader = &Reader{
|
||||
source: source,
|
||||
firstResourceOffset: firstResourceOffset,
|
||||
directoryOffset: dirOffset,
|
||||
directory: directory,
|
||||
cache: make(map[uint16]resource.View)}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// IDs returns the resource identifier available via this reader.
|
||||
// The order in the slice is the same as in the underlying serialized form.
|
||||
func (reader *Reader) IDs() []resource.ID {
|
||||
ids := make([]resource.ID, len(reader.directory))
|
||||
for index, entry := range reader.directory {
|
||||
ids[index] = resource.ID(entry.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// View returns a reader prepared to extract data for the specified resource.
|
||||
// An error is returned if either the ID is not known, or the resource could not be prepared.
|
||||
func (reader *Reader) View(id resource.ID) (retrievedResource resource.View, err error) {
|
||||
if cachedResource, existing := reader.cache[id.Value()]; existing {
|
||||
return cachedResource, nil
|
||||
}
|
||||
resourceStartOffset, entry := reader.findEntry(id.Value())
|
||||
if entry == nil {
|
||||
return nil, resource.ErrNotFound(id)
|
||||
}
|
||||
resourceType := entry.resourceType()
|
||||
compressed := (resourceType & format.ResourceTypeFlagCompressed) != 0
|
||||
compound := (resourceType & format.ResourceTypeFlagCompound) != 0
|
||||
contentType := resource.ContentType(entry.contentType())
|
||||
|
||||
if compound {
|
||||
retrievedResource, err = reader.newCompoundResourceReader(entry, contentType, compressed, resourceStartOffset)
|
||||
} else {
|
||||
retrievedResource, err = reader.newSingleBlockResourceReader(entry, contentType, compressed, resourceStartOffset)
|
||||
}
|
||||
if err == nil {
|
||||
reader.cache[id.Value()] = retrievedResource
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func readAndVerifyHeader(source io.ReadSeeker) (dirOffset uint32, err error) {
|
||||
coder := serial.NewPositioningDecoder(source)
|
||||
data := make([]byte, format.ResourceDirectoryFileOffsetPos)
|
||||
coder.Code(data)
|
||||
coder.Code(&dirOffset)
|
||||
|
||||
expected := make([]byte, len(format.HeaderString)+1)
|
||||
for index, r := range format.HeaderString {
|
||||
expected[index] = byte(r)
|
||||
}
|
||||
expected[len(format.HeaderString)] = format.CommentTerminator
|
||||
if !bytes.Equal(data[:len(expected)], expected) {
|
||||
return 0, ErrFormatMismatch
|
||||
}
|
||||
|
||||
return dirOffset, coder.FirstError()
|
||||
}
|
||||
|
||||
func readDirectoryAt(dirOffset uint32, source io.ReaderAt) (firstResourceOffset uint32, directory []resourceDirectoryEntry, err error) {
|
||||
var header resourceDirectoryHeader
|
||||
headerSize := int64(binary.Size(&header))
|
||||
{
|
||||
headerCoder := serial.NewDecoder(io.NewSectionReader(source, int64(dirOffset), headerSize))
|
||||
headerCoder.Code(&header)
|
||||
if headerCoder.FirstError() != nil {
|
||||
return 0, nil, headerCoder.FirstError()
|
||||
}
|
||||
}
|
||||
|
||||
firstResourceOffset = header.FirstResourceOffset
|
||||
directory = make([]resourceDirectoryEntry, header.ResourceCount)
|
||||
if header.ResourceCount > 0 {
|
||||
listCoder := serial.NewDecoder(io.NewSectionReader(source, int64(dirOffset)+headerSize, int64(binary.Size(directory))))
|
||||
listCoder.Code(directory)
|
||||
err = listCoder.FirstError()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (reader *Reader) findEntry(id uint16) (startOffset uint32, entry *resourceDirectoryEntry) {
|
||||
startOffset = reader.firstResourceOffset
|
||||
for index := 0; (index < len(reader.directory)) && (entry == nil); index++ {
|
||||
cur := &reader.directory[index]
|
||||
if cur.ID == id {
|
||||
entry = cur
|
||||
} else {
|
||||
startOffset += cur.packedLength()
|
||||
startOffset += (format.BoundarySize - (startOffset % format.BoundarySize)) % format.BoundarySize
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type blockListEntry struct {
|
||||
start uint32
|
||||
size uint32
|
||||
}
|
||||
|
||||
func (reader *Reader) newCompoundResourceReader(entry *resourceDirectoryEntry,
|
||||
contentType resource.ContentType, compressed bool, resourceStartOffset uint32) (resource.View, error) {
|
||||
resourceDataReader := io.NewSectionReader(reader.source, int64(resourceStartOffset), int64(entry.packedLength()))
|
||||
|
||||
firstBlockOffset, blockList, err := reader.readBlockList(resourceDataReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blockCount := len(blockList)
|
||||
|
||||
rawBlockDataReader := io.NewSectionReader(resourceDataReader, int64(firstBlockOffset), resourceDataReader.Size()-int64(firstBlockOffset))
|
||||
var uncompressedReader io.ReaderAt
|
||||
if !compressed {
|
||||
uncompressedReader = rawBlockDataReader
|
||||
}
|
||||
|
||||
blockFunc := func(index int) (io.Reader, error) {
|
||||
if (index < 0) || (index >= blockCount) {
|
||||
return nil, resource.ErrBlockNotFound(index, blockCount)
|
||||
}
|
||||
|
||||
if compressed && (uncompressedReader == nil) {
|
||||
decompressor := compression.NewDecompressor(rawBlockDataReader)
|
||||
decompressedData, err := ioutil.ReadAll(decompressor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uncompressedReader = bytes.NewReader(decompressedData)
|
||||
}
|
||||
|
||||
entry := blockList[index]
|
||||
reader := io.NewSectionReader(uncompressedReader, int64(entry.start)-int64(firstBlockOffset), int64(entry.size))
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
return &readerResource{
|
||||
Properties: resource.Properties{
|
||||
Compound: true,
|
||||
ContentType: contentType,
|
||||
Compressed: compressed,
|
||||
},
|
||||
blockReader: blockReader{blockCount: len(blockList), blockFunc: blockFunc}}, nil
|
||||
}
|
||||
|
||||
func (reader *Reader) readBlockList(source io.Reader) (uint32, []blockListEntry, error) {
|
||||
listDecoder := serial.NewDecoder(source)
|
||||
var blockCount uint16
|
||||
listDecoder.Code(&blockCount)
|
||||
var firstBlockOffset uint32
|
||||
listDecoder.Code(&firstBlockOffset)
|
||||
lastBlockEndOffset := firstBlockOffset
|
||||
blockList := make([]blockListEntry, blockCount)
|
||||
for blockIndex := uint16(0); blockIndex < blockCount; blockIndex++ {
|
||||
var endOffset uint32
|
||||
listDecoder.Code(&endOffset)
|
||||
blockList[blockIndex].start = lastBlockEndOffset
|
||||
blockList[blockIndex].size = endOffset - lastBlockEndOffset
|
||||
lastBlockEndOffset = endOffset
|
||||
}
|
||||
|
||||
return firstBlockOffset, blockList, listDecoder.FirstError()
|
||||
}
|
||||
|
||||
func (reader *Reader) newSingleBlockResourceReader(entry *resourceDirectoryEntry,
|
||||
contentType resource.ContentType, compressed bool, resourceStartOffset uint32) (resource.View, error) {
|
||||
blockFunc := func(index int) (io.Reader, error) {
|
||||
if index != 0 {
|
||||
return nil, resource.ErrBlockNotFound(index, 1)
|
||||
}
|
||||
resourceSize := entry.packedLength()
|
||||
// The following check is a hack to re-create the behaviour of the engine.
|
||||
// While in truth the resource should only cover what its entry needs, Movie resources
|
||||
// can get larger than the 24bit size-field would allow (16MiB).
|
||||
// Interestingly, the engine does not limit this and simply reads beyond the limit.
|
||||
// In order to support such "broken" files, the behaviour is re-created here. This is mainly to
|
||||
// still allow creating such files and also give the editor a chance to work (and display such a case)
|
||||
// without completely having to rework the whole architecture here.
|
||||
// As audio of logs is also stored with this content type, limit to only single-resource files
|
||||
// to avoid a sudden increase of other resources in the file.
|
||||
isCutscene := (resource.ContentType(entry.contentType()) == resource.Movie) && (len(reader.directory) == 1)
|
||||
if isCutscene && (resourceStartOffset < reader.directoryOffset) {
|
||||
resourceSize = reader.directoryOffset - resourceStartOffset
|
||||
}
|
||||
var resourceSource io.Reader = io.NewSectionReader(reader.source, int64(resourceStartOffset), int64(resourceSize))
|
||||
if compressed {
|
||||
resourceSize = entry.unpackedLength()
|
||||
resourceSource = compression.NewDecompressor(resourceSource)
|
||||
}
|
||||
return io.LimitReader(resourceSource, int64(resourceSize)), nil
|
||||
}
|
||||
|
||||
return &readerResource{
|
||||
Properties: resource.Properties{
|
||||
Compound: false,
|
||||
ContentType: contentType,
|
||||
Compressed: compressed,
|
||||
},
|
||||
blockReader: blockReader{blockCount: 1, blockFunc: blockFunc}}, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package lgres
|
||||
|
||||
import "github.com/inkyblackness/hacked/ss1/resource"
|
||||
|
||||
type readerResource struct {
|
||||
resource.Properties
|
||||
blockReader
|
||||
}
|
||||
|
||||
func (rr readerResource) Compound() bool {
|
||||
return rr.Properties.Compound
|
||||
}
|
||||
|
||||
func (rr readerResource) ContentType() resource.ContentType {
|
||||
return rr.Properties.ContentType
|
||||
}
|
||||
|
||||
func (rr readerResource) Compressed() bool {
|
||||
return rr.Properties.Compressed
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package lgres_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReaderFromReturnsErrorForNilSource(t *testing.T) {
|
||||
reader, err := lgres.ReaderFrom(nil)
|
||||
|
||||
assert.Nil(t, reader, "reader should be nil")
|
||||
assert.Equal(t, lgres.ErrSourceNil, err)
|
||||
}
|
||||
|
||||
func TestReaderFromReturnsInstanceOnEmptySource(t *testing.T) {
|
||||
source := bytes.NewReader(emptyResourceFile())
|
||||
reader, err := lgres.ReaderFrom(source)
|
||||
|
||||
assert.Nil(t, err, "Error should be nil")
|
||||
assert.NotNil(t, reader)
|
||||
}
|
||||
|
||||
func TestReaderFromReturnsErrorOnInvalidHeaderString(t *testing.T) {
|
||||
sourceData := emptyResourceFile()
|
||||
sourceData[10] = "A"[0]
|
||||
|
||||
_, err := lgres.ReaderFrom(bytes.NewReader(sourceData))
|
||||
|
||||
assert.Equal(t, lgres.ErrFormatMismatch, err)
|
||||
}
|
||||
|
||||
func TestReaderFromReturnsErrorOnMissingCommentTerminator(t *testing.T) {
|
||||
sourceData := emptyResourceFile()
|
||||
sourceData[len(format.HeaderString)] = byte(0)
|
||||
|
||||
_, err := lgres.ReaderFrom(bytes.NewReader(sourceData))
|
||||
|
||||
assert.Equal(t, lgres.ErrFormatMismatch, err)
|
||||
}
|
||||
|
||||
func TestReaderFromReturnsErrorOnInvalidDirectoryStart(t *testing.T) {
|
||||
sourceData := emptyResourceFile()
|
||||
sourceData[format.ResourceDirectoryFileOffsetPos] = byte(0xFF)
|
||||
sourceData[format.ResourceDirectoryFileOffsetPos+1] = byte(0xFF)
|
||||
|
||||
_, err := lgres.ReaderFrom(bytes.NewReader(sourceData))
|
||||
|
||||
assert.NotNil(t, err, "error expected")
|
||||
}
|
||||
|
||||
func TestReaderFromCanDecodeExampleResourceFile(t *testing.T) {
|
||||
_, err := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
assert.Nil(t, err, "no error expected")
|
||||
}
|
||||
|
||||
func TestReaderIDsReturnsTheStoredResourceIDsInOrderFromFile(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
|
||||
assert.Equal(t, []resource.ID{exampleResourceIDSingleBlockResource, exampleResourceIDSingleBlockResourceCompressed,
|
||||
exampleResourceIDCompoundResource, exampleResourceIDCompoundResourceCompressed}, reader.IDs())
|
||||
}
|
||||
|
||||
func TestReaderResourceReturnsErrorForUnknownID(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(emptyResourceFile()))
|
||||
resourceReader, err := reader.View(resource.ID(0x1111))
|
||||
assert.Nil(t, resourceReader, "no reader expected")
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestReaderResourceReturnsAResourceReaderForKnownID(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
resourceReader, err := reader.View(exampleResourceIDSingleBlockResource)
|
||||
assert.Nil(t, err, "no error expected")
|
||||
assert.NotNil(t, resourceReader)
|
||||
}
|
||||
|
||||
func TestReaderResourceReturnsResourceWithMetaInformation(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
info := func(resourceID resource.ID, name string, expected interface{}) string {
|
||||
return fmt.Sprintf("Resource 0x%04X should have %v = %v", resourceID.Value(), name, expected)
|
||||
}
|
||||
verifyResource := func(resourceID resource.ID, compound bool, contentType resource.ContentType, compressed bool) {
|
||||
resourceReader, _ := reader.View(resourceID)
|
||||
assert.Equal(t, compound, resourceReader.Compound(), info(resourceID, "compound", compound))
|
||||
assert.Equal(t, contentType, resourceReader.ContentType(), info(resourceID, "contentType", contentType))
|
||||
assert.Equal(t, compressed, resourceReader.Compressed(), info(resourceID, "compressed", compressed))
|
||||
}
|
||||
verifyResource(exampleResourceIDSingleBlockResource, false, resource.ContentType(0x01), false)
|
||||
verifyResource(exampleResourceIDSingleBlockResourceCompressed, false, resource.ContentType(0x02), true)
|
||||
verifyResource(exampleResourceIDCompoundResource, true, resource.ContentType(0x03), false)
|
||||
verifyResource(exampleResourceIDCompoundResourceCompressed, true, resource.ContentType(0x04), true)
|
||||
}
|
||||
|
||||
func TestReaderResourceReturnsSameInstance(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
|
||||
c1, _ := reader.View(exampleResourceIDSingleBlockResource)
|
||||
c2, _ := reader.View(exampleResourceIDSingleBlockResource)
|
||||
|
||||
assert.Equal(t, c1, c2, "Resources should be the same")
|
||||
}
|
||||
|
||||
func TestReaderResourceWithUncompressedSingleBlockContent(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
resourceReader, _ := reader.View(exampleResourceIDSingleBlockResource)
|
||||
|
||||
assert.Equal(t, 1, resourceReader.BlockCount())
|
||||
verifyBlockContent(t, resourceReader, 0, []byte{0x01, 0x01, 0x01})
|
||||
}
|
||||
|
||||
func TestReaderResourceWithCompressedSingleBlockContent(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
resourceReader, _ := reader.View(exampleResourceIDSingleBlockResourceCompressed)
|
||||
|
||||
assert.Equal(t, 1, resourceReader.BlockCount())
|
||||
verifyBlockContent(t, resourceReader, 0, []byte{0x02, 0x02})
|
||||
}
|
||||
|
||||
func TestReaderResourceWithUncompressedCompoundContent(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
resourceReader, _ := reader.View(exampleResourceIDCompoundResource)
|
||||
|
||||
assert.Equal(t, 2, resourceReader.BlockCount())
|
||||
verifyBlockContent(t, resourceReader, 0, []byte{0x30, 0x30, 0x30, 0x30})
|
||||
verifyBlockContent(t, resourceReader, 1, []byte{0x31, 0x31, 0x31})
|
||||
}
|
||||
|
||||
func TestReaderResourceWithCompressedCompoundContent(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
resourceReader, _ := reader.View(exampleResourceIDCompoundResourceCompressed)
|
||||
|
||||
assert.Equal(t, 3, resourceReader.BlockCount())
|
||||
verifyBlockContent(t, resourceReader, 0, []byte{0x40, 0x40})
|
||||
verifyBlockContent(t, resourceReader, 1, []byte{0x41, 0x41, 0x41, 0x41})
|
||||
verifyBlockContent(t, resourceReader, 2, []byte{0x42})
|
||||
}
|
||||
|
||||
func verifyBlockContent(t *testing.T, blockProvider resource.BlockProvider, blockIndex int, expected []byte) {
|
||||
t.Helper()
|
||||
blockReader, readerErr := blockProvider.Block(blockIndex)
|
||||
assert.Nil(t, readerErr, "error retrieving reader")
|
||||
require.NotNil(t, blockReader, "reader is nil")
|
||||
data, dataErr := ioutil.ReadAll(blockReader)
|
||||
assert.Nil(t, dataErr, "no error expected reading data")
|
||||
assert.Equal(t, expected, data)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package lgres
|
||||
|
||||
import (
|
||||
"math"
|
||||
)
|
||||
|
||||
type resourceDirectoryEntry struct {
|
||||
ID uint16
|
||||
UnpackedLengthAndResourceType uint32
|
||||
PackedLengthAndContentType uint32
|
||||
}
|
||||
|
||||
func maskBits(field uint32, bitOffset uint, bitCount int) uint32 {
|
||||
return (field >> bitOffset) & uint32(^(uint64(math.MaxUint64) << uint64(bitCount)))
|
||||
}
|
||||
|
||||
func setBits(field uint32, bitOffset uint, bitCount int, value uint32) uint32 {
|
||||
mask := uint32(^(uint64(math.MaxUint64) << uint64(bitCount)))
|
||||
return (field & (^mask << bitOffset)) | ((value & mask) << bitOffset)
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) setUnpackedLength(value uint32) {
|
||||
entry.UnpackedLengthAndResourceType = setBits(entry.UnpackedLengthAndResourceType, 0, 24, value)
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) unpackedLength() uint32 {
|
||||
return maskBits(entry.UnpackedLengthAndResourceType, 0, 24)
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) setResourceType(value byte) {
|
||||
entry.UnpackedLengthAndResourceType = setBits(entry.UnpackedLengthAndResourceType, 24, 8, uint32(value))
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) resourceType() byte {
|
||||
return byte(maskBits(entry.UnpackedLengthAndResourceType, 24, 8))
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) setPackedLength(value uint32) {
|
||||
entry.PackedLengthAndContentType = setBits(entry.PackedLengthAndContentType, 0, 24, value)
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) packedLength() uint32 {
|
||||
return maskBits(entry.PackedLengthAndContentType, 0, 24)
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) setContentType(value byte) {
|
||||
entry.PackedLengthAndContentType = setBits(entry.PackedLengthAndContentType, 24, 8, uint32(value))
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) contentType() byte {
|
||||
return byte(maskBits(entry.PackedLengthAndContentType, 24, 8))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package lgres
|
||||
|
||||
type resourceDirectoryHeader struct {
|
||||
ResourceCount uint16
|
||||
FirstResourceOffset uint32
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package lgres
|
||||
|
||||
type resourceWriter interface {
|
||||
// finish signals the writer that the resource is to be finished.
|
||||
// Any pending data needs to be sent to the output and the uncompressed
|
||||
// size shall be returned.
|
||||
finish() uint32
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package lgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
)
|
||||
|
||||
type resourceInconsistentError struct {
|
||||
ID resource.ID
|
||||
BlockCount int
|
||||
}
|
||||
|
||||
func (err resourceInconsistentError) Error() string {
|
||||
return fmt.Sprintf("simple resource %v has invalid number of blocks: %d", err.ID, err.BlockCount)
|
||||
}
|
||||
|
||||
// Write serializes the resources from given source into the target.
|
||||
// It is a convenience function for using Writer.
|
||||
func Write(target io.WriteSeeker, source resource.Viewer) error {
|
||||
writer, writerErr := NewWriter(target)
|
||||
if writerErr != nil {
|
||||
return writerErr
|
||||
}
|
||||
|
||||
for _, id := range source.IDs() {
|
||||
entry, resourceErr := source.View(id)
|
||||
if resourceErr != nil {
|
||||
return resourceErr
|
||||
}
|
||||
|
||||
switch {
|
||||
case entry.Compound():
|
||||
resourceWriter, resourceWriterErr := writer.CreateCompoundResource(id, entry.ContentType(), entry.Compressed())
|
||||
if resourceWriterErr != nil {
|
||||
return resourceWriterErr
|
||||
}
|
||||
copyErr := copyBlocks(entry, func() io.Writer { return resourceWriter.CreateBlock() })
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
case entry.BlockCount() == 1:
|
||||
blockWriter, resourceWriterErr := writer.CreateResource(id, entry.ContentType(), entry.Compressed())
|
||||
if resourceWriterErr != nil {
|
||||
return resourceWriterErr
|
||||
}
|
||||
copyErr := copyBlocks(entry, func() io.Writer { return blockWriter })
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
default:
|
||||
return resourceInconsistentError{ID: id, BlockCount: entry.BlockCount()}
|
||||
}
|
||||
}
|
||||
|
||||
return writer.Finish()
|
||||
}
|
||||
|
||||
func copyBlocks(source resource.BlockProvider, nextWriter func() io.Writer) error {
|
||||
for blockIndex := 0; blockIndex < source.BlockCount(); blockIndex++ {
|
||||
blockReader, blockErr := source.Block(blockIndex)
|
||||
if blockErr != nil {
|
||||
return blockErr
|
||||
}
|
||||
_, copyErr := io.Copy(nextWriter(), blockReader)
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package lgres_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
target := serial.NewByteStore()
|
||||
var store resource.Store
|
||||
aResource := func(compressed bool, contentType resource.ContentType, compound bool, blocks resource.Blocks) resource.View {
|
||||
return resource.Resource{
|
||||
Properties: resource.Properties{
|
||||
Compressed: compressed,
|
||||
ContentType: contentType,
|
||||
Compound: compound,
|
||||
},
|
||||
Blocks: blocks,
|
||||
}
|
||||
}
|
||||
|
||||
_ = store.Put(resource.ID(1), aResource(false, resource.Bitmap, false, resource.BlocksFrom([][]byte{{0x11}})))
|
||||
_ = store.Put(resource.ID(3), aResource(false, resource.Font, true, resource.BlocksFrom([][]byte{{0x21}, {0x22, 0x23}})))
|
||||
_ = store.Put(resource.ID(2), aResource(true, resource.Geometry, false, resource.BlocksFrom([][]byte{{0x31}})))
|
||||
_ = store.Put(resource.ID(4), aResource(true, resource.Archive, true, resource.BlocksFrom([][]byte{{0x41}, {0x42, 0x43}})))
|
||||
|
||||
errWrite := lgres.Write(target, store)
|
||||
if errWrite != nil {
|
||||
assert.Nil(t, errWrite, "no error expected writing")
|
||||
}
|
||||
|
||||
reader, errReader := lgres.ReaderFrom(bytes.NewReader(target.Data()))
|
||||
if errReader != nil {
|
||||
assert.Nil(t, errReader, "no error expected reading")
|
||||
}
|
||||
|
||||
assert.Equal(t, []resource.ID{resource.ID(1), resource.ID(3), resource.ID(2), resource.ID(4)}, reader.IDs())
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package lgres
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
// Writer provides methods to write a new resource file from scratch.
|
||||
// Resources have to be created sequentially. The writer does not support
|
||||
// concurrent creation and modification of resources.
|
||||
type Writer struct {
|
||||
encoder *serial.PositioningEncoder
|
||||
|
||||
firstResourceOffset uint32
|
||||
currentResourceStartOffset uint32
|
||||
currentResource resourceWriter
|
||||
|
||||
directory []*resourceDirectoryEntry
|
||||
}
|
||||
|
||||
// ErrTargetNil is returned if the target cannot be used.
|
||||
const ErrTargetNil ss1.StringError = "target is nil"
|
||||
|
||||
// NewWriter returns a new Writer instance prepared to add resources.
|
||||
// To finalize the created file, call Finish().
|
||||
//
|
||||
// This function will write initial information to the target and will return
|
||||
// an error if the writer did. In such a case, the returned writer instance
|
||||
// will produce invalid results and the state of the target is undefined.
|
||||
func NewWriter(target io.WriteSeeker) (*Writer, error) {
|
||||
if target == nil {
|
||||
return nil, ErrTargetNil
|
||||
}
|
||||
|
||||
encoder := serial.NewPositioningEncoder(target)
|
||||
writer := &Writer{encoder: encoder}
|
||||
writer.writeHeader()
|
||||
writer.firstResourceOffset = writer.encoder.CurPos()
|
||||
|
||||
return writer, writer.encoder.FirstError()
|
||||
}
|
||||
|
||||
// ErrWriterFinished is returned if the writer was already finished.
|
||||
const ErrWriterFinished ss1.StringError = "writer is finished"
|
||||
|
||||
// CreateResource adds a new single-block resource to the current resource file.
|
||||
// This resource is closed by creating another resource, or by finishing the writer.
|
||||
func (writer *Writer) CreateResource(id resource.ID, contentType resource.ContentType,
|
||||
compressed bool) (*BlockWriter, error) {
|
||||
if writer.encoder == nil {
|
||||
return nil, ErrWriterFinished
|
||||
}
|
||||
|
||||
writer.finishLastResource()
|
||||
if writer.encoder.FirstError() != nil {
|
||||
return nil, writer.encoder.FirstError()
|
||||
}
|
||||
|
||||
var targetWriter io.Writer = serial.NewEncoder(writer.encoder)
|
||||
targetFinisher := func() {}
|
||||
resourceType := byte(0x00)
|
||||
if compressed {
|
||||
compressor := compression.NewCompressor(targetWriter)
|
||||
resourceType |= format.ResourceTypeFlagCompressed
|
||||
targetWriter = compressor
|
||||
targetFinisher = func() { compressor.Close() } // nolint: errcheck
|
||||
}
|
||||
blockWriter := &BlockWriter{target: targetWriter, finisher: targetFinisher}
|
||||
writer.addNewResource(id, contentType, resourceType, blockWriter)
|
||||
|
||||
return blockWriter, nil
|
||||
}
|
||||
|
||||
// CreateCompoundResource adds a new compound resource to the current resource file.
|
||||
// This resource is closed by creating another resource, or by finishing the writer.
|
||||
func (writer *Writer) CreateCompoundResource(id resource.ID, contentType resource.ContentType,
|
||||
compressed bool) (*CompoundResourceWriter, error) {
|
||||
if writer.encoder == nil {
|
||||
return nil, ErrWriterFinished
|
||||
}
|
||||
|
||||
writer.finishLastResource()
|
||||
if writer.encoder.FirstError() != nil {
|
||||
return nil, writer.encoder.FirstError()
|
||||
}
|
||||
|
||||
resourceType := format.ResourceTypeFlagCompound
|
||||
if compressed {
|
||||
resourceType |= format.ResourceTypeFlagCompressed
|
||||
}
|
||||
resourceWriter := &CompoundResourceWriter{
|
||||
target: serial.NewPositioningEncoder(writer.encoder),
|
||||
compressed: compressed,
|
||||
dataPaddingSize: writer.dataPaddingSizeForCompoundResource(id)}
|
||||
writer.addNewResource(id, contentType, resourceType, resourceWriter)
|
||||
|
||||
return resourceWriter, nil
|
||||
}
|
||||
|
||||
// Finish finalizes the resource file. After calling this function, the
|
||||
// writer becomes unusable.
|
||||
func (writer *Writer) Finish() (err error) {
|
||||
if writer.encoder == nil {
|
||||
return ErrWriterFinished
|
||||
}
|
||||
|
||||
writer.finishLastResource()
|
||||
|
||||
directoryOffset := writer.encoder.CurPos()
|
||||
writer.encoder.SetCurPos(format.ResourceDirectoryFileOffsetPos)
|
||||
writer.encoder.Code(directoryOffset)
|
||||
writer.encoder.SetCurPos(directoryOffset)
|
||||
writer.encoder.Code(uint16(len(writer.directory)))
|
||||
writer.encoder.Code(writer.firstResourceOffset)
|
||||
for _, entry := range writer.directory {
|
||||
writer.encoder.Code(entry)
|
||||
}
|
||||
|
||||
err = writer.encoder.FirstError()
|
||||
writer.encoder = nil
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (writer *Writer) writeHeader() {
|
||||
header := make([]byte, format.ResourceDirectoryFileOffsetPos)
|
||||
for index, r := range format.HeaderString {
|
||||
header[index] = byte(r)
|
||||
}
|
||||
header[len(format.HeaderString)] = format.CommentTerminator
|
||||
writer.encoder.Code(header)
|
||||
writer.encoder.Code(uint32(math.MaxUint32))
|
||||
}
|
||||
|
||||
func (writer *Writer) addNewResource(id resource.ID, contentType resource.ContentType, resourceType byte, newResource resourceWriter) {
|
||||
entry := &resourceDirectoryEntry{ID: id.Value()}
|
||||
entry.setContentType(byte(contentType))
|
||||
entry.setResourceType(resourceType)
|
||||
writer.directory = append(writer.directory, entry)
|
||||
writer.currentResource = newResource
|
||||
writer.currentResourceStartOffset = writer.encoder.CurPos()
|
||||
}
|
||||
|
||||
func (writer *Writer) finishLastResource() {
|
||||
if writer.currentResource != nil {
|
||||
currentEntry := writer.directory[len(writer.directory)-1]
|
||||
currentEntry.setUnpackedLength(writer.currentResource.finish())
|
||||
currentEntry.setPackedLength(writer.encoder.CurPos() - writer.currentResourceStartOffset)
|
||||
|
||||
writer.currentResourceStartOffset = 0
|
||||
writer.currentResource = nil
|
||||
}
|
||||
writer.alignToBoundary()
|
||||
}
|
||||
|
||||
func (writer *Writer) alignToBoundary() {
|
||||
extraBytes := writer.encoder.CurPos() % format.BoundarySize
|
||||
if extraBytes > 0 {
|
||||
padding := make([]byte, format.BoundarySize-extraBytes)
|
||||
writer.encoder.Code(padding)
|
||||
}
|
||||
}
|
||||
|
||||
func (writer *Writer) dataPaddingSizeForCompoundResource(id resource.ID) (padding int) {
|
||||
// Some directories have a 2byte padding before the actual data
|
||||
idValue := id.Value()
|
||||
if (idValue >= 0x08FC) && (idValue <= 0x094B) { // all resources in obj3d.res
|
||||
padding = 2
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package lgres_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewWriterReturnsErrorForNilTarget(t *testing.T) {
|
||||
writer, err := lgres.NewWriter(nil)
|
||||
|
||||
assert.Nil(t, writer, "writer should be nil")
|
||||
assert.Equal(t, lgres.ErrTargetNil, err)
|
||||
}
|
||||
|
||||
func TestWriterFinishWithoutAddingResourcesCreatesValidFileWithoutResources(t *testing.T) {
|
||||
emptyFileData := emptyResourceFile()
|
||||
store := serial.NewByteStore()
|
||||
writer, err := lgres.NewWriter(store)
|
||||
assert.Nil(t, err, "no error expected creating writer")
|
||||
|
||||
err = writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing writer")
|
||||
assert.Equal(t, emptyFileData, store.Data())
|
||||
}
|
||||
|
||||
func TestWriterFinishReturnsErrorWhenAlreadyFinished(t *testing.T) {
|
||||
writer, _ := lgres.NewWriter(serial.NewByteStore())
|
||||
|
||||
err := writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing")
|
||||
|
||||
err = writer.Finish()
|
||||
assert.Equal(t, lgres.ErrWriterFinished, err)
|
||||
}
|
||||
|
||||
func TestWriterUncompressedSingleBlockResourceCanBeWritten(t *testing.T) {
|
||||
data := []byte{0xAB, 0x01, 0xCD, 0x02, 0xEF}
|
||||
store := serial.NewByteStore()
|
||||
writer, _ := lgres.NewWriter(store)
|
||||
resourceWriter, err := writer.CreateResource(resource.ID(0x1234), resource.ContentType(0x0A), false)
|
||||
assert.Nil(t, err, "no error expected creating resource")
|
||||
_, err = resourceWriter.Write(data)
|
||||
assert.Nil(t, err, "no error expected writing")
|
||||
err = writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing")
|
||||
|
||||
result := store.Data()
|
||||
|
||||
var expected []byte
|
||||
expected = append(expected, data...)
|
||||
expected = append(expected,
|
||||
0x00, 0x00, 0x00, // alignment for directory
|
||||
0x01, 0x00, // resource count
|
||||
0x80, 0x00, 0x00, 0x00, // offset to first resource
|
||||
0x34, 0x12, // resource ID
|
||||
0x05, 0x00, 0x00, // resource length (uncompressed)
|
||||
0x00, // resource type (uncompressed, single-block)
|
||||
0x05, 0x00, 0x00, // resource length in file
|
||||
0x0A) // content type
|
||||
assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:])
|
||||
}
|
||||
|
||||
func TestWriterUncompressedCompoundResourceCanBeWritten(t *testing.T) {
|
||||
blockData1 := []byte{0xAB, 0x01, 0xCD}
|
||||
blockData2 := []byte{0x11, 0x22, 0x33, 0x44}
|
||||
store := serial.NewByteStore()
|
||||
writer, _ := lgres.NewWriter(store)
|
||||
resourceWriter, err := writer.CreateCompoundResource(resource.ID(0x5678), resource.ContentType(0x0B), false)
|
||||
assert.Nil(t, err, "no error expected creating resource")
|
||||
_, _ = resourceWriter.CreateBlock().Write(blockData1)
|
||||
_, _ = resourceWriter.CreateBlock().Write(blockData2)
|
||||
err = writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing")
|
||||
|
||||
result := store.Data()
|
||||
|
||||
var expected []byte
|
||||
expected = append(expected,
|
||||
0x02, 0x00, // number of blocks
|
||||
0x0E, 0x00, 0x00, 0x00, // offset to first block
|
||||
0x11, 0x00, 0x00, 0x00, // offset to second block
|
||||
0x15, 0x00, 0x00, 0x00) // size of resource
|
||||
expected = append(expected, blockData1...)
|
||||
expected = append(expected, blockData2...)
|
||||
expected = append(expected,
|
||||
0x00, 0x00, 0x00, // alignment for directory
|
||||
0x01, 0x00, // resource count
|
||||
0x80, 0x00, 0x00, 0x00, // offset to first resource
|
||||
0x78, 0x56, // resource ID
|
||||
0x15, 0x00, 0x00, // resource length (uncompressed)
|
||||
0x02, // resource type
|
||||
0x15, 0x00, 0x00, // resource length in file
|
||||
0x0B) // content type
|
||||
assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:])
|
||||
}
|
||||
|
||||
func TestWriterUncompressedCompoundResourceCanBeWrittenWithPaddingForSpecialID(t *testing.T) {
|
||||
blockData1 := []byte{0xAB, 0x01, 0xCD}
|
||||
blockData2 := []byte{0x11, 0x22, 0x33, 0x44}
|
||||
store := serial.NewByteStore()
|
||||
writer, _ := lgres.NewWriter(store)
|
||||
resourceWriter, err := writer.CreateCompoundResource(resource.ID(0x08FD), resource.ContentType(0x0B), false)
|
||||
assert.Nil(t, err, "no error expected creating resource")
|
||||
_, _ = resourceWriter.CreateBlock().Write(blockData1)
|
||||
_, _ = resourceWriter.CreateBlock().Write(blockData2)
|
||||
err = writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing")
|
||||
|
||||
result := store.Data()
|
||||
|
||||
var expected []byte
|
||||
expected = append(expected,
|
||||
0x02, 0x00, // number of blocks
|
||||
0x10, 0x00, 0x00, 0x00, // offset to first block
|
||||
0x13, 0x00, 0x00, 0x00, // offset to second block
|
||||
0x17, 0x00, 0x00, 0x00, // size of resource
|
||||
0x00, 0x00) // padding
|
||||
expected = append(expected, blockData1...)
|
||||
expected = append(expected, blockData2...)
|
||||
expected = append(expected,
|
||||
0x00, // alignment for directory
|
||||
0x01, 0x00, // resource count
|
||||
0x80, 0x00, 0x00, 0x00, // offset to first resource
|
||||
0xFD, 0x08, // resource ID
|
||||
0x17, 0x00, 0x00, // resource length (uncompressed)
|
||||
0x02, // resource type
|
||||
0x17, 0x00, 0x00, // resource length in file
|
||||
0x0B) // content type
|
||||
assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:])
|
||||
}
|
||||
|
||||
func TestWriterCompressedSingleBlockResourceCanBeWritten(t *testing.T) {
|
||||
data := []byte{0x01, 0x02, 0x01, 0x02}
|
||||
store := serial.NewByteStore()
|
||||
writer, _ := lgres.NewWriter(store)
|
||||
resourceWriter, err := writer.CreateResource(resource.ID(0x1122), resource.ContentType(0x0C), true)
|
||||
assert.Nil(t, err, "no error expected creating resource")
|
||||
_, _ = resourceWriter.Write(data)
|
||||
err = writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing")
|
||||
|
||||
result := store.Data()
|
||||
|
||||
var expected []byte
|
||||
// 0000 0000|0000 0100|0000 0000|0010 0000|0100 0000|0011 1111|1111 1111
|
||||
expected = append(expected,
|
||||
0x00, 0x04, 0x00, 0x20, 0x40, 0x3F, 0xFF, 0x00, // 14bit words 0x0001 0x0002 0x0100 0x3FFF + trailing 0x00
|
||||
// (no bytes) alignment for directory
|
||||
0x01, 0x00, // resource count
|
||||
0x80, 0x00, 0x00, 0x00, // offset to first resource
|
||||
0x22, 0x11, // resource ID
|
||||
0x04, 0x00, 0x00, // resource length (uncompressed)
|
||||
0x01, // resource type
|
||||
0x08, 0x00, 0x00, // resource length in file
|
||||
0x0C) // content type
|
||||
assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:])
|
||||
}
|
||||
|
||||
func TestWriterCompressedCompoundResourceCanBeWritten(t *testing.T) {
|
||||
blockData1 := []byte{0x01, 0x02, 0x01, 0x02}
|
||||
blockData2 := []byte{0x01, 0x02, 0x01, 0x02}
|
||||
store := serial.NewByteStore()
|
||||
writer, _ := lgres.NewWriter(store)
|
||||
resourceWriter, err := writer.CreateCompoundResource(resource.ID(0x5544), resource.ContentType(0x09), true)
|
||||
assert.Nil(t, err, "no error expected creating resource")
|
||||
_, _ = resourceWriter.CreateBlock().Write(blockData1)
|
||||
_, _ = resourceWriter.CreateBlock().Write(blockData2)
|
||||
err = writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing")
|
||||
|
||||
result := store.Data()
|
||||
|
||||
var expected []byte
|
||||
expected = append(expected,
|
||||
0x02, 0x00, // number of blocks
|
||||
0x0E, 0x00, 0x00, 0x00, // offset to first block
|
||||
0x12, 0x00, 0x00, 0x00, // offset to second block
|
||||
0x16, 0x00, 0x00, 0x00, // size of resource
|
||||
0x00, 0x04, 0x00, 0x20, 0x40, // compressed data, part 1
|
||||
0x01, 0x02, 0x00, 0x0B, 0xFF, 0xF0, 0x00, // compressed data, part 2
|
||||
0x00, 0x00, // alignment for directory
|
||||
0x01, 0x00, // resource count
|
||||
0x80, 0x00, 0x00, 0x00, // offset to first resource
|
||||
0x44, 0x55, // resource ID
|
||||
0x16, 0x00, 0x00, // resource length (uncompressed)
|
||||
0x03, // resource type
|
||||
0x1A, 0x00, 0x00, // resource length in file
|
||||
0x09) // content type
|
||||
assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:])
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
Package lgres implements serialization of resources using the DOS format.
|
||||
*/
|
||||
package lgres
|
||||
@@ -0,0 +1,54 @@
|
||||
package lgres_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
func emptyResourceFile() []byte {
|
||||
buf := bytes.NewBufferString(format.HeaderString)
|
||||
headerTrailer := make([]byte, format.ResourceDirectoryFileOffsetPos-buf.Len())
|
||||
headerTrailer[0] = format.CommentTerminator
|
||||
|
||||
_ = binary.Write(buf, binary.LittleEndian, headerTrailer)
|
||||
dictionaryOffset := uint32(buf.Len() + 4)
|
||||
_ = binary.Write(buf, binary.LittleEndian, &dictionaryOffset)
|
||||
|
||||
numberOfResources := uint16(0)
|
||||
firstResourceOffset := uint32(buf.Len())
|
||||
|
||||
_ = binary.Write(buf, binary.LittleEndian, &numberOfResources)
|
||||
_ = binary.Write(buf, binary.LittleEndian, &firstResourceOffset)
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
var exampleResourceIDSingleBlockResource = resource.ID(0x4000)
|
||||
var exampleResourceIDSingleBlockResourceCompressed = resource.ID(0x1000)
|
||||
var exampleResourceIDCompoundResource = resource.ID(0x2000)
|
||||
var exampleResourceIDCompoundResourceCompressed = resource.ID(0x5000)
|
||||
|
||||
func exampleResourceFile() []byte {
|
||||
store := serial.NewByteStore()
|
||||
writer, _ := lgres.NewWriter(store)
|
||||
|
||||
resource1, _ := writer.CreateResource(exampleResourceIDSingleBlockResource, resource.ContentType(0x01), false)
|
||||
_, _ = resource1.Write([]byte{0x01, 0x01, 0x01})
|
||||
resource2, _ := writer.CreateResource(exampleResourceIDSingleBlockResourceCompressed, resource.ContentType(0x02), true)
|
||||
_, _ = resource2.Write([]byte{0x02, 0x02})
|
||||
resource3, _ := writer.CreateCompoundResource(exampleResourceIDCompoundResource, resource.ContentType(0x03), false)
|
||||
_, _ = resource3.CreateBlock().Write([]byte{0x30, 0x30, 0x30, 0x30})
|
||||
_, _ = resource3.CreateBlock().Write([]byte{0x31, 0x31, 0x31})
|
||||
resource4, _ := writer.CreateCompoundResource(exampleResourceIDCompoundResourceCompressed, resource.ContentType(0x04), true)
|
||||
_, _ = resource4.CreateBlock().Write([]byte{0x40, 0x40})
|
||||
_, _ = resource4.CreateBlock().Write([]byte{0x41, 0x41, 0x41, 0x41})
|
||||
_, _ = resource4.CreateBlock().Write([]byte{0x42})
|
||||
_ = writer.Finish()
|
||||
|
||||
return store.Data()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package compression
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
type compressor struct {
|
||||
coder serial.Coder
|
||||
writer *WordWriter
|
||||
|
||||
dictBuffer dictEntryBuffer
|
||||
overtime int
|
||||
dictionary *dictEntry
|
||||
dictionarySize int
|
||||
curEntry *dictEntry
|
||||
}
|
||||
|
||||
// NewCompressor creates a new compressor instance over a writer.
|
||||
func NewCompressor(target io.Writer) io.WriteCloser {
|
||||
coder := serial.NewEncoder(target)
|
||||
obj := &compressor{
|
||||
coder: coder,
|
||||
writer: NewWordWriter(coder),
|
||||
dictionary: rootDictEntry(),
|
||||
dictionarySize: 0,
|
||||
overtime: 0}
|
||||
|
||||
obj.resetDictionary()
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
func (obj *compressor) resetDictionary() {
|
||||
obj.dictionarySize = 0
|
||||
for i := 0; i < 0x100; i++ {
|
||||
obj.dictionary.Add(byte(i), Word(i), obj.dictBuffer.entry(Word(i)))
|
||||
}
|
||||
obj.curEntry = obj.dictionary
|
||||
}
|
||||
|
||||
func (obj *compressor) Close() error {
|
||||
obj.writer.Write(obj.curEntry.key)
|
||||
obj.writer.Close()
|
||||
|
||||
return obj.coder.FirstError()
|
||||
}
|
||||
|
||||
func (obj *compressor) Write(p []byte) (n int, err error) {
|
||||
for _, input := range p {
|
||||
obj.addByte(input)
|
||||
}
|
||||
|
||||
return len(p), obj.coder.FirstError()
|
||||
}
|
||||
|
||||
func (obj *compressor) addByte(value byte) {
|
||||
nextEntry := obj.curEntry.next[int(value)]
|
||||
if nextEntry != nil {
|
||||
obj.curEntry = nextEntry
|
||||
} else {
|
||||
obj.writer.Write(obj.curEntry.key)
|
||||
|
||||
key := Word(int(literalLimit) + obj.dictionarySize)
|
||||
if key < Reset {
|
||||
obj.curEntry.Add(value, key, obj.dictBuffer.entry(key))
|
||||
obj.dictionarySize++
|
||||
} else {
|
||||
obj.onKeySaturation()
|
||||
}
|
||||
|
||||
obj.curEntry = obj.dictionary.next[value]
|
||||
}
|
||||
}
|
||||
|
||||
func (obj *compressor) onKeySaturation() {
|
||||
obj.overtime++
|
||||
if obj.overtime > 1000 {
|
||||
obj.writer.Write(Reset)
|
||||
obj.resetDictionary()
|
||||
obj.overtime = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type CompressorSuite struct {
|
||||
suite.Suite
|
||||
store *serial.ByteStore
|
||||
compressor io.WriteCloser
|
||||
}
|
||||
|
||||
func TestCompressorSuite(t *testing.T) {
|
||||
suite.Run(t, new(CompressorSuite))
|
||||
}
|
||||
|
||||
func (suite *CompressorSuite) SetupTest() {
|
||||
suite.store = serial.NewByteStore()
|
||||
suite.compressor = compression.NewCompressor(suite.store)
|
||||
}
|
||||
|
||||
func (suite *CompressorSuite) TestWriteCompressesFirstReocurrence() {
|
||||
suite.givenWrittenData([]byte{0x00, 0x01})
|
||||
suite.givenWrittenData([]byte{0x00, 0x01})
|
||||
|
||||
err := suite.compressor.Close()
|
||||
assert.Nil(suite.T(), err, "no error expected")
|
||||
|
||||
suite.thenWordsShouldBe(compression.Word(0x0000), compression.Word(0x0001), compression.Word(0x0100))
|
||||
}
|
||||
|
||||
func (suite *CompressorSuite) TestWriteCompressesTest1() {
|
||||
suite.givenWrittenData([]byte{0x00, 0x01, 0x00, 0x02, 0x01, 0x00, 0x01})
|
||||
|
||||
err := suite.compressor.Close()
|
||||
assert.Nil(suite.T(), err, "no error expected")
|
||||
|
||||
suite.thenWordsShouldBe(
|
||||
compression.Word(0x0000), compression.Word(0x0001), compression.Word(0x0000),
|
||||
compression.Word(0x0002), compression.Word(0x0101), compression.Word(0x0001))
|
||||
}
|
||||
|
||||
func (suite *CompressorSuite) givenWrittenData(data []byte) {
|
||||
written, err := suite.compressor.Write(data)
|
||||
require.Nil(suite.T(), err, "no error expected writing")
|
||||
require.Equal(suite.T(), len(data), written, "expected all bytes to be written")
|
||||
}
|
||||
|
||||
func (suite *CompressorSuite) thenWordsShouldBe(expected ...compression.Word) {
|
||||
source := bytes.NewReader(suite.store.Data())
|
||||
reader := compression.NewWordReader(serial.NewDecoder(source))
|
||||
var words []compression.Word
|
||||
|
||||
for read := reader.Read(); read != compression.EndOfStream; read = reader.Read() {
|
||||
words = append(words, read)
|
||||
}
|
||||
|
||||
assert.Equal(suite.T(), expected, words)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package compression
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
type decompressor struct {
|
||||
coder serial.Coder
|
||||
reader *WordReader
|
||||
|
||||
isEndOfStream bool
|
||||
dictBuffer dictEntryBuffer
|
||||
dictionary *dictEntry
|
||||
dictionarySize int
|
||||
lastEntry *dictEntry
|
||||
lookup []*dictEntry
|
||||
|
||||
scratch []byte
|
||||
leftover []byte
|
||||
}
|
||||
|
||||
// NewDecompressor creates a new decompressor instance over a reader.
|
||||
func NewDecompressor(source io.Reader) io.Reader {
|
||||
coder := serial.NewDecoder(source)
|
||||
obj := &decompressor{
|
||||
coder: coder,
|
||||
reader: NewWordReader(coder),
|
||||
dictionary: rootDictEntry(),
|
||||
scratch: make([]byte, 1024)}
|
||||
obj.resetDictionary()
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
func (obj *decompressor) resetDictionary() {
|
||||
obj.dictionarySize = 0
|
||||
obj.lookup = make([]*dictEntry, 1024)
|
||||
obj.dictionary = rootDictEntry()
|
||||
for i := 0; i < 0x100; i++ {
|
||||
entry := obj.dictionary.Add(byte(i), Word(i), obj.dictBuffer.entry(Word(i)))
|
||||
obj.lookup[Word(i)] = entry
|
||||
}
|
||||
obj.lastEntry = obj.dictionary
|
||||
}
|
||||
|
||||
func (obj *decompressor) Read(p []byte) (n int, err error) {
|
||||
requested := len(p)
|
||||
|
||||
for n < requested && !obj.isEndOfStream && obj.coder.FirstError() == nil {
|
||||
n += obj.takeFromLeftover(p[n:])
|
||||
if n < requested {
|
||||
obj.readNextWord()
|
||||
n += obj.takeFromLeftover(p[n:])
|
||||
}
|
||||
}
|
||||
err = obj.coder.FirstError()
|
||||
if err == nil && obj.isEndOfStream {
|
||||
err = io.EOF
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (obj *decompressor) takeFromLeftover(target []byte) (provided int) {
|
||||
requested := len(target)
|
||||
available := len(obj.leftover)
|
||||
|
||||
if available > 0 && requested > 0 {
|
||||
provided = available
|
||||
if provided > requested {
|
||||
provided = requested
|
||||
}
|
||||
copy(target[0:provided], obj.leftover)
|
||||
obj.leftover = obj.leftover[provided:]
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (obj *decompressor) readNextWord() {
|
||||
nextWord := obj.reader.Read()
|
||||
|
||||
if obj.lastEntry.depth > len(obj.scratch) {
|
||||
obj.scratch = make([]byte, len(obj.scratch)+1024)
|
||||
}
|
||||
obj.leftover = obj.scratch[:obj.lastEntry.depth]
|
||||
obj.lastEntry.Data(obj.leftover)
|
||||
switch nextWord {
|
||||
case EndOfStream:
|
||||
obj.isEndOfStream = true
|
||||
case Reset:
|
||||
obj.resetDictionary()
|
||||
default:
|
||||
var nextEntry *dictEntry
|
||||
if int(nextWord) < len(obj.lookup) {
|
||||
nextEntry = obj.lookup[int(nextWord)]
|
||||
}
|
||||
|
||||
switch {
|
||||
case nextEntry != nil:
|
||||
if obj.lastEntry.depth > 0 {
|
||||
obj.addToDictionary(nextEntry.FirstByte())
|
||||
}
|
||||
obj.lastEntry = nextEntry
|
||||
case nextWord >= literalLimit:
|
||||
nextValue := obj.lastEntry.FirstByte()
|
||||
obj.addToDictionary(nextValue)
|
||||
obj.lastEntry = obj.lastEntry.next[nextValue]
|
||||
default:
|
||||
nextValue := byte(nextWord)
|
||||
obj.addToDictionary(nextValue)
|
||||
obj.lastEntry = obj.dictionary.next[nextValue]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (obj *decompressor) addToDictionary(value byte) {
|
||||
key := Word(int(literalLimit) + obj.dictionarySize)
|
||||
nextEntry := obj.lastEntry.Add(value, key, obj.dictBuffer.entry(key))
|
||||
if int(key) >= len(obj.lookup) {
|
||||
newLookup := make([]*dictEntry, len(obj.lookup)+1024)
|
||||
copy(newLookup, obj.lookup)
|
||||
obj.lookup = newLookup
|
||||
}
|
||||
obj.lookup[key] = nextEntry
|
||||
obj.dictionarySize++
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
type DecompressorSuite struct {
|
||||
suite.Suite
|
||||
store *serial.ByteStore
|
||||
compressor io.WriteCloser
|
||||
}
|
||||
|
||||
func TestDecompressorSuite(t *testing.T) {
|
||||
suite.Run(t, new(DecompressorSuite))
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) SetupTest() {
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressTest1() {
|
||||
input := []byte{0x00, 0x01, 0x00, 0x01}
|
||||
|
||||
suite.verify(input)
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressTest2() {
|
||||
input := []byte{0x00, 0x01, 0x00, 0x01, 0x00, 0x01}
|
||||
|
||||
suite.verify(input)
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressTest3() {
|
||||
suite.verify([]byte{})
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressTest4() {
|
||||
input := []byte{0x00, 0x01, 0x00, 0x02, 0x01, 0x00, 0x01}
|
||||
|
||||
suite.verify(input)
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressTest5() {
|
||||
input := []byte{0x00, 0x01, 0x00, 0x02, 0x01, 0x00, 0x01, 0x02, 0x01, 0x02, 0x01, 0x00, 0x01, 0x02}
|
||||
|
||||
suite.verify(input)
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressTestRandom() {
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
for testCase := 0; testCase < 100; testCase++ {
|
||||
input := make([]byte, r.Intn(1024))
|
||||
for i := 0; i < len(input); i++ {
|
||||
input[i] = byte(r.Intn(256))
|
||||
}
|
||||
suite.verify(input)
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressHandlesDictionaryResets() {
|
||||
suite.writeWords(0x0001, 0x0002, 0x0100, compression.Reset, 0x0003, 0x0004, 0x0100, compression.EndOfStream)
|
||||
|
||||
suite.verifyOutput([]byte{0x01, 0x02, 0x01, 0x02, 0x03, 0x04, 0x03, 0x04})
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressHandlesSelfReferencingWords() {
|
||||
suite.writeWords(0x0001, 0x0002, 0x0101, compression.EndOfStream)
|
||||
|
||||
suite.verifyOutput([]byte{0x01, 0x02, 0x02, 0x02})
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) writeWords(values ...compression.Word) {
|
||||
suite.store = serial.NewByteStore()
|
||||
coder := serial.NewEncoder(suite.store)
|
||||
writer := compression.NewWordWriter(coder)
|
||||
|
||||
for _, value := range values {
|
||||
writer.Write(value)
|
||||
}
|
||||
writer.Close()
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) verify(input []byte) {
|
||||
suite.store = serial.NewByteStore()
|
||||
suite.compressor = compression.NewCompressor(serial.NewEncoder(suite.store))
|
||||
|
||||
n, err := suite.compressor.Write(input)
|
||||
assert.Nil(suite.T(), err, "no error expected writing")
|
||||
assert.Equal(suite.T(), len(input), n, "invalid number written")
|
||||
err = suite.compressor.Close()
|
||||
assert.Nil(suite.T(), err, "no error expected closing")
|
||||
|
||||
suite.verifyOutput(input)
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) verifyOutput(expected []byte) {
|
||||
output := suite.buffer(len(expected))
|
||||
source := bytes.NewReader(suite.store.Data())
|
||||
decompressor := compression.NewDecompressor(source)
|
||||
read, err := decompressor.Read(output)
|
||||
|
||||
assert.True(suite.T(), (err == nil) || errors.Is(err, io.EOF), fmt.Sprintf("unexpected error: %v", err))
|
||||
assert.Equal(suite.T(), len(output), read, "unexpected number of bytes read")
|
||||
assert.Equal(suite.T(), expected, output)
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) buffer(byteCount int) []byte {
|
||||
result := make([]byte, byteCount)
|
||||
for i := range result {
|
||||
result[i] = 0xFF
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package compression
|
||||
|
||||
type dictEntry struct {
|
||||
prev *dictEntry
|
||||
depth int
|
||||
|
||||
next [256]*dictEntry
|
||||
|
||||
key Word
|
||||
value byte
|
||||
first byte
|
||||
used bool
|
||||
}
|
||||
|
||||
func rootDictEntry() *dictEntry {
|
||||
return &dictEntry{prev: nil, depth: 0, value: 0x00, key: Reset}
|
||||
}
|
||||
|
||||
var emptyList [256]*dictEntry
|
||||
|
||||
func (entry *dictEntry) Add(value byte, key Word, newEntry *dictEntry) *dictEntry {
|
||||
newEntry.prev = entry
|
||||
newEntry.depth = entry.depth + 1
|
||||
newEntry.value = value
|
||||
newEntry.key = key
|
||||
newEntry.first = entry.first
|
||||
if newEntry.used {
|
||||
copy(newEntry.next[:], emptyList[:])
|
||||
}
|
||||
newEntry.used = true
|
||||
|
||||
entry.next[value] = newEntry
|
||||
if entry.depth == 0 {
|
||||
newEntry.first = value
|
||||
}
|
||||
|
||||
return newEntry
|
||||
}
|
||||
|
||||
func (entry *dictEntry) Data(bytes []byte) {
|
||||
cur := entry
|
||||
for i := entry.depth - 1; i >= 0; i-- {
|
||||
bytes[i] = cur.value
|
||||
cur = cur.prev
|
||||
}
|
||||
}
|
||||
|
||||
func (entry *dictEntry) FirstByte() byte {
|
||||
return entry.first
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package compression
|
||||
|
||||
// dictEntryBucketSizeBits governs how big one bucket in the buffer is.
|
||||
const dictEntryBucketSizeBits = 8
|
||||
|
||||
type dictEntryBucket [1 << dictEntryBucketSizeBits]dictEntry
|
||||
|
||||
type dictEntryBuffer struct {
|
||||
buckets []*dictEntryBucket
|
||||
}
|
||||
|
||||
func (buffer *dictEntryBuffer) entry(key Word) *dictEntry {
|
||||
bucketIndex := int(key) >> dictEntryBucketSizeBits
|
||||
index := int(key & ((1 << dictEntryBucketSizeBits) - 1))
|
||||
|
||||
for bucketIndex >= len(buffer.buckets) {
|
||||
buffer.buckets = append(buffer.buckets, new(dictEntryBucket))
|
||||
}
|
||||
|
||||
return &buffer.buckets[bucketIndex][index]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package compression
|
||||
|
||||
// Word is a compression stream entry.
|
||||
type Word uint16
|
||||
|
||||
const (
|
||||
bitsPerWord = uint(14)
|
||||
|
||||
// EndOfStream indicates the last word in the compressed stream.
|
||||
EndOfStream = Word(0x3FFF)
|
||||
// Reset indicates a reset of the dictionary.
|
||||
Reset = Word(0x3FFE)
|
||||
literalLimit = Word(0x0100)
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package compression
|
||||
|
||||
import "github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
// WordReader provides word instances from a serialized stream.
|
||||
type WordReader struct {
|
||||
coder serial.Coder
|
||||
|
||||
buffer byte
|
||||
bufferBitsAvailable uint
|
||||
}
|
||||
|
||||
// NewWordReader returns a new instance.
|
||||
func NewWordReader(coder serial.Coder) *WordReader {
|
||||
reader := &WordReader{coder: coder, buffer: 0x00, bufferBitsAvailable: 0}
|
||||
|
||||
return reader
|
||||
}
|
||||
|
||||
// Read returns the next word from the stream.
|
||||
func (reader *WordReader) Read() (value Word) {
|
||||
remaining := bitsPerWord
|
||||
|
||||
for remaining > reader.bufferBitsAvailable {
|
||||
value = Word((uint(value) << reader.bufferBitsAvailable) | uint(reader.buffer))
|
||||
remaining -= reader.bufferBitsAvailable
|
||||
|
||||
reader.bufferByte()
|
||||
}
|
||||
|
||||
value = Word((uint(value) << remaining) | uint(reader.buffer)>>(8-remaining))
|
||||
reader.buffer &= 1<<(8-remaining) - 1
|
||||
reader.bufferBitsAvailable -= remaining
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (reader *WordReader) bufferByte() {
|
||||
reader.coder.Code(&reader.buffer)
|
||||
reader.bufferBitsAvailable = 8
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type WordReaderSuite struct {
|
||||
suite.Suite
|
||||
reader *compression.WordReader
|
||||
}
|
||||
|
||||
func TestWordReaderSuite(t *testing.T) {
|
||||
suite.Run(t, new(WordReaderSuite))
|
||||
}
|
||||
|
||||
func (suite *WordReaderSuite) TestReadCanReadFirstWord() {
|
||||
suite.givenReaderFrom()
|
||||
|
||||
assert.Equal(suite.T(), compression.EndOfStream, suite.reader.Read())
|
||||
}
|
||||
|
||||
func (suite *WordReaderSuite) TestReadCanReadSeveralWords() {
|
||||
input := []compression.Word{
|
||||
compression.Word(0x3FFF), compression.Word(0x0000), compression.Word(0x3FFF),
|
||||
compression.Word(0x0000), compression.Word(0x2001), compression.Word(0x1234),
|
||||
}
|
||||
suite.givenReaderFrom(input...)
|
||||
|
||||
output := make([]compression.Word, len(input)+1)
|
||||
for i := 0; i < len(output); i++ {
|
||||
output[i] = suite.reader.Read()
|
||||
}
|
||||
|
||||
assert.Equal(suite.T(), append(input, compression.EndOfStream), output)
|
||||
}
|
||||
|
||||
func (suite *WordReaderSuite) givenReaderFrom(words ...compression.Word) {
|
||||
store := serial.NewByteStore()
|
||||
encoder := serial.NewEncoder(store)
|
||||
writer := compression.NewWordWriter(encoder)
|
||||
|
||||
for _, value := range words {
|
||||
writer.Write(value)
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
source := bytes.NewReader(store.Data())
|
||||
suite.reader = compression.NewWordReader(serial.NewDecoder(source))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package compression
|
||||
|
||||
import "github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
// WordWriter serializes a stream of word entries.
|
||||
type WordWriter struct {
|
||||
coder serial.Coder
|
||||
|
||||
bufferedBits uint
|
||||
scratch uint32
|
||||
|
||||
outBuffer [1024]byte
|
||||
outUsed int
|
||||
}
|
||||
|
||||
// NewWordWriter returns a new instance.
|
||||
func NewWordWriter(coder serial.Coder) *WordWriter {
|
||||
writer := &WordWriter{coder: coder, bufferedBits: 0, scratch: 0}
|
||||
|
||||
return writer
|
||||
}
|
||||
|
||||
// Close finishes the stream of words.
|
||||
func (writer *WordWriter) Close() {
|
||||
writer.Write(EndOfStream)
|
||||
if writer.bufferedBits > 0 {
|
||||
writer.writeByte(byte(writer.scratch >> 16))
|
||||
}
|
||||
writer.writeByte(byte(0x00))
|
||||
writer.flushBuffer()
|
||||
}
|
||||
|
||||
// Write adds the given word to the stream.
|
||||
func (writer *WordWriter) Write(value Word) {
|
||||
writer.scratch |= uint32(value) << ((16 - bitsPerWord) + (8 - writer.bufferedBits))
|
||||
writer.bufferedBits += bitsPerWord
|
||||
writer.writeByte(byte(writer.scratch >> 16))
|
||||
writer.scratch <<= 8
|
||||
writer.bufferedBits -= 8
|
||||
|
||||
if writer.bufferedBits >= 8 {
|
||||
writer.writeByte(byte(writer.scratch >> 16))
|
||||
writer.scratch <<= 8
|
||||
writer.bufferedBits -= 8
|
||||
}
|
||||
}
|
||||
|
||||
func (writer *WordWriter) writeByte(value byte) {
|
||||
writer.outBuffer[writer.outUsed] = value
|
||||
writer.outUsed++
|
||||
if writer.outUsed >= len(writer.outBuffer) {
|
||||
writer.flushBuffer()
|
||||
}
|
||||
}
|
||||
|
||||
func (writer *WordWriter) flushBuffer() {
|
||||
writer.coder.Code(writer.outBuffer[:writer.outUsed])
|
||||
writer.outUsed = 0
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type WordWriterSuite struct {
|
||||
suite.Suite
|
||||
writer *compression.WordWriter
|
||||
store *serial.ByteStore
|
||||
}
|
||||
|
||||
func TestWordWriterSuite(t *testing.T) {
|
||||
suite.Run(t, new(WordWriterSuite))
|
||||
}
|
||||
|
||||
func (suite *WordWriterSuite) SetupTest() {
|
||||
suite.store = serial.NewByteStore()
|
||||
coder := serial.NewEncoder(suite.store)
|
||||
suite.writer = compression.NewWordWriter(coder)
|
||||
}
|
||||
|
||||
func (suite *WordWriterSuite) TestCloseWritesEndOfStreamMarkerAndTrailingZeroByte() {
|
||||
suite.writer.Close()
|
||||
|
||||
assert.Equal(suite.T(), []byte{0xFF, 0xFC, 0x00}, suite.store.Data())
|
||||
}
|
||||
|
||||
func (suite *WordWriterSuite) TestCloseWritesRemainderOnlyIfNotEmpty() {
|
||||
suite.writer.Write(compression.Word(0x0000))
|
||||
suite.writer.Write(compression.Word(0x0000))
|
||||
suite.writer.Write(compression.Word(0x0000))
|
||||
suite.writer.Close()
|
||||
|
||||
assert.Equal(suite.T(), []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0x00}, suite.store.Data())
|
||||
}
|
||||
|
||||
func (suite *WordWriterSuite) TestWriteAndCloseLinesUpBits() {
|
||||
suite.writer.Write(compression.Word(0x1FFE)) // 0111111 1111110
|
||||
suite.writer.Close() // 1111111 1111111
|
||||
|
||||
assert.Equal(suite.T(), []byte{0x7F, 0xFB, 0xFF, 0xF0, 0x00}, suite.store.Data())
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"runtime/pprof"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
// To run a profile, run it with the following command:
|
||||
// go test -bench . -args -cpuprofile=prof
|
||||
var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
|
||||
|
||||
func rawData(size int) []byte {
|
||||
data := make([]byte, size)
|
||||
rand.Read(data) // nolint:gas
|
||||
return data
|
||||
}
|
||||
|
||||
func initProfiling(b *testing.B, nameSuffix string) func() {
|
||||
b.Helper()
|
||||
flag.Parse()
|
||||
if *cpuprofile != "" {
|
||||
f, err := os.Create(*cpuprofile + "-" + b.Name() + nameSuffix)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_ = pprof.StartCPUProfile(f)
|
||||
return func() { pprof.StopCPUProfile() }
|
||||
}
|
||||
return func() {}
|
||||
}
|
||||
|
||||
func BenchmarkRawDataStorage(b *testing.B) {
|
||||
profileStop := initProfiling(b, "")
|
||||
defer profileStop()
|
||||
data := rawData(1024 * 1024)
|
||||
b.ResetTimer()
|
||||
for run := 0; run < b.N; run++ {
|
||||
encoder := serial.NewEncoder(serial.NewByteStore())
|
||||
_, _ = encoder.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkCompression(b *testing.B, size int, nameSuffix string) {
|
||||
b.Helper()
|
||||
profileStop := initProfiling(b, nameSuffix)
|
||||
defer profileStop()
|
||||
data := rawData(size)
|
||||
b.ResetTimer()
|
||||
for run := 0; run < b.N; run++ {
|
||||
compressor := compression.NewCompressor(serial.NewByteStore())
|
||||
_, _ = compressor.Write(data)
|
||||
_ = compressor.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCompression1KB(b *testing.B) {
|
||||
benchmarkCompression(b, 1024, "1KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompression16KB(b *testing.B) {
|
||||
benchmarkCompression(b, 1024*16, "16KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompression128KB(b *testing.B) {
|
||||
benchmarkCompression(b, 1024*128, "128KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompression512KB(b *testing.B) {
|
||||
benchmarkCompression(b, 1024*512, "512KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompression1024KB(b *testing.B) {
|
||||
benchmarkCompression(b, 1024*1024, "1024KB")
|
||||
}
|
||||
|
||||
func benchmarkCompressionDecompression(b *testing.B, size int, nameSuffix string) {
|
||||
b.Helper()
|
||||
profileStop := initProfiling(b, nameSuffix)
|
||||
defer profileStop()
|
||||
data := rawData(size)
|
||||
output := make([]byte, len(data))
|
||||
b.ResetTimer()
|
||||
for run := 0; run < b.N; run++ {
|
||||
store := serial.NewByteStore()
|
||||
compressor := compression.NewCompressor(store)
|
||||
_, _ = compressor.Write(data)
|
||||
_ = compressor.Close()
|
||||
_, _ = store.Seek(0, io.SeekStart)
|
||||
decompressor := compression.NewDecompressor(store)
|
||||
_, _ = decompressor.Read(output)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCompressionDecompression1KB(b *testing.B) {
|
||||
benchmarkCompressionDecompression(b, 1024, "1KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompressionDecompression16KB(b *testing.B) {
|
||||
benchmarkCompressionDecompression(b, 1024*16, "16KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompressionDecompression128KB(b *testing.B) {
|
||||
benchmarkCompressionDecompression(b, 1024*128, "128KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompressionDecompression512KB(b *testing.B) {
|
||||
benchmarkCompressionDecompression(b, 1024*512, "512KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompressionDecompression1024KB(b *testing.B) {
|
||||
benchmarkCompressionDecompression(b, 1024*1024, "1024KB")
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
Package compression implements the LG resource file compression algorithm.
|
||||
*/
|
||||
package compression
|
||||
@@ -0,0 +1,17 @@
|
||||
package format
|
||||
|
||||
const (
|
||||
// HeaderString is the start of an LG Res file.
|
||||
HeaderString = "LG Res File v2\r\n"
|
||||
// CommentTerminator terminates the header string.
|
||||
CommentTerminator = byte(0x1A)
|
||||
// ResourceDirectoryFileOffsetPos is the byte offset for the pointer to the directory.
|
||||
ResourceDirectoryFileOffsetPos = 0x7C
|
||||
// BoundarySize is the byte interval at which entries must be placed.
|
||||
BoundarySize = 4
|
||||
|
||||
// ResourceTypeFlagCompressed indicates a compressed resource.
|
||||
ResourceTypeFlagCompressed = byte(0x01)
|
||||
// ResourceTypeFlagCompound indicates a compound resource.
|
||||
ResourceTypeFlagCompound = byte(0x02)
|
||||
)
|
||||
Reference in New Issue
Block a user