Adding modding folder with a hacked build.

This commit is contained in:
ml
2026-09-26 08:53:08 +02:00
parent cdb960dc71
commit c91080aba7
755 changed files with 50424 additions and 0 deletions
+53
View File
@@ -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
}
+132
View File
@@ -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
}
+211
View File
@@ -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)
}
+20
View File
@@ -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),
},
}},
}
}
+208
View File
@@ -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,
},
},
},
}
}
+143
View File
@@ -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)
}

Some files were not shown because too many files have changed in this diff Show More