Adding modding folder with a hacked build.

This commit is contained in:
ml
2026-09-26 08:53:08 +02:00
parent cdb960dc71
commit c91080aba7
755 changed files with 50424 additions and 0 deletions
@@ -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)
}
+62
View File
@@ -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)))
}
}
+51
View File
@@ -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}
}
+47
View File
@@ -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")
}
}
+20
View File
@@ -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}
}
+14
View File
@@ -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))
}
+46
View File
@@ -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))
}
+24
View File
@@ -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
}
+62
View File
@@ -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
}
+52
View File
@@ -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
}
+38
View File
@@ -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)
}
+34
View File
@@ -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
}
+85
View File
@@ -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
}
+146
View File
@@ -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)}})}
}
+29
View File
@@ -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
}
+10
View File
@@ -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)
}
+4
View File
@@ -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()
}
+256
View File
@@ -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())
}
+177
View File
@@ -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:])
}
+4
View File
@@ -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)
)