Adding modding folder with a hacked build.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
package lgres
|
||||
|
||||
import "io"
|
||||
|
||||
type blockFunc func(index int) (io.Reader, error)
|
||||
|
||||
type blockReader struct {
|
||||
blockCount int
|
||||
blockFunc blockFunc
|
||||
}
|
||||
|
||||
// BlockCount returns the number of available blocks.
|
||||
func (reader *blockReader) BlockCount() int {
|
||||
return reader.blockCount
|
||||
}
|
||||
|
||||
// Block returns the reader for the identified block.
|
||||
// Each call returns a new reader instance.
|
||||
// Data provided by this reader is always uncompressed.
|
||||
func (reader *blockReader) Block(index int) (io.Reader, error) {
|
||||
return reader.blockFunc(index)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package lgres
|
||||
|
||||
import "io"
|
||||
|
||||
// BlockWriter is for writing data of a single block in a resource.
|
||||
type BlockWriter struct {
|
||||
target io.Writer
|
||||
finisher func()
|
||||
bytesWritten uint32
|
||||
}
|
||||
|
||||
func (writer *BlockWriter) finish() (length uint32) {
|
||||
writer.finisher()
|
||||
writer.finisher = nil
|
||||
writer.target = nil
|
||||
return writer.bytesWritten
|
||||
}
|
||||
|
||||
// Write stores the given data in the block and follows the Writer interface.
|
||||
func (writer *BlockWriter) Write(data []byte) (written int, err error) {
|
||||
written, err = writer.target.Write(data)
|
||||
writer.bytesWritten += uint32(written)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package lgres
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
// CompoundResourceWriter writes a resource with zero, one, or more blocks.
|
||||
// Multiple blocks can be created and then written concurrently. Only when the
|
||||
// resource is finished, the blocks are finalized.
|
||||
type CompoundResourceWriter struct {
|
||||
target *serial.PositioningEncoder
|
||||
|
||||
compressed bool
|
||||
dataPaddingSize int
|
||||
blockStores []*serial.ByteStore
|
||||
blockWriter []*BlockWriter
|
||||
}
|
||||
|
||||
// CreateBlock provides a new, dedicated writer for a new block.
|
||||
func (writer *CompoundResourceWriter) CreateBlock() *BlockWriter {
|
||||
store := serial.NewByteStore()
|
||||
blockWriter := &BlockWriter{target: serial.NewEncoder(store), finisher: func() {}}
|
||||
writer.blockStores = append(writer.blockStores, store)
|
||||
writer.blockWriter = append(writer.blockWriter, blockWriter)
|
||||
return blockWriter
|
||||
}
|
||||
|
||||
func (writer *CompoundResourceWriter) finish() (length uint32) {
|
||||
var unpackedSize uint32
|
||||
blockCount := len(writer.blockStores)
|
||||
writer.target.Code(uint16(blockCount))
|
||||
offset := 2 + (blockCount+1)*4 + writer.dataPaddingSize
|
||||
for index, store := range writer.blockStores {
|
||||
unpackedSize += writer.blockWriter[index].finish()
|
||||
writer.target.Code(uint32(offset))
|
||||
offset += len(store.Data())
|
||||
}
|
||||
writer.target.Code(uint32(offset))
|
||||
unpackedSize += writer.target.CurPos() + uint32(writer.dataPaddingSize)
|
||||
|
||||
writer.writeBlocks()
|
||||
|
||||
return unpackedSize
|
||||
}
|
||||
|
||||
func (writer *CompoundResourceWriter) writeBlocks() {
|
||||
var targetWriter io.Writer = writer.target
|
||||
targetFinisher := func() {}
|
||||
if writer.compressed {
|
||||
compressor := compression.NewCompressor(targetWriter)
|
||||
targetWriter = compressor
|
||||
targetFinisher = func() { compressor.Close() } // nolint: errcheck
|
||||
}
|
||||
for i := 0; i < writer.dataPaddingSize; i++ {
|
||||
targetWriter.Write([]byte{0x00}) // nolint: errcheck
|
||||
}
|
||||
for _, store := range writer.blockStores {
|
||||
targetWriter.Write(store.Data()) // nolint: errcheck
|
||||
}
|
||||
targetFinisher()
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package lgres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
// Reader provides methods to extract resource data from a serialized form.
|
||||
// Resources may be accessed out of sequence due to the nature of the underlying io.ReaderAt.
|
||||
type Reader struct {
|
||||
source io.ReaderAt
|
||||
firstResourceOffset uint32
|
||||
directoryOffset uint32
|
||||
directory []resourceDirectoryEntry
|
||||
|
||||
cache map[uint16]resource.View
|
||||
}
|
||||
|
||||
const (
|
||||
// ErrSourceNil is returned if the source is not valid.
|
||||
ErrSourceNil ss1.StringError = "source is nil"
|
||||
|
||||
// ErrFormatMismatch is returned if the format is unknown.
|
||||
ErrFormatMismatch ss1.StringError = "format mismatch"
|
||||
)
|
||||
|
||||
// ReaderFrom accesses the provided source and creates a new Reader instance
|
||||
// from it.
|
||||
// Should the provided decoder not follow the resource file format, an error
|
||||
// is returned.
|
||||
func ReaderFrom(source io.ReaderAt) (reader *Reader, err error) {
|
||||
if source == nil {
|
||||
return nil, ErrSourceNil
|
||||
}
|
||||
|
||||
var dirOffset uint32
|
||||
dirOffset, err = readAndVerifyHeader(io.NewSectionReader(source, 0, format.ResourceDirectoryFileOffsetPos+4))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firstResourceOffset, directory, err := readDirectoryAt(dirOffset, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader = &Reader{
|
||||
source: source,
|
||||
firstResourceOffset: firstResourceOffset,
|
||||
directoryOffset: dirOffset,
|
||||
directory: directory,
|
||||
cache: make(map[uint16]resource.View)}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// IDs returns the resource identifier available via this reader.
|
||||
// The order in the slice is the same as in the underlying serialized form.
|
||||
func (reader *Reader) IDs() []resource.ID {
|
||||
ids := make([]resource.ID, len(reader.directory))
|
||||
for index, entry := range reader.directory {
|
||||
ids[index] = resource.ID(entry.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// View returns a reader prepared to extract data for the specified resource.
|
||||
// An error is returned if either the ID is not known, or the resource could not be prepared.
|
||||
func (reader *Reader) View(id resource.ID) (retrievedResource resource.View, err error) {
|
||||
if cachedResource, existing := reader.cache[id.Value()]; existing {
|
||||
return cachedResource, nil
|
||||
}
|
||||
resourceStartOffset, entry := reader.findEntry(id.Value())
|
||||
if entry == nil {
|
||||
return nil, resource.ErrNotFound(id)
|
||||
}
|
||||
resourceType := entry.resourceType()
|
||||
compressed := (resourceType & format.ResourceTypeFlagCompressed) != 0
|
||||
compound := (resourceType & format.ResourceTypeFlagCompound) != 0
|
||||
contentType := resource.ContentType(entry.contentType())
|
||||
|
||||
if compound {
|
||||
retrievedResource, err = reader.newCompoundResourceReader(entry, contentType, compressed, resourceStartOffset)
|
||||
} else {
|
||||
retrievedResource, err = reader.newSingleBlockResourceReader(entry, contentType, compressed, resourceStartOffset)
|
||||
}
|
||||
if err == nil {
|
||||
reader.cache[id.Value()] = retrievedResource
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func readAndVerifyHeader(source io.ReadSeeker) (dirOffset uint32, err error) {
|
||||
coder := serial.NewPositioningDecoder(source)
|
||||
data := make([]byte, format.ResourceDirectoryFileOffsetPos)
|
||||
coder.Code(data)
|
||||
coder.Code(&dirOffset)
|
||||
|
||||
expected := make([]byte, len(format.HeaderString)+1)
|
||||
for index, r := range format.HeaderString {
|
||||
expected[index] = byte(r)
|
||||
}
|
||||
expected[len(format.HeaderString)] = format.CommentTerminator
|
||||
if !bytes.Equal(data[:len(expected)], expected) {
|
||||
return 0, ErrFormatMismatch
|
||||
}
|
||||
|
||||
return dirOffset, coder.FirstError()
|
||||
}
|
||||
|
||||
func readDirectoryAt(dirOffset uint32, source io.ReaderAt) (firstResourceOffset uint32, directory []resourceDirectoryEntry, err error) {
|
||||
var header resourceDirectoryHeader
|
||||
headerSize := int64(binary.Size(&header))
|
||||
{
|
||||
headerCoder := serial.NewDecoder(io.NewSectionReader(source, int64(dirOffset), headerSize))
|
||||
headerCoder.Code(&header)
|
||||
if headerCoder.FirstError() != nil {
|
||||
return 0, nil, headerCoder.FirstError()
|
||||
}
|
||||
}
|
||||
|
||||
firstResourceOffset = header.FirstResourceOffset
|
||||
directory = make([]resourceDirectoryEntry, header.ResourceCount)
|
||||
if header.ResourceCount > 0 {
|
||||
listCoder := serial.NewDecoder(io.NewSectionReader(source, int64(dirOffset)+headerSize, int64(binary.Size(directory))))
|
||||
listCoder.Code(directory)
|
||||
err = listCoder.FirstError()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (reader *Reader) findEntry(id uint16) (startOffset uint32, entry *resourceDirectoryEntry) {
|
||||
startOffset = reader.firstResourceOffset
|
||||
for index := 0; (index < len(reader.directory)) && (entry == nil); index++ {
|
||||
cur := &reader.directory[index]
|
||||
if cur.ID == id {
|
||||
entry = cur
|
||||
} else {
|
||||
startOffset += cur.packedLength()
|
||||
startOffset += (format.BoundarySize - (startOffset % format.BoundarySize)) % format.BoundarySize
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type blockListEntry struct {
|
||||
start uint32
|
||||
size uint32
|
||||
}
|
||||
|
||||
func (reader *Reader) newCompoundResourceReader(entry *resourceDirectoryEntry,
|
||||
contentType resource.ContentType, compressed bool, resourceStartOffset uint32) (resource.View, error) {
|
||||
resourceDataReader := io.NewSectionReader(reader.source, int64(resourceStartOffset), int64(entry.packedLength()))
|
||||
|
||||
firstBlockOffset, blockList, err := reader.readBlockList(resourceDataReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blockCount := len(blockList)
|
||||
|
||||
rawBlockDataReader := io.NewSectionReader(resourceDataReader, int64(firstBlockOffset), resourceDataReader.Size()-int64(firstBlockOffset))
|
||||
var uncompressedReader io.ReaderAt
|
||||
if !compressed {
|
||||
uncompressedReader = rawBlockDataReader
|
||||
}
|
||||
|
||||
blockFunc := func(index int) (io.Reader, error) {
|
||||
if (index < 0) || (index >= blockCount) {
|
||||
return nil, resource.ErrBlockNotFound(index, blockCount)
|
||||
}
|
||||
|
||||
if compressed && (uncompressedReader == nil) {
|
||||
decompressor := compression.NewDecompressor(rawBlockDataReader)
|
||||
decompressedData, err := ioutil.ReadAll(decompressor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uncompressedReader = bytes.NewReader(decompressedData)
|
||||
}
|
||||
|
||||
entry := blockList[index]
|
||||
reader := io.NewSectionReader(uncompressedReader, int64(entry.start)-int64(firstBlockOffset), int64(entry.size))
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
return &readerResource{
|
||||
Properties: resource.Properties{
|
||||
Compound: true,
|
||||
ContentType: contentType,
|
||||
Compressed: compressed,
|
||||
},
|
||||
blockReader: blockReader{blockCount: len(blockList), blockFunc: blockFunc}}, nil
|
||||
}
|
||||
|
||||
func (reader *Reader) readBlockList(source io.Reader) (uint32, []blockListEntry, error) {
|
||||
listDecoder := serial.NewDecoder(source)
|
||||
var blockCount uint16
|
||||
listDecoder.Code(&blockCount)
|
||||
var firstBlockOffset uint32
|
||||
listDecoder.Code(&firstBlockOffset)
|
||||
lastBlockEndOffset := firstBlockOffset
|
||||
blockList := make([]blockListEntry, blockCount)
|
||||
for blockIndex := uint16(0); blockIndex < blockCount; blockIndex++ {
|
||||
var endOffset uint32
|
||||
listDecoder.Code(&endOffset)
|
||||
blockList[blockIndex].start = lastBlockEndOffset
|
||||
blockList[blockIndex].size = endOffset - lastBlockEndOffset
|
||||
lastBlockEndOffset = endOffset
|
||||
}
|
||||
|
||||
return firstBlockOffset, blockList, listDecoder.FirstError()
|
||||
}
|
||||
|
||||
func (reader *Reader) newSingleBlockResourceReader(entry *resourceDirectoryEntry,
|
||||
contentType resource.ContentType, compressed bool, resourceStartOffset uint32) (resource.View, error) {
|
||||
blockFunc := func(index int) (io.Reader, error) {
|
||||
if index != 0 {
|
||||
return nil, resource.ErrBlockNotFound(index, 1)
|
||||
}
|
||||
resourceSize := entry.packedLength()
|
||||
// The following check is a hack to re-create the behaviour of the engine.
|
||||
// While in truth the resource should only cover what its entry needs, Movie resources
|
||||
// can get larger than the 24bit size-field would allow (16MiB).
|
||||
// Interestingly, the engine does not limit this and simply reads beyond the limit.
|
||||
// In order to support such "broken" files, the behaviour is re-created here. This is mainly to
|
||||
// still allow creating such files and also give the editor a chance to work (and display such a case)
|
||||
// without completely having to rework the whole architecture here.
|
||||
// As audio of logs is also stored with this content type, limit to only single-resource files
|
||||
// to avoid a sudden increase of other resources in the file.
|
||||
isCutscene := (resource.ContentType(entry.contentType()) == resource.Movie) && (len(reader.directory) == 1)
|
||||
if isCutscene && (resourceStartOffset < reader.directoryOffset) {
|
||||
resourceSize = reader.directoryOffset - resourceStartOffset
|
||||
}
|
||||
var resourceSource io.Reader = io.NewSectionReader(reader.source, int64(resourceStartOffset), int64(resourceSize))
|
||||
if compressed {
|
||||
resourceSize = entry.unpackedLength()
|
||||
resourceSource = compression.NewDecompressor(resourceSource)
|
||||
}
|
||||
return io.LimitReader(resourceSource, int64(resourceSize)), nil
|
||||
}
|
||||
|
||||
return &readerResource{
|
||||
Properties: resource.Properties{
|
||||
Compound: false,
|
||||
ContentType: contentType,
|
||||
Compressed: compressed,
|
||||
},
|
||||
blockReader: blockReader{blockCount: 1, blockFunc: blockFunc}}, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package lgres
|
||||
|
||||
import "github.com/inkyblackness/hacked/ss1/resource"
|
||||
|
||||
type readerResource struct {
|
||||
resource.Properties
|
||||
blockReader
|
||||
}
|
||||
|
||||
func (rr readerResource) Compound() bool {
|
||||
return rr.Properties.Compound
|
||||
}
|
||||
|
||||
func (rr readerResource) ContentType() resource.ContentType {
|
||||
return rr.Properties.ContentType
|
||||
}
|
||||
|
||||
func (rr readerResource) Compressed() bool {
|
||||
return rr.Properties.Compressed
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package lgres_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReaderFromReturnsErrorForNilSource(t *testing.T) {
|
||||
reader, err := lgres.ReaderFrom(nil)
|
||||
|
||||
assert.Nil(t, reader, "reader should be nil")
|
||||
assert.Equal(t, lgres.ErrSourceNil, err)
|
||||
}
|
||||
|
||||
func TestReaderFromReturnsInstanceOnEmptySource(t *testing.T) {
|
||||
source := bytes.NewReader(emptyResourceFile())
|
||||
reader, err := lgres.ReaderFrom(source)
|
||||
|
||||
assert.Nil(t, err, "Error should be nil")
|
||||
assert.NotNil(t, reader)
|
||||
}
|
||||
|
||||
func TestReaderFromReturnsErrorOnInvalidHeaderString(t *testing.T) {
|
||||
sourceData := emptyResourceFile()
|
||||
sourceData[10] = "A"[0]
|
||||
|
||||
_, err := lgres.ReaderFrom(bytes.NewReader(sourceData))
|
||||
|
||||
assert.Equal(t, lgres.ErrFormatMismatch, err)
|
||||
}
|
||||
|
||||
func TestReaderFromReturnsErrorOnMissingCommentTerminator(t *testing.T) {
|
||||
sourceData := emptyResourceFile()
|
||||
sourceData[len(format.HeaderString)] = byte(0)
|
||||
|
||||
_, err := lgres.ReaderFrom(bytes.NewReader(sourceData))
|
||||
|
||||
assert.Equal(t, lgres.ErrFormatMismatch, err)
|
||||
}
|
||||
|
||||
func TestReaderFromReturnsErrorOnInvalidDirectoryStart(t *testing.T) {
|
||||
sourceData := emptyResourceFile()
|
||||
sourceData[format.ResourceDirectoryFileOffsetPos] = byte(0xFF)
|
||||
sourceData[format.ResourceDirectoryFileOffsetPos+1] = byte(0xFF)
|
||||
|
||||
_, err := lgres.ReaderFrom(bytes.NewReader(sourceData))
|
||||
|
||||
assert.NotNil(t, err, "error expected")
|
||||
}
|
||||
|
||||
func TestReaderFromCanDecodeExampleResourceFile(t *testing.T) {
|
||||
_, err := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
assert.Nil(t, err, "no error expected")
|
||||
}
|
||||
|
||||
func TestReaderIDsReturnsTheStoredResourceIDsInOrderFromFile(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
|
||||
assert.Equal(t, []resource.ID{exampleResourceIDSingleBlockResource, exampleResourceIDSingleBlockResourceCompressed,
|
||||
exampleResourceIDCompoundResource, exampleResourceIDCompoundResourceCompressed}, reader.IDs())
|
||||
}
|
||||
|
||||
func TestReaderResourceReturnsErrorForUnknownID(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(emptyResourceFile()))
|
||||
resourceReader, err := reader.View(resource.ID(0x1111))
|
||||
assert.Nil(t, resourceReader, "no reader expected")
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestReaderResourceReturnsAResourceReaderForKnownID(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
resourceReader, err := reader.View(exampleResourceIDSingleBlockResource)
|
||||
assert.Nil(t, err, "no error expected")
|
||||
assert.NotNil(t, resourceReader)
|
||||
}
|
||||
|
||||
func TestReaderResourceReturnsResourceWithMetaInformation(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
info := func(resourceID resource.ID, name string, expected interface{}) string {
|
||||
return fmt.Sprintf("Resource 0x%04X should have %v = %v", resourceID.Value(), name, expected)
|
||||
}
|
||||
verifyResource := func(resourceID resource.ID, compound bool, contentType resource.ContentType, compressed bool) {
|
||||
resourceReader, _ := reader.View(resourceID)
|
||||
assert.Equal(t, compound, resourceReader.Compound(), info(resourceID, "compound", compound))
|
||||
assert.Equal(t, contentType, resourceReader.ContentType(), info(resourceID, "contentType", contentType))
|
||||
assert.Equal(t, compressed, resourceReader.Compressed(), info(resourceID, "compressed", compressed))
|
||||
}
|
||||
verifyResource(exampleResourceIDSingleBlockResource, false, resource.ContentType(0x01), false)
|
||||
verifyResource(exampleResourceIDSingleBlockResourceCompressed, false, resource.ContentType(0x02), true)
|
||||
verifyResource(exampleResourceIDCompoundResource, true, resource.ContentType(0x03), false)
|
||||
verifyResource(exampleResourceIDCompoundResourceCompressed, true, resource.ContentType(0x04), true)
|
||||
}
|
||||
|
||||
func TestReaderResourceReturnsSameInstance(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
|
||||
c1, _ := reader.View(exampleResourceIDSingleBlockResource)
|
||||
c2, _ := reader.View(exampleResourceIDSingleBlockResource)
|
||||
|
||||
assert.Equal(t, c1, c2, "Resources should be the same")
|
||||
}
|
||||
|
||||
func TestReaderResourceWithUncompressedSingleBlockContent(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
resourceReader, _ := reader.View(exampleResourceIDSingleBlockResource)
|
||||
|
||||
assert.Equal(t, 1, resourceReader.BlockCount())
|
||||
verifyBlockContent(t, resourceReader, 0, []byte{0x01, 0x01, 0x01})
|
||||
}
|
||||
|
||||
func TestReaderResourceWithCompressedSingleBlockContent(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
resourceReader, _ := reader.View(exampleResourceIDSingleBlockResourceCompressed)
|
||||
|
||||
assert.Equal(t, 1, resourceReader.BlockCount())
|
||||
verifyBlockContent(t, resourceReader, 0, []byte{0x02, 0x02})
|
||||
}
|
||||
|
||||
func TestReaderResourceWithUncompressedCompoundContent(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
resourceReader, _ := reader.View(exampleResourceIDCompoundResource)
|
||||
|
||||
assert.Equal(t, 2, resourceReader.BlockCount())
|
||||
verifyBlockContent(t, resourceReader, 0, []byte{0x30, 0x30, 0x30, 0x30})
|
||||
verifyBlockContent(t, resourceReader, 1, []byte{0x31, 0x31, 0x31})
|
||||
}
|
||||
|
||||
func TestReaderResourceWithCompressedCompoundContent(t *testing.T) {
|
||||
reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile()))
|
||||
resourceReader, _ := reader.View(exampleResourceIDCompoundResourceCompressed)
|
||||
|
||||
assert.Equal(t, 3, resourceReader.BlockCount())
|
||||
verifyBlockContent(t, resourceReader, 0, []byte{0x40, 0x40})
|
||||
verifyBlockContent(t, resourceReader, 1, []byte{0x41, 0x41, 0x41, 0x41})
|
||||
verifyBlockContent(t, resourceReader, 2, []byte{0x42})
|
||||
}
|
||||
|
||||
func verifyBlockContent(t *testing.T, blockProvider resource.BlockProvider, blockIndex int, expected []byte) {
|
||||
t.Helper()
|
||||
blockReader, readerErr := blockProvider.Block(blockIndex)
|
||||
assert.Nil(t, readerErr, "error retrieving reader")
|
||||
require.NotNil(t, blockReader, "reader is nil")
|
||||
data, dataErr := ioutil.ReadAll(blockReader)
|
||||
assert.Nil(t, dataErr, "no error expected reading data")
|
||||
assert.Equal(t, expected, data)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package lgres
|
||||
|
||||
import (
|
||||
"math"
|
||||
)
|
||||
|
||||
type resourceDirectoryEntry struct {
|
||||
ID uint16
|
||||
UnpackedLengthAndResourceType uint32
|
||||
PackedLengthAndContentType uint32
|
||||
}
|
||||
|
||||
func maskBits(field uint32, bitOffset uint, bitCount int) uint32 {
|
||||
return (field >> bitOffset) & uint32(^(uint64(math.MaxUint64) << uint64(bitCount)))
|
||||
}
|
||||
|
||||
func setBits(field uint32, bitOffset uint, bitCount int, value uint32) uint32 {
|
||||
mask := uint32(^(uint64(math.MaxUint64) << uint64(bitCount)))
|
||||
return (field & (^mask << bitOffset)) | ((value & mask) << bitOffset)
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) setUnpackedLength(value uint32) {
|
||||
entry.UnpackedLengthAndResourceType = setBits(entry.UnpackedLengthAndResourceType, 0, 24, value)
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) unpackedLength() uint32 {
|
||||
return maskBits(entry.UnpackedLengthAndResourceType, 0, 24)
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) setResourceType(value byte) {
|
||||
entry.UnpackedLengthAndResourceType = setBits(entry.UnpackedLengthAndResourceType, 24, 8, uint32(value))
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) resourceType() byte {
|
||||
return byte(maskBits(entry.UnpackedLengthAndResourceType, 24, 8))
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) setPackedLength(value uint32) {
|
||||
entry.PackedLengthAndContentType = setBits(entry.PackedLengthAndContentType, 0, 24, value)
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) packedLength() uint32 {
|
||||
return maskBits(entry.PackedLengthAndContentType, 0, 24)
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) setContentType(value byte) {
|
||||
entry.PackedLengthAndContentType = setBits(entry.PackedLengthAndContentType, 24, 8, uint32(value))
|
||||
}
|
||||
|
||||
func (entry *resourceDirectoryEntry) contentType() byte {
|
||||
return byte(maskBits(entry.PackedLengthAndContentType, 24, 8))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package lgres
|
||||
|
||||
type resourceDirectoryHeader struct {
|
||||
ResourceCount uint16
|
||||
FirstResourceOffset uint32
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package lgres
|
||||
|
||||
type resourceWriter interface {
|
||||
// finish signals the writer that the resource is to be finished.
|
||||
// Any pending data needs to be sent to the output and the uncompressed
|
||||
// size shall be returned.
|
||||
finish() uint32
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package lgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
)
|
||||
|
||||
type resourceInconsistentError struct {
|
||||
ID resource.ID
|
||||
BlockCount int
|
||||
}
|
||||
|
||||
func (err resourceInconsistentError) Error() string {
|
||||
return fmt.Sprintf("simple resource %v has invalid number of blocks: %d", err.ID, err.BlockCount)
|
||||
}
|
||||
|
||||
// Write serializes the resources from given source into the target.
|
||||
// It is a convenience function for using Writer.
|
||||
func Write(target io.WriteSeeker, source resource.Viewer) error {
|
||||
writer, writerErr := NewWriter(target)
|
||||
if writerErr != nil {
|
||||
return writerErr
|
||||
}
|
||||
|
||||
for _, id := range source.IDs() {
|
||||
entry, resourceErr := source.View(id)
|
||||
if resourceErr != nil {
|
||||
return resourceErr
|
||||
}
|
||||
|
||||
switch {
|
||||
case entry.Compound():
|
||||
resourceWriter, resourceWriterErr := writer.CreateCompoundResource(id, entry.ContentType(), entry.Compressed())
|
||||
if resourceWriterErr != nil {
|
||||
return resourceWriterErr
|
||||
}
|
||||
copyErr := copyBlocks(entry, func() io.Writer { return resourceWriter.CreateBlock() })
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
case entry.BlockCount() == 1:
|
||||
blockWriter, resourceWriterErr := writer.CreateResource(id, entry.ContentType(), entry.Compressed())
|
||||
if resourceWriterErr != nil {
|
||||
return resourceWriterErr
|
||||
}
|
||||
copyErr := copyBlocks(entry, func() io.Writer { return blockWriter })
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
default:
|
||||
return resourceInconsistentError{ID: id, BlockCount: entry.BlockCount()}
|
||||
}
|
||||
}
|
||||
|
||||
return writer.Finish()
|
||||
}
|
||||
|
||||
func copyBlocks(source resource.BlockProvider, nextWriter func() io.Writer) error {
|
||||
for blockIndex := 0; blockIndex < source.BlockCount(); blockIndex++ {
|
||||
blockReader, blockErr := source.Block(blockIndex)
|
||||
if blockErr != nil {
|
||||
return blockErr
|
||||
}
|
||||
_, copyErr := io.Copy(nextWriter(), blockReader)
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package lgres_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
target := serial.NewByteStore()
|
||||
var store resource.Store
|
||||
aResource := func(compressed bool, contentType resource.ContentType, compound bool, blocks resource.Blocks) resource.View {
|
||||
return resource.Resource{
|
||||
Properties: resource.Properties{
|
||||
Compressed: compressed,
|
||||
ContentType: contentType,
|
||||
Compound: compound,
|
||||
},
|
||||
Blocks: blocks,
|
||||
}
|
||||
}
|
||||
|
||||
_ = store.Put(resource.ID(1), aResource(false, resource.Bitmap, false, resource.BlocksFrom([][]byte{{0x11}})))
|
||||
_ = store.Put(resource.ID(3), aResource(false, resource.Font, true, resource.BlocksFrom([][]byte{{0x21}, {0x22, 0x23}})))
|
||||
_ = store.Put(resource.ID(2), aResource(true, resource.Geometry, false, resource.BlocksFrom([][]byte{{0x31}})))
|
||||
_ = store.Put(resource.ID(4), aResource(true, resource.Archive, true, resource.BlocksFrom([][]byte{{0x41}, {0x42, 0x43}})))
|
||||
|
||||
errWrite := lgres.Write(target, store)
|
||||
if errWrite != nil {
|
||||
assert.Nil(t, errWrite, "no error expected writing")
|
||||
}
|
||||
|
||||
reader, errReader := lgres.ReaderFrom(bytes.NewReader(target.Data()))
|
||||
if errReader != nil {
|
||||
assert.Nil(t, errReader, "no error expected reading")
|
||||
}
|
||||
|
||||
assert.Equal(t, []resource.ID{resource.ID(1), resource.ID(3), resource.ID(2), resource.ID(4)}, reader.IDs())
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package lgres
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
// Writer provides methods to write a new resource file from scratch.
|
||||
// Resources have to be created sequentially. The writer does not support
|
||||
// concurrent creation and modification of resources.
|
||||
type Writer struct {
|
||||
encoder *serial.PositioningEncoder
|
||||
|
||||
firstResourceOffset uint32
|
||||
currentResourceStartOffset uint32
|
||||
currentResource resourceWriter
|
||||
|
||||
directory []*resourceDirectoryEntry
|
||||
}
|
||||
|
||||
// ErrTargetNil is returned if the target cannot be used.
|
||||
const ErrTargetNil ss1.StringError = "target is nil"
|
||||
|
||||
// NewWriter returns a new Writer instance prepared to add resources.
|
||||
// To finalize the created file, call Finish().
|
||||
//
|
||||
// This function will write initial information to the target and will return
|
||||
// an error if the writer did. In such a case, the returned writer instance
|
||||
// will produce invalid results and the state of the target is undefined.
|
||||
func NewWriter(target io.WriteSeeker) (*Writer, error) {
|
||||
if target == nil {
|
||||
return nil, ErrTargetNil
|
||||
}
|
||||
|
||||
encoder := serial.NewPositioningEncoder(target)
|
||||
writer := &Writer{encoder: encoder}
|
||||
writer.writeHeader()
|
||||
writer.firstResourceOffset = writer.encoder.CurPos()
|
||||
|
||||
return writer, writer.encoder.FirstError()
|
||||
}
|
||||
|
||||
// ErrWriterFinished is returned if the writer was already finished.
|
||||
const ErrWriterFinished ss1.StringError = "writer is finished"
|
||||
|
||||
// CreateResource adds a new single-block resource to the current resource file.
|
||||
// This resource is closed by creating another resource, or by finishing the writer.
|
||||
func (writer *Writer) CreateResource(id resource.ID, contentType resource.ContentType,
|
||||
compressed bool) (*BlockWriter, error) {
|
||||
if writer.encoder == nil {
|
||||
return nil, ErrWriterFinished
|
||||
}
|
||||
|
||||
writer.finishLastResource()
|
||||
if writer.encoder.FirstError() != nil {
|
||||
return nil, writer.encoder.FirstError()
|
||||
}
|
||||
|
||||
var targetWriter io.Writer = serial.NewEncoder(writer.encoder)
|
||||
targetFinisher := func() {}
|
||||
resourceType := byte(0x00)
|
||||
if compressed {
|
||||
compressor := compression.NewCompressor(targetWriter)
|
||||
resourceType |= format.ResourceTypeFlagCompressed
|
||||
targetWriter = compressor
|
||||
targetFinisher = func() { compressor.Close() } // nolint: errcheck
|
||||
}
|
||||
blockWriter := &BlockWriter{target: targetWriter, finisher: targetFinisher}
|
||||
writer.addNewResource(id, contentType, resourceType, blockWriter)
|
||||
|
||||
return blockWriter, nil
|
||||
}
|
||||
|
||||
// CreateCompoundResource adds a new compound resource to the current resource file.
|
||||
// This resource is closed by creating another resource, or by finishing the writer.
|
||||
func (writer *Writer) CreateCompoundResource(id resource.ID, contentType resource.ContentType,
|
||||
compressed bool) (*CompoundResourceWriter, error) {
|
||||
if writer.encoder == nil {
|
||||
return nil, ErrWriterFinished
|
||||
}
|
||||
|
||||
writer.finishLastResource()
|
||||
if writer.encoder.FirstError() != nil {
|
||||
return nil, writer.encoder.FirstError()
|
||||
}
|
||||
|
||||
resourceType := format.ResourceTypeFlagCompound
|
||||
if compressed {
|
||||
resourceType |= format.ResourceTypeFlagCompressed
|
||||
}
|
||||
resourceWriter := &CompoundResourceWriter{
|
||||
target: serial.NewPositioningEncoder(writer.encoder),
|
||||
compressed: compressed,
|
||||
dataPaddingSize: writer.dataPaddingSizeForCompoundResource(id)}
|
||||
writer.addNewResource(id, contentType, resourceType, resourceWriter)
|
||||
|
||||
return resourceWriter, nil
|
||||
}
|
||||
|
||||
// Finish finalizes the resource file. After calling this function, the
|
||||
// writer becomes unusable.
|
||||
func (writer *Writer) Finish() (err error) {
|
||||
if writer.encoder == nil {
|
||||
return ErrWriterFinished
|
||||
}
|
||||
|
||||
writer.finishLastResource()
|
||||
|
||||
directoryOffset := writer.encoder.CurPos()
|
||||
writer.encoder.SetCurPos(format.ResourceDirectoryFileOffsetPos)
|
||||
writer.encoder.Code(directoryOffset)
|
||||
writer.encoder.SetCurPos(directoryOffset)
|
||||
writer.encoder.Code(uint16(len(writer.directory)))
|
||||
writer.encoder.Code(writer.firstResourceOffset)
|
||||
for _, entry := range writer.directory {
|
||||
writer.encoder.Code(entry)
|
||||
}
|
||||
|
||||
err = writer.encoder.FirstError()
|
||||
writer.encoder = nil
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (writer *Writer) writeHeader() {
|
||||
header := make([]byte, format.ResourceDirectoryFileOffsetPos)
|
||||
for index, r := range format.HeaderString {
|
||||
header[index] = byte(r)
|
||||
}
|
||||
header[len(format.HeaderString)] = format.CommentTerminator
|
||||
writer.encoder.Code(header)
|
||||
writer.encoder.Code(uint32(math.MaxUint32))
|
||||
}
|
||||
|
||||
func (writer *Writer) addNewResource(id resource.ID, contentType resource.ContentType, resourceType byte, newResource resourceWriter) {
|
||||
entry := &resourceDirectoryEntry{ID: id.Value()}
|
||||
entry.setContentType(byte(contentType))
|
||||
entry.setResourceType(resourceType)
|
||||
writer.directory = append(writer.directory, entry)
|
||||
writer.currentResource = newResource
|
||||
writer.currentResourceStartOffset = writer.encoder.CurPos()
|
||||
}
|
||||
|
||||
func (writer *Writer) finishLastResource() {
|
||||
if writer.currentResource != nil {
|
||||
currentEntry := writer.directory[len(writer.directory)-1]
|
||||
currentEntry.setUnpackedLength(writer.currentResource.finish())
|
||||
currentEntry.setPackedLength(writer.encoder.CurPos() - writer.currentResourceStartOffset)
|
||||
|
||||
writer.currentResourceStartOffset = 0
|
||||
writer.currentResource = nil
|
||||
}
|
||||
writer.alignToBoundary()
|
||||
}
|
||||
|
||||
func (writer *Writer) alignToBoundary() {
|
||||
extraBytes := writer.encoder.CurPos() % format.BoundarySize
|
||||
if extraBytes > 0 {
|
||||
padding := make([]byte, format.BoundarySize-extraBytes)
|
||||
writer.encoder.Code(padding)
|
||||
}
|
||||
}
|
||||
|
||||
func (writer *Writer) dataPaddingSizeForCompoundResource(id resource.ID) (padding int) {
|
||||
// Some directories have a 2byte padding before the actual data
|
||||
idValue := id.Value()
|
||||
if (idValue >= 0x08FC) && (idValue <= 0x094B) { // all resources in obj3d.res
|
||||
padding = 2
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package lgres_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewWriterReturnsErrorForNilTarget(t *testing.T) {
|
||||
writer, err := lgres.NewWriter(nil)
|
||||
|
||||
assert.Nil(t, writer, "writer should be nil")
|
||||
assert.Equal(t, lgres.ErrTargetNil, err)
|
||||
}
|
||||
|
||||
func TestWriterFinishWithoutAddingResourcesCreatesValidFileWithoutResources(t *testing.T) {
|
||||
emptyFileData := emptyResourceFile()
|
||||
store := serial.NewByteStore()
|
||||
writer, err := lgres.NewWriter(store)
|
||||
assert.Nil(t, err, "no error expected creating writer")
|
||||
|
||||
err = writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing writer")
|
||||
assert.Equal(t, emptyFileData, store.Data())
|
||||
}
|
||||
|
||||
func TestWriterFinishReturnsErrorWhenAlreadyFinished(t *testing.T) {
|
||||
writer, _ := lgres.NewWriter(serial.NewByteStore())
|
||||
|
||||
err := writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing")
|
||||
|
||||
err = writer.Finish()
|
||||
assert.Equal(t, lgres.ErrWriterFinished, err)
|
||||
}
|
||||
|
||||
func TestWriterUncompressedSingleBlockResourceCanBeWritten(t *testing.T) {
|
||||
data := []byte{0xAB, 0x01, 0xCD, 0x02, 0xEF}
|
||||
store := serial.NewByteStore()
|
||||
writer, _ := lgres.NewWriter(store)
|
||||
resourceWriter, err := writer.CreateResource(resource.ID(0x1234), resource.ContentType(0x0A), false)
|
||||
assert.Nil(t, err, "no error expected creating resource")
|
||||
_, err = resourceWriter.Write(data)
|
||||
assert.Nil(t, err, "no error expected writing")
|
||||
err = writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing")
|
||||
|
||||
result := store.Data()
|
||||
|
||||
var expected []byte
|
||||
expected = append(expected, data...)
|
||||
expected = append(expected,
|
||||
0x00, 0x00, 0x00, // alignment for directory
|
||||
0x01, 0x00, // resource count
|
||||
0x80, 0x00, 0x00, 0x00, // offset to first resource
|
||||
0x34, 0x12, // resource ID
|
||||
0x05, 0x00, 0x00, // resource length (uncompressed)
|
||||
0x00, // resource type (uncompressed, single-block)
|
||||
0x05, 0x00, 0x00, // resource length in file
|
||||
0x0A) // content type
|
||||
assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:])
|
||||
}
|
||||
|
||||
func TestWriterUncompressedCompoundResourceCanBeWritten(t *testing.T) {
|
||||
blockData1 := []byte{0xAB, 0x01, 0xCD}
|
||||
blockData2 := []byte{0x11, 0x22, 0x33, 0x44}
|
||||
store := serial.NewByteStore()
|
||||
writer, _ := lgres.NewWriter(store)
|
||||
resourceWriter, err := writer.CreateCompoundResource(resource.ID(0x5678), resource.ContentType(0x0B), false)
|
||||
assert.Nil(t, err, "no error expected creating resource")
|
||||
_, _ = resourceWriter.CreateBlock().Write(blockData1)
|
||||
_, _ = resourceWriter.CreateBlock().Write(blockData2)
|
||||
err = writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing")
|
||||
|
||||
result := store.Data()
|
||||
|
||||
var expected []byte
|
||||
expected = append(expected,
|
||||
0x02, 0x00, // number of blocks
|
||||
0x0E, 0x00, 0x00, 0x00, // offset to first block
|
||||
0x11, 0x00, 0x00, 0x00, // offset to second block
|
||||
0x15, 0x00, 0x00, 0x00) // size of resource
|
||||
expected = append(expected, blockData1...)
|
||||
expected = append(expected, blockData2...)
|
||||
expected = append(expected,
|
||||
0x00, 0x00, 0x00, // alignment for directory
|
||||
0x01, 0x00, // resource count
|
||||
0x80, 0x00, 0x00, 0x00, // offset to first resource
|
||||
0x78, 0x56, // resource ID
|
||||
0x15, 0x00, 0x00, // resource length (uncompressed)
|
||||
0x02, // resource type
|
||||
0x15, 0x00, 0x00, // resource length in file
|
||||
0x0B) // content type
|
||||
assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:])
|
||||
}
|
||||
|
||||
func TestWriterUncompressedCompoundResourceCanBeWrittenWithPaddingForSpecialID(t *testing.T) {
|
||||
blockData1 := []byte{0xAB, 0x01, 0xCD}
|
||||
blockData2 := []byte{0x11, 0x22, 0x33, 0x44}
|
||||
store := serial.NewByteStore()
|
||||
writer, _ := lgres.NewWriter(store)
|
||||
resourceWriter, err := writer.CreateCompoundResource(resource.ID(0x08FD), resource.ContentType(0x0B), false)
|
||||
assert.Nil(t, err, "no error expected creating resource")
|
||||
_, _ = resourceWriter.CreateBlock().Write(blockData1)
|
||||
_, _ = resourceWriter.CreateBlock().Write(blockData2)
|
||||
err = writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing")
|
||||
|
||||
result := store.Data()
|
||||
|
||||
var expected []byte
|
||||
expected = append(expected,
|
||||
0x02, 0x00, // number of blocks
|
||||
0x10, 0x00, 0x00, 0x00, // offset to first block
|
||||
0x13, 0x00, 0x00, 0x00, // offset to second block
|
||||
0x17, 0x00, 0x00, 0x00, // size of resource
|
||||
0x00, 0x00) // padding
|
||||
expected = append(expected, blockData1...)
|
||||
expected = append(expected, blockData2...)
|
||||
expected = append(expected,
|
||||
0x00, // alignment for directory
|
||||
0x01, 0x00, // resource count
|
||||
0x80, 0x00, 0x00, 0x00, // offset to first resource
|
||||
0xFD, 0x08, // resource ID
|
||||
0x17, 0x00, 0x00, // resource length (uncompressed)
|
||||
0x02, // resource type
|
||||
0x17, 0x00, 0x00, // resource length in file
|
||||
0x0B) // content type
|
||||
assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:])
|
||||
}
|
||||
|
||||
func TestWriterCompressedSingleBlockResourceCanBeWritten(t *testing.T) {
|
||||
data := []byte{0x01, 0x02, 0x01, 0x02}
|
||||
store := serial.NewByteStore()
|
||||
writer, _ := lgres.NewWriter(store)
|
||||
resourceWriter, err := writer.CreateResource(resource.ID(0x1122), resource.ContentType(0x0C), true)
|
||||
assert.Nil(t, err, "no error expected creating resource")
|
||||
_, _ = resourceWriter.Write(data)
|
||||
err = writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing")
|
||||
|
||||
result := store.Data()
|
||||
|
||||
var expected []byte
|
||||
// 0000 0000|0000 0100|0000 0000|0010 0000|0100 0000|0011 1111|1111 1111
|
||||
expected = append(expected,
|
||||
0x00, 0x04, 0x00, 0x20, 0x40, 0x3F, 0xFF, 0x00, // 14bit words 0x0001 0x0002 0x0100 0x3FFF + trailing 0x00
|
||||
// (no bytes) alignment for directory
|
||||
0x01, 0x00, // resource count
|
||||
0x80, 0x00, 0x00, 0x00, // offset to first resource
|
||||
0x22, 0x11, // resource ID
|
||||
0x04, 0x00, 0x00, // resource length (uncompressed)
|
||||
0x01, // resource type
|
||||
0x08, 0x00, 0x00, // resource length in file
|
||||
0x0C) // content type
|
||||
assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:])
|
||||
}
|
||||
|
||||
func TestWriterCompressedCompoundResourceCanBeWritten(t *testing.T) {
|
||||
blockData1 := []byte{0x01, 0x02, 0x01, 0x02}
|
||||
blockData2 := []byte{0x01, 0x02, 0x01, 0x02}
|
||||
store := serial.NewByteStore()
|
||||
writer, _ := lgres.NewWriter(store)
|
||||
resourceWriter, err := writer.CreateCompoundResource(resource.ID(0x5544), resource.ContentType(0x09), true)
|
||||
assert.Nil(t, err, "no error expected creating resource")
|
||||
_, _ = resourceWriter.CreateBlock().Write(blockData1)
|
||||
_, _ = resourceWriter.CreateBlock().Write(blockData2)
|
||||
err = writer.Finish()
|
||||
assert.Nil(t, err, "no error expected finishing")
|
||||
|
||||
result := store.Data()
|
||||
|
||||
var expected []byte
|
||||
expected = append(expected,
|
||||
0x02, 0x00, // number of blocks
|
||||
0x0E, 0x00, 0x00, 0x00, // offset to first block
|
||||
0x12, 0x00, 0x00, 0x00, // offset to second block
|
||||
0x16, 0x00, 0x00, 0x00, // size of resource
|
||||
0x00, 0x04, 0x00, 0x20, 0x40, // compressed data, part 1
|
||||
0x01, 0x02, 0x00, 0x0B, 0xFF, 0xF0, 0x00, // compressed data, part 2
|
||||
0x00, 0x00, // alignment for directory
|
||||
0x01, 0x00, // resource count
|
||||
0x80, 0x00, 0x00, 0x00, // offset to first resource
|
||||
0x44, 0x55, // resource ID
|
||||
0x16, 0x00, 0x00, // resource length (uncompressed)
|
||||
0x03, // resource type
|
||||
0x1A, 0x00, 0x00, // resource length in file
|
||||
0x09) // content type
|
||||
assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:])
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
Package lgres implements serialization of resources using the DOS format.
|
||||
*/
|
||||
package lgres
|
||||
@@ -0,0 +1,54 @@
|
||||
package lgres_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres"
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
func emptyResourceFile() []byte {
|
||||
buf := bytes.NewBufferString(format.HeaderString)
|
||||
headerTrailer := make([]byte, format.ResourceDirectoryFileOffsetPos-buf.Len())
|
||||
headerTrailer[0] = format.CommentTerminator
|
||||
|
||||
_ = binary.Write(buf, binary.LittleEndian, headerTrailer)
|
||||
dictionaryOffset := uint32(buf.Len() + 4)
|
||||
_ = binary.Write(buf, binary.LittleEndian, &dictionaryOffset)
|
||||
|
||||
numberOfResources := uint16(0)
|
||||
firstResourceOffset := uint32(buf.Len())
|
||||
|
||||
_ = binary.Write(buf, binary.LittleEndian, &numberOfResources)
|
||||
_ = binary.Write(buf, binary.LittleEndian, &firstResourceOffset)
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
var exampleResourceIDSingleBlockResource = resource.ID(0x4000)
|
||||
var exampleResourceIDSingleBlockResourceCompressed = resource.ID(0x1000)
|
||||
var exampleResourceIDCompoundResource = resource.ID(0x2000)
|
||||
var exampleResourceIDCompoundResourceCompressed = resource.ID(0x5000)
|
||||
|
||||
func exampleResourceFile() []byte {
|
||||
store := serial.NewByteStore()
|
||||
writer, _ := lgres.NewWriter(store)
|
||||
|
||||
resource1, _ := writer.CreateResource(exampleResourceIDSingleBlockResource, resource.ContentType(0x01), false)
|
||||
_, _ = resource1.Write([]byte{0x01, 0x01, 0x01})
|
||||
resource2, _ := writer.CreateResource(exampleResourceIDSingleBlockResourceCompressed, resource.ContentType(0x02), true)
|
||||
_, _ = resource2.Write([]byte{0x02, 0x02})
|
||||
resource3, _ := writer.CreateCompoundResource(exampleResourceIDCompoundResource, resource.ContentType(0x03), false)
|
||||
_, _ = resource3.CreateBlock().Write([]byte{0x30, 0x30, 0x30, 0x30})
|
||||
_, _ = resource3.CreateBlock().Write([]byte{0x31, 0x31, 0x31})
|
||||
resource4, _ := writer.CreateCompoundResource(exampleResourceIDCompoundResourceCompressed, resource.ContentType(0x04), true)
|
||||
_, _ = resource4.CreateBlock().Write([]byte{0x40, 0x40})
|
||||
_, _ = resource4.CreateBlock().Write([]byte{0x41, 0x41, 0x41, 0x41})
|
||||
_, _ = resource4.CreateBlock().Write([]byte{0x42})
|
||||
_ = writer.Finish()
|
||||
|
||||
return store.Data()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package compression
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
type compressor struct {
|
||||
coder serial.Coder
|
||||
writer *WordWriter
|
||||
|
||||
dictBuffer dictEntryBuffer
|
||||
overtime int
|
||||
dictionary *dictEntry
|
||||
dictionarySize int
|
||||
curEntry *dictEntry
|
||||
}
|
||||
|
||||
// NewCompressor creates a new compressor instance over a writer.
|
||||
func NewCompressor(target io.Writer) io.WriteCloser {
|
||||
coder := serial.NewEncoder(target)
|
||||
obj := &compressor{
|
||||
coder: coder,
|
||||
writer: NewWordWriter(coder),
|
||||
dictionary: rootDictEntry(),
|
||||
dictionarySize: 0,
|
||||
overtime: 0}
|
||||
|
||||
obj.resetDictionary()
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
func (obj *compressor) resetDictionary() {
|
||||
obj.dictionarySize = 0
|
||||
for i := 0; i < 0x100; i++ {
|
||||
obj.dictionary.Add(byte(i), Word(i), obj.dictBuffer.entry(Word(i)))
|
||||
}
|
||||
obj.curEntry = obj.dictionary
|
||||
}
|
||||
|
||||
func (obj *compressor) Close() error {
|
||||
obj.writer.Write(obj.curEntry.key)
|
||||
obj.writer.Close()
|
||||
|
||||
return obj.coder.FirstError()
|
||||
}
|
||||
|
||||
func (obj *compressor) Write(p []byte) (n int, err error) {
|
||||
for _, input := range p {
|
||||
obj.addByte(input)
|
||||
}
|
||||
|
||||
return len(p), obj.coder.FirstError()
|
||||
}
|
||||
|
||||
func (obj *compressor) addByte(value byte) {
|
||||
nextEntry := obj.curEntry.next[int(value)]
|
||||
if nextEntry != nil {
|
||||
obj.curEntry = nextEntry
|
||||
} else {
|
||||
obj.writer.Write(obj.curEntry.key)
|
||||
|
||||
key := Word(int(literalLimit) + obj.dictionarySize)
|
||||
if key < Reset {
|
||||
obj.curEntry.Add(value, key, obj.dictBuffer.entry(key))
|
||||
obj.dictionarySize++
|
||||
} else {
|
||||
obj.onKeySaturation()
|
||||
}
|
||||
|
||||
obj.curEntry = obj.dictionary.next[value]
|
||||
}
|
||||
}
|
||||
|
||||
func (obj *compressor) onKeySaturation() {
|
||||
obj.overtime++
|
||||
if obj.overtime > 1000 {
|
||||
obj.writer.Write(Reset)
|
||||
obj.resetDictionary()
|
||||
obj.overtime = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type CompressorSuite struct {
|
||||
suite.Suite
|
||||
store *serial.ByteStore
|
||||
compressor io.WriteCloser
|
||||
}
|
||||
|
||||
func TestCompressorSuite(t *testing.T) {
|
||||
suite.Run(t, new(CompressorSuite))
|
||||
}
|
||||
|
||||
func (suite *CompressorSuite) SetupTest() {
|
||||
suite.store = serial.NewByteStore()
|
||||
suite.compressor = compression.NewCompressor(suite.store)
|
||||
}
|
||||
|
||||
func (suite *CompressorSuite) TestWriteCompressesFirstReocurrence() {
|
||||
suite.givenWrittenData([]byte{0x00, 0x01})
|
||||
suite.givenWrittenData([]byte{0x00, 0x01})
|
||||
|
||||
err := suite.compressor.Close()
|
||||
assert.Nil(suite.T(), err, "no error expected")
|
||||
|
||||
suite.thenWordsShouldBe(compression.Word(0x0000), compression.Word(0x0001), compression.Word(0x0100))
|
||||
}
|
||||
|
||||
func (suite *CompressorSuite) TestWriteCompressesTest1() {
|
||||
suite.givenWrittenData([]byte{0x00, 0x01, 0x00, 0x02, 0x01, 0x00, 0x01})
|
||||
|
||||
err := suite.compressor.Close()
|
||||
assert.Nil(suite.T(), err, "no error expected")
|
||||
|
||||
suite.thenWordsShouldBe(
|
||||
compression.Word(0x0000), compression.Word(0x0001), compression.Word(0x0000),
|
||||
compression.Word(0x0002), compression.Word(0x0101), compression.Word(0x0001))
|
||||
}
|
||||
|
||||
func (suite *CompressorSuite) givenWrittenData(data []byte) {
|
||||
written, err := suite.compressor.Write(data)
|
||||
require.Nil(suite.T(), err, "no error expected writing")
|
||||
require.Equal(suite.T(), len(data), written, "expected all bytes to be written")
|
||||
}
|
||||
|
||||
func (suite *CompressorSuite) thenWordsShouldBe(expected ...compression.Word) {
|
||||
source := bytes.NewReader(suite.store.Data())
|
||||
reader := compression.NewWordReader(serial.NewDecoder(source))
|
||||
var words []compression.Word
|
||||
|
||||
for read := reader.Read(); read != compression.EndOfStream; read = reader.Read() {
|
||||
words = append(words, read)
|
||||
}
|
||||
|
||||
assert.Equal(suite.T(), expected, words)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package compression
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
type decompressor struct {
|
||||
coder serial.Coder
|
||||
reader *WordReader
|
||||
|
||||
isEndOfStream bool
|
||||
dictBuffer dictEntryBuffer
|
||||
dictionary *dictEntry
|
||||
dictionarySize int
|
||||
lastEntry *dictEntry
|
||||
lookup []*dictEntry
|
||||
|
||||
scratch []byte
|
||||
leftover []byte
|
||||
}
|
||||
|
||||
// NewDecompressor creates a new decompressor instance over a reader.
|
||||
func NewDecompressor(source io.Reader) io.Reader {
|
||||
coder := serial.NewDecoder(source)
|
||||
obj := &decompressor{
|
||||
coder: coder,
|
||||
reader: NewWordReader(coder),
|
||||
dictionary: rootDictEntry(),
|
||||
scratch: make([]byte, 1024)}
|
||||
obj.resetDictionary()
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
func (obj *decompressor) resetDictionary() {
|
||||
obj.dictionarySize = 0
|
||||
obj.lookup = make([]*dictEntry, 1024)
|
||||
obj.dictionary = rootDictEntry()
|
||||
for i := 0; i < 0x100; i++ {
|
||||
entry := obj.dictionary.Add(byte(i), Word(i), obj.dictBuffer.entry(Word(i)))
|
||||
obj.lookup[Word(i)] = entry
|
||||
}
|
||||
obj.lastEntry = obj.dictionary
|
||||
}
|
||||
|
||||
func (obj *decompressor) Read(p []byte) (n int, err error) {
|
||||
requested := len(p)
|
||||
|
||||
for n < requested && !obj.isEndOfStream && obj.coder.FirstError() == nil {
|
||||
n += obj.takeFromLeftover(p[n:])
|
||||
if n < requested {
|
||||
obj.readNextWord()
|
||||
n += obj.takeFromLeftover(p[n:])
|
||||
}
|
||||
}
|
||||
err = obj.coder.FirstError()
|
||||
if err == nil && obj.isEndOfStream {
|
||||
err = io.EOF
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (obj *decompressor) takeFromLeftover(target []byte) (provided int) {
|
||||
requested := len(target)
|
||||
available := len(obj.leftover)
|
||||
|
||||
if available > 0 && requested > 0 {
|
||||
provided = available
|
||||
if provided > requested {
|
||||
provided = requested
|
||||
}
|
||||
copy(target[0:provided], obj.leftover)
|
||||
obj.leftover = obj.leftover[provided:]
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (obj *decompressor) readNextWord() {
|
||||
nextWord := obj.reader.Read()
|
||||
|
||||
if obj.lastEntry.depth > len(obj.scratch) {
|
||||
obj.scratch = make([]byte, len(obj.scratch)+1024)
|
||||
}
|
||||
obj.leftover = obj.scratch[:obj.lastEntry.depth]
|
||||
obj.lastEntry.Data(obj.leftover)
|
||||
switch nextWord {
|
||||
case EndOfStream:
|
||||
obj.isEndOfStream = true
|
||||
case Reset:
|
||||
obj.resetDictionary()
|
||||
default:
|
||||
var nextEntry *dictEntry
|
||||
if int(nextWord) < len(obj.lookup) {
|
||||
nextEntry = obj.lookup[int(nextWord)]
|
||||
}
|
||||
|
||||
switch {
|
||||
case nextEntry != nil:
|
||||
if obj.lastEntry.depth > 0 {
|
||||
obj.addToDictionary(nextEntry.FirstByte())
|
||||
}
|
||||
obj.lastEntry = nextEntry
|
||||
case nextWord >= literalLimit:
|
||||
nextValue := obj.lastEntry.FirstByte()
|
||||
obj.addToDictionary(nextValue)
|
||||
obj.lastEntry = obj.lastEntry.next[nextValue]
|
||||
default:
|
||||
nextValue := byte(nextWord)
|
||||
obj.addToDictionary(nextValue)
|
||||
obj.lastEntry = obj.dictionary.next[nextValue]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (obj *decompressor) addToDictionary(value byte) {
|
||||
key := Word(int(literalLimit) + obj.dictionarySize)
|
||||
nextEntry := obj.lastEntry.Add(value, key, obj.dictBuffer.entry(key))
|
||||
if int(key) >= len(obj.lookup) {
|
||||
newLookup := make([]*dictEntry, len(obj.lookup)+1024)
|
||||
copy(newLookup, obj.lookup)
|
||||
obj.lookup = newLookup
|
||||
}
|
||||
obj.lookup[key] = nextEntry
|
||||
obj.dictionarySize++
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
type DecompressorSuite struct {
|
||||
suite.Suite
|
||||
store *serial.ByteStore
|
||||
compressor io.WriteCloser
|
||||
}
|
||||
|
||||
func TestDecompressorSuite(t *testing.T) {
|
||||
suite.Run(t, new(DecompressorSuite))
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) SetupTest() {
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressTest1() {
|
||||
input := []byte{0x00, 0x01, 0x00, 0x01}
|
||||
|
||||
suite.verify(input)
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressTest2() {
|
||||
input := []byte{0x00, 0x01, 0x00, 0x01, 0x00, 0x01}
|
||||
|
||||
suite.verify(input)
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressTest3() {
|
||||
suite.verify([]byte{})
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressTest4() {
|
||||
input := []byte{0x00, 0x01, 0x00, 0x02, 0x01, 0x00, 0x01}
|
||||
|
||||
suite.verify(input)
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressTest5() {
|
||||
input := []byte{0x00, 0x01, 0x00, 0x02, 0x01, 0x00, 0x01, 0x02, 0x01, 0x02, 0x01, 0x00, 0x01, 0x02}
|
||||
|
||||
suite.verify(input)
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressTestRandom() {
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
for testCase := 0; testCase < 100; testCase++ {
|
||||
input := make([]byte, r.Intn(1024))
|
||||
for i := 0; i < len(input); i++ {
|
||||
input[i] = byte(r.Intn(256))
|
||||
}
|
||||
suite.verify(input)
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressHandlesDictionaryResets() {
|
||||
suite.writeWords(0x0001, 0x0002, 0x0100, compression.Reset, 0x0003, 0x0004, 0x0100, compression.EndOfStream)
|
||||
|
||||
suite.verifyOutput([]byte{0x01, 0x02, 0x01, 0x02, 0x03, 0x04, 0x03, 0x04})
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) TestDecompressHandlesSelfReferencingWords() {
|
||||
suite.writeWords(0x0001, 0x0002, 0x0101, compression.EndOfStream)
|
||||
|
||||
suite.verifyOutput([]byte{0x01, 0x02, 0x02, 0x02})
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) writeWords(values ...compression.Word) {
|
||||
suite.store = serial.NewByteStore()
|
||||
coder := serial.NewEncoder(suite.store)
|
||||
writer := compression.NewWordWriter(coder)
|
||||
|
||||
for _, value := range values {
|
||||
writer.Write(value)
|
||||
}
|
||||
writer.Close()
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) verify(input []byte) {
|
||||
suite.store = serial.NewByteStore()
|
||||
suite.compressor = compression.NewCompressor(serial.NewEncoder(suite.store))
|
||||
|
||||
n, err := suite.compressor.Write(input)
|
||||
assert.Nil(suite.T(), err, "no error expected writing")
|
||||
assert.Equal(suite.T(), len(input), n, "invalid number written")
|
||||
err = suite.compressor.Close()
|
||||
assert.Nil(suite.T(), err, "no error expected closing")
|
||||
|
||||
suite.verifyOutput(input)
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) verifyOutput(expected []byte) {
|
||||
output := suite.buffer(len(expected))
|
||||
source := bytes.NewReader(suite.store.Data())
|
||||
decompressor := compression.NewDecompressor(source)
|
||||
read, err := decompressor.Read(output)
|
||||
|
||||
assert.True(suite.T(), (err == nil) || errors.Is(err, io.EOF), fmt.Sprintf("unexpected error: %v", err))
|
||||
assert.Equal(suite.T(), len(output), read, "unexpected number of bytes read")
|
||||
assert.Equal(suite.T(), expected, output)
|
||||
}
|
||||
|
||||
func (suite *DecompressorSuite) buffer(byteCount int) []byte {
|
||||
result := make([]byte, byteCount)
|
||||
for i := range result {
|
||||
result[i] = 0xFF
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package compression
|
||||
|
||||
type dictEntry struct {
|
||||
prev *dictEntry
|
||||
depth int
|
||||
|
||||
next [256]*dictEntry
|
||||
|
||||
key Word
|
||||
value byte
|
||||
first byte
|
||||
used bool
|
||||
}
|
||||
|
||||
func rootDictEntry() *dictEntry {
|
||||
return &dictEntry{prev: nil, depth: 0, value: 0x00, key: Reset}
|
||||
}
|
||||
|
||||
var emptyList [256]*dictEntry
|
||||
|
||||
func (entry *dictEntry) Add(value byte, key Word, newEntry *dictEntry) *dictEntry {
|
||||
newEntry.prev = entry
|
||||
newEntry.depth = entry.depth + 1
|
||||
newEntry.value = value
|
||||
newEntry.key = key
|
||||
newEntry.first = entry.first
|
||||
if newEntry.used {
|
||||
copy(newEntry.next[:], emptyList[:])
|
||||
}
|
||||
newEntry.used = true
|
||||
|
||||
entry.next[value] = newEntry
|
||||
if entry.depth == 0 {
|
||||
newEntry.first = value
|
||||
}
|
||||
|
||||
return newEntry
|
||||
}
|
||||
|
||||
func (entry *dictEntry) Data(bytes []byte) {
|
||||
cur := entry
|
||||
for i := entry.depth - 1; i >= 0; i-- {
|
||||
bytes[i] = cur.value
|
||||
cur = cur.prev
|
||||
}
|
||||
}
|
||||
|
||||
func (entry *dictEntry) FirstByte() byte {
|
||||
return entry.first
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package compression
|
||||
|
||||
// dictEntryBucketSizeBits governs how big one bucket in the buffer is.
|
||||
const dictEntryBucketSizeBits = 8
|
||||
|
||||
type dictEntryBucket [1 << dictEntryBucketSizeBits]dictEntry
|
||||
|
||||
type dictEntryBuffer struct {
|
||||
buckets []*dictEntryBucket
|
||||
}
|
||||
|
||||
func (buffer *dictEntryBuffer) entry(key Word) *dictEntry {
|
||||
bucketIndex := int(key) >> dictEntryBucketSizeBits
|
||||
index := int(key & ((1 << dictEntryBucketSizeBits) - 1))
|
||||
|
||||
for bucketIndex >= len(buffer.buckets) {
|
||||
buffer.buckets = append(buffer.buckets, new(dictEntryBucket))
|
||||
}
|
||||
|
||||
return &buffer.buckets[bucketIndex][index]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package compression
|
||||
|
||||
// Word is a compression stream entry.
|
||||
type Word uint16
|
||||
|
||||
const (
|
||||
bitsPerWord = uint(14)
|
||||
|
||||
// EndOfStream indicates the last word in the compressed stream.
|
||||
EndOfStream = Word(0x3FFF)
|
||||
// Reset indicates a reset of the dictionary.
|
||||
Reset = Word(0x3FFE)
|
||||
literalLimit = Word(0x0100)
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package compression
|
||||
|
||||
import "github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
// WordReader provides word instances from a serialized stream.
|
||||
type WordReader struct {
|
||||
coder serial.Coder
|
||||
|
||||
buffer byte
|
||||
bufferBitsAvailable uint
|
||||
}
|
||||
|
||||
// NewWordReader returns a new instance.
|
||||
func NewWordReader(coder serial.Coder) *WordReader {
|
||||
reader := &WordReader{coder: coder, buffer: 0x00, bufferBitsAvailable: 0}
|
||||
|
||||
return reader
|
||||
}
|
||||
|
||||
// Read returns the next word from the stream.
|
||||
func (reader *WordReader) Read() (value Word) {
|
||||
remaining := bitsPerWord
|
||||
|
||||
for remaining > reader.bufferBitsAvailable {
|
||||
value = Word((uint(value) << reader.bufferBitsAvailable) | uint(reader.buffer))
|
||||
remaining -= reader.bufferBitsAvailable
|
||||
|
||||
reader.bufferByte()
|
||||
}
|
||||
|
||||
value = Word((uint(value) << remaining) | uint(reader.buffer)>>(8-remaining))
|
||||
reader.buffer &= 1<<(8-remaining) - 1
|
||||
reader.bufferBitsAvailable -= remaining
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (reader *WordReader) bufferByte() {
|
||||
reader.coder.Code(&reader.buffer)
|
||||
reader.bufferBitsAvailable = 8
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type WordReaderSuite struct {
|
||||
suite.Suite
|
||||
reader *compression.WordReader
|
||||
}
|
||||
|
||||
func TestWordReaderSuite(t *testing.T) {
|
||||
suite.Run(t, new(WordReaderSuite))
|
||||
}
|
||||
|
||||
func (suite *WordReaderSuite) TestReadCanReadFirstWord() {
|
||||
suite.givenReaderFrom()
|
||||
|
||||
assert.Equal(suite.T(), compression.EndOfStream, suite.reader.Read())
|
||||
}
|
||||
|
||||
func (suite *WordReaderSuite) TestReadCanReadSeveralWords() {
|
||||
input := []compression.Word{
|
||||
compression.Word(0x3FFF), compression.Word(0x0000), compression.Word(0x3FFF),
|
||||
compression.Word(0x0000), compression.Word(0x2001), compression.Word(0x1234),
|
||||
}
|
||||
suite.givenReaderFrom(input...)
|
||||
|
||||
output := make([]compression.Word, len(input)+1)
|
||||
for i := 0; i < len(output); i++ {
|
||||
output[i] = suite.reader.Read()
|
||||
}
|
||||
|
||||
assert.Equal(suite.T(), append(input, compression.EndOfStream), output)
|
||||
}
|
||||
|
||||
func (suite *WordReaderSuite) givenReaderFrom(words ...compression.Word) {
|
||||
store := serial.NewByteStore()
|
||||
encoder := serial.NewEncoder(store)
|
||||
writer := compression.NewWordWriter(encoder)
|
||||
|
||||
for _, value := range words {
|
||||
writer.Write(value)
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
source := bytes.NewReader(store.Data())
|
||||
suite.reader = compression.NewWordReader(serial.NewDecoder(source))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package compression
|
||||
|
||||
import "github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
// WordWriter serializes a stream of word entries.
|
||||
type WordWriter struct {
|
||||
coder serial.Coder
|
||||
|
||||
bufferedBits uint
|
||||
scratch uint32
|
||||
|
||||
outBuffer [1024]byte
|
||||
outUsed int
|
||||
}
|
||||
|
||||
// NewWordWriter returns a new instance.
|
||||
func NewWordWriter(coder serial.Coder) *WordWriter {
|
||||
writer := &WordWriter{coder: coder, bufferedBits: 0, scratch: 0}
|
||||
|
||||
return writer
|
||||
}
|
||||
|
||||
// Close finishes the stream of words.
|
||||
func (writer *WordWriter) Close() {
|
||||
writer.Write(EndOfStream)
|
||||
if writer.bufferedBits > 0 {
|
||||
writer.writeByte(byte(writer.scratch >> 16))
|
||||
}
|
||||
writer.writeByte(byte(0x00))
|
||||
writer.flushBuffer()
|
||||
}
|
||||
|
||||
// Write adds the given word to the stream.
|
||||
func (writer *WordWriter) Write(value Word) {
|
||||
writer.scratch |= uint32(value) << ((16 - bitsPerWord) + (8 - writer.bufferedBits))
|
||||
writer.bufferedBits += bitsPerWord
|
||||
writer.writeByte(byte(writer.scratch >> 16))
|
||||
writer.scratch <<= 8
|
||||
writer.bufferedBits -= 8
|
||||
|
||||
if writer.bufferedBits >= 8 {
|
||||
writer.writeByte(byte(writer.scratch >> 16))
|
||||
writer.scratch <<= 8
|
||||
writer.bufferedBits -= 8
|
||||
}
|
||||
}
|
||||
|
||||
func (writer *WordWriter) writeByte(value byte) {
|
||||
writer.outBuffer[writer.outUsed] = value
|
||||
writer.outUsed++
|
||||
if writer.outUsed >= len(writer.outBuffer) {
|
||||
writer.flushBuffer()
|
||||
}
|
||||
}
|
||||
|
||||
func (writer *WordWriter) flushBuffer() {
|
||||
writer.coder.Code(writer.outBuffer[:writer.outUsed])
|
||||
writer.outUsed = 0
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type WordWriterSuite struct {
|
||||
suite.Suite
|
||||
writer *compression.WordWriter
|
||||
store *serial.ByteStore
|
||||
}
|
||||
|
||||
func TestWordWriterSuite(t *testing.T) {
|
||||
suite.Run(t, new(WordWriterSuite))
|
||||
}
|
||||
|
||||
func (suite *WordWriterSuite) SetupTest() {
|
||||
suite.store = serial.NewByteStore()
|
||||
coder := serial.NewEncoder(suite.store)
|
||||
suite.writer = compression.NewWordWriter(coder)
|
||||
}
|
||||
|
||||
func (suite *WordWriterSuite) TestCloseWritesEndOfStreamMarkerAndTrailingZeroByte() {
|
||||
suite.writer.Close()
|
||||
|
||||
assert.Equal(suite.T(), []byte{0xFF, 0xFC, 0x00}, suite.store.Data())
|
||||
}
|
||||
|
||||
func (suite *WordWriterSuite) TestCloseWritesRemainderOnlyIfNotEmpty() {
|
||||
suite.writer.Write(compression.Word(0x0000))
|
||||
suite.writer.Write(compression.Word(0x0000))
|
||||
suite.writer.Write(compression.Word(0x0000))
|
||||
suite.writer.Close()
|
||||
|
||||
assert.Equal(suite.T(), []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0x00}, suite.store.Data())
|
||||
}
|
||||
|
||||
func (suite *WordWriterSuite) TestWriteAndCloseLinesUpBits() {
|
||||
suite.writer.Write(compression.Word(0x1FFE)) // 0111111 1111110
|
||||
suite.writer.Close() // 1111111 1111111
|
||||
|
||||
assert.Equal(suite.T(), []byte{0x7F, 0xFB, 0xFF, 0xF0, 0x00}, suite.store.Data())
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"runtime/pprof"
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
// To run a profile, run it with the following command:
|
||||
// go test -bench . -args -cpuprofile=prof
|
||||
var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
|
||||
|
||||
func rawData(size int) []byte {
|
||||
data := make([]byte, size)
|
||||
rand.Read(data) // nolint:gas
|
||||
return data
|
||||
}
|
||||
|
||||
func initProfiling(b *testing.B, nameSuffix string) func() {
|
||||
b.Helper()
|
||||
flag.Parse()
|
||||
if *cpuprofile != "" {
|
||||
f, err := os.Create(*cpuprofile + "-" + b.Name() + nameSuffix)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_ = pprof.StartCPUProfile(f)
|
||||
return func() { pprof.StopCPUProfile() }
|
||||
}
|
||||
return func() {}
|
||||
}
|
||||
|
||||
func BenchmarkRawDataStorage(b *testing.B) {
|
||||
profileStop := initProfiling(b, "")
|
||||
defer profileStop()
|
||||
data := rawData(1024 * 1024)
|
||||
b.ResetTimer()
|
||||
for run := 0; run < b.N; run++ {
|
||||
encoder := serial.NewEncoder(serial.NewByteStore())
|
||||
_, _ = encoder.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkCompression(b *testing.B, size int, nameSuffix string) {
|
||||
b.Helper()
|
||||
profileStop := initProfiling(b, nameSuffix)
|
||||
defer profileStop()
|
||||
data := rawData(size)
|
||||
b.ResetTimer()
|
||||
for run := 0; run < b.N; run++ {
|
||||
compressor := compression.NewCompressor(serial.NewByteStore())
|
||||
_, _ = compressor.Write(data)
|
||||
_ = compressor.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCompression1KB(b *testing.B) {
|
||||
benchmarkCompression(b, 1024, "1KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompression16KB(b *testing.B) {
|
||||
benchmarkCompression(b, 1024*16, "16KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompression128KB(b *testing.B) {
|
||||
benchmarkCompression(b, 1024*128, "128KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompression512KB(b *testing.B) {
|
||||
benchmarkCompression(b, 1024*512, "512KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompression1024KB(b *testing.B) {
|
||||
benchmarkCompression(b, 1024*1024, "1024KB")
|
||||
}
|
||||
|
||||
func benchmarkCompressionDecompression(b *testing.B, size int, nameSuffix string) {
|
||||
b.Helper()
|
||||
profileStop := initProfiling(b, nameSuffix)
|
||||
defer profileStop()
|
||||
data := rawData(size)
|
||||
output := make([]byte, len(data))
|
||||
b.ResetTimer()
|
||||
for run := 0; run < b.N; run++ {
|
||||
store := serial.NewByteStore()
|
||||
compressor := compression.NewCompressor(store)
|
||||
_, _ = compressor.Write(data)
|
||||
_ = compressor.Close()
|
||||
_, _ = store.Seek(0, io.SeekStart)
|
||||
decompressor := compression.NewDecompressor(store)
|
||||
_, _ = decompressor.Read(output)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCompressionDecompression1KB(b *testing.B) {
|
||||
benchmarkCompressionDecompression(b, 1024, "1KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompressionDecompression16KB(b *testing.B) {
|
||||
benchmarkCompressionDecompression(b, 1024*16, "16KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompressionDecompression128KB(b *testing.B) {
|
||||
benchmarkCompressionDecompression(b, 1024*128, "128KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompressionDecompression512KB(b *testing.B) {
|
||||
benchmarkCompressionDecompression(b, 1024*512, "512KB")
|
||||
}
|
||||
|
||||
func BenchmarkCompressionDecompression1024KB(b *testing.B) {
|
||||
benchmarkCompressionDecompression(b, 1024*1024, "1024KB")
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
Package compression implements the LG resource file compression algorithm.
|
||||
*/
|
||||
package compression
|
||||
@@ -0,0 +1,17 @@
|
||||
package format
|
||||
|
||||
const (
|
||||
// HeaderString is the start of an LG Res file.
|
||||
HeaderString = "LG Res File v2\r\n"
|
||||
// CommentTerminator terminates the header string.
|
||||
CommentTerminator = byte(0x1A)
|
||||
// ResourceDirectoryFileOffsetPos is the byte offset for the pointer to the directory.
|
||||
ResourceDirectoryFileOffsetPos = 0x7C
|
||||
// BoundarySize is the byte interval at which entries must be placed.
|
||||
BoundarySize = 4
|
||||
|
||||
// ResourceTypeFlagCompressed indicates a compressed resource.
|
||||
ResourceTypeFlagCompressed = byte(0x01)
|
||||
// ResourceTypeFlagCompound indicates a compound resource.
|
||||
ResourceTypeFlagCompound = byte(0x02)
|
||||
)
|
||||
Reference in New Issue
Block a user