Adding modding folder with a hacked build.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package movie
|
||||
|
||||
import (
|
||||
"github.com/inkyblackness/hacked/ss1/content/audio"
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/format"
|
||||
)
|
||||
|
||||
const audioEntrySize = 0x2000
|
||||
|
||||
// Audio represents the sound part of a movie.
|
||||
type Audio struct {
|
||||
Sound audio.L8
|
||||
}
|
||||
|
||||
func (a Audio) duration() format.Timestamp {
|
||||
if a.Sound.SampleRate <= 0 {
|
||||
return format.Timestamp{}
|
||||
}
|
||||
return format.TimestampFromSeconds(a.Sound.Duration())
|
||||
}
|
||||
|
||||
func (a Audio) encode() []format.EntryBucket {
|
||||
buckets := make([]format.EntryBucket, 0, (len(a.Sound.Samples)/audioEntrySize)+1)
|
||||
addBucket := func(ts format.Timestamp, samples []byte) {
|
||||
buckets = append(buckets,
|
||||
format.EntryBucket{
|
||||
Priority: format.EntryBucketPriorityAudio,
|
||||
Timestamp: ts,
|
||||
Entries: []format.Entry{
|
||||
{
|
||||
Timestamp: ts,
|
||||
Data: format.AudioEntryData{
|
||||
Samples: samples,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
startOffset := 0
|
||||
for (startOffset + audioEntrySize) <= len(a.Sound.Samples) {
|
||||
ts := format.TimestampFromSeconds(float32(startOffset) / a.Sound.SampleRate)
|
||||
endOffset := startOffset + audioEntrySize
|
||||
addBucket(ts, a.Sound.Samples[startOffset:endOffset])
|
||||
startOffset = endOffset
|
||||
}
|
||||
if startOffset < len(a.Sound.Samples) {
|
||||
ts := format.TimestampFromSeconds(float32(startOffset) / a.Sound.SampleRate)
|
||||
addBucket(ts, a.Sound.Samples[startOffset:])
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package movie
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/audio"
|
||||
"github.com/inkyblackness/hacked/ss1/content/text"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
)
|
||||
|
||||
const sizeLimit = 0xFFFFFF
|
||||
|
||||
// Cache retrieves movie container from a localizer and keeps them decoded until they are invalidated.
|
||||
type Cache struct {
|
||||
cp text.Codepage
|
||||
|
||||
localizer resource.Localizer
|
||||
|
||||
movies map[resource.Key]*cachedMovie
|
||||
}
|
||||
|
||||
type cachedMovie struct {
|
||||
sizeWarning bool
|
||||
container Container
|
||||
scenes []Scene
|
||||
}
|
||||
|
||||
func (cached *cachedMovie) video() []Scene {
|
||||
if len(cached.scenes) > 0 {
|
||||
return cached.scenes
|
||||
}
|
||||
cached.scenes, _ = cached.container.Video.Decompress()
|
||||
return cached.scenes
|
||||
}
|
||||
|
||||
// NewCache returns a new instance.
|
||||
func NewCache(cp text.Codepage, localizer resource.Localizer) *Cache {
|
||||
cache := &Cache{
|
||||
cp: cp,
|
||||
localizer: localizer,
|
||||
|
||||
movies: make(map[resource.Key]*cachedMovie),
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
// InvalidateResources lets the cache remove any movies from resources that are specified in the given slice.
|
||||
func (cache *Cache) InvalidateResources(ids []resource.ID) {
|
||||
for _, id := range ids {
|
||||
for key := range cache.movies {
|
||||
if key.ID == id {
|
||||
delete(cache.movies, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *Cache) cached(key resource.Key) (*cachedMovie, error) {
|
||||
value, existing := cache.movies[key]
|
||||
if existing {
|
||||
return value, nil
|
||||
}
|
||||
selector := cache.localizer.LocalizedResources(key.Lang)
|
||||
view, err := selector.Select(key.ID.Plus(key.Index))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (view.ContentType() != resource.Movie) || view.Compound() || (view.BlockCount() != 1) {
|
||||
return nil, resource.ErrWrongType(key, resource.Movie)
|
||||
}
|
||||
reader, err := view.Block(0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := ioutil.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
container, err := Read(bytes.NewReader(data), cache.cp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cached := &cachedMovie{
|
||||
sizeWarning: len(data) > sizeLimit,
|
||||
container: container,
|
||||
}
|
||||
cache.movies[key] = cached
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// SizeWarning returns true if the given movie is cached and is larger than the underlying archive would support.
|
||||
func (cache *Cache) SizeWarning(key resource.Key) bool {
|
||||
cached, existing := cache.movies[key]
|
||||
return existing && cached.sizeWarning
|
||||
}
|
||||
|
||||
// Container retrieves and caches the underlying movie, and returns the complete container.
|
||||
func (cache *Cache) Container(key resource.Key) (Container, error) {
|
||||
cached, err := cache.cached(key)
|
||||
if err != nil {
|
||||
return Container{}, err
|
||||
}
|
||||
return cached.container, nil
|
||||
}
|
||||
|
||||
// Audio retrieves and caches the underlying movie, and returns the audio only.
|
||||
func (cache *Cache) Audio(key resource.Key) (sound audio.L8, err error) {
|
||||
cached, err := cache.cached(key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return cached.container.Audio.Sound, nil
|
||||
}
|
||||
|
||||
// Video retrieves and caches the underlying movie, and returns the complete list of decoded scenes.
|
||||
func (cache *Cache) Video(key resource.Key) ([]Scene, error) {
|
||||
cached, err := cache.cached(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cached.video(), nil
|
||||
}
|
||||
|
||||
// Subtitles retrieves and caches the underlying movie, and returns the subtitles for given language.
|
||||
func (cache *Cache) Subtitles(key resource.Key, language resource.Language) (SubtitleList, error) {
|
||||
cached, err := cache.cached(key)
|
||||
if err != nil {
|
||||
return SubtitleList{}, err
|
||||
}
|
||||
return cached.container.Subtitles.PerLanguage[language], nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package movie
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/audio"
|
||||
"github.com/inkyblackness/hacked/ss1/content/text"
|
||||
)
|
||||
|
||||
// ContainSoundData packs a sound data into a container and encodes it.
|
||||
func ContainSoundData(soundData audio.L8) []byte {
|
||||
var container Container
|
||||
container.Audio.Sound = soundData
|
||||
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
_ = Write(buffer, container, text.DefaultCodepage())
|
||||
return buffer.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package movie
|
||||
|
||||
import "github.com/inkyblackness/hacked/ss1/content/movie/internal/format"
|
||||
|
||||
// Container wraps the information and data of a MOVI container.
|
||||
type Container struct {
|
||||
Audio Audio
|
||||
Video Video
|
||||
Subtitles Subtitles
|
||||
}
|
||||
|
||||
func (container Container) duration() format.Timestamp {
|
||||
var max format.Timestamp
|
||||
for _, ts := range []format.Timestamp{container.Audio.duration(), container.Video.duration(), container.Subtitles.duration()} {
|
||||
if max.IsBefore(ts) {
|
||||
max = ts
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package movie
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1"
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/format"
|
||||
"github.com/inkyblackness/hacked/ss1/content/text"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/serial"
|
||||
)
|
||||
|
||||
const (
|
||||
errSourceIsNil ss1.StringError = "source is nil"
|
||||
errInvalidFormat ss1.StringError = "not a MOVI format"
|
||||
errLowResVideoNotSupported ss1.StringError = "low-res video not supported"
|
||||
)
|
||||
|
||||
// Read tries to extract a MOVI container from the provided reader.
|
||||
// On success the position of the reader is past the last data entry.
|
||||
// On failure the position of the reader is undefined.
|
||||
func Read(source io.ReadSeeker, cp text.Codepage) (Container, error) {
|
||||
if source == nil {
|
||||
return Container{}, errSourceIsNil
|
||||
}
|
||||
|
||||
var header format.Header
|
||||
startPos, _ := source.Seek(0, io.SeekCurrent)
|
||||
var container Container
|
||||
err := binary.Read(source, binary.LittleEndian, &header)
|
||||
if err != nil {
|
||||
return Container{}, err
|
||||
}
|
||||
err = verifyAndExtractHeader(&header, &container)
|
||||
if err != nil {
|
||||
return Container{}, err
|
||||
}
|
||||
var startPalette bitmap.Palette
|
||||
err = readPalette(source, &startPalette)
|
||||
if err != nil {
|
||||
return Container{}, err
|
||||
}
|
||||
entries, err := readIndexAndEntries(source, startPos, &header)
|
||||
if err != nil {
|
||||
return Container{}, err
|
||||
}
|
||||
err = parseEntries(entries, &container,
|
||||
cp, startPalette, format.Timestamp{Second: byte(header.Duration.Number), Fraction: header.Duration.Fraction})
|
||||
if err != nil {
|
||||
return Container{}, err
|
||||
}
|
||||
|
||||
return container, nil
|
||||
}
|
||||
|
||||
func verifyAndExtractHeader(header *format.Header, container *Container) error {
|
||||
if !bytes.Equal(header.Tag[:], bytes.NewBufferString(format.Tag).Bytes()) {
|
||||
return errInvalidFormat
|
||||
}
|
||||
|
||||
container.Video.Height = header.VideoHeight
|
||||
container.Video.Width = header.VideoWidth
|
||||
container.Audio.Sound.SampleRate = header.AudioSampleRate.ToFloat()
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPalette(source io.Reader, pal *bitmap.Palette) error {
|
||||
decoder := serial.NewDecoder(source)
|
||||
decoder.Code(pal)
|
||||
return decoder.FirstError()
|
||||
}
|
||||
|
||||
func readIndexAndEntries(source io.ReadSeeker, startPos int64, header *format.Header) ([]format.Entry, error) {
|
||||
entries := make([]format.Entry, 0, header.IndexEntryCount)
|
||||
indexEntries := make([]format.IndexTableEntry, header.IndexEntryCount)
|
||||
err := binary.Read(source, binary.LittleEndian, indexEntries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index, indexEntry := range indexEntries {
|
||||
entryType := format.DataType(indexEntry.Type)
|
||||
|
||||
if entryType != format.DataTypeEndOfMedia {
|
||||
timestamp := format.Timestamp{
|
||||
Second: indexEntry.TimestampSecond,
|
||||
Fraction: indexEntry.TimestampFraction,
|
||||
}
|
||||
length := int(indexEntries[index+1].DataOffset - indexEntry.DataOffset)
|
||||
|
||||
_, err = source.Seek(startPos+int64(indexEntry.DataOffset), io.SeekStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entryData, err := readEntry(entryType, source, length)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, format.Entry{
|
||||
Timestamp: timestamp,
|
||||
Data: entryData,
|
||||
})
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func readEntry(entryType format.DataType, source io.Reader, dataSize int) (format.EntryData, error) {
|
||||
limited := io.LimitReader(source, int64(dataSize))
|
||||
switch entryType {
|
||||
case format.DataTypeLowResVideo:
|
||||
return format.LowResVideoEntryFrom(limited, dataSize)
|
||||
case format.DataTypeHighResVideo:
|
||||
return format.HighResVideoEntryFrom(limited, dataSize)
|
||||
case format.DataTypeAudio:
|
||||
return format.AudioEntryFrom(limited, dataSize)
|
||||
case format.DataTypeSubtitle:
|
||||
return format.SubtitleEntryFrom(limited, dataSize)
|
||||
case format.DataTypePalette:
|
||||
return format.PaletteEntryFrom(limited)
|
||||
case format.DataTypePaletteReset:
|
||||
return format.PaletteResetEntryFrom()
|
||||
case format.DataTypePaletteLookupList:
|
||||
return format.PaletteLookupEntryFrom(limited, dataSize)
|
||||
case format.DataTypeControlDictionary:
|
||||
return format.ControlDictionaryEntryFrom(limited, dataSize)
|
||||
default:
|
||||
return format.UnknownEntryFrom(entryType, limited, dataSize)
|
||||
}
|
||||
}
|
||||
|
||||
func parseEntries(entries []format.Entry, container *Container,
|
||||
cp text.Codepage, startPalette bitmap.Palette, endTimestamp format.Timestamp) error {
|
||||
palette := startPalette
|
||||
var paletteLookup []byte
|
||||
var controlDictionary []compression.ControlWord
|
||||
var highResScene *HighResScene
|
||||
sceneChanging := true
|
||||
finishScene := func() {
|
||||
if highResScene != nil {
|
||||
container.Video.Scenes = append(container.Video.Scenes, *highResScene)
|
||||
}
|
||||
highResScene = nil
|
||||
}
|
||||
var frame *HighResFrame
|
||||
finishFrame := func(timestamp format.Timestamp) {
|
||||
if frame != nil {
|
||||
frame.displayTime = timestamp.Minus(frame.displayTime)
|
||||
highResScene.frames = append(highResScene.frames, *frame)
|
||||
}
|
||||
frame = nil
|
||||
}
|
||||
for _, entry := range entries {
|
||||
switch data := entry.Data.(type) {
|
||||
case format.AudioEntryData:
|
||||
container.Audio.Sound.Samples = append(container.Audio.Sound.Samples, data.Samples...)
|
||||
case format.SubtitleEntryData:
|
||||
subText := cp.Decode(data.Text)
|
||||
switch data.Control {
|
||||
case format.SubtitleTextStd:
|
||||
container.Subtitles.add(resource.LangDefault, entry.Timestamp.ToDuration(), subText)
|
||||
case format.SubtitleTextGer:
|
||||
container.Subtitles.add(resource.LangGerman, entry.Timestamp.ToDuration(), subText)
|
||||
case format.SubtitleTextFrn:
|
||||
container.Subtitles.add(resource.LangFrench, entry.Timestamp.ToDuration(), subText)
|
||||
case format.SubtitleArea:
|
||||
default:
|
||||
}
|
||||
case format.LowResVideoEntryData:
|
||||
// ignored for now
|
||||
return errLowResVideoNotSupported
|
||||
case format.PaletteLookupEntryData:
|
||||
sceneChanging = true
|
||||
paletteLookup = data.List
|
||||
case format.ControlDictionaryEntryData:
|
||||
sceneChanging = true
|
||||
controlDictionary = data.Words
|
||||
case format.PaletteResetEntryData:
|
||||
sceneChanging = true
|
||||
palette = bitmap.Palette{}
|
||||
case format.PaletteEntryData:
|
||||
sceneChanging = true
|
||||
palette = data.Colors
|
||||
case format.HighResVideoEntryData:
|
||||
finishFrame(entry.Timestamp)
|
||||
if sceneChanging {
|
||||
finishScene()
|
||||
highResScene = &HighResScene{
|
||||
palette: palette,
|
||||
paletteLookup: paletteLookup,
|
||||
controlWords: controlDictionary,
|
||||
}
|
||||
sceneChanging = false
|
||||
}
|
||||
frame = &HighResFrame{
|
||||
bitstream: data.Bitstream,
|
||||
maskstream: data.Maskstream,
|
||||
displayTime: entry.Timestamp,
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
finishFrame(endTimestamp)
|
||||
finishScene()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package movie_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie"
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/format"
|
||||
"github.com/inkyblackness/hacked/ss1/content/text"
|
||||
)
|
||||
|
||||
func TestReadReturnsErrorOnNil(t *testing.T) {
|
||||
_, err := movie.Read(nil, text.DefaultCodepage())
|
||||
|
||||
assert.Errorf(t, err, "source is nil")
|
||||
}
|
||||
|
||||
func TestReadReturnsContainerOnEmptyFile(t *testing.T) {
|
||||
buffer := bytes.NewBufferString(format.Tag)
|
||||
buffer.Write(make([]byte, 0x100+0x300-len(format.Tag)))
|
||||
emptyFile := buffer.Bytes()
|
||||
source := bytes.NewReader(emptyFile)
|
||||
container, _ := movie.Read(source, text.DefaultCodepage())
|
||||
|
||||
assert.NotNil(t, container)
|
||||
}
|
||||
|
||||
func TestReadReturnsErrorOnMissingTag(t *testing.T) {
|
||||
emptyFile := make([]byte, 0x100+0x300)
|
||||
source := bytes.NewReader(emptyFile)
|
||||
_, err := movie.Read(source, text.DefaultCodepage())
|
||||
|
||||
assert.Errorf(t, err, "Not a MOVI format")
|
||||
}
|
||||
|
||||
func TestReadReturnsContainerWithBasicPropertiesSet(t *testing.T) {
|
||||
buffer := bytes.NewBufferString(format.Tag)
|
||||
buffer.Write(make([]byte, 0x100+0x300-len(format.Tag)))
|
||||
emptyFile := buffer.Bytes()
|
||||
|
||||
emptyFile[0x10] = 0x80
|
||||
emptyFile[0x11] = 0x40
|
||||
emptyFile[0x12] = 0x03
|
||||
emptyFile[0x18] = 0x80
|
||||
emptyFile[0x19] = 0x02
|
||||
emptyFile[0x1A] = 0xE0
|
||||
emptyFile[0x1B] = 0x01
|
||||
emptyFile[0x26] = 0x22
|
||||
emptyFile[0x27] = 0x56
|
||||
|
||||
source := bytes.NewReader(emptyFile)
|
||||
container, _ := movie.Read(source, text.DefaultCodepage())
|
||||
|
||||
assert.Equal(t, uint16(640), container.Video.Width)
|
||||
assert.Equal(t, uint16(480), container.Video.Height)
|
||||
assert.Equal(t, uint16(22050), uint16(container.Audio.Sound.SampleRate))
|
||||
}
|
||||
|
||||
func TestReadReturnsContainerWithAudioData(t *testing.T) {
|
||||
testData := []byte{0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
buffer := bytes.NewBufferString(format.Tag)
|
||||
buffer.Write(make([]byte, 0x100+0x300-len(format.Tag)))
|
||||
buffer.Write(make([]byte, 0xC00))
|
||||
buffer.Write(testData)
|
||||
raw := buffer.Bytes()
|
||||
|
||||
raw[0x04] = 2
|
||||
// size of index table
|
||||
raw[0x08] = 0x00
|
||||
raw[0x09] = 0x0C
|
||||
|
||||
// index entry 0
|
||||
raw[0x0400+3] = 0x02
|
||||
raw[0x0400+4] = 0x00
|
||||
raw[0x0400+5] = 0x10
|
||||
// index entry 1
|
||||
raw[0x0408+3] = 0x00
|
||||
raw[0x0408+4] = byte(len(testData))
|
||||
raw[0x0408+5] = 0x10
|
||||
|
||||
source := bytes.NewReader(raw)
|
||||
container, _ := movie.Read(source, text.DefaultCodepage())
|
||||
|
||||
assert.Equal(t, testData, container.Audio.Sound.Samples)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package movie
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
)
|
||||
|
||||
// Scene describes a series of frames that share a common palette.
|
||||
// Ideally, they also share the same framerate.
|
||||
type Scene struct {
|
||||
Palette bitmap.Palette
|
||||
Frames []Frame
|
||||
}
|
||||
|
||||
// Frame describes a bitmap and how long it shall be displayed.
|
||||
type Frame struct {
|
||||
Pixels []byte
|
||||
DisplayTime time.Duration
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package movie
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/format"
|
||||
"github.com/inkyblackness/hacked/ss1/content/text"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
)
|
||||
|
||||
// Subtitles contains all the subtitles in all languages.
|
||||
type Subtitles struct {
|
||||
PerLanguage [resource.LanguageCount]SubtitleList
|
||||
}
|
||||
|
||||
func (sub *Subtitles) add(lang resource.Language, timestamp time.Duration, text string) {
|
||||
sub.PerLanguage[lang].Entries = append(sub.PerLanguage[lang].Entries, Subtitle{
|
||||
Timestamp: timestamp,
|
||||
Text: text,
|
||||
})
|
||||
}
|
||||
|
||||
// ArePresent returns true if at least one language makes use of subtitles.
|
||||
func (sub Subtitles) ArePresent() bool {
|
||||
for _, lang := range sub.PerLanguage {
|
||||
if len(lang.Entries) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (sub Subtitles) duration() format.Timestamp {
|
||||
var highest time.Duration
|
||||
for _, list := range sub.PerLanguage {
|
||||
for _, sub := range list.Entries {
|
||||
if highest < sub.Timestamp {
|
||||
highest = sub.Timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
return format.TimestampFromDuration(highest)
|
||||
}
|
||||
|
||||
func (sub Subtitles) encode(cp text.Codepage) [][]format.EntryBucket {
|
||||
if !sub.ArePresent() {
|
||||
return nil
|
||||
}
|
||||
bucketsPerLanguage := make([][]format.EntryBucket, len(sub.PerLanguage)+1)
|
||||
|
||||
// Ensure a subtitle area is defined.
|
||||
// The area is hardcoded. While the engine respects any area, placing the text in the
|
||||
// frame area will have the pixels become overwritten. As such, there are many "wrong" options,
|
||||
// and only a few right ones. There's no need to make them editable.
|
||||
bucketsPerLanguage[0] = []format.EntryBucket{{
|
||||
Priority: format.EntryBucketPrioritySubtitleControl,
|
||||
Timestamp: format.Timestamp{},
|
||||
Entries: []format.Entry{{
|
||||
Timestamp: format.Timestamp{},
|
||||
Data: format.SubtitleEntryData{
|
||||
Control: format.SubtitleArea,
|
||||
Text: cp.Encode("20 365 620 395 CLR"),
|
||||
},
|
||||
}},
|
||||
}}
|
||||
for index, lang := range sub.PerLanguage {
|
||||
bucketsPerLanguage[index+1] = lang.encode(format.SubtitleControlForLanguage(resource.Language(index)), cp)
|
||||
}
|
||||
return bucketsPerLanguage
|
||||
}
|
||||
|
||||
// SubtitleList describes the textual representation of a movie in one language.
|
||||
type SubtitleList struct {
|
||||
Entries []Subtitle
|
||||
}
|
||||
|
||||
func (sub SubtitleList) encode(control format.SubtitleControl, cp text.Codepage) []format.EntryBucket {
|
||||
buckets := make([]format.EntryBucket, 0, len(sub.Entries))
|
||||
for _, entry := range sub.Entries {
|
||||
buckets = append(buckets, entry.encode(control, cp))
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
// Subtitle is a timestamped text for subtitles.
|
||||
type Subtitle struct {
|
||||
Timestamp time.Duration
|
||||
Text string
|
||||
}
|
||||
|
||||
func (sub Subtitle) encode(control format.SubtitleControl, cp text.Codepage) format.EntryBucket {
|
||||
return format.EntryBucket{
|
||||
Priority: format.EntryBucketPrioritySubtitle,
|
||||
Timestamp: format.TimestampFromDuration(sub.Timestamp),
|
||||
Entries: []format.Entry{{
|
||||
Timestamp: format.TimestampFromDuration(sub.Timestamp),
|
||||
Data: format.SubtitleEntryData{
|
||||
Control: control,
|
||||
Text: cp.Encode(sub.Text),
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package movie
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/format"
|
||||
)
|
||||
|
||||
// Constants for video.
|
||||
const (
|
||||
HighResDefaultWidth = 600
|
||||
HighResDefaultHeight = 300
|
||||
)
|
||||
|
||||
// Video describes the visual part of a movie.
|
||||
type Video struct {
|
||||
// Width is the width of the video in pixel.
|
||||
Width uint16
|
||||
// Height is the height of the video in pixel.
|
||||
Height uint16
|
||||
// Scenes contain the frames of the video.
|
||||
Scenes []HighResScene
|
||||
}
|
||||
|
||||
// StartPalette returns the palette of the first scene. If no scene is present, a black palette is returned.
|
||||
func (video Video) StartPalette() bitmap.Palette {
|
||||
if len(video.Scenes) == 0 {
|
||||
return bitmap.Palette{}
|
||||
}
|
||||
return video.Scenes[0].palette
|
||||
}
|
||||
|
||||
func (video Video) duration() format.Timestamp {
|
||||
var sum format.Timestamp
|
||||
for _, scene := range video.Scenes {
|
||||
sum = sum.Plus(scene.duration())
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
func (video Video) encode() []format.EntryBucket {
|
||||
var sceneTime format.Timestamp
|
||||
var buckets []format.EntryBucket
|
||||
for index, scene := range video.Scenes {
|
||||
buckets = append(buckets, scene.encode(sceneTime, index != 0)...)
|
||||
sceneTime = sceneTime.Plus(scene.duration())
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
// Decompress unpacks all the frames of all scenes.
|
||||
func (video Video) Decompress() ([]Scene, error) {
|
||||
var scenes []Scene
|
||||
width := int(video.Width)
|
||||
height := int(video.Height)
|
||||
frameBuffer := make([]byte, width*height)
|
||||
decoderBuilder := compression.NewFrameDecoderBuilder(width, height)
|
||||
decoderBuilder.ForStandardFrame(frameBuffer, width)
|
||||
|
||||
cloneFramebuffer := func() []byte {
|
||||
bufferCopy := make([]byte, len(frameBuffer))
|
||||
copy(bufferCopy, frameBuffer)
|
||||
return bufferCopy
|
||||
}
|
||||
|
||||
for _, compressedScene := range video.Scenes {
|
||||
decoderBuilder.WithControlWords(compressedScene.controlWords)
|
||||
decoderBuilder.WithPaletteLookupList(compressedScene.paletteLookup)
|
||||
decoder := decoderBuilder.Build()
|
||||
var scene Scene
|
||||
scene.Palette = compressedScene.palette
|
||||
for _, compressedFrame := range compressedScene.frames {
|
||||
err := decoder.Decode(compressedFrame.bitstream, compressedFrame.maskstream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scene.Frames = append(scene.Frames, Frame{
|
||||
Pixels: cloneFramebuffer(),
|
||||
DisplayTime: compressedFrame.displayTime.ToDuration(),
|
||||
})
|
||||
}
|
||||
scenes = append(scenes, scene)
|
||||
}
|
||||
return scenes, nil
|
||||
}
|
||||
|
||||
// HighResScene is a set of frames with high-resolution compression.
|
||||
type HighResScene struct {
|
||||
palette bitmap.Palette
|
||||
paletteLookup []byte
|
||||
controlWords []compression.ControlWord
|
||||
frames []HighResFrame
|
||||
}
|
||||
|
||||
// HighResSceneFrom compresses given scene and returns the compression result.
|
||||
func HighResSceneFrom(ctx context.Context, scene Scene, width, height int) (HighResScene, error) {
|
||||
encoder := compression.NewSceneEncoder(width, height)
|
||||
for _, frame := range scene.Frames {
|
||||
err := encoder.AddFrame(frame.Pixels)
|
||||
if err != nil {
|
||||
return HighResScene{}, err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return HighResScene{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
words, paletteLookup, frames, err := encoder.Encode(ctx)
|
||||
if err != nil {
|
||||
return HighResScene{}, err
|
||||
}
|
||||
compressedScene := HighResScene{
|
||||
palette: scene.Palette,
|
||||
paletteLookup: paletteLookup,
|
||||
controlWords: words,
|
||||
frames: make([]HighResFrame, len(frames)),
|
||||
}
|
||||
for index, frame := range frames {
|
||||
compressedScene.frames[index] = HighResFrame{
|
||||
bitstream: frame.Bitstream,
|
||||
maskstream: frame.Maskstream,
|
||||
displayTime: format.TimestampFromDuration(scene.Frames[index].DisplayTime),
|
||||
}
|
||||
}
|
||||
|
||||
return compressedScene, nil
|
||||
}
|
||||
|
||||
// WithFrameDisplayTime returns a new scene instance with the given display time set for all frames.
|
||||
func (scene HighResScene) WithFrameDisplayTime(displayTime time.Duration) HighResScene {
|
||||
newScene := scene
|
||||
newFrames := make([]HighResFrame, len(scene.frames))
|
||||
for index, frame := range scene.frames {
|
||||
newFrames[index] = frame.WithDisplayTime(displayTime)
|
||||
}
|
||||
newScene.frames = newFrames
|
||||
return newScene
|
||||
}
|
||||
|
||||
func (scene HighResScene) duration() format.Timestamp {
|
||||
var sum format.Timestamp
|
||||
for _, frame := range scene.frames {
|
||||
sum = sum.Plus(frame.duration())
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
func (scene HighResScene) encode(start format.Timestamp, withPalette bool) []format.EntryBucket {
|
||||
buckets := make([]format.EntryBucket, 0, len(scene.frames)+1)
|
||||
controlEntries := []format.Entry{
|
||||
{Timestamp: format.Timestamp{}, Data: format.PaletteLookupEntryData{List: scene.paletteLookup}},
|
||||
{Timestamp: format.Timestamp{}, Data: format.ControlDictionaryEntryData{Words: scene.controlWords}},
|
||||
}
|
||||
if withPalette {
|
||||
controlEntries = append(controlEntries,
|
||||
format.Entry{Timestamp: start, Data: format.PaletteResetEntryData{}},
|
||||
format.Entry{Timestamp: start, Data: format.PaletteEntryData{Colors: scene.palette}})
|
||||
}
|
||||
buckets = append(buckets,
|
||||
format.EntryBucket{
|
||||
Priority: format.EntryBucketPriorityVideoControl,
|
||||
Timestamp: start,
|
||||
Entries: controlEntries,
|
||||
})
|
||||
frameTime := start
|
||||
for _, frame := range scene.frames {
|
||||
buckets = append(buckets, frame.encode(frameTime))
|
||||
frameTime = frameTime.Plus(frame.displayTime)
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
|
||||
// HighResFrame contains the compressed information of a high-resolution picture in a scene.
|
||||
type HighResFrame struct {
|
||||
bitstream []byte
|
||||
maskstream []byte
|
||||
displayTime format.Timestamp
|
||||
}
|
||||
|
||||
// WithDisplayTime returns a new instance with the given display time set.
|
||||
func (frame HighResFrame) WithDisplayTime(displayTime time.Duration) HighResFrame {
|
||||
newFrame := frame
|
||||
newFrame.displayTime = format.TimestampFromDuration(displayTime)
|
||||
return newFrame
|
||||
}
|
||||
|
||||
func (frame HighResFrame) duration() format.Timestamp {
|
||||
return frame.displayTime
|
||||
}
|
||||
|
||||
func (frame HighResFrame) encode(start format.Timestamp) format.EntryBucket {
|
||||
return format.EntryBucket{
|
||||
Priority: format.EntryBucketPriorityFrame,
|
||||
Timestamp: start,
|
||||
Entries: []format.Entry{
|
||||
{
|
||||
Timestamp: start,
|
||||
Data: format.HighResVideoEntryData{
|
||||
Bitstream: frame.bitstream,
|
||||
Maskstream: frame.maskstream,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package movie
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"sort"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/format"
|
||||
"github.com/inkyblackness/hacked/ss1/content/text"
|
||||
)
|
||||
|
||||
// Write encodes the provided container into the given writer.
|
||||
func Write(dest io.Writer, container Container, cp text.Codepage) error {
|
||||
var indexEntries []format.IndexTableEntry
|
||||
var header format.Header
|
||||
palette := paletteDataFromContainer(container)
|
||||
|
||||
// setup header
|
||||
copy(header.Tag[:], bytes.NewBufferString(format.Tag).Bytes())
|
||||
endTimestamp := container.duration()
|
||||
header.Duration.Number = int16(endTimestamp.Second)
|
||||
header.Duration.Fraction = endTimestamp.Fraction
|
||||
header.VideoWidth = container.Video.Width
|
||||
header.VideoHeight = container.Video.Height
|
||||
|
||||
if header.VideoWidth != 0 {
|
||||
header.VideoBitsPerPixel = 8
|
||||
header.VideoPalettePresent = 1
|
||||
header.VideoFrameRate = format.FixFromFloat(12.5) // take a good guess on what a typical framerate would be.
|
||||
}
|
||||
if !container.Audio.Sound.Empty() {
|
||||
header.AudioChannelCount = 1
|
||||
header.AudioBytesPerSample = 1
|
||||
header.AudioSampleRate = format.FixFromFloat(container.Audio.Sound.SampleRate)
|
||||
}
|
||||
|
||||
var buckets []format.EntryBucket
|
||||
buckets = append(buckets, container.Audio.encode()...)
|
||||
buckets = append(buckets, container.Video.encode()...)
|
||||
subtitleBucketsList := container.Subtitles.encode(cp)
|
||||
for _, subtitleBuckets := range subtitleBucketsList {
|
||||
buckets = append(buckets, subtitleBuckets...)
|
||||
}
|
||||
sort.Slice(buckets, func(a, b int) bool {
|
||||
bucketA := buckets[a]
|
||||
bucketB := buckets[b]
|
||||
if bucketA.Timestamp.IsBefore(bucketB.Timestamp) {
|
||||
return true
|
||||
}
|
||||
if bucketB.Timestamp.IsBefore(bucketA.Timestamp) {
|
||||
return false
|
||||
}
|
||||
return bucketA.Priority < bucketB.Priority
|
||||
})
|
||||
|
||||
// create index
|
||||
var entries []format.Entry
|
||||
for _, bucket := range buckets {
|
||||
entries = append(entries, bucket.Entries...)
|
||||
}
|
||||
moveSubtitlesAfterSceneChange(entries)
|
||||
|
||||
for _, dataEntry := range entries {
|
||||
indexEntry := format.IndexTableEntry{
|
||||
Type: byte(dataEntry.Data.Type()),
|
||||
DataOffset: header.ContentSize,
|
||||
TimestampSecond: dataEntry.Timestamp.Second,
|
||||
TimestampFraction: dataEntry.Timestamp.Fraction,
|
||||
}
|
||||
|
||||
header.ContentSize += int32(len(dataEntry.Data.Bytes()))
|
||||
indexEntries = append(indexEntries, indexEntry)
|
||||
}
|
||||
// calculate size fields
|
||||
header.IndexSize = int32(format.IndexTableSizeFor(len(indexEntries) + 1))
|
||||
dataStartOffset := format.HeaderSize + int32(len(palette)) + header.IndexSize
|
||||
for i := range indexEntries {
|
||||
indexEntries[i].DataOffset += dataStartOffset
|
||||
}
|
||||
|
||||
// determine end
|
||||
lastEntry := format.IndexTableEntry{
|
||||
TimestampSecond: byte(header.Duration.Number),
|
||||
TimestampFraction: header.Duration.Fraction,
|
||||
Type: byte(format.DataTypeEndOfMedia),
|
||||
DataOffset: dataStartOffset + header.ContentSize}
|
||||
indexEntries = append(indexEntries, lastEntry)
|
||||
header.IndexEntryCount = int32(len(indexEntries))
|
||||
|
||||
// write data
|
||||
err := binary.Write(dest, binary.LittleEndian, &header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dest.Write(palette)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = binary.Write(dest, binary.LittleEndian, indexEntries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dest.Write(make([]byte, int(header.IndexSize)-int(header.IndexEntryCount)*format.IndexTableEntrySize))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, dataEntry := range entries {
|
||||
_, err = dest.Write(dataEntry.Data.Bytes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func moveSubtitlesAfterSceneChange(entries []format.Entry) {
|
||||
nextSceneChangeIndex := -1
|
||||
var nextSceneChangeTimestamp format.Timestamp
|
||||
delta := format.TimestampFromSeconds(0.5)
|
||||
for i := len(entries) - 1; i >= 0; i-- {
|
||||
entry := entries[i]
|
||||
|
||||
switch entry.Data.Type() {
|
||||
case format.DataTypePalette:
|
||||
nextSceneChangeIndex = i
|
||||
nextSceneChangeTimestamp = entry.Timestamp
|
||||
case format.DataTypeSubtitle:
|
||||
if (nextSceneChangeIndex > 0) && nextSceneChangeTimestamp.IsBefore(entry.Timestamp.Plus(delta)) {
|
||||
copy(entries[i:nextSceneChangeIndex], entries[i+1:nextSceneChangeIndex+1])
|
||||
entries[nextSceneChangeIndex] = entry
|
||||
nextSceneChangeIndex--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func paletteDataFromContainer(container Container) []byte {
|
||||
palette := container.Video.StartPalette()
|
||||
buf := bytes.NewBuffer(nil)
|
||||
_ = binary.Write(buf, binary.LittleEndian, &palette)
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package movie_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie"
|
||||
"github.com/inkyblackness/hacked/ss1/content/text"
|
||||
)
|
||||
|
||||
func TestWriteOfEmptyContainerCreatesMinimumSizeData(t *testing.T) {
|
||||
var container movie.Container
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
|
||||
err := movie.Write(buffer, container, text.DefaultCodepage())
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, 0x0800, len(buffer.Bytes()))
|
||||
}
|
||||
|
||||
func TestWriteCanSaveEmptyContainer(t *testing.T) {
|
||||
var container movie.Container
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
|
||||
err := movie.Write(buffer, container, text.DefaultCodepage())
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = movie.Read(bytes.NewReader(buffer.Bytes()), text.DefaultCodepage())
|
||||
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestWriteSavesAudio(t *testing.T) {
|
||||
dataBytes := []byte{0x01, 0x02, 0x03}
|
||||
var container movie.Container
|
||||
container.Audio.Sound.SampleRate = 22050.0
|
||||
container.Audio.Sound.Samples = dataBytes
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
|
||||
err := movie.Write(buffer, container, text.DefaultCodepage())
|
||||
require.Nil(t, err)
|
||||
|
||||
result, err := movie.Read(bytes.NewReader(buffer.Bytes()), text.DefaultCodepage())
|
||||
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, dataBytes, result.Audio.Sound.Samples)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package compression
|
||||
|
||||
// BitstreamReader is a utility to read big-endian integer values of arbitrary bit size from a bitstream.
|
||||
type BitstreamReader struct {
|
||||
source []byte
|
||||
nextSourceIndex int
|
||||
sourceBitLen uint64
|
||||
|
||||
currentBitPos uint64
|
||||
|
||||
buffer uint64
|
||||
bitsBuffered uint64
|
||||
}
|
||||
|
||||
// NewBitstreamReader returns a new instance of a bistream reader using the provided byte slice as source.
|
||||
func NewBitstreamReader(data []byte) *BitstreamReader {
|
||||
return &BitstreamReader{
|
||||
source: data,
|
||||
sourceBitLen: uint64(len(data)) * 8,
|
||||
}
|
||||
}
|
||||
|
||||
func (reader *BitstreamReader) bufferNextByte() {
|
||||
reader.buffer = (reader.buffer << 8) | uint64(reader.source[reader.nextSourceIndex])
|
||||
reader.bitsBuffered += 8
|
||||
reader.nextSourceIndex++
|
||||
}
|
||||
|
||||
// Exhausted returns true if the current position is at or beyond the possible length.
|
||||
// Reading an exhausted stream yields only zeroes.
|
||||
func (reader *BitstreamReader) Exhausted() bool {
|
||||
return reader.currentBitPos >= reader.sourceBitLen
|
||||
}
|
||||
|
||||
// Read returns a value with the requested bit size, right aligned, as a uint32.
|
||||
// Reading does not advance the current position. A successful read of a certain size will return the same
|
||||
// value when called repeatedly with the same parameter.
|
||||
// Reading is possible beyond the available size. For the missing bits, a value of 0 is provided.
|
||||
//
|
||||
// The function panics when reading more than 32 bits.
|
||||
func (reader *BitstreamReader) Read(bits int) (result uint32) {
|
||||
if bits > 32 {
|
||||
panic("Limit of bit count: 32")
|
||||
}
|
||||
|
||||
available := reader.sourceBitLen - reader.currentBitPos
|
||||
toRead := uint64(bits)
|
||||
if toRead > available {
|
||||
toRead = available
|
||||
}
|
||||
|
||||
for reader.bitsBuffered < toRead {
|
||||
reader.bufferNextByte()
|
||||
}
|
||||
result = uint32(reader.buffer >> (reader.bitsBuffered - toRead) & ^(uint64(0xFFFFFFFFFFFFFFFF) << toRead))
|
||||
result <<= uint64(bits) - toRead
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Advance skips the provided amount of bits and puts the current position there.
|
||||
// Successive read operations will return values from the new position.
|
||||
// It is possible to advance beyond the available length.
|
||||
//
|
||||
// This function panics when advancing with negative values.
|
||||
func (reader *BitstreamReader) Advance(bits int) {
|
||||
if bits < 0 {
|
||||
panic("Can only advance forward")
|
||||
}
|
||||
|
||||
newIndex := reader.currentBitPos + uint64(bits)
|
||||
if newIndex > reader.sourceBitLen {
|
||||
newIndex = reader.sourceBitLen
|
||||
}
|
||||
reader.currentBitPos = newIndex
|
||||
if uint64(bits) > reader.bitsBuffered {
|
||||
reader.bitsBuffered = 0
|
||||
reader.nextSourceIndex = int(reader.currentBitPos / 8)
|
||||
remainder := reader.currentBitPos % 8
|
||||
if remainder > 0 {
|
||||
reader.bufferNextByte()
|
||||
reader.bitsBuffered -= remainder
|
||||
}
|
||||
} else {
|
||||
reader.bitsBuffered -= uint64(bits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBitstreamReadPanicsForMoreThan32Bits(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0x11, 0x22, 0x33, 0x44})
|
||||
|
||||
assert.Panicsf(t, func() { reader.Read(33) }, "Limit of bit count: 32")
|
||||
}
|
||||
|
||||
func TestBitstreamReadReturnsValueOfRequestedBitSize(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xAF})
|
||||
|
||||
result := reader.Read(3)
|
||||
|
||||
assert.Equal(t, uint32(5), result)
|
||||
}
|
||||
|
||||
func TestBitstreamRepeatedReadReturnsSameValue(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xAF})
|
||||
|
||||
result1 := reader.Read(3)
|
||||
result2 := reader.Read(3)
|
||||
|
||||
assert.Equal(t, uint32(5), result1)
|
||||
assert.Equal(t, result1, result2)
|
||||
}
|
||||
|
||||
func TestBitstreamReadReturnsZeroesForBitsBeyondEndA(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xAF})
|
||||
|
||||
result := reader.Read(9)
|
||||
|
||||
assert.Equal(t, uint32(0x15E), result)
|
||||
}
|
||||
|
||||
func TestBitstreamReadReturnsZeroesForBitsBeyondEndB(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xBF})
|
||||
|
||||
result := reader.Read(32)
|
||||
|
||||
assert.Equal(t, uint32(0xBF000000), result)
|
||||
}
|
||||
|
||||
func TestBitstreamAdvancePanicsForNegativeValues(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0x11, 0x22, 0x33, 0x44})
|
||||
|
||||
assert.Panicsf(t, func() { reader.Advance(-10) }, "Can only advance forward")
|
||||
}
|
||||
|
||||
func TestBitstreamAdvanceLetsReadFurtherBits(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xAF})
|
||||
|
||||
reader.Advance(2)
|
||||
result := reader.Read(4)
|
||||
|
||||
assert.Equal(t, uint32(0x0B), result)
|
||||
}
|
||||
|
||||
func TestBitstreamAdvanceToEndIsPossible(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xAF})
|
||||
|
||||
reader.Advance(8)
|
||||
result := reader.Read(8)
|
||||
|
||||
assert.Equal(t, uint32(0), result)
|
||||
}
|
||||
|
||||
func TestBitstreamInternalBufferDoesNotLoseData(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0x7F, 0xFF, 0xFF, 0xFF, 0x80})
|
||||
|
||||
reader.Advance(1)
|
||||
result := reader.Read(32)
|
||||
|
||||
assert.Equal(t, uint32(0xFFFFFFFF), result)
|
||||
}
|
||||
|
||||
func TestBitstreamAdvanceCanJumpToLastBit(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0x00, 0x00, 0x01})
|
||||
|
||||
reader.Advance(23)
|
||||
result := reader.Read(1)
|
||||
|
||||
assert.Equal(t, uint32(1), result)
|
||||
}
|
||||
|
||||
func TestBitstreamReadAdvanceBeyondFirstRead(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xFF, 0x00, 0xFA})
|
||||
|
||||
reader.Read(10)
|
||||
reader.Advance(20)
|
||||
result := reader.Read(4)
|
||||
|
||||
assert.Equal(t, uint32(0x0A), result)
|
||||
}
|
||||
|
||||
func TestBitstreamReadAdvanceWithinFirstRead(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xFF, 0x00, 0xFA})
|
||||
|
||||
reader.Read(10)
|
||||
reader.Advance(4)
|
||||
result := reader.Read(20)
|
||||
|
||||
assert.Equal(t, uint32(0xF00FA), result)
|
||||
}
|
||||
|
||||
func TestBitstreamReadOfZeroBitsIsPossibleMidStream(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xFF})
|
||||
|
||||
reader.Read(4)
|
||||
result := reader.Read(0)
|
||||
|
||||
assert.Equal(t, uint32(0), result)
|
||||
}
|
||||
|
||||
func TestBitstreamReadOfZeroBitsIsPossibleAtEnd(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xFF})
|
||||
|
||||
reader.Advance(8)
|
||||
result := reader.Read(0)
|
||||
|
||||
assert.Equal(t, uint32(0), result)
|
||||
}
|
||||
|
||||
func TestBitstreamReadOfZeroBitsIsPossibleWithEmptySource(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{})
|
||||
|
||||
result := reader.Read(0)
|
||||
|
||||
assert.Equal(t, uint32(0), result)
|
||||
}
|
||||
|
||||
func TestBitstreamAdvanceBeyondEndIsPossible(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xFF})
|
||||
|
||||
reader.Advance(9)
|
||||
result := reader.Read(32)
|
||||
|
||||
assert.Equal(t, uint32(0), result)
|
||||
}
|
||||
|
||||
func TestBitstreamExhaustedReturnsFalseForAvailableBits(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xFF})
|
||||
|
||||
result := reader.Exhausted()
|
||||
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
||||
func TestBitstreamExhaustedReturnsFalseForStillOneAvailableBit(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xFF})
|
||||
|
||||
reader.Advance(7)
|
||||
result := reader.Exhausted()
|
||||
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
||||
func TestBitstreamExhaustedReturnsTrueAfterAdvancingToEnd(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xFF, 0xFF})
|
||||
|
||||
reader.Advance(16)
|
||||
result := reader.Exhausted()
|
||||
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestBitstreamExhaustedReturnsTrueAfterAdvancingBeyondEnd(t *testing.T) {
|
||||
reader := compression.NewBitstreamReader([]byte{0xFF, 0xFF})
|
||||
|
||||
reader.Advance(30)
|
||||
result := reader.Exhausted()
|
||||
|
||||
assert.True(t, result)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package compression
|
||||
|
||||
// BitstreamWriter is a utility to write big-endian integer values of arbitrary bit size to a bitstream.
|
||||
type BitstreamWriter struct {
|
||||
buf []byte
|
||||
|
||||
offset uint
|
||||
}
|
||||
|
||||
// Buffer returns the currently stored buffer.
|
||||
func (w BitstreamWriter) Buffer() []byte {
|
||||
return w.buf
|
||||
}
|
||||
|
||||
// Write stores the given amount of bits from the provided value.
|
||||
// Trying to write more than 32 bits will cause panic.
|
||||
func (w *BitstreamWriter) Write(bits uint, value uint32) {
|
||||
if bits > 32 {
|
||||
panic("maximum of 32 bits are possible to be written")
|
||||
}
|
||||
for bits > 0 {
|
||||
temp := (uint64(value) & ^(^uint64(0) << bits)) << (40 - bits - w.offset)
|
||||
if w.offset == 0 {
|
||||
w.buf = append(w.buf, 0x00)
|
||||
}
|
||||
w.buf[len(w.buf)-1] |= byte(temp >> 32)
|
||||
written := 8 - w.offset
|
||||
if written > bits {
|
||||
written = bits
|
||||
}
|
||||
bits -= written
|
||||
w.offset = (w.offset + written) % 8
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type BitstreamWriterSuite struct {
|
||||
suite.Suite
|
||||
|
||||
writer compression.BitstreamWriter
|
||||
}
|
||||
|
||||
func TestBitstreamWriter(t *testing.T) {
|
||||
suite.Run(t, new(BitstreamWriterSuite))
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) SetupTest() {
|
||||
suite.writer = compression.BitstreamWriter{}
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) TestWriteMoreThan32BitsPanics() {
|
||||
assert.Panics(suite.T(), func() { suite.whenWriting(33, 0) })
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) TestWriteZeroBitsIsIgnored() {
|
||||
suite.whenWriting(0, 1)
|
||||
suite.thenBufferShouldBe([]byte{})
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) TestWriteOneBit() {
|
||||
suite.whenWriting(1, 1)
|
||||
suite.thenBufferShouldBe([]byte{0x80})
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) TestWriteEightBits() {
|
||||
suite.whenWriting(8, 0xEF)
|
||||
suite.thenBufferShouldBe([]byte{0xEF})
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) TestWriteTwelveBits() {
|
||||
suite.whenWriting(12, 0xFEF)
|
||||
suite.thenBufferShouldBe([]byte{0xFE, 0xF0})
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) TestWriteOneBitTwice() {
|
||||
suite.givenWritten(1, 1)
|
||||
suite.whenWriting(1, 1)
|
||||
suite.thenBufferShouldBe([]byte{0xC0})
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) TestWriteArbitrary1() {
|
||||
suite.givenWritten(1, 1)
|
||||
suite.givenWritten(10, 0x2AA)
|
||||
suite.whenWriting(16, 0xFFFF)
|
||||
suite.thenBufferShouldBe([]byte{0xD5, 0x5F, 0xFF, 0xE0}) // [1 10 10101] [010 11111] [11111111] [111 00000]
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) TestWriteArbitrary2() {
|
||||
suite.givenWritten(1, 1)
|
||||
suite.givenWritten(10, 0x2AA)
|
||||
suite.whenWriting(24, 0xFFFFFF)
|
||||
suite.thenBufferShouldBe([]byte{0xD5, 0x5F, 0xFF, 0xFF, 0xE0}) // extension of Arbitrary1
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) TestWriteLessBitsThanGiven() {
|
||||
suite.givenWritten(4, 0x0)
|
||||
suite.whenWriting(8, 0xFFF)
|
||||
suite.thenBufferShouldBe([]byte{0x0F, 0xF0})
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) TestWriteArbitrary3() {
|
||||
suite.givenWritten(1, 1)
|
||||
suite.givenWritten(10, 0x2AA)
|
||||
suite.whenWriting(32, 0xFFFFFFFF)
|
||||
suite.thenBufferShouldBe([]byte{0xD5, 0x5F, 0xFF, 0xFF, 0xFF, 0xE0}) // extension of Arbitrary1+2
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) givenWritten(bits uint, value uint32) {
|
||||
suite.writer.Write(bits, value)
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) whenWriting(bits uint, value uint32) {
|
||||
suite.writer.Write(bits, value)
|
||||
}
|
||||
|
||||
func (suite *BitstreamWriterSuite) thenBufferShouldBe(expected []byte) {
|
||||
suite.T().Helper()
|
||||
buf := suite.writer.Buffer()
|
||||
if len(expected) == 0 {
|
||||
assert.Equal(suite.T(), 0, len(buf))
|
||||
} else {
|
||||
assert.Equal(suite.T(), expected, buf)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type CompressionSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func TestTileSideLength(t *testing.T) {
|
||||
assert.Equal(t, 4, compression.TileSideLength, "The following tests assume a tile side length of 4")
|
||||
}
|
||||
|
||||
func TestCompressionTest(t *testing.T) {
|
||||
suite.Run(t, new(CompressionSuite))
|
||||
}
|
||||
|
||||
func (suite *CompressionSuite) SetupTest() {
|
||||
|
||||
}
|
||||
|
||||
func (suite *CompressionSuite) TestOneFrameOneTile() {
|
||||
frame0 := []byte{
|
||||
0x01, 0x02, 0x03, 0x04,
|
||||
0x05, 0x06, 0x07, 0x08,
|
||||
0x09, 0x0A, 0x0B, 0x0C,
|
||||
0x0D, 0x0E, 0x0F, 0x10,
|
||||
}
|
||||
suite.verifyCompression(4, 4, frame0)
|
||||
}
|
||||
|
||||
func (suite *CompressionSuite) TestTwoFramesOneTile() {
|
||||
frame0 := []byte{
|
||||
0x01, 0x02, 0x03, 0x04,
|
||||
0x05, 0x06, 0x07, 0x08,
|
||||
0x09, 0x0A, 0x0B, 0x0C,
|
||||
0x0D, 0x0E, 0x0F, 0x10,
|
||||
}
|
||||
frame1 := []byte{
|
||||
0x20, 0x21, 0x22, 0x23,
|
||||
0x24, 0x25, 0x26, 0x27,
|
||||
0x28, 0x29, 0x2A, 0x2B,
|
||||
0x2C, 0x2D, 0x2E, 0x2F,
|
||||
}
|
||||
suite.verifyCompression(4, 4, frame0, frame1)
|
||||
}
|
||||
|
||||
func (suite *CompressionSuite) TestTwoTiles() {
|
||||
frame0 := []byte{
|
||||
0x01, 0x02, 0x03, 0x04, 0x20, 0x21, 0x22, 0x23,
|
||||
0x05, 0x06, 0x07, 0x08, 0x24, 0x25, 0x26, 0x27,
|
||||
0x09, 0x0A, 0x0B, 0x0C, 0x28, 0x29, 0x2A, 0x2B,
|
||||
0x0D, 0x0E, 0x0F, 0x10, 0x2C, 0x2D, 0x2E, 0x2F,
|
||||
}
|
||||
suite.verifyCompression(8, 4, frame0)
|
||||
}
|
||||
|
||||
func (suite *CompressionSuite) verifyCompression(width, height int, inFrames ...[]byte) {
|
||||
suite.T().Helper()
|
||||
verifyCompression(suite.T(), width, height, inFrames...)
|
||||
}
|
||||
|
||||
func BenchmarkRecompressionDeth00(b *testing.B) {
|
||||
recompress(b, "deth", "scene00")
|
||||
}
|
||||
|
||||
func BenchmarkRecompressionDeth01(b *testing.B) {
|
||||
recompress(b, "deth", "scene01")
|
||||
}
|
||||
|
||||
func BenchmarkRecompressionDeth02(b *testing.B) {
|
||||
recompress(b, "deth", "scene02")
|
||||
}
|
||||
|
||||
func recompress(tb testing.TB, dir1, dir2 string) {
|
||||
tb.Helper()
|
||||
scenepath := filepath.Join(".", "_testdata", dir1, dir2)
|
||||
read := func(name string) []byte {
|
||||
data, err := ioutil.ReadFile(filepath.Join(scenepath, name))
|
||||
require.Nil(tb, err, "no error expected reading %v", name)
|
||||
return data
|
||||
}
|
||||
controlWordsData := read("controlDict.bin")
|
||||
controlWords, err := compression.UnpackControlWords(controlWordsData)
|
||||
require.Nil(tb, err, "no error expected unpacking control words")
|
||||
paletteLookup := read("paletteLookup.bin")
|
||||
|
||||
width := 600
|
||||
height := 300
|
||||
frame := make([]byte, width*height)
|
||||
decoderBuilder := compression.NewFrameDecoderBuilder(600, 300)
|
||||
|
||||
logStatistics(tb, "In", controlWords, paletteLookup)
|
||||
|
||||
decoderBuilder.WithControlWords(controlWords)
|
||||
decoderBuilder.WithPaletteLookupList(paletteLookup)
|
||||
decoderBuilder.ForStandardFrame(frame, width)
|
||||
decoder := decoderBuilder.Build()
|
||||
canDecodeFrame := func(index int) bool {
|
||||
bitstream, err := ioutil.ReadFile(filepath.Join(scenepath, fmt.Sprintf("frame%02d_bitstream.bin", index)))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
maskstream, err := ioutil.ReadFile(filepath.Join(scenepath, fmt.Sprintf("frame%02d_maskstream.bin", index)))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
tb.Logf("InStatistics F%2d: Bitstream: %v, Maskstream: %v", index, len(bitstream), len(maskstream))
|
||||
err = decoder.Decode(bitstream, maskstream)
|
||||
if err != nil {
|
||||
tb.Logf("Failed to decode frame F%2d: %v", index, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
frameIndex := 0
|
||||
var frames [][]byte
|
||||
for canDecodeFrame(frameIndex) {
|
||||
frameCopy := make([]byte, len(frame))
|
||||
copy(frameCopy, frame)
|
||||
frames = append(frames, frameCopy)
|
||||
frameIndex++
|
||||
}
|
||||
|
||||
tb.Logf("verifying compression of %v frames\n", len(frames))
|
||||
verifyCompression(tb, width, height, frames...)
|
||||
}
|
||||
|
||||
func BenchmarkRandomFrames(b *testing.B) {
|
||||
// With a frame size of 600x300 we have 150*75=11250 tiles per frame.
|
||||
// The maximum palette offset is 0x1FFFF, allowing for 8191 entries of full 16 byte (fully random tile) palettes.
|
||||
// This means not even one frame of completely randomized tiles can be encoded.
|
||||
// As a result, this test will first create a set of 4000 random tiles, and then create a sequence of frames
|
||||
// using this set of tiles, instead of creating complete random frames.
|
||||
// Still, this test is rather slow, so it is moved to be a benchmark for now.
|
||||
|
||||
seed := time.Now().UnixNano()
|
||||
b.Logf("Running with seed %v", seed)
|
||||
hTiles := 150
|
||||
vTiles := 75
|
||||
width := 4 * hTiles
|
||||
height := 4 * vTiles
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
tiles := make([][4][4]byte, 4000)
|
||||
for tileIndex := 0; tileIndex < len(tiles); tileIndex++ {
|
||||
tile := &tiles[tileIndex]
|
||||
for y := 0; y < 4; y++ {
|
||||
for x := 0; x < 4; x++ {
|
||||
tile[y][x] = byte(1 + r.Intn(255)) // value of 0x00 may not be encoded.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frames := make([][]byte, 5)
|
||||
for frameIndex := 0; frameIndex < len(frames); frameIndex++ {
|
||||
frame := make([]byte, width*height)
|
||||
for y := 0; y < height; y += 4 {
|
||||
for x := 0; x < width; x += 4 {
|
||||
tile := &tiles[r.Intn(len(tiles))]
|
||||
for line := 0; line < 4; line++ {
|
||||
copy(frame[(y+line)*width+x:], tile[line][:])
|
||||
}
|
||||
}
|
||||
}
|
||||
frames[frameIndex] = frame
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
verifyCompression(b, width, height, frames...)
|
||||
}
|
||||
|
||||
func verifyCompression(tb testing.TB, width, height int, inFrames ...[]byte) {
|
||||
tb.Helper()
|
||||
encoder := compression.NewSceneEncoder(width, height)
|
||||
for frameIndex, frame := range inFrames {
|
||||
require.Equal(tb, width*height, len(frame), fmt.Sprintf("Length of frame %d is wrong for dimension", frameIndex))
|
||||
err := encoder.AddFrame(frame)
|
||||
require.Nil(tb, err, fmt.Sprintf("no error expected adding frame %d: %v", frameIndex, err))
|
||||
}
|
||||
controlWords, paletteLookup, encodedFrames, err := encoder.Encode(context.Background())
|
||||
require.Equal(tb, len(inFrames), len(encodedFrames), "expected equal amount of encoded frames for input frames")
|
||||
require.Nil(tb, err, fmt.Sprintf("no error expected encoding: %v", err))
|
||||
|
||||
logStatistics(tb, "Out", controlWords, paletteLookup)
|
||||
|
||||
decoderBuilder := compression.NewFrameDecoderBuilder(width, height)
|
||||
decoderBuilder.WithControlWords(controlWords)
|
||||
decoderBuilder.WithPaletteLookupList(paletteLookup)
|
||||
frameBuffer := make([]byte, width*height)
|
||||
decoderBuilder.ForStandardFrame(frameBuffer, width)
|
||||
for frameIndex, encodedFrame := range encodedFrames {
|
||||
decoder := decoderBuilder.Build()
|
||||
tb.Logf("Statistics F%2d: Bitstream: %v, Maskstream: %v",
|
||||
frameIndex, len(encodedFrame.Bitstream), len(encodedFrame.Maskstream))
|
||||
err = decoder.Decode(encodedFrame.Bitstream, encodedFrame.Maskstream)
|
||||
assert.Nil(tb, err, fmt.Sprintf("no error expected re-decoding: %v", err))
|
||||
assert.Equal(tb, inFrames[frameIndex], frameBuffer, fmt.Sprintf("Frame content mismatch for frame %d", frameIndex))
|
||||
}
|
||||
}
|
||||
|
||||
func logStatistics(tb testing.TB, prefix string, controlWords []compression.ControlWord, paletteLookup []byte) {
|
||||
tb.Helper()
|
||||
controls := make(map[compression.ControlType]int)
|
||||
skips := make(map[int]int)
|
||||
longOffsets := 0
|
||||
for _, word := range controlWords {
|
||||
if word.IsLongOffset() {
|
||||
longOffsets++
|
||||
} else {
|
||||
skips[word.Count()]++
|
||||
controls[word.Type()]++
|
||||
}
|
||||
}
|
||||
firstWords := ""
|
||||
for i := 0; i < 16; i++ {
|
||||
if i < len(controlWords) {
|
||||
word := controlWords[i]
|
||||
if word.IsLongOffset() {
|
||||
firstWords += fmt.Sprintf(" LO: %v", word.LongOffset())
|
||||
} else {
|
||||
firstWords += fmt.Sprintf(" [%v %v %v]", word.Count(), word.Type(), word.Parameter())
|
||||
}
|
||||
}
|
||||
}
|
||||
tb.Logf("%vStatistics: PaletteLookup: %vB, ControlWords: %v, LO: %v, CTRL: %v, SKIP: %v -- firstWords: %v",
|
||||
prefix, len(paletteLookup), len(controlWords), longOffsets, controls, skips, firstWords)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package compression
|
||||
|
||||
const (
|
||||
bytesPerControlWord = 3
|
||||
|
||||
// TileSideLength is the number of pixel per side of a square frame tile.
|
||||
TileSideLength = 4
|
||||
// PixelPerTile is the number of pixel within one frame tile.
|
||||
PixelPerTile = TileSideLength * TileSideLength
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package compression
|
||||
|
||||
// ControlType defines how to interpret a ControlWord.
|
||||
type ControlType byte
|
||||
|
||||
// Control type constants are listed below.
|
||||
const (
|
||||
CtrlColorTile2ColorsStatic = ControlType(0)
|
||||
CtrlColorTile2ColorsMasked = ControlType(1)
|
||||
CtrlColorTile4ColorsMasked = ControlType(2)
|
||||
CtrlColorTile8ColorsMasked = ControlType(3)
|
||||
CtrlColorTile16ColorsMasked = ControlType(4)
|
||||
|
||||
CtrlSkip = ControlType(5)
|
||||
|
||||
CtrlRepeatPrevious = ControlType(6)
|
||||
CtrlUnknown = ControlType(7)
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
package compression
|
||||
|
||||
// ControlWordParamLimit is the highest number the param property of a control word can take.
|
||||
const ControlWordParamLimit = 0x1FFFF
|
||||
|
||||
// ControlWord describes the current compression action.
|
||||
type ControlWord uint32
|
||||
|
||||
// ControlWordOf returns a word with the given parameters.
|
||||
func ControlWordOf(count int, controlType ControlType, param uint32) ControlWord {
|
||||
return (ControlWord(count) << 20) | (ControlWord(controlType&0x7) << 17) | ControlWord(param&ControlWordParamLimit)
|
||||
}
|
||||
|
||||
// LongOffsetOf returns a word that indicates a long offset with given value.
|
||||
func LongOffsetOf(offset uint32) ControlWord {
|
||||
return ControlWord(offset & 0xFFFFF)
|
||||
}
|
||||
|
||||
// Packed returns the control word packed in a PackedControlWord with the given number of times.
|
||||
func (word ControlWord) Packed(times int) PackedControlWord {
|
||||
return PackedControlWord((uint32(word) & 0x00FFFFFF) | (uint32(times) << 24))
|
||||
}
|
||||
|
||||
// Count returns the count value of the control.
|
||||
func (word ControlWord) Count() int {
|
||||
return int((uint32(word) >> 20) & 0xF)
|
||||
}
|
||||
|
||||
// IsLongOffset returns true if Count() returns 0.
|
||||
func (word ControlWord) IsLongOffset() bool {
|
||||
return word.Count() == 0
|
||||
}
|
||||
|
||||
// LongOffset returns the long offset value. Only relevant if IsLongOffset() returns true.
|
||||
func (word ControlWord) LongOffset() uint32 {
|
||||
return (uint32(word) >> 0) & 0xFFFFF
|
||||
}
|
||||
|
||||
// Type returns the type of the control. Only relevant if IsLongOffset() returns false.
|
||||
func (word ControlWord) Type() ControlType {
|
||||
return ControlType((uint32(word) >> 17) & 0x7)
|
||||
}
|
||||
|
||||
// Parameter returns the type specific parameter value.
|
||||
func (word ControlWord) Parameter() uint32 {
|
||||
return (uint32(word) >> 0) & ControlWordParamLimit
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package compression
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1"
|
||||
)
|
||||
|
||||
const (
|
||||
errTooHighOperationOffset ss1.StringError = "too high operation offset"
|
||||
)
|
||||
|
||||
// TileColorOp describes one operation how a tile should be colored.
|
||||
type TileColorOp struct {
|
||||
Type ControlType
|
||||
Offset uint32
|
||||
}
|
||||
|
||||
// ControlWordSequencer receives a list of requested tile coloring operations
|
||||
// and produces a sequence of low-level control words to reproduce the requested list.
|
||||
type ControlWordSequencer struct {
|
||||
// BitstreamIndexLimit specifies the highest value the sequencer may use to store offset values in the bitstream.
|
||||
// If this value is 0, then the default of 0xFFF (12 bits) is used.
|
||||
BitstreamIndexLimit uint32
|
||||
|
||||
// DirectIndexLimit specifies the amount of direct pointers into the bitstream.
|
||||
// If this value is 0, then the default of BitstreamIndexLimit / 4 is used.
|
||||
DirectIndexLimit uint32
|
||||
|
||||
ops map[TileColorOp]uint32
|
||||
}
|
||||
|
||||
// Add extends the list of requested coloring operations with the given entry.
|
||||
// The offset of the entry must be a value less than or equal ControlWordParamLimit, otherwise the function returns an error.
|
||||
func (seq *ControlWordSequencer) Add(op TileColorOp) error {
|
||||
if op.Offset > ControlWordParamLimit {
|
||||
return errTooHighOperationOffset
|
||||
}
|
||||
if seq.ops == nil {
|
||||
seq.ops = make(map[TileColorOp]uint32)
|
||||
}
|
||||
seq.ops[op]++
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sequence packs the requested list of entries into a sequence of control words.
|
||||
// An error is returned if the operations would exceed the possible storage space.
|
||||
func (seq ControlWordSequencer) Sequence() (ControlWordSequence, error) {
|
||||
var result ControlWordSequence
|
||||
var sortedOps []TileColorOp
|
||||
for op := range seq.ops {
|
||||
sortedOps = append(sortedOps, op)
|
||||
}
|
||||
sort.Slice(sortedOps, func(a, b int) bool {
|
||||
opA := sortedOps[a]
|
||||
opB := sortedOps[b]
|
||||
countA := seq.ops[opA]
|
||||
countB := seq.ops[opB]
|
||||
if countA != countB {
|
||||
return countA > countB
|
||||
}
|
||||
if opA.Offset != opB.Offset {
|
||||
return opA.Offset < opB.Offset
|
||||
}
|
||||
if opA.Type != opB.Type {
|
||||
return opA.Type < opB.Type
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
bitstreamIndexLimit := seq.BitstreamIndexLimit
|
||||
if bitstreamIndexLimit == 0 {
|
||||
bitstreamIndexLimit = 0xFFF
|
||||
}
|
||||
maximumDirect := seq.DirectIndexLimit
|
||||
if maximumDirect > bitstreamIndexLimit {
|
||||
maximumDirect = bitstreamIndexLimit
|
||||
} else if maximumDirect == 0 {
|
||||
maximumDirect = bitstreamIndexLimit / 4
|
||||
}
|
||||
requiredExtensions := uint32(0)
|
||||
|
||||
totalOps := uint32(len(sortedOps))
|
||||
if totalOps > maximumDirect {
|
||||
requiredExtensions = (totalOps - maximumDirect + 15) / 16
|
||||
if requiredExtensions > (bitstreamIndexLimit - maximumDirect) {
|
||||
return result, tooManyWordsError{
|
||||
Total: totalOps,
|
||||
MaximumDirect: maximumDirect,
|
||||
BitstreamIndexLimit: bitstreamIndexLimit,
|
||||
}
|
||||
}
|
||||
}
|
||||
extensionStart := maximumDirect + requiredExtensions
|
||||
|
||||
result.opPaths = make(map[TileColorOp]nestedTileColorOp)
|
||||
for opIndex, op := range sortedOps {
|
||||
if uint32(opIndex) == maximumDirect {
|
||||
// write required amount of long offset entries
|
||||
for i := uint32(0); i < requiredExtensions; i++ {
|
||||
result.words = append(result.words, LongOffsetOf(extensionStart+i*16))
|
||||
}
|
||||
}
|
||||
if uint32(opIndex) >= maximumDirect {
|
||||
offset := uint32(opIndex) - maximumDirect
|
||||
result.opPaths[op] = nestedTileColorOp{
|
||||
parent: &nestedTileColorOp{relOffsetBits: 12, relOffset: maximumDirect + offset/16},
|
||||
relOffsetBits: 4,
|
||||
relOffset: offset % 16,
|
||||
}
|
||||
result.words = append(result.words, ControlWordOf(4, op.Type, op.Offset))
|
||||
} else {
|
||||
result.opPaths[op] = nestedTileColorOp{parent: nil, relOffsetBits: 12, relOffset: uint32(opIndex)}
|
||||
result.words = append(result.words, ControlWordOf(12, op.Type, op.Offset))
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type nestedTileColorOp struct {
|
||||
parent *nestedTileColorOp
|
||||
relOffsetBits uint
|
||||
relOffset uint32
|
||||
}
|
||||
|
||||
func (op nestedTileColorOp) writeTo(w *BitstreamWriter) {
|
||||
if op.parent != nil {
|
||||
op.parent.writeTo(w)
|
||||
}
|
||||
w.Write(op.relOffsetBits, op.relOffset)
|
||||
}
|
||||
|
||||
// ControlWordSequence is a finalized set of control words to reproduce a list of
|
||||
// tile coloring operations. Based on this sequence, a bitstream can be created based
|
||||
// on a selection of such coloring operations (i.e., per frame).
|
||||
type ControlWordSequence struct {
|
||||
// HTiles specifies the amount of horizontal tiles (= operations) a frame has.
|
||||
// This number is relevant for skip operations. If 0, then no compression of skip-operations is done.
|
||||
HTiles uint32
|
||||
|
||||
words []ControlWord
|
||||
opPaths map[TileColorOp]nestedTileColorOp
|
||||
}
|
||||
|
||||
// ControlWords returns the list of low-level control words of the sequence.
|
||||
func (seq ControlWordSequence) ControlWords() []ControlWord {
|
||||
return seq.words
|
||||
}
|
||||
|
||||
// BitstreamFor returns the bitstream to reproduce the provided list of coloring operations
|
||||
// from this sequence.
|
||||
func (seq ControlWordSequence) BitstreamFor(ops []TileColorOp) ([]byte, error) {
|
||||
var writer BitstreamWriter
|
||||
writeOp := func(op TileColorOp) {
|
||||
nested := seq.opPaths[op]
|
||||
nested.writeTo(&writer)
|
||||
}
|
||||
pendingSkips := uint32(0)
|
||||
writePendingSkips := func() {
|
||||
for pendingSkips != 0 {
|
||||
toSkip := pendingSkips
|
||||
if toSkip >= 0x1F {
|
||||
toSkip = 0x1E
|
||||
}
|
||||
writeOp(TileColorOp{Type: CtrlSkip})
|
||||
writer.Write(5, toSkip-1)
|
||||
pendingSkips -= toSkip
|
||||
}
|
||||
}
|
||||
writeLineSkip := func() {
|
||||
if pendingSkips > 0 {
|
||||
writeOp(TileColorOp{Type: CtrlSkip})
|
||||
writer.Write(5, 0x1F)
|
||||
pendingSkips = 0
|
||||
}
|
||||
}
|
||||
hasWidthConfig := seq.HTiles > 0
|
||||
for opIndex, op := range ops {
|
||||
atRowStart := hasWidthConfig && ((uint32(opIndex) % seq.HTiles) == 0)
|
||||
if (pendingSkips > 0) && atRowStart {
|
||||
writeLineSkip()
|
||||
}
|
||||
if op.Type == CtrlSkip {
|
||||
pendingSkips++
|
||||
if !hasWidthConfig {
|
||||
writePendingSkips()
|
||||
}
|
||||
} else {
|
||||
writePendingSkips()
|
||||
writeOp(op)
|
||||
}
|
||||
}
|
||||
if pendingSkips > 0 {
|
||||
writeLineSkip()
|
||||
}
|
||||
return writer.Buffer(), nil
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
)
|
||||
|
||||
type ControlWordSequencerSuite struct {
|
||||
suite.Suite
|
||||
|
||||
sequencer compression.ControlWordSequencer
|
||||
|
||||
sequenceErr error
|
||||
sequence compression.ControlWordSequence
|
||||
hTiles uint32
|
||||
}
|
||||
|
||||
func TestControlWordSequencer(t *testing.T) {
|
||||
suite.Run(t, new(ControlWordSequencerSuite))
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) SetupTest() {
|
||||
suite.sequencer = compression.ControlWordSequencer{}
|
||||
suite.sequenceErr = nil
|
||||
suite.sequence = compression.ControlWordSequence{}
|
||||
suite.hTiles = 0
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestEmptySequence() {
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenControlWordsShouldHaveALenOf(0)
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestAddReturnsErrorOnWrongOffset() {
|
||||
err := suite.sequencer.Add(compression.TileColorOp{Offset: compression.ControlWordParamLimit + 1})
|
||||
assert.NotNil(suite.T(), err, "error expected adding an operation with too high offset")
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestSingleOperation() {
|
||||
suite.givenRegisteredOperations(compression.TileColorOp{})
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenControlWordsShouldHaveALenOf(1)
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestDuplicatedOperationShouldResultInOneControlWord() {
|
||||
suite.givenRegisteredOperations(compression.TileColorOp{}, compression.TileColorOp{})
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenControlWordsShouldHaveALenOf(1)
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestControlWordsAreOrderedByOperationFrequencyFirst() {
|
||||
suite.givenRegisteredOperations(
|
||||
compression.TileColorOp{Offset: 1},
|
||||
compression.TileColorOp{}, compression.TileColorOp{})
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenControlWordsShouldBe(compression.ControlWordOf(12, 0, 0), compression.ControlWordOf(12, 0, 1))
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestControlWordsAreOrderedByOffsetSecond() {
|
||||
suite.givenRegisteredOperations(
|
||||
compression.TileColorOp{Offset: 1}, compression.TileColorOp{Offset: 1},
|
||||
compression.TileColorOp{}, compression.TileColorOp{})
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenControlWordsShouldBe(compression.ControlWordOf(12, 0, 0), compression.ControlWordOf(12, 0, 1))
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestControlWordsAreOrderedByControlTypeThird() {
|
||||
suite.givenRegisteredOperations(
|
||||
compression.TileColorOp{Type: compression.CtrlColorTile16ColorsMasked},
|
||||
compression.TileColorOp{Type: compression.CtrlColorTile16ColorsMasked},
|
||||
compression.TileColorOp{}, compression.TileColorOp{})
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenControlWordsShouldBe(
|
||||
compression.ControlWordOf(12, 0, 0),
|
||||
compression.ControlWordOf(12, compression.CtrlColorTile16ColorsMasked, 0))
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestControlWordLongOffsetsWhenBitstreamSpaceExhausted_FirstTime() {
|
||||
suite.givenSequencerLimitsOf(1, 0)
|
||||
suite.givenRegisteredOperations(
|
||||
compression.TileColorOp{Offset: 0},
|
||||
compression.TileColorOp{Offset: 1},
|
||||
compression.TileColorOp{Offset: 2},
|
||||
compression.TileColorOp{Offset: 3})
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenControlWordsShouldBe(
|
||||
compression.ControlWordOf(0, 0, 1), // first long offset jump to index 1
|
||||
compression.ControlWordOf(4, 0, 0),
|
||||
compression.ControlWordOf(4, 0, 1),
|
||||
compression.ControlWordOf(4, 0, 2),
|
||||
compression.ControlWordOf(4, 0, 3))
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestControlWordLongOffsetsWhenBitstreamSpaceExhausted_SecondTime() {
|
||||
suite.givenSequencerLimitsOf(3, 1)
|
||||
suite.givenRegisteredOperations(
|
||||
compression.TileColorOp{Offset: 0}, compression.TileColorOp{Offset: 1},
|
||||
compression.TileColorOp{Offset: 2}, compression.TileColorOp{Offset: 3},
|
||||
compression.TileColorOp{Offset: 4}, compression.TileColorOp{Offset: 5},
|
||||
compression.TileColorOp{Offset: 6}, compression.TileColorOp{Offset: 7},
|
||||
compression.TileColorOp{Offset: 8}, compression.TileColorOp{Offset: 9},
|
||||
compression.TileColorOp{Offset: 10}, compression.TileColorOp{Offset: 11},
|
||||
compression.TileColorOp{Offset: 12}, compression.TileColorOp{Offset: 13},
|
||||
compression.TileColorOp{Offset: 14}, compression.TileColorOp{Offset: 15},
|
||||
compression.TileColorOp{Offset: 16}, compression.TileColorOp{Offset: 17})
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenControlWordsShouldBe(
|
||||
compression.ControlWordOf(12, 0, 0),
|
||||
compression.ControlWordOf(0, 0, 3), // first long offset jump to index 3
|
||||
compression.ControlWordOf(0, 0, 19), // first long offset jump to index 19
|
||||
compression.ControlWordOf(4, 0, 1), compression.ControlWordOf(4, 0, 2),
|
||||
compression.ControlWordOf(4, 0, 3), compression.ControlWordOf(4, 0, 4),
|
||||
compression.ControlWordOf(4, 0, 5), compression.ControlWordOf(4, 0, 6),
|
||||
compression.ControlWordOf(4, 0, 7), compression.ControlWordOf(4, 0, 8),
|
||||
compression.ControlWordOf(4, 0, 9), compression.ControlWordOf(4, 0, 10),
|
||||
compression.ControlWordOf(4, 0, 11), compression.ControlWordOf(4, 0, 12),
|
||||
compression.ControlWordOf(4, 0, 13), compression.ControlWordOf(4, 0, 14),
|
||||
compression.ControlWordOf(4, 0, 15), compression.ControlWordOf(4, 0, 16),
|
||||
compression.ControlWordOf(4, 0, 17))
|
||||
}
|
||||
|
||||
type bitstreamExpectation struct {
|
||||
bits int
|
||||
value uint32
|
||||
}
|
||||
|
||||
func bits12(value uint32) bitstreamExpectation {
|
||||
return bitstreamExpectation{bits: 12, value: value}
|
||||
}
|
||||
|
||||
func bits5(value uint32) bitstreamExpectation {
|
||||
return bitstreamExpectation{bits: 5, value: value}
|
||||
}
|
||||
|
||||
func bits4(value uint32) bitstreamExpectation {
|
||||
return bitstreamExpectation{bits: 4, value: value}
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestBitstreamForSimpleSequence() {
|
||||
ops := []compression.TileColorOp{{Offset: 1}, {}, {}}
|
||||
suite.givenRegisteredOperations(ops...)
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenBitstreamShouldBeFor(ops,
|
||||
[]bitstreamExpectation{bits12(1), bits12(0), bits12(0)})
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestBitstreamForExtendedSequence() {
|
||||
suite.givenSequencerLimitsOf(2, 1)
|
||||
suite.givenRegisteredOperations(
|
||||
compression.TileColorOp{Offset: 0}, compression.TileColorOp{Offset: 1},
|
||||
compression.TileColorOp{Offset: 2}, compression.TileColorOp{Offset: 3},
|
||||
compression.TileColorOp{Offset: 4}, compression.TileColorOp{Offset: 5},
|
||||
compression.TileColorOp{Offset: 6}, compression.TileColorOp{Offset: 7},
|
||||
compression.TileColorOp{Offset: 8}, compression.TileColorOp{Offset: 9},
|
||||
compression.TileColorOp{Offset: 10}, compression.TileColorOp{Offset: 11},
|
||||
compression.TileColorOp{Offset: 12}, compression.TileColorOp{Offset: 13},
|
||||
compression.TileColorOp{Offset: 14}, compression.TileColorOp{Offset: 15},
|
||||
compression.TileColorOp{Offset: 16})
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenBitstreamShouldBeFor([]compression.TileColorOp{{Offset: 16}},
|
||||
[]bitstreamExpectation{bits12(1), bits4(15)})
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestSkipOperation_Single() {
|
||||
suite.givenHorizontalTileCountOf(3)
|
||||
ops := []compression.TileColorOp{{Offset: 0}, {Type: compression.CtrlSkip}, {Offset: 1}}
|
||||
suite.givenRegisteredOperations(ops...)
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenBitstreamShouldBeFor(ops,
|
||||
[]bitstreamExpectation{bits12(0), bits12(1), bits5(0), bits12(2)})
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestSkipOperation_Double() {
|
||||
suite.givenHorizontalTileCountOf(4)
|
||||
ops := []compression.TileColorOp{
|
||||
{Offset: 0}, {Type: compression.CtrlSkip},
|
||||
{Type: compression.CtrlSkip}, {Offset: 1}}
|
||||
suite.givenRegisteredOperations(ops...)
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenBitstreamShouldBeFor(ops,
|
||||
[]bitstreamExpectation{bits12(1), bits12(0), bits5(1), bits12(2)})
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) TestSkipOperation_EndOfLine() {
|
||||
suite.givenHorizontalTileCountOf(2)
|
||||
ops := []compression.TileColorOp{
|
||||
{Offset: 0}, {Type: compression.CtrlSkip},
|
||||
{Offset: 1}, {Offset: 2}}
|
||||
suite.givenRegisteredOperations(ops...)
|
||||
suite.whenSequenceIsCreated()
|
||||
suite.thenBitstreamShouldBeFor(ops,
|
||||
[]bitstreamExpectation{bits12(0), bits12(1), bits5(0x1F), bits12(2), bits12(3)})
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) givenSequencerLimitsOf(bitstream uint32, direct uint32) {
|
||||
suite.sequencer.BitstreamIndexLimit = bitstream
|
||||
suite.sequencer.DirectIndexLimit = direct
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) givenHorizontalTileCountOf(value uint32) {
|
||||
suite.hTiles = value
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) givenRegisteredOperations(ops ...compression.TileColorOp) {
|
||||
suite.T().Helper()
|
||||
for index, op := range ops {
|
||||
err := suite.sequencer.Add(op)
|
||||
require.Nil(suite.T(), err, "no error expected adding operation %v", index)
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) whenSequenceIsCreated() {
|
||||
suite.sequence, suite.sequenceErr = suite.sequencer.Sequence()
|
||||
suite.sequence.HTiles = suite.hTiles
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) thenControlWordsShouldHaveALenOf(expected int) {
|
||||
suite.T().Helper()
|
||||
require.Nil(suite.T(), suite.sequenceErr, "no error expected in this verification")
|
||||
words := suite.sequence.ControlWords()
|
||||
assert.Equal(suite.T(), expected, len(words))
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) thenControlWordsShouldBe(expected ...compression.ControlWord) {
|
||||
suite.T().Helper()
|
||||
require.Nil(suite.T(), suite.sequenceErr, "no error expected in this verification")
|
||||
words := suite.sequence.ControlWords()
|
||||
assert.Equal(suite.T(), len(expected), len(words), "length mismatch")
|
||||
assert.Equal(suite.T(), expected, words, "words mismatch")
|
||||
}
|
||||
|
||||
func (suite *ControlWordSequencerSuite) thenBitstreamShouldBeFor(ops []compression.TileColorOp, expected []bitstreamExpectation) {
|
||||
suite.T().Helper()
|
||||
require.Nil(suite.T(), suite.sequenceErr, "no error expected in this verification")
|
||||
data, err := suite.sequence.BitstreamFor(ops)
|
||||
require.Nil(suite.T(), err, "no error expected extracting bitstream")
|
||||
bitstream := compression.NewBitstreamReader(data)
|
||||
for index, exp := range expected {
|
||||
value := bitstream.Read(exp.bits)
|
||||
assert.Equal(suite.T(), exp.value, value, "Value mismatch at index %v - bitstream is 0x%v", index, hex.EncodeToString(data))
|
||||
bitstream.Advance(exp.bits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
)
|
||||
|
||||
func TestControlWordPackedReturnsPackedControlWord(t *testing.T) {
|
||||
word := compression.ControlWord(0x00FFAA55)
|
||||
|
||||
assert.Equal(t, compression.PackedControlWord(0x80FFAA55), word.Packed(128))
|
||||
}
|
||||
|
||||
func TestControlWordPackedClearsHighestByteBeforeSettingCount(t *testing.T) {
|
||||
word := compression.ControlWord(0x55FFAA55)
|
||||
|
||||
assert.Equal(t, compression.PackedControlWord(0xC0FFAA55), word.Packed(0xC0))
|
||||
}
|
||||
|
||||
func TestControlWordCountReturnsBits20To23(t *testing.T) {
|
||||
word := compression.ControlWord(0x00F00000)
|
||||
|
||||
assert.Equal(t, 15, word.Count())
|
||||
}
|
||||
|
||||
func TestControlWordIsLongOffsetReturnsTrueForCount0(t *testing.T) {
|
||||
word := compression.ControlWord(0x00000000)
|
||||
|
||||
assert.True(t, word.IsLongOffset())
|
||||
}
|
||||
|
||||
func TestControlWordIsLongOffsetReturnsFalseForCountGreater0(t *testing.T) {
|
||||
word := compression.ControlWord(0x00300000)
|
||||
|
||||
assert.False(t, word.IsLongOffset())
|
||||
}
|
||||
|
||||
func TestControlWordLongOffsetReturnsBits00To19(t *testing.T) {
|
||||
word := compression.ControlWord(0xFFFA6665)
|
||||
|
||||
assert.Equal(t, uint32(0xA6665), word.LongOffset())
|
||||
}
|
||||
|
||||
func TestControlWordParameterReturnsBits00To16(t *testing.T) {
|
||||
word := compression.ControlWord(0xFFFF1665)
|
||||
|
||||
assert.Equal(t, uint32(0x11665), word.Parameter())
|
||||
}
|
||||
|
||||
func TestControlWordTypeReturnsBits17To19(t *testing.T) {
|
||||
word := compression.ControlWord(0xFFFAFFFF)
|
||||
|
||||
assert.Equal(t, compression.ControlType(5), word.Type())
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package compression
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrFormat indicates errors in the compression format.
|
||||
ErrFormat ss1.StringError = "format error"
|
||||
|
||||
errCannotRepeatWordOnFirstTileOfRow ss1.StringError = "cannot repeat word on first tile of row"
|
||||
errUnknownControl ss1.StringError = "found unknown control"
|
||||
errTooManyLongOffsets ss1.StringError = "too many long offsets"
|
||||
errInvalidByteCount ss1.StringError = "invalid byte count"
|
||||
errInvalidFrameSize ss1.StringError = "invalid frame size"
|
||||
)
|
||||
|
||||
type tooManyWordsError struct {
|
||||
Total uint32
|
||||
MaximumDirect uint32
|
||||
BitstreamIndexLimit uint32
|
||||
}
|
||||
|
||||
func (err tooManyWordsError) Error() string {
|
||||
return fmt.Sprintf("too many words: total=%v, maxDirect=%v, indexLimit=%v", err.Total, err.MaximumDirect, err.BitstreamIndexLimit)
|
||||
}
|
||||
|
||||
type wordIndexOutOfRangeError struct {
|
||||
Index uint32
|
||||
Available uint32
|
||||
}
|
||||
|
||||
func (err wordIndexOutOfRangeError) Error() string {
|
||||
return fmt.Sprintf("control word index out of range: %v/%v", err.Index, err.Available)
|
||||
}
|
||||
|
||||
type paletteLookupTooBigError struct {
|
||||
Size int
|
||||
}
|
||||
|
||||
func (err paletteLookupTooBigError) Error() string {
|
||||
return fmt.Sprintf("palette lookup is too big: %vB", err.Size)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package compression
|
||||
|
||||
// FrameDecoder is for decoding compressed frames with the help of data streams.
|
||||
// A new instance of a decoder is created with a FrameDecoderBuilder.
|
||||
type FrameDecoder struct {
|
||||
horizontalTiles int
|
||||
verticalTiles int
|
||||
|
||||
colorer TileColorFunction
|
||||
|
||||
paletteLookupList []byte
|
||||
controlWords []ControlWord
|
||||
}
|
||||
|
||||
func newFrameDecoder(builder *FrameDecoderBuilder) *FrameDecoder {
|
||||
listLen := len(builder.paletteLookupList)
|
||||
decoder := &FrameDecoder{
|
||||
horizontalTiles: builder.width / TileSideLength,
|
||||
verticalTiles: builder.height / TileSideLength,
|
||||
colorer: builder.colorer,
|
||||
paletteLookupList: make([]byte, listLen+16),
|
||||
controlWords: builder.controlWords,
|
||||
}
|
||||
|
||||
copy(decoder.paletteLookupList[:listLen], builder.paletteLookupList)
|
||||
|
||||
return decoder
|
||||
}
|
||||
|
||||
// Decode reads the provided streams to paint a frame.
|
||||
func (decoder *FrameDecoder) Decode(bitstreamData []byte, maskstreamData []byte) error {
|
||||
bitstream := NewBitstreamReader(bitstreamData)
|
||||
maskstream := NewMaskstreamReader(maskstreamData)
|
||||
|
||||
for vTile := 0; vTile < decoder.verticalTiles && !bitstream.Exhausted(); vTile++ {
|
||||
lastControl := ControlWordOf(0, CtrlUnknown, 0)
|
||||
for hTile := 0; hTile < decoder.horizontalTiles && !bitstream.Exhausted(); hTile++ {
|
||||
control, err := decoder.readNextControlWord(bitstream)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if control.Type() == CtrlRepeatPrevious {
|
||||
if hTile == 0 {
|
||||
return errCannotRepeatWordOnFirstTileOfRow
|
||||
}
|
||||
control = lastControl
|
||||
}
|
||||
if control.Type() == CtrlUnknown {
|
||||
return errUnknownControl
|
||||
}
|
||||
|
||||
if control.Type() == CtrlSkip {
|
||||
skipCount := bitstream.Read(5)
|
||||
bitstream.Advance(5)
|
||||
if skipCount == 0x1F {
|
||||
hTile = decoder.horizontalTiles
|
||||
} else {
|
||||
hTile += int(skipCount)
|
||||
}
|
||||
} else {
|
||||
decoder.colorTile(hTile, vTile, control, maskstream)
|
||||
}
|
||||
|
||||
lastControl = control
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (decoder *FrameDecoder) readNextControlWord(bitstream *BitstreamReader) (ControlWord, error) {
|
||||
availableWords := uint32(len(decoder.controlWords))
|
||||
controlIndex := bitstream.Read(12)
|
||||
if controlIndex > availableWords {
|
||||
return ControlWordOf(0, CtrlUnknown, 0),
|
||||
wordIndexOutOfRangeError{
|
||||
Index: controlIndex,
|
||||
Available: availableWords,
|
||||
}
|
||||
}
|
||||
|
||||
control := decoder.controlWords[controlIndex]
|
||||
if control.IsLongOffset() {
|
||||
longOffsetCount := 0
|
||||
bitstream.Advance(8)
|
||||
for control.IsLongOffset() {
|
||||
longOffsetCount++
|
||||
if longOffsetCount > 100 {
|
||||
return ControlWordOf(0, CtrlUnknown, 0), errTooManyLongOffsets
|
||||
}
|
||||
bitstream.Advance(4)
|
||||
offset := bitstream.Read(4)
|
||||
controlIndex = control.LongOffset() + offset
|
||||
if controlIndex > availableWords {
|
||||
return ControlWordOf(0, CtrlUnknown, 0),
|
||||
wordIndexOutOfRangeError{
|
||||
Index: controlIndex,
|
||||
Available: availableWords,
|
||||
}
|
||||
}
|
||||
control = decoder.controlWords[controlIndex]
|
||||
}
|
||||
}
|
||||
bitstream.Advance(control.Count())
|
||||
return control, nil
|
||||
}
|
||||
|
||||
func (decoder *FrameDecoder) colorTile(hTile, vTile int, control ControlWord, maskstream *MaskstreamReader) {
|
||||
param := control.Parameter()
|
||||
|
||||
switch control.Type() {
|
||||
case CtrlColorTile2ColorsStatic:
|
||||
decoder.colorer(hTile, vTile, []byte{byte(param & 0xFF), byte(param >> 8 & 0xFF)}, 0xAAAA, 1)
|
||||
case CtrlColorTile2ColorsMasked:
|
||||
decoder.colorer(hTile, vTile, []byte{byte(param & 0xFF), byte(param >> 8 & 0xFF)}, maskstream.Read(2), 1)
|
||||
case CtrlColorTile4ColorsMasked:
|
||||
decoder.colorer(hTile, vTile, decoder.paletteLookupList[param:param+4], maskstream.Read(4), 2)
|
||||
case CtrlColorTile8ColorsMasked:
|
||||
decoder.colorer(hTile, vTile, decoder.paletteLookupList[param:param+8], maskstream.Read(6), 3)
|
||||
case CtrlColorTile16ColorsMasked:
|
||||
decoder.colorer(hTile, vTile, decoder.paletteLookupList[param:param+16], maskstream.Read(8), 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package compression
|
||||
|
||||
// FrameDecoderBuilder implements the builder pattern for creating new instances of FrameDecoder.
|
||||
// This builder can be reused during the processing of a movie. Every Build() call creates a new instance.
|
||||
type FrameDecoderBuilder struct {
|
||||
width int
|
||||
height int
|
||||
|
||||
colorer TileColorFunction
|
||||
|
||||
paletteLookupList []byte
|
||||
controlWords []ControlWord
|
||||
}
|
||||
|
||||
// NewFrameDecoderBuilder returns a new instance of a builder with given initial values.
|
||||
func NewFrameDecoderBuilder(width, height int) *FrameDecoderBuilder {
|
||||
return &FrameDecoderBuilder{
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// Build creates a new instance of a decoder with the most recent parameters.
|
||||
func (builder *FrameDecoderBuilder) Build() *FrameDecoder {
|
||||
return newFrameDecoder(builder)
|
||||
}
|
||||
|
||||
// ForStandardFrame registers the frame buffer and sets the standard coloring method.
|
||||
func (builder *FrameDecoderBuilder) ForStandardFrame(frame []byte, stride int) {
|
||||
builder.colorer = StandardTileColorer(frame, stride)
|
||||
}
|
||||
|
||||
// WithPaletteLookupList registers the new list.
|
||||
func (builder *FrameDecoderBuilder) WithPaletteLookupList(list []byte) {
|
||||
builder.paletteLookupList = list
|
||||
}
|
||||
|
||||
// WithControlWords registers the new word dictionary.
|
||||
func (builder *FrameDecoderBuilder) WithControlWords(words []ControlWord) {
|
||||
builder.controlWords = words
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package compression
|
||||
|
||||
// MaskstreamWriter writes mask integers to a byte array.
|
||||
type MaskstreamWriter struct {
|
||||
Buffer []byte
|
||||
}
|
||||
|
||||
// Write adds the given amount of bytes from the mask to the buffer.
|
||||
func (w *MaskstreamWriter) Write(byteCount uint, mask uint64) error {
|
||||
if byteCount > 8 {
|
||||
return errInvalidByteCount
|
||||
}
|
||||
if w.Buffer == nil {
|
||||
w.Buffer = make([]byte, 0, 1024*32)
|
||||
}
|
||||
for b := uint(0); b < byteCount; b++ {
|
||||
w.Buffer = append(w.Buffer, byte(mask>>(b*8)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaskstreamReader reads mask integers from a byte array.
|
||||
type MaskstreamReader struct {
|
||||
source []byte
|
||||
curPos int
|
||||
}
|
||||
|
||||
// NewMaskstreamReader returns a new reader instance for given source.
|
||||
func NewMaskstreamReader(source []byte) *MaskstreamReader {
|
||||
return &MaskstreamReader{source: source}
|
||||
}
|
||||
|
||||
// Read returns a mask integer of given byte length from the current position.
|
||||
// If the current position is at or beyond the available size, 0x00 is assumed for the missing bytes.
|
||||
//
|
||||
// Reading more than 8, or less than 0, bytes panics.
|
||||
func (reader *MaskstreamReader) Read(bytes int) (value uint64) {
|
||||
if bytes > 8 {
|
||||
panic("Limit of byte count: 8")
|
||||
}
|
||||
if bytes < 0 {
|
||||
panic("Minimum byte count: 0")
|
||||
}
|
||||
|
||||
for i := 0; i < bytes; i++ {
|
||||
value |= reader.nextByte() << uint64(8*i)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (reader *MaskstreamReader) nextByte() (value uint64) {
|
||||
if reader.curPos < len(reader.source) {
|
||||
value = uint64(reader.source[reader.curPos])
|
||||
reader.curPos++
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMaskstreamReaderReadPanicsForMoreThan8Bytes(t *testing.T) {
|
||||
reader := compression.NewMaskstreamReader([]byte{})
|
||||
|
||||
assert.Panicsf(t, func() { reader.Read(9) }, "Limit of byte count: 8")
|
||||
}
|
||||
|
||||
func TestMaskstreamReaderReadPanicsForLessThan0Bytes(t *testing.T) {
|
||||
reader := compression.NewMaskstreamReader([]byte{})
|
||||
|
||||
assert.Panicsf(t, func() { reader.Read(-1) }, "Minimum byte count: 0")
|
||||
}
|
||||
|
||||
func TestMaskstreamReaderReadReturnsValueOfRequestedByteSize(t *testing.T) {
|
||||
reader := compression.NewMaskstreamReader([]byte{0xAF})
|
||||
|
||||
result := reader.Read(1)
|
||||
|
||||
assert.Equal(t, uint64(0xAF), result)
|
||||
}
|
||||
|
||||
func TestMaskstreamReaderReadIntegerFromSourceInLittleEndianOrder(t *testing.T) {
|
||||
reader := compression.NewMaskstreamReader([]byte{0x11, 0x22})
|
||||
|
||||
result := reader.Read(2)
|
||||
|
||||
assert.Equal(t, uint64(0x2211), result)
|
||||
}
|
||||
|
||||
func TestMaskstreamReaderReadCanProvideUpTo64BitValues(t *testing.T) {
|
||||
reader := compression.NewMaskstreamReader([]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88})
|
||||
|
||||
result := reader.Read(8)
|
||||
|
||||
assert.Equal(t, uint64(0x8877665544332211), result)
|
||||
}
|
||||
|
||||
func TestMaskstreamReaderReadFillsMissingBytesWithZero(t *testing.T) {
|
||||
reader := compression.NewMaskstreamReader([]byte{0xAA, 0xBB})
|
||||
|
||||
result := reader.Read(8)
|
||||
|
||||
assert.Equal(t, uint64(0x00BBAA), result)
|
||||
}
|
||||
|
||||
func TestMaskstreamReaderReadAdvancesCurrentPosition(t *testing.T) {
|
||||
reader := compression.NewMaskstreamReader([]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88})
|
||||
|
||||
reader.Read(2)
|
||||
result := reader.Read(3)
|
||||
|
||||
assert.Equal(t, uint64(0x554433), result)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package compression
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
// PackControlWords packs and encodes the given words into a byte stream.
|
||||
func PackControlWords(words []ControlWord) []byte {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
controlBytes := uint32(len(words) * bytesPerControlWord)
|
||||
|
||||
_ = binary.Write(buf, binary.LittleEndian, controlBytes)
|
||||
if controlBytes > 0 {
|
||||
lastPacked := words[0].Packed(1)
|
||||
|
||||
for _, word := range words[1:] {
|
||||
if (lastPacked.Value() == word) && (lastPacked.Times() < 0xFF) {
|
||||
lastPacked = word.Packed(lastPacked.Times() + 1)
|
||||
} else {
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(lastPacked))
|
||||
lastPacked = word.Packed(1)
|
||||
}
|
||||
}
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(lastPacked))
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
)
|
||||
|
||||
func TestControlWordsEmptyArrayResultsInZeroCount(t *testing.T) {
|
||||
data := compression.PackControlWords(nil)
|
||||
|
||||
assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, data)
|
||||
}
|
||||
|
||||
func TestControlWordsCountIsInLengthTimesThree(t *testing.T) {
|
||||
data := compression.PackControlWords([]compression.ControlWord{
|
||||
compression.ControlWord(0),
|
||||
compression.ControlWord(1),
|
||||
})
|
||||
|
||||
assert.Equal(t, []byte{0x06, 0x00, 0x00, 0x00}, data[:4])
|
||||
}
|
||||
|
||||
func TestControlWordsSingleItem(t *testing.T) {
|
||||
data := compression.PackControlWords([]compression.ControlWord{compression.ControlWord(0x00BBCCDD)})
|
||||
|
||||
assert.Equal(t, []byte{0x03, 0x00, 0x00, 0x00, 0xDD, 0xCC, 0xBB, 0x01}, data)
|
||||
}
|
||||
|
||||
func TestControlWordsMultiItemArray(t *testing.T) {
|
||||
data := compression.PackControlWords([]compression.ControlWord{
|
||||
compression.ControlWord(0x00BBCCDD),
|
||||
compression.ControlWord(0x00112233),
|
||||
})
|
||||
|
||||
assert.Equal(t, []byte{0x06, 0x00, 0x00, 0x00, 0xDD, 0xCC, 0xBB, 0x01, 0x33, 0x22, 0x11, 0x01}, data)
|
||||
}
|
||||
|
||||
func TestControlWordsIdenticalWordsArePacked(t *testing.T) {
|
||||
data := compression.PackControlWords([]compression.ControlWord{
|
||||
compression.ControlWord(0x00BBCCDD),
|
||||
compression.ControlWord(0x00BBCCDD),
|
||||
})
|
||||
|
||||
require.Equal(t, 8, len(data))
|
||||
assert.Equal(t, []byte{0xDD, 0xCC, 0xBB, 0x02}, data[4:8])
|
||||
}
|
||||
|
||||
func TestControlWordsCountIsResetForFurtherWords(t *testing.T) {
|
||||
data := compression.PackControlWords([]compression.ControlWord{
|
||||
compression.ControlWord(0x00BBCCDD),
|
||||
compression.ControlWord(0x00BBCCDD),
|
||||
compression.ControlWord(0x00112233),
|
||||
})
|
||||
|
||||
require.Equal(t, 12, len(data))
|
||||
assert.Equal(t, []byte{0xDD, 0xCC, 0xBB, 0x02, 0x33, 0x22, 0x11, 0x01}, data[4:12])
|
||||
}
|
||||
|
||||
func TestControlWordsMaximumCountIs255(t *testing.T) {
|
||||
words := make([]compression.ControlWord, 260)
|
||||
for i := 0; i < len(words); i++ {
|
||||
words[i] = compression.ControlWord(0x00112233)
|
||||
}
|
||||
data := compression.PackControlWords(words)
|
||||
|
||||
require.Equal(t, 12, len(data))
|
||||
assert.Equal(t, []byte{0x0C, 0x03, 0x00, 0x00, 0x33, 0x22, 0x11, 0xFF, 0x33, 0x22, 0x11, 0x05}, data)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package compression
|
||||
|
||||
// PackedControlWord contains a ControlWord together with a count value of occurrences.
|
||||
type PackedControlWord uint32
|
||||
|
||||
// Times returns the amount of occurrences of the contained control word.
|
||||
func (packed PackedControlWord) Times() int {
|
||||
return int((packed >> 24) & 0xFF)
|
||||
}
|
||||
|
||||
// Value returns the control word value.
|
||||
func (packed PackedControlWord) Value() ControlWord {
|
||||
return ControlWord(uint32(packed) & 0x00FFFFFF)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
)
|
||||
|
||||
func TestPackedControlWordTimesReturnsHighestByte(t *testing.T) {
|
||||
packed := compression.PackedControlWord(0xFF706050)
|
||||
|
||||
assert.Equal(t, int(0xFF), packed.Times())
|
||||
}
|
||||
|
||||
func TestPackedControlWordValueReturnsTheControlWord(t *testing.T) {
|
||||
packed := compression.PackedControlWord(0xFF706050)
|
||||
|
||||
assert.Equal(t, compression.ControlWord(0x00706050), packed.Value())
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package compression
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/bits"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type paletteLookupEntry struct {
|
||||
start int
|
||||
size int
|
||||
}
|
||||
|
||||
// PaletteLookup is a dictionary of tile delta data, found in a palette buffer.
|
||||
type PaletteLookup struct {
|
||||
buffer []byte
|
||||
entries map[TilePaletteKey]paletteLookupEntry
|
||||
}
|
||||
|
||||
// Buffer returns the underlying slice.
|
||||
func (lookup *PaletteLookup) Buffer() []byte {
|
||||
return lookup.buffer
|
||||
}
|
||||
|
||||
// Lookup finds the given tile again and returns the properties where and how to reproduce it.
|
||||
func (lookup *PaletteLookup) Lookup(tile tileDelta) (index int, pal []byte, mask uint64) {
|
||||
key := TilePaletteKeyFrom(tile[:])
|
||||
entry, inLookup := lookup.entries[key]
|
||||
if inLookup {
|
||||
index = entry.start
|
||||
pal = lookup.buffer[entry.start : entry.start+entry.size]
|
||||
} else {
|
||||
pal = key.Buffer()
|
||||
}
|
||||
var mapped [256]byte
|
||||
for mappedIndex := len(pal) - 1; mappedIndex >= 0; mappedIndex-- {
|
||||
mapped[pal[mappedIndex]] = byte(mappedIndex)
|
||||
}
|
||||
bitSize := uint(bits.Len(uint(len(pal) - 1)))
|
||||
for tileIndex := PixelPerTile - 1; tileIndex >= 0; tileIndex-- {
|
||||
mask <<= bitSize
|
||||
mask |= uint64(mapped[tile[tileIndex]])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// PaletteLookupGenerator creates palette lookups based on a set of registered tiles.
|
||||
type PaletteLookupGenerator struct {
|
||||
keyUses map[TilePaletteKey]int
|
||||
}
|
||||
|
||||
// Generate creates a lookup based on all currently registered tile deltas.
|
||||
func (gen *PaletteLookupGenerator) Generate(ctx context.Context) (PaletteLookup, error) {
|
||||
var lookup PaletteLookup
|
||||
lookup.entries = make(map[TilePaletteKey]paletteLookupEntry)
|
||||
|
||||
remainder := make(map[TilePaletteKey]struct{})
|
||||
for key := range gen.keyUses {
|
||||
remainder[key] = struct{}{}
|
||||
}
|
||||
|
||||
type sizedEntry struct {
|
||||
entries map[TilePaletteKey]paletteLookupEntry
|
||||
lastOffset int
|
||||
}
|
||||
sizedEntries := make(map[int]*sizedEntry)
|
||||
knownSizes := []int{4, 8, 16}
|
||||
for _, size := range knownSizes {
|
||||
sizedEntries[size] = &sizedEntry{
|
||||
entries: make(map[TilePaletteKey]paletteLookupEntry),
|
||||
}
|
||||
}
|
||||
|
||||
toDeleteCache := make([]TilePaletteKey, 0, len(remainder))
|
||||
addToBuffer := func(data []byte) {
|
||||
lookup.buffer = append(lookup.buffer, data...)
|
||||
|
||||
newSize := len(lookup.buffer)
|
||||
|
||||
for _, fitSize := range knownSizes {
|
||||
fitLimit := newSize - fitSize
|
||||
entry := sizedEntries[fitSize]
|
||||
|
||||
// remove all entries beyond a certain limit. as these bytes don't change, retrying won't help.
|
||||
toDelete := toDeleteCache[0:0]
|
||||
limit := newSize - 16 - len(data)
|
||||
for key, entry := range entry.entries {
|
||||
if entry.start < limit {
|
||||
toDelete = append(toDelete, key)
|
||||
}
|
||||
}
|
||||
for _, key := range toDelete {
|
||||
delete(entry.entries, key)
|
||||
}
|
||||
|
||||
// find any new keys
|
||||
for start := entry.lastOffset; start < fitLimit; start++ {
|
||||
tempKey := TilePaletteKeyFrom(lookup.buffer[start : start+fitSize])
|
||||
if _, existing := entry.entries[tempKey]; !existing {
|
||||
entry.entries[tempKey] = paletteLookupEntry{
|
||||
start: start,
|
||||
size: fitSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
if fitLimit > 0 {
|
||||
entry.lastOffset = fitLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addEarlyEntry := func(key TilePaletteKey, limitSize int) bool {
|
||||
for _, fitSize := range knownSizes {
|
||||
if key.size <= fitSize && fitSize <= limitSize {
|
||||
entry := sizedEntries[fitSize]
|
||||
for tempKey, paletteEntry := range entry.entries {
|
||||
if tempKey.Contains(&key) && (!key.HasColor(0x00) || (lookup.buffer[paletteEntry.start] == 0x00)) {
|
||||
lookup.entries[key] = paletteEntry
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
sizeLimitForSize := map[int]int{3: 4, 4: 8, 5: 8, 6: 8, 7: 8, 8: 8, 9: 16, 10: 16, 11: 16, 12: 16, 13: 16, 14: 16, 15: 16, 16: 16}
|
||||
for size := PixelPerTile; (size > 2) && (ctx.Err() == nil); size-- {
|
||||
keysInSize := make([]TilePaletteKey, 0, len(remainder))
|
||||
for key := range remainder {
|
||||
if key.size == size {
|
||||
keysInSize = append(keysInSize, key)
|
||||
}
|
||||
}
|
||||
sort.Slice(keysInSize, func(a, b int) bool { return keysInSize[a].LessThan(&keysInSize[b]) })
|
||||
|
||||
// fmt.Printf("Working on key size %v, have %v sized, %v total remaining\n", size, len(keysInSize), len(remainder))
|
||||
for _, sizedKey := range keysInSize {
|
||||
if ctx.Err() != nil {
|
||||
return PaletteLookup{}, ctx.Err()
|
||||
}
|
||||
|
||||
{
|
||||
var earlyRemoved []TilePaletteKey
|
||||
for key := range remainder {
|
||||
if addEarlyEntry(key, sizeLimitForSize[size]) {
|
||||
earlyRemoved = append(earlyRemoved, key)
|
||||
}
|
||||
}
|
||||
for _, key := range earlyRemoved {
|
||||
delete(remainder, key)
|
||||
}
|
||||
}
|
||||
if _, stillRemaining := remainder[sizedKey]; stillRemaining {
|
||||
bytes := sizedKey.Buffer()
|
||||
lookup.entries[sizedKey] = paletteLookupEntry{start: len(lookup.buffer), size: len(bytes)}
|
||||
addToBuffer(bytes)
|
||||
|
||||
delete(remainder, sizedKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lookup, ctx.Err()
|
||||
}
|
||||
|
||||
// Add registers a further delta to the generator.
|
||||
func (gen *PaletteLookupGenerator) Add(delta tileDelta) {
|
||||
key := TilePaletteKeyFrom(delta[:])
|
||||
if key.size > 2 {
|
||||
if gen.keyUses == nil {
|
||||
gen.keyUses = make(map[TilePaletteKey]int)
|
||||
}
|
||||
gen.keyUses[key]++
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package compression
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// EncodedFrame contains the streams of one compressed frame.
|
||||
type EncodedFrame struct {
|
||||
Bitstream []byte
|
||||
Maskstream []byte
|
||||
}
|
||||
|
||||
type tileDelta [PixelPerTile]byte
|
||||
|
||||
type frameDelta struct {
|
||||
tiles []tileDelta
|
||||
}
|
||||
|
||||
// SceneEncoder encodes an entire scene of bitmaps sharing the same palette.
|
||||
type SceneEncoder struct {
|
||||
hTiles int
|
||||
vTiles int
|
||||
lineStride int
|
||||
tileStride int
|
||||
|
||||
lastFrame []byte
|
||||
deltas []frameDelta
|
||||
}
|
||||
|
||||
// NewSceneEncoder returns a new instance.
|
||||
func NewSceneEncoder(width, height int) *SceneEncoder {
|
||||
e := &SceneEncoder{
|
||||
hTiles: width / TileSideLength,
|
||||
vTiles: height / TileSideLength,
|
||||
lineStride: width,
|
||||
}
|
||||
e.tileStride = e.lineStride * TileSideLength
|
||||
e.lastFrame = make([]byte, e.vTiles*TileSideLength*e.lineStride)
|
||||
return e
|
||||
}
|
||||
|
||||
// AddFrame registers a further frame to the scene.
|
||||
func (e *SceneEncoder) AddFrame(frame []byte) error {
|
||||
if len(frame) != len(e.lastFrame) {
|
||||
return errInvalidFrameSize
|
||||
}
|
||||
var delta frameDelta
|
||||
isFirstFrame := len(e.deltas) == 0
|
||||
vStart := 0
|
||||
for vTile := 0; vTile < e.vTiles; vTile++ {
|
||||
tileStart := vStart
|
||||
for hTile := 0; hTile < e.hTiles; hTile++ {
|
||||
delta.tiles = append(delta.tiles, e.deltaTile(isFirstFrame, tileStart, frame))
|
||||
tileStart += TileSideLength
|
||||
}
|
||||
vStart += e.tileStride
|
||||
}
|
||||
e.deltas = append(e.deltas, delta)
|
||||
copy(e.lastFrame, frame)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SceneEncoder) deltaTile(isFirstFrame bool, offset int, frame []byte) tileDelta {
|
||||
var delta tileDelta
|
||||
for y := 0; y < TileSideLength; y++ {
|
||||
start := offset + (y * e.lineStride)
|
||||
for x := 0; x < TileSideLength; x++ {
|
||||
pixel := frame[start+x]
|
||||
if isFirstFrame || (pixel != e.lastFrame[start+x]) {
|
||||
delta[y*TileSideLength+x] = pixel
|
||||
}
|
||||
}
|
||||
}
|
||||
return delta
|
||||
}
|
||||
|
||||
// Encode processes all the previously registered frames and creates the necessary components for decoding.
|
||||
func (e *SceneEncoder) Encode(ctx context.Context) (
|
||||
words []ControlWord, paletteLookupBuffer []byte, frames []EncodedFrame, err error) {
|
||||
var wordSequencer ControlWordSequencer
|
||||
tileColorOpsPerFrame := make([][]TileColorOp, len(e.deltas))
|
||||
paletteLookup, err := e.createPaletteLookup(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
paletteLookupBuffer = paletteLookup.Buffer()
|
||||
if len(paletteLookupBuffer) > 0x1FFFF {
|
||||
err = paletteLookupTooBigError{Size: len(paletteLookupBuffer)}
|
||||
return
|
||||
}
|
||||
|
||||
frames = make([]EncodedFrame, len(e.deltas))
|
||||
for frameIndex := 0; frameIndex < len(e.deltas); frameIndex++ {
|
||||
var maskstreamWriter MaskstreamWriter
|
||||
outFrame := &frames[frameIndex]
|
||||
delta := e.deltas[frameIndex]
|
||||
|
||||
lastOp := TileColorOp{Type: CtrlUnknown}
|
||||
for tileIndex, tile := range delta.tiles {
|
||||
var op TileColorOp
|
||||
paletteIndex, pal, mask := paletteLookup.Lookup(tile)
|
||||
palSize := len(pal)
|
||||
|
||||
switch {
|
||||
case palSize == 1 && (pal[0] == 0x00):
|
||||
op.Type = CtrlSkip
|
||||
case palSize == 1:
|
||||
op.Type = CtrlColorTile2ColorsStatic
|
||||
op.Offset = uint32(pal[0])<<8 | uint32(pal[0])
|
||||
case palSize == 2 && mask == 0xAAAA && (pal[0] != 0x00) && (pal[1] != 0x00):
|
||||
op.Type = CtrlColorTile2ColorsStatic
|
||||
op.Offset = uint32(pal[1])<<8 | uint32(pal[0])
|
||||
case palSize == 2 && mask == 0x5555 && (pal[0] != 0x00) && (pal[1] != 0x00):
|
||||
op.Type = CtrlColorTile2ColorsStatic
|
||||
op.Offset = uint32(pal[0])<<8 | uint32(pal[1])
|
||||
case palSize <= 2:
|
||||
op.Type = CtrlColorTile2ColorsMasked
|
||||
if palSize == 2 {
|
||||
op.Offset = uint32(pal[1])
|
||||
op.Offset <<= 8
|
||||
}
|
||||
if palSize > 0 {
|
||||
op.Offset |= uint32(pal[0])
|
||||
}
|
||||
|
||||
_ = maskstreamWriter.Write(2, mask)
|
||||
case palSize <= 4:
|
||||
op.Type = CtrlColorTile4ColorsMasked
|
||||
op.Offset = uint32(paletteIndex)
|
||||
_ = maskstreamWriter.Write(4, mask)
|
||||
case palSize <= 8:
|
||||
op.Type = CtrlColorTile8ColorsMasked
|
||||
op.Offset = uint32(paletteIndex)
|
||||
_ = maskstreamWriter.Write(6, mask)
|
||||
default:
|
||||
op.Type = CtrlColorTile16ColorsMasked
|
||||
op.Offset = uint32(paletteIndex)
|
||||
_ = maskstreamWriter.Write(8, mask)
|
||||
}
|
||||
|
||||
if op.Type != CtrlSkip && (tileIndex%e.hTiles) != 0 && lastOp == op {
|
||||
op = TileColorOp{Type: CtrlRepeatPrevious}
|
||||
} else {
|
||||
lastOp = op
|
||||
}
|
||||
|
||||
err = wordSequencer.Add(op)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
tileColorOpsPerFrame[frameIndex] = append(tileColorOpsPerFrame[frameIndex], op)
|
||||
}
|
||||
outFrame.Maskstream = maskstreamWriter.Buffer
|
||||
}
|
||||
|
||||
wordSequence, err := wordSequencer.Sequence()
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
wordSequence.HTiles = uint32(e.hTiles)
|
||||
words = wordSequence.ControlWords()
|
||||
for frameIndex, ops := range tileColorOpsPerFrame {
|
||||
frames[frameIndex].Bitstream, err = wordSequence.BitstreamFor(ops)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (e *SceneEncoder) createPaletteLookup(ctx context.Context) (PaletteLookup, error) {
|
||||
var paletteLookupGenerator PaletteLookupGenerator
|
||||
for _, delta := range e.deltas {
|
||||
for _, tile := range delta.tiles {
|
||||
paletteLookupGenerator.Add(tile)
|
||||
}
|
||||
}
|
||||
return paletteLookupGenerator.Generate(ctx)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package compression
|
||||
|
||||
// StandardTileColorer returns a TileColorFunction for given frame buffer.
|
||||
//
|
||||
// This colorer iterates over all pixel of a tile. It sets the pixel value if the
|
||||
// corresponding palette value is > 0x00.
|
||||
func StandardTileColorer(frame []byte, stride int) TileColorFunction {
|
||||
return func(hTile int, vTile int, lookupArray []byte, mask uint64, indexBitSize uint64) {
|
||||
start := vTile*TileSideLength*stride + hTile*TileSideLength
|
||||
singleMask := ^(^uint64(0) << indexBitSize)
|
||||
|
||||
for i := 0; i < PixelPerTile; i++ {
|
||||
pixelValue := lookupArray[(mask>>(indexBitSize*uint64(i)))&singleMask]
|
||||
|
||||
if pixelValue != 0x00 {
|
||||
offset := start + (i % TileSideLength) + stride*(i/TileSideLength)
|
||||
|
||||
frame[offset] = pixelValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
)
|
||||
|
||||
type StandardTileColorerSuite struct {
|
||||
suite.Suite
|
||||
|
||||
buffer []byte
|
||||
stride int
|
||||
|
||||
colorer compression.TileColorFunction
|
||||
}
|
||||
|
||||
func TestStandardTileColorerSuite(t *testing.T) {
|
||||
suite.Run(t, new(StandardTileColorerSuite))
|
||||
}
|
||||
|
||||
func (suite *StandardTileColorerSuite) SetupTest() {
|
||||
suite.buffer = make([]byte, compression.PixelPerTile*9)
|
||||
suite.stride = compression.TileSideLength * 3
|
||||
|
||||
for i := 0; i < len(suite.buffer); i++ {
|
||||
suite.buffer[i] = 0xDD
|
||||
}
|
||||
|
||||
suite.colorer = compression.StandardTileColorer(suite.buffer, suite.stride)
|
||||
}
|
||||
|
||||
func (suite *StandardTileColorerSuite) TestFunctionColorsAllPixelOfATile() {
|
||||
suite.whenTileIsColored(1, 1, []byte{0xFF, 0x00}, 0, 1)
|
||||
|
||||
suite.thenTileShouldBe(1, 1, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
|
||||
}
|
||||
|
||||
func (suite *StandardTileColorerSuite) TestColoringATileLeavesPixelOfOthersAlone() {
|
||||
suite.whenTileIsColored(1, 1, []byte{0xFF}, 0, 1)
|
||||
|
||||
suite.thenTileShouldBeUntouched(0, 0)
|
||||
suite.thenTileShouldBeUntouched(1, 0)
|
||||
suite.thenTileShouldBeUntouched(2, 0)
|
||||
suite.thenTileShouldBeUntouched(0, 1)
|
||||
suite.thenTileShouldBeUntouched(2, 1)
|
||||
suite.thenTileShouldBeUntouched(0, 2)
|
||||
suite.thenTileShouldBeUntouched(1, 2)
|
||||
suite.thenTileShouldBeUntouched(2, 2)
|
||||
}
|
||||
|
||||
func (suite *StandardTileColorerSuite) TestFunctionWorksWithMaximumIndexSize() {
|
||||
suite.whenTileIsColored(1, 1, []byte{0x01, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF},
|
||||
0xFEDCBA9876543210, 4)
|
||||
|
||||
suite.thenTileShouldBe(1, 1, []byte{0x01, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF})
|
||||
}
|
||||
|
||||
func (suite *StandardTileColorerSuite) TestFunctionSkipsZeroPaletteIndices() {
|
||||
suite.givenTileIsFilled(1, 1, 0xFF)
|
||||
|
||||
suite.whenTileIsColored(1, 1, []byte{0x33, 0x00}, 0xAAAA, 1)
|
||||
|
||||
suite.thenTileShouldBe(1, 1, []byte{0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF})
|
||||
}
|
||||
|
||||
func (suite *StandardTileColorerSuite) givenTileIsFilled(hTile int, vTile int, initValue byte) {
|
||||
suite.colorer(hTile, vTile, []byte{initValue, 0xEE}, 0x0000, 1)
|
||||
}
|
||||
|
||||
func (suite *StandardTileColorerSuite) whenTileIsColored(hTile int, vTile int, lookupArray []byte, mask uint64, indexBitSize uint64) {
|
||||
suite.colorer(hTile, vTile, lookupArray, mask, indexBitSize)
|
||||
}
|
||||
|
||||
func (suite *StandardTileColorerSuite) thenTileShouldBe(hTile, vTile int, expected []byte) {
|
||||
tile := suite.getTile(hTile, vTile)
|
||||
|
||||
assert.Equal(suite.T(), expected, tile)
|
||||
}
|
||||
|
||||
func (suite *StandardTileColorerSuite) thenTileShouldBeUntouched(hTile, vTile int) {
|
||||
expected := make([]byte, compression.PixelPerTile)
|
||||
for i := 0; i < len(expected); i++ {
|
||||
expected[i] = 0xDD
|
||||
}
|
||||
|
||||
suite.thenTileShouldBe(hTile, vTile, expected)
|
||||
}
|
||||
|
||||
func (suite *StandardTileColorerSuite) getTile(hTile, vTile int) []byte {
|
||||
tileBuffer := make([]byte, compression.PixelPerTile)
|
||||
start := vTile*compression.TileSideLength*suite.stride + hTile*compression.TileSideLength
|
||||
|
||||
for i := 0; i < compression.TileSideLength; i++ {
|
||||
outOffset := compression.TileSideLength * i
|
||||
inOffset := start + suite.stride*i
|
||||
copy(tileBuffer[outOffset:outOffset+compression.TileSideLength], suite.buffer[inOffset:inOffset+compression.TileSideLength])
|
||||
}
|
||||
|
||||
return tileBuffer
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package compression
|
||||
|
||||
// TileColorFunction is one which shall color the pixel of a video frame tile using the provided values.
|
||||
// mask is a packed field of 16 indices, each with a length of indexBitSize. These indices point into a
|
||||
// value of lookupArray.
|
||||
type TileColorFunction func(hTile int, vTile int, lookupArray []byte, mask uint64, indexBitSize uint64)
|
||||
@@ -0,0 +1,70 @@
|
||||
package compression
|
||||
|
||||
import "math/bits"
|
||||
|
||||
// TilePaletteKey marks use of colors of a 256 palette.
|
||||
type TilePaletteKey struct {
|
||||
usedColors [4]uint64
|
||||
size int
|
||||
}
|
||||
|
||||
// TilePaletteKeyFrom returns a key for the given slice of colors.
|
||||
func TilePaletteKeyFrom(colors []byte) TilePaletteKey {
|
||||
var key TilePaletteKey
|
||||
for _, c := range colors {
|
||||
key.UseColor(c)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Buffer returns the color indices marked in use by this key.
|
||||
func (key *TilePaletteKey) Buffer() []byte {
|
||||
result := make([]byte, 0, key.size)
|
||||
for i := 0; i < 256; i++ {
|
||||
if key.HasColor(byte(i)) {
|
||||
result = append(result, byte(i))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UseColor marks the given color index in use.
|
||||
func (key *TilePaletteKey) UseColor(index byte) {
|
||||
if !key.HasColor(index) {
|
||||
key.usedColors[index/64] |= 1 << uint(index%64)
|
||||
key.size++
|
||||
}
|
||||
}
|
||||
|
||||
// HasColor returns true if the given color index is in use by this key.
|
||||
func (key *TilePaletteKey) HasColor(index byte) bool {
|
||||
return (key.usedColors[index/64] & (1 << uint(index%64))) != 0
|
||||
}
|
||||
|
||||
// Contains returns true if this key is equal to, or a superset of, the given key.
|
||||
func (key *TilePaletteKey) Contains(other *TilePaletteKey) bool {
|
||||
return ((^key.usedColors[0] & other.usedColors[0]) == 0) &&
|
||||
((^key.usedColors[1] & other.usedColors[1]) == 0) &&
|
||||
((^key.usedColors[2] & other.usedColors[2]) == 0) &&
|
||||
((^key.usedColors[3] & other.usedColors[3]) == 0)
|
||||
}
|
||||
|
||||
// Without returns a new key instance that has all remaining colors marked in use.
|
||||
func (key *TilePaletteKey) Without(other *TilePaletteKey) TilePaletteKey {
|
||||
var result TilePaletteKey
|
||||
for i := 0; i < 4; i++ {
|
||||
result.usedColors[i] = key.usedColors[i] & ^other.usedColors[i]
|
||||
result.size += bits.OnesCount64(result.usedColors[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// LessThan is a sorting function to order keys.
|
||||
func (key *TilePaletteKey) LessThan(other *TilePaletteKey) bool {
|
||||
for i := 0; i < 4; i++ {
|
||||
if key.usedColors[i] < other.usedColors[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
)
|
||||
|
||||
func TestTilePaletteKeyNullValueHasNoColorsSet(t *testing.T) {
|
||||
var key compression.TilePaletteKey
|
||||
for color := 0; color < 256; color++ {
|
||||
assert.False(t, key.HasColor(byte(color)), "color set although should not %v", color)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTilePaletteKeyCanMarkSingleColors(t *testing.T) {
|
||||
for color := 0; color < 256; color++ {
|
||||
var key compression.TilePaletteKey
|
||||
key.UseColor(byte(color))
|
||||
assert.True(t, key.HasColor(byte(color)), "color not set %v", color)
|
||||
for otherColor := 0; otherColor < 256; otherColor++ {
|
||||
if otherColor != color {
|
||||
assert.False(t, key.HasColor(byte(otherColor)),
|
||||
"color %v set although should not for %v", otherColor, color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTilePaletteKeyCanMarkAllColors(t *testing.T) {
|
||||
var key compression.TilePaletteKey
|
||||
for color := 0; color < 256; color++ {
|
||||
key.UseColor(byte(color))
|
||||
}
|
||||
for color := 0; color < 256; color++ {
|
||||
assert.True(t, key.HasColor(byte(color)), "should have color set %v", color)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTilePaletteKeyContains(t *testing.T) {
|
||||
baseKey := compression.TilePaletteKeyFrom([]byte{1, 2, 3, 4})
|
||||
tt := []struct {
|
||||
other []byte
|
||||
expected bool
|
||||
}{
|
||||
{other: []byte{1, 2, 3, 4}, expected: true},
|
||||
{other: []byte{2, 3, 4}, expected: true},
|
||||
{other: []byte{1, 2, 4}, expected: true},
|
||||
{other: []byte{4}, expected: true},
|
||||
{other: []byte{}, expected: true},
|
||||
{other: []byte{1, 2, 3, 4, 5}, expected: false},
|
||||
{other: []byte{3, 4, 5}, expected: false},
|
||||
{other: []byte{5}, expected: false},
|
||||
}
|
||||
|
||||
for index, tc := range tt {
|
||||
td := tc
|
||||
t.Run(fmt.Sprintf("%v", index), func(t *testing.T) {
|
||||
other := compression.TilePaletteKeyFrom(td.other)
|
||||
result := baseKey.Contains(&other)
|
||||
assert.Equal(t, result, td.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTilePaletteKeyWithout(t *testing.T) {
|
||||
baseKey := compression.TilePaletteKeyFrom([]byte{1, 2, 3, 4})
|
||||
tt := []struct {
|
||||
other []byte
|
||||
expected []byte
|
||||
}{
|
||||
{other: []byte{1, 2, 3, 4}, expected: []byte{}},
|
||||
{other: []byte{2, 3, 4}, expected: []byte{1}},
|
||||
{other: []byte{1, 2, 4}, expected: []byte{3}},
|
||||
{other: []byte{4}, expected: []byte{1, 2, 3}},
|
||||
{other: []byte{}, expected: []byte{1, 2, 3, 4}},
|
||||
{other: []byte{1, 2, 3, 4, 5}, expected: []byte{}},
|
||||
{other: []byte{3, 4, 5}, expected: []byte{1, 2}},
|
||||
{other: []byte{5}, expected: []byte{1, 2, 3, 4}},
|
||||
}
|
||||
|
||||
for index, tc := range tt {
|
||||
td := tc
|
||||
t.Run(fmt.Sprintf("%v", index), func(t *testing.T) {
|
||||
other := compression.TilePaletteKeyFrom(td.other)
|
||||
result := baseKey.Without(&other)
|
||||
expected := compression.TilePaletteKeyFrom(td.expected)
|
||||
assert.Equal(t, result, expected, "Mismatch for %v", td.other)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package compression
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
// UnpackControlWords reads from an encoded string of bytes a series of packed control words.
|
||||
// If all is OK, the control words are returned. If the function returns an error, something
|
||||
// could not be read/decoded properly.
|
||||
func UnpackControlWords(data []byte) (words []ControlWord, err error) {
|
||||
if len(data) < 4 {
|
||||
return nil, ErrFormat
|
||||
}
|
||||
|
||||
var controlBytes uint32
|
||||
reader := bytes.NewReader(data)
|
||||
|
||||
err = binary.Read(reader, binary.LittleEndian, &controlBytes)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if controlBytes%bytesPerControlWord != 0 {
|
||||
return nil, ErrFormat
|
||||
}
|
||||
wordCount := int(controlBytes / bytesPerControlWord)
|
||||
unpacked := 0
|
||||
|
||||
words = make([]ControlWord, wordCount)
|
||||
for unpacked < wordCount {
|
||||
var packed PackedControlWord
|
||||
err = binary.Read(reader, binary.LittleEndian, &packed)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
times := packed.Times()
|
||||
if times > len(words)-unpacked {
|
||||
return nil, ErrFormat
|
||||
}
|
||||
for i := 0; i < times; i++ {
|
||||
words[unpacked+i] = packed.Value()
|
||||
}
|
||||
unpacked += times
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package compression_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie/internal/compression"
|
||||
)
|
||||
|
||||
func TestUnpackControlWordsFormatErrorForEmptyArray(t *testing.T) {
|
||||
_, err := compression.UnpackControlWords(nil)
|
||||
|
||||
assert.Equal(t, compression.ErrFormat, err)
|
||||
}
|
||||
|
||||
func TestUnpackControlWordsFormatErrorForTooSmallSizeField(t *testing.T) {
|
||||
_, err := compression.UnpackControlWords(make([]byte, 3))
|
||||
|
||||
assert.Equal(t, compression.ErrFormat, err)
|
||||
}
|
||||
|
||||
func TestUnpackControlWordsFormatErrorForSizeValueNotMultipleOfThree(t *testing.T) {
|
||||
_, err := compression.UnpackControlWords([]byte{0x02, 0x00, 0x00, 0x00, 0xCC, 0xBB, 0xAA, 0x01})
|
||||
|
||||
assert.Equal(t, compression.ErrFormat, err)
|
||||
}
|
||||
|
||||
func TestUnpackControlWordsEmptyResultForNoEntries(t *testing.T) {
|
||||
words, err := compression.UnpackControlWords([]byte{0x00, 0x00, 0x00, 0x00})
|
||||
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, 0, len(words))
|
||||
}
|
||||
|
||||
func TestUnpackControlWordsSingleEntry(t *testing.T) {
|
||||
words, err := compression.UnpackControlWords([]byte{0x03, 0x00, 0x00, 0x00, 0xCC, 0xBB, 0xAA, 0x01})
|
||||
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, []compression.ControlWord{compression.ControlWord(0xAABBCC)}, words)
|
||||
}
|
||||
|
||||
func TestUnpackControlWordsMultipleEntry(t *testing.T) {
|
||||
words, err := compression.UnpackControlWords([]byte{0x09, 0x00, 0x00, 0x00, 0xCC, 0xBB, 0xAA, 0x02, 0x33, 0x22, 0x11, 0x01})
|
||||
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, []compression.ControlWord{compression.ControlWord(0xAABBCC), compression.ControlWord(0xAABBCC), compression.ControlWord(0x112233)}, words)
|
||||
}
|
||||
|
||||
func TestUnpackControlWordsErrorForTooManyUnpacked(t *testing.T) {
|
||||
_, err := compression.UnpackControlWords([]byte{0x03, 0x00, 0x00, 0x00, 0xCC, 0xBB, 0xAA, 0x02})
|
||||
|
||||
require.NotNil(t, err)
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user