From dc7f2e05f256e6add7b2bae6606a2beb93f14256 Mon Sep 17 00:00:00 2001 From: kanimaru Date: Tue, 8 Sep 2026 11:54:28 +0200 Subject: [PATCH] Fix GIF decode failure on streams that fill the LZW code table GIF89a caps LZW codes at 12 bits, so the code table stops at 4096 entries. compress_lzw has always honoured that -- it emits a Clear Code rather than adding entry 4096 -- but decompress_lzw widened straight off its own counter with no ceiling. The decoder trails the encoder by exactly one table entry, so it reaches counter == 4096 while reading the last code the encoder wrote before that Clear. get_bits_number_for(4096) is 13, so the next read took 13 bits out of a stream still written in 12. Every code after that was shifted: a Clear Code was mis-detected, the table reset, and the reader then asked for a code past the end of it. In the editor that faulted with "Nonexistent function add in base Nil" at lzw.gd:193. In a release build decompress_lzw simply abandoned the frame, so load_gif returned a SpriteFrames whose get_frame_count() looked healthy while the textures were empty -- failing much later and much further away from the cause. Twitch first-party emotes are small enough never to approach the boundary, which is why this went unnoticed; the reported trigger was a 318-frame 7TV emote that genuinely uses the full 12-bit space. Adds a generated GIF fixture that reproduces it end to end. Without this fix, test_round_trips_a_stream_that_fills_the_code_table and test_decodes_a_gif_that_fills_the_lzw_code_table both fail. Fixes #132 Co-Authored-By: Claude Opus 5 --- addons/twitcher/media/native/gif-lzw/lzw.gd | 17 +++- test/fixtures/gif/README.md | 21 +++++ test/fixtures/gif/lzw_full_code_table.gif | Bin 0 -> 28287 bytes .../gif/lzw_full_code_table.gif.import | 13 +++ test/unit/media/native/gif-lzw/test_lzw.gd | 77 ++++++++++++++++++ .../unit/media/native/gif-lzw/test_lzw.gd.uid | 1 + test/unit/media/native/test_gif_reader.gd | 56 +++++++++++++ test/unit/media/native/test_gif_reader.gd.uid | 1 + 8 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/gif/README.md create mode 100644 test/fixtures/gif/lzw_full_code_table.gif create mode 100644 test/fixtures/gif/lzw_full_code_table.gif.import create mode 100644 test/unit/media/native/gif-lzw/test_lzw.gd create mode 100644 test/unit/media/native/gif-lzw/test_lzw.gd.uid create mode 100644 test/unit/media/native/test_gif_reader.gd create mode 100644 test/unit/media/native/test_gif_reader.gd.uid diff --git a/addons/twitcher/media/native/gif-lzw/lzw.gd b/addons/twitcher/media/native/gif-lzw/lzw.gd index d07626e2..0884c7fa 100644 --- a/addons/twitcher/media/native/gif-lzw/lzw.gd +++ b/addons/twitcher/media/native/gif-lzw/lzw.gd @@ -4,6 +4,12 @@ extends RefCounted var lsbbitpacker = preload("./lsbbitpacker.gd") var lsbbitunpacker = preload("./lsbbitunpacker.gd") +## GIF89a caps LZW codes at 12 bits, so the code table never grows past 4096 +## entries. [method compress_lzw] already honours this by emitting a Clear Code +## instead of adding entry 4096; the decompressor has to stop widening at the +## same point or it desynchronises from the encoder. +const MAX_CODE_SIZE: int = 12 + class CodeEntry: var sequence: PackedByteArray var raw_array: PackedByteArray @@ -206,8 +212,17 @@ func decompress_lzw(code_stream_data: PackedByteArray, min_code_size: int, color prevcode = code # Detect when we should increase current code size and increase it. + # + # The clamp to MAX_CODE_SIZE is load bearing. The decoder trails the + # encoder by exactly one table entry, so it reaches counter == 4096 while + # reading the last code the encoder wrote before it gave up and emitted a + # Clear Code. get_bits_number_for(4096) is 13, so without the clamp the + # next read takes 13 bits out of a stream still written in 12 — every + # code after that is shifted, a Clear Code is mis-detected, and the reader + # eventually asks for a code past the end of its own table. That surfaces + # as a null CodeEntry a few lines up. var new_code_size_candidate: int = get_bits_number_for(code_table.counter) if new_code_size_candidate > current_code_size: - current_code_size = new_code_size_candidate + current_code_size = mini(new_code_size_candidate, MAX_CODE_SIZE) return index_stream diff --git a/test/fixtures/gif/README.md b/test/fixtures/gif/README.md new file mode 100644 index 00000000..21cc676b --- /dev/null +++ b/test/fixtures/gif/README.md @@ -0,0 +1,21 @@ +# GIF fixtures + +## `lzw_full_code_table.gif` + +160x125, one frame, 256-entry greyscale global colour table, no transparency. + +Generated rather than captured. The pixel indices come from the LCG +`state = (state * 1103515245 + 12345) & 0x7FFFFFFF`, seeded at `12345`, taking +`(state >> 16) & 0xFF` — 20 000 near-random bytes. Incompressible input is the +whole point: a real-world image finds repeats early and never fills the LZW code +table, which is why this bug survived until a 318-frame 7TV emote hit it +(issue #132). The stream was then encoded by Twitcher's own `compress_lzw`, so +it is a stream the codebase must be able to read back. + +It is a valid GIF89a — Pillow decodes it and reproduces the index stream exactly. +Decoding it drives the LZW code table to its full 4096 entries and back through +six mid-stream Clear Codes, which is the boundary +`decompress_lzw` used to mishandle. + +`test_gif_reader.gd` regenerates the index stream from the same LCG, so the +fixture and its expected output cannot drift apart. diff --git a/test/fixtures/gif/lzw_full_code_table.gif b/test/fixtures/gif/lzw_full_code_table.gif new file mode 100644 index 0000000000000000000000000000000000000000..30ac17d8e935589ea6c1a470be76598677b58fd4 GIT binary patch literal 28287 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 F{y(t^RjdF2 literal 0 HcmV?d00001 diff --git a/test/fixtures/gif/lzw_full_code_table.gif.import b/test/fixtures/gif/lzw_full_code_table.gif.import new file mode 100644 index 00000000..7d08f04f --- /dev/null +++ b/test/fixtures/gif/lzw_full_code_table.gif.import @@ -0,0 +1,13 @@ +[remap] + +importer="gif.animated.texture.plugin" +type="SpriteFrames" +uid="uid://b87setm3use05" +valid=false + +[deps] + +source_file="res://test/fixtures/gif/lzw_full_code_table.gif" + +[params] + diff --git a/test/unit/media/native/gif-lzw/test_lzw.gd b/test/unit/media/native/gif-lzw/test_lzw.gd new file mode 100644 index 00000000..b84291df --- /dev/null +++ b/test/unit/media/native/gif-lzw/test_lzw.gd @@ -0,0 +1,77 @@ +## Unit tests for the vendored GIF LZW codec. +## +## This file comes from [url=https://github.com/jegor377/godot-gdgifexporter]gdgifexporter[/url] +## and is the only piece of Twitcher that has to agree, bit for bit, with an +## encoder it does not control. The interesting failures are all boundary +## conditions in the flexible code size, so that is what these tests pin. +extends TwitcherTest + +const LZW_SCRIPT := preload("res://addons/twitcher/media/native/gif-lzw/lzw.gd") + +var _codec: RefCounted + + +func before_each() -> void: + super() + _codec = LZW_SCRIPT.new() + + +## A 256-entry palette, which is what every GIF frame Twitcher decodes uses. +func _palette() -> PackedByteArray: + var colors := PackedByteArray() + for index: int in 256: + colors.append(index) + return colors + + +## Near-random bytes from a fixed LCG. Incompressible input is the point: a +## smooth gradient finds repeats early and never fills the code table, so it +## would sail past the boundary these tests exist to cover. The seed is fixed so +## a failure is reproducible rather than flaky. +func _noise(count: int) -> PackedByteArray: + var out := PackedByteArray() + var state := 12345 + for _step: int in count: + state = (state * 1103515245 + 12345) & 0x7FFFFFFF + out.append((state >> 16) & 0xFF) + return out + + +func _round_trip(indices: PackedByteArray) -> PackedByteArray: + var colors := _palette() + var compressed: Array = _codec.compress_lzw(indices, colors) + return _codec.decompress_lzw(compressed[0], compressed[1], colors) + + +func test_round_trips_a_short_stream() -> void: + var indices := PackedByteArray([1, 1, 1, 2, 2, 3, 1, 1, 1, 2, 2, 3, 4]) + assert_eq(_round_trip(indices), indices) + + +func test_round_trips_a_stream_that_never_fills_the_code_table() -> void: + var indices := _noise(2000) + assert_eq(_round_trip(indices), indices) + + +## The regression test for the 12-bit ceiling. +## +## GIF89a caps LZW codes at 12 bits, so the code table stops at 4096 entries. +## [code]compress_lzw[/code] has always honoured that — it emits a Clear Code +## rather than adding entry 4096 — but [code]decompress_lzw[/code] used to widen +## straight off its own counter. The decoder trails the encoder by exactly one +## entry, so it hit counter == 4096 while reading the last code before that +## Clear, computed a 13-bit code size, and started reading 13 bits out of a +## stream still written in 12. Every code after that was shifted: a Clear Code +## was mis-detected, the table reset, and the reader then asked for a code past +## the end of it. In the editor that faulted with "Nonexistent function 'add' in +## base 'Nil'"; in a release build [code]decompress_lzw[/code] simply abandoned +## the frame and handed back a short buffer, which failed much later and much +## further away. +## +## 20 000 near-random indices fill and clear the table several times over, so +## this covers the boundary in both directions. +func test_round_trips_a_stream_that_fills_the_code_table() -> void: + var indices := _noise(20000) + var restored := _round_trip(indices) + assert_eq(restored.size(), indices.size(), "decode must not stop short") + assert_eq(restored, indices) diff --git a/test/unit/media/native/gif-lzw/test_lzw.gd.uid b/test/unit/media/native/gif-lzw/test_lzw.gd.uid new file mode 100644 index 00000000..bd447300 --- /dev/null +++ b/test/unit/media/native/gif-lzw/test_lzw.gd.uid @@ -0,0 +1 @@ +uid://dd0mb0itdhf23 diff --git a/test/unit/media/native/test_gif_reader.gd b/test/unit/media/native/test_gif_reader.gd new file mode 100644 index 00000000..fd1cb0f4 --- /dev/null +++ b/test/unit/media/native/test_gif_reader.gd @@ -0,0 +1,56 @@ +## End-to-end test for [GifReader] over a GIF that fills the LZW code table. +## +## [code]test_lzw.gd[/code] pins the codec directly. This suite covers the path +## that actually broke in the wild: a 7TV emote large enough to reach the 12-bit +## code ceiling would make [method GifReader.load_gif] return a [SpriteFrames] +## whose [code]get_frame_count()[/code] looked healthy while the frame textures +## were empty, so the failure surfaced far away from its cause. +extends TwitcherTest + +const FIXTURE := "res://test/fixtures/gif/lzw_full_code_table.gif" +const WIDTH := 160 +const HEIGHT := 125 + + +## Regenerates the pixel indices the fixture was built from. Same LCG and seed as +## the generator, so the assertion below is exact rather than a smoke test. +func _expected_indices() -> PackedByteArray: + var out := PackedByteArray() + var state := 12345 + for _step: int in WIDTH * HEIGHT: + state = (state * 1103515245 + 12345) & 0x7FFFFFFF + out.append((state >> 16) & 0xFF) + return out + + +func test_decodes_a_gif_that_fills_the_lzw_code_table() -> void: + var reader := GifReader.new() + var frames: SpriteFrames = reader.read(FIXTURE) + + assert_not_null(frames, "fixture missing or unreadable: %s" % FIXTURE) + if frames == null: + return + assert_eq(frames.get_frame_count(&"default"), 1) + + var texture: Texture2D = frames.get_frame_texture(&"default", 0) + assert_not_null(texture, "a decode that fails mid-frame leaves a null texture") + if texture == null: + return + + var image: Image = texture.get_image() + assert_eq(Vector2i(image.get_width(), image.get_height()), Vector2i(WIDTH, HEIGHT)) + + # The fixture's palette is grey ramp (i, i, i), so the red channel of a + # decoded pixel is the palette index the encoder wrote. + var expected := _expected_indices() + var mismatches := 0 + var first_mismatch := "" + for y: int in HEIGHT: + for x: int in WIDTH: + var actual: int = image.get_pixel(x, y).r8 + var want: int = expected[y * WIDTH + x] + if actual != want: + mismatches += 1 + if first_mismatch == "": + first_mismatch = "(%d, %d): got %d, want %d" % [x, y, actual, want] + assert_eq(mismatches, 0, "pixel mismatches, first at %s" % first_mismatch) diff --git a/test/unit/media/native/test_gif_reader.gd.uid b/test/unit/media/native/test_gif_reader.gd.uid new file mode 100644 index 00000000..7c646468 --- /dev/null +++ b/test/unit/media/native/test_gif_reader.gd.uid @@ -0,0 +1 @@ +uid://7ro5fqigpw4k