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,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)
)