diff --git a/README.md b/README.md index edf0fe0..0a9013e 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ Teeworlds huffman compression library. ## Installation ```shell -// for latest tagged release +# for latest tagged release go get github.com/teeworlds-go/huffman/v2@latest -// for bleeding edge master branch version +# for bleeding edge master branch version go get github.com/teeworlds-go/huffman@master ``` diff --git a/go.mod b/go.mod index 1803353..e28d231 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/teeworlds-go/huffman/v2 -go 1.22.3 +go 1.24.0 diff --git a/huffman.go b/huffman.go index 0a97bb9..74133e9 100644 --- a/huffman.go +++ b/huffman.go @@ -50,8 +50,20 @@ func (huff *Huffman) Decompress(data []byte) ([]byte, error) { if len(data) == 0 { return []byte{}, nil } + return huff.DecompressTo(nil, data) +} + +// DecompressTo decompresses data, APPENDING the decompressed bytes to dst and +// returning the extended slice (like Go's append). Pass a reused buffer's +// dst[:0] to avoid allocating a fresh output slice on every call — useful on +// hot paths that decompress many packets (e.g. a 50Hz snapshot stream). dst may +// be nil. huff is not modified, so a single Huffman value is safe for +// concurrent DecompressTo calls with distinct dst buffers. +func (huff *Huffman) DecompressTo(dst, data []byte) ([]byte, error) { + if len(data) == 0 { + return dst, nil + } - dst := []byte{} srcIndex := 0 size := len(data) bits := uint32(0) diff --git a/huffman_test.go b/huffman_test.go index b25d436..379c350 100644 --- a/huffman_test.go +++ b/huffman_test.go @@ -196,3 +196,59 @@ func FuzzHuffmannCompressDecompress(f *testing.F) { } }) } + +// DecompressTo must produce byte-identical output to Decompress, regardless of +// what the caller-supplied buffer already holds (its old contents are discarded). +func TestDecompressToMatchesDecompress(t *testing.T) { + huff := NewHuffman() + + inputs := [][]byte{ + []byte("hello world"), + []byte("1234567890abcdef1234567890abcdef"), + bytes.Repeat([]byte("teeworlds snapshot payload "), 32), + } + + // A reused buffer with stale contents, to prove DecompressTo resets it. + reuse := make([]byte, 0, 512) + reuse = append(reuse, "stale"...) + + for _, in := range inputs { + compressed, err := huff.Compress(in) + if err != nil { + t.Fatalf("compress: %v", err) + } + + want, err := huff.Decompress(compressed) + if err != nil { + t.Fatalf("decompress: %v", err) + } + + got, err := huff.DecompressTo(reuse[:0], compressed) + if err != nil { + t.Fatalf("decompress to: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("DecompressTo mismatch: got %v want %v", got, want) + } + reuse = got // keep growing the shared buffer across iterations + } +} + +// BenchmarkDecompressTo shows that reusing the output buffer across calls +// reaches a steady state of 0 allocs/op (the whole reason DecompressTo exists). +func BenchmarkDecompressTo(b *testing.B) { + huff := NewHuffman() + compressed, err := huff.Compress(bytes.Repeat([]byte("teeworlds snapshot payload "), 32)) + if err != nil { + b.Fatal(err) + } + + buf := make([]byte, 0, 4096) + b.ReportAllocs() + for b.Loop() { + buf, err = huff.DecompressTo(buf[:0], compressed) + if err != nil { + b.Fatal(err) + } + } +}