diff --git a/d2common/d2fileformats/d2animdata/animdata.go b/d2common/d2fileformats/d2animdata/animdata.go new file mode 100644 index 00000000..9c8ad18d --- /dev/null +++ b/d2common/d2fileformats/d2animdata/animdata.go @@ -0,0 +1,130 @@ +package d2animdata + +import ( + "errors" + "fmt" + "strings" + + "github.com/OpenDiablo2/OpenDiablo2/d2common" +) + +const ( + numBlocks = 256 + maxRecordsPerBlock = 67 + byteCountName = 8 + byteCountSpeedPadding = 2 + numEvents = 144 + speedDivisor = 256 + speedBaseFPS = 25 + milliseconds = 1000 +) + +// AnimationData is a representation of the binary data from `data/global/AnimData.d2` +type AnimationData struct { + hashTable + blocks [numBlocks]*block + entries map[string][]*AnimationDataRecord +} + +// GetRecordNames returns a slice of all record name strings +func (ad *AnimationData) GetRecordNames() []string { + result := make([]string, 0) + + for name := range ad.entries { + result = append(result, name) + } + + return result +} + +// GetRecord returns a single AnimationDataRecord with the given name string. If there is more +// than one record with the given name string, the last record entry will be returned. +func (ad *AnimationData) GetRecord(name string) *AnimationDataRecord { + records := ad.GetRecords(name) + numRecords := len(records) + + if numRecords < 1 { + return nil + } + + return records[numRecords-1] +} + +// GetRecords returns all records that have the given name string. The AnimData.d2 files have +// multiple records with the same name, but other values in the record are different. +func (ad *AnimationData) GetRecords(name string) []*AnimationDataRecord { + return ad.entries[name] +} + +// Load loads the data into an AnimationData struct +func Load(data []byte) (*AnimationData, error) { + reader := d2common.CreateStreamReader(data) + animdata := &AnimationData{} + hashIdx := 0 + + animdata.entries = make(map[string][]*AnimationDataRecord) + + for blockIdx := range animdata.blocks { + recordCount := reader.GetUInt32() + if recordCount > maxRecordsPerBlock { + return nil, fmt.Errorf("more than %d records in block", maxRecordsPerBlock) + } + + records := make([]*AnimationDataRecord, recordCount) + + for recordIdx := uint32(0); recordIdx < recordCount; recordIdx++ { + nameBytes := reader.ReadBytes(byteCountName) + + if nameBytes[byteCountName-1] != byte(0) { + return nil, errors.New("animdata AnimationDataRecord name missing null terminator byte") + } + + name := string(nameBytes) + name = strings.ReplaceAll(name, string(byte(0)), "") + + animdata.hashTable[hashIdx] = hashName(name) + + frames := reader.GetUInt32() + speed := reader.GetUInt16() + + reader.SkipBytes(byteCountSpeedPadding) + + events := make(map[int]AnimationEvent) + + for eventIdx := 0; eventIdx < numEvents; eventIdx++ { + event := AnimationEvent(reader.GetByte()) + if event != AnimationEventNone { + events[eventIdx] = event + } + } + + r := &AnimationDataRecord{ + name, + frames, + speed, + events, + } + + records[recordIdx] = r + + if _, found := animdata.entries[r.name]; !found { + animdata.entries[r.name] = make([]*AnimationDataRecord, 0) + } + + animdata.entries[r.name] = append(animdata.entries[r.name], r) + } + + b := &block{ + recordCount, + records, + } + + animdata.blocks[blockIdx] = b + } + + if reader.GetPosition() != uint64(len(data)) { + return nil, errors.New("unable to parse animation data") + } + + return animdata, nil +} diff --git a/d2common/d2fileformats/d2animdata/animdata_test.go b/d2common/d2fileformats/d2animdata/animdata_test.go new file mode 100644 index 00000000..3734753f --- /dev/null +++ b/d2common/d2fileformats/d2animdata/animdata_test.go @@ -0,0 +1,147 @@ +package d2animdata + +import ( + "os" + "testing" +) + +func TestLoad(t *testing.T) { + testFile, fileErr := os.Open("testdata/AnimData.d2") + if fileErr != nil { + t.Error("cannot open test data file") + return + } + + data := make([]byte, 0) + buf := make([]byte, 16) + + for { + numRead, err := testFile.Read(buf) + + data = append(data, buf[:numRead]...) + + if err != nil { + break + } + } + + _, loadErr := Load(data) + if loadErr != nil { + t.Error(loadErr) + } + + _ = testFile.Close() +} + +func TestLoad_BadData(t *testing.T) { + testFile, fileErr := os.Open("testdata/BadData.d2") + if fileErr != nil { + t.Error("cannot open test data file") + return + } + + data := make([]byte, 0) + buf := make([]byte, 16) + + for { + numRead, err := testFile.Read(buf) + + data = append(data, buf[:numRead]...) + + if err != nil { + break + } + } + + _, loadErr := Load(data) + if loadErr == nil { + t.Error("bad data file should not be parsed") + } + + _ = testFile.Close() +} + +func TestAnimationData_GetRecordNames(t *testing.T) { + animdata := &AnimationData{ + hashTable: hashTable{}, + blocks: [256]*block{}, + entries: map[string][]*AnimationDataRecord{ + "a": {{}}, + "b": {{}}, + "c": {{}}, + }, + } + + names := animdata.GetRecordNames() + if len(names) != 3 { + t.Error("record name count mismatch") + } +} + +func TestAnimationData_GetRecords(t *testing.T) { + animdata := &AnimationData{ + hashTable: hashTable{}, + blocks: [256]*block{}, + entries: map[string][]*AnimationDataRecord{ + "a": { + {name: "a", speed: 1, framesPerDirection: 1}, + {name: "a", speed: 2, framesPerDirection: 2}, + {name: "a", speed: 3, framesPerDirection: 3}, + }, + }, + } + + if len(animdata.GetRecords("a")) != 3 { + t.Error("record count is incorrect") + } + + if len(animdata.GetRecords("b")) > 0 { + t.Error("retrieved records for unknown record name") + } +} + +func TestAnimationData_GetRecord(t *testing.T) { + animdata := &AnimationData{ + hashTable: hashTable{}, + blocks: [256]*block{}, + entries: map[string][]*AnimationDataRecord{ + "a": { + {name: "a", speed: 1, framesPerDirection: 1}, + {name: "a", speed: 2, framesPerDirection: 2}, + {name: "a", speed: 3, framesPerDirection: 3}, + }, + }, + } + + record := animdata.GetRecord("a") + if record.speed != 3 { + t.Error("record returned is incorrect") + } +} + +func TestAnimationDataRecord_FPS(t *testing.T) { + record := &AnimationDataRecord{} + + var fps float64 + + record.speed = 256 + fps = record.FPS() + + if fps != float64(speedBaseFPS) { + t.Error("incorrect fps") + } + + record.speed = 512 + fps = record.FPS() + + if fps != float64(speedBaseFPS)*2 { + t.Error("incorrect fps") + } + + record.speed = 128 + fps = record.FPS() + + if fps != float64(speedBaseFPS)/2 { + t.Error("incorrect fps") + } +} diff --git a/d2common/d2fileformats/d2animdata/block.go b/d2common/d2fileformats/d2animdata/block.go new file mode 100644 index 00000000..241f344e --- /dev/null +++ b/d2common/d2fileformats/d2animdata/block.go @@ -0,0 +1,6 @@ +package d2animdata + +type block struct { + recordCount uint32 + records []*AnimationDataRecord +} diff --git a/d2common/d2fileformats/d2animdata/doc.go b/d2common/d2fileformats/d2animdata/doc.go new file mode 100644 index 00000000..ef13a8f7 --- /dev/null +++ b/d2common/d2fileformats/d2animdata/doc.go @@ -0,0 +1,32 @@ +// Package d2animdata provides a file parser for AnimData files. AnimData files have the '.d2' +// file extension, but we do not call this package `d2d2` because multiple file types share this +// extension, and because the project namespace prefix makes it sound terrible. +package d2animdata + +/* +The AnimData.d2 file is a binary file that contains speed and event data for animations. + +The data is encoded as little-endian binary data + +The file contents look like this: + +type animData struct { + blocks [256]struct{ + recordCount uint8 + records []struct{ // *see note below + name [8]byte // last byte is always \0 + framesPerDirection uint32 + speed uint16 // **see note below + _ uint16 // just padded 0's + events [144]uint8 // ***see not below + } + } +} + +*NOTE: can contain 0 to 67 records + +**NOTE: game fps is assumed to be 25, the speed is calculated as (25 * record.speed / 256) + +**NOTE: Animation events can be one of `None`, `Attack`, `Missile`, `Sound`, or `Skill` + +*/ diff --git a/d2common/d2fileformats/d2animdata/events.go b/d2common/d2fileformats/d2animdata/events.go new file mode 100644 index 00000000..6526a701 --- /dev/null +++ b/d2common/d2fileformats/d2animdata/events.go @@ -0,0 +1,13 @@ +package d2animdata + +// AnimationEvent represents an event that can happen on a frame of animation +type AnimationEvent byte + +// Animation events +const ( + AnimationEventNone AnimationEvent = iota + AnimationEventAttack + AnimationEventMissile + AnimationEventSound + AnimationEventSkill +) diff --git a/d2common/d2fileformats/d2animdata/hash.go b/d2common/d2fileformats/d2animdata/hash.go new file mode 100644 index 00000000..ed715e4a --- /dev/null +++ b/d2common/d2fileformats/d2animdata/hash.go @@ -0,0 +1,18 @@ +package d2animdata + +import "strings" + +type hashTable [numBlocks]byte + +func hashName(name string) byte { + hashBytes := []byte(strings.ToUpper(name)) + + var hash uint32 + for hashByteIdx := range hashBytes { + hash += uint32(hashBytes[hashByteIdx]) + } + + hash %= numBlocks + + return byte(hash) +} diff --git a/d2common/d2fileformats/d2animdata/record.go b/d2common/d2fileformats/d2animdata/record.go new file mode 100644 index 00000000..9d165efa --- /dev/null +++ b/d2common/d2fileformats/d2animdata/record.go @@ -0,0 +1,23 @@ +package d2animdata + +// AnimationDataRecord represents a single record from the AnimData.d2 file +type AnimationDataRecord struct { + name string + framesPerDirection uint32 + speed uint16 + events map[int]AnimationEvent +} + +// FPS returns the frames per second for this animation record +func (r *AnimationDataRecord) FPS() float64 { + speedf := float64(r.speed) + divisorf := float64(speedDivisor) + basef := float64(speedBaseFPS) + + return basef * speedf / divisorf +} + +// FrameDurationMS returns the duration in milliseconds that a frame is displayed +func (r *AnimationDataRecord) FrameDurationMS() float64 { + return milliseconds / r.FPS() +} diff --git a/d2common/d2fileformats/d2animdata/testdata/AnimData.d2 b/d2common/d2fileformats/d2animdata/testdata/AnimData.d2 new file mode 100644 index 00000000..f2023d92 Binary files /dev/null and b/d2common/d2fileformats/d2animdata/testdata/AnimData.d2 differ diff --git a/d2common/d2fileformats/d2animdata/testdata/BadData.d2 b/d2common/d2fileformats/d2animdata/testdata/BadData.d2 new file mode 100644 index 00000000..8a454e4d Binary files /dev/null and b/d2common/d2fileformats/d2animdata/testdata/BadData.d2 differ