Twitcher 2.5.1 · Godot 4.7.1 · Windows
addons/twitcher/media/native/gif-lzw/lzw.gd is vendored from jegor377/godot-gdgifexporter (per the header on GIF2SpriteFramesPlugin.gd). Its decompress_lzw is missing the GIF89a 12-bit code-size ceiling, and faults on GIFs large enough to fill the LZW code table. Upstream has since replaced this file; details at the bottom.
Symptom
Fetching emotes on a busy channel parks the game at a debugger break within ~15 s of connecting:
Invalid call. Nonexistent function 'add' in base 'Nil'
at lzw.gd:193 @ decompress_lzw()
<- GIFReader.gd:89 @ load_gif()
(the sibling variant is Invalid access to property or key 'sequence' on a base object of type 'Nil' a few lines down). Both are code_table.get_entry(prevcode) returning null.
It looks random but is perfectly deterministic — it depends only on whether one particular emote gets fetched. In a released build the fault instead abandons the function mid-decode, so load_gif returns frames whose textures are null while get_frame_count() still looks healthy; that then fails much later and much further away.
Cause
GIF89a caps LZW codes at 12 bits / 4096 table entries. compress_lzw in this same file honours that:
var last_entry_index: int = code_table.counter - 1
if last_entry_index != 4095:
code_table.add(new_index_buffer)
else:
# output Clear Code and reset everything
decompress_lzw has no equivalent clamp — it widens straight off the table counter:
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 # <-- no 12-bit ceiling
On a stream that fills the table, get_bits_number_for(counter) returns 13, so the reader starts taking 13-bit codes while the encoder is still writing 12 and emitting a Clear. Every read after that is off by a bit; the reader mis-detects a later Clear, resets, and then asks for a code far past its own table. Observed fault state: prevcode=312 against counter=267.
Reproduction — no binary fixture needed
The file's own compressor is always correct at the boundary, so it can generate the input. This fails on stock Twitcher and passes with the one-line fix:
var codec: RefCounted = load("res://addons/twitcher/media/native/gif-lzw/lzw.gd").new()
var colors: PackedByteArray = PackedByteArray()
for index: int in 256:
colors.append(index)
20k pseudo-random indices — far more than 4096 distinct entries, so the table fills and clears
several times over. Fixed LCG so a failure is reproducible rather than flaky.
var indices: PackedByteArray = PackedByteArray()
var state: int = 12345
for _step: int in 20000:
state = (state * 1103515245 + 12345) & 0x7FFFFFFF
indices.append((state >> 16) & 0xFF)
var compressed: Array = codec.compress_lzw(indices, colors)
var restored: PackedByteArray = codec.decompress_lzw(compressed[0], compressed[1], colors)
assert(restored == indices) # faults inside decompress_lzw before it gets here
The real-world trigger for me was a 7TV emote: 1.13 MB, 318 frames, 108x64, a local colour table per frame. It is a valid file — PIL decodes all 318 frames, and a reference LZW decode shows maxcode=4090, i.e. it genuinely uses the full 12-bit space. Twitch's first-party emotes are small enough that they never approach the boundary, which is presumably why this hasn't surfaced before.
Minimal fix
const MAX_CODE_SIZE: int = 12 # GIF89a ceiling; compress_lzw already respects it
...in decompress_lzw:
current_code_size = mini(new_code_size_candidate, MAX_CODE_SIZE)
Verified: unpatched dies on frame 0 of that emote deterministically; patched returns all 318 frames with every frame's pixel count exact.
The better fix is probably to re-vendor
Upstream replaced this code entirely: gdgifexporter PR #25, "Implement gif importing", merged 2025-11-05. The author's description says he "rewrote the LZW decompression code because I couldn't make the old one work" — it was never filed as an issue, just replaced. The rewritten decompress_lzw on master carries exactly the missing constraint:
if next_code > max_code and code_size < 12:
Note the signature changed (decompress_lzw(min_code_size, data) there vs decompress_lzw(data, min_code_size, colors) here), so it is not a drop-in swap.
Twitcher 2.5.1 · Godot 4.7.1 · Windows
addons/twitcher/media/native/gif-lzw/lzw.gd is vendored from jegor377/godot-gdgifexporter (per the header on GIF2SpriteFramesPlugin.gd). Its decompress_lzw is missing the GIF89a 12-bit code-size ceiling, and faults on GIFs large enough to fill the LZW code table. Upstream has since replaced this file; details at the bottom.
Symptom
Fetching emotes on a busy channel parks the game at a debugger break within ~15 s of connecting:
Invalid call. Nonexistent function 'add' in base 'Nil'
at lzw.gd:193 @ decompress_lzw()
<- GIFReader.gd:89 @ load_gif()
(the sibling variant is Invalid access to property or key 'sequence' on a base object of type 'Nil' a few lines down). Both are code_table.get_entry(prevcode) returning null.
It looks random but is perfectly deterministic — it depends only on whether one particular emote gets fetched. In a released build the fault instead abandons the function mid-decode, so load_gif returns frames whose textures are null while get_frame_count() still looks healthy; that then fails much later and much further away.
Cause
GIF89a caps LZW codes at 12 bits / 4096 table entries. compress_lzw in this same file honours that:
var last_entry_index: int = code_table.counter - 1
if last_entry_index != 4095:
code_table.add(new_index_buffer)
else:
# output Clear Code and reset everything
decompress_lzw has no equivalent clamp — it widens straight off the table counter:
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 # <-- no 12-bit ceiling
On a stream that fills the table, get_bits_number_for(counter) returns 13, so the reader starts taking 13-bit codes while the encoder is still writing 12 and emitting a Clear. Every read after that is off by a bit; the reader mis-detects a later Clear, resets, and then asks for a code far past its own table. Observed fault state: prevcode=312 against counter=267.
Reproduction — no binary fixture needed
The file's own compressor is always correct at the boundary, so it can generate the input. This fails on stock Twitcher and passes with the one-line fix:
var codec: RefCounted = load("res://addons/twitcher/media/native/gif-lzw/lzw.gd").new()
var colors: PackedByteArray = PackedByteArray()
for index: int in 256:
colors.append(index)
20k pseudo-random indices — far more than 4096 distinct entries, so the table fills and clears
several times over. Fixed LCG so a failure is reproducible rather than flaky.
var indices: PackedByteArray = PackedByteArray()
var state: int = 12345
for _step: int in 20000:
state = (state * 1103515245 + 12345) & 0x7FFFFFFF
indices.append((state >> 16) & 0xFF)
var compressed: Array = codec.compress_lzw(indices, colors)
var restored: PackedByteArray = codec.decompress_lzw(compressed[0], compressed[1], colors)
assert(restored == indices) # faults inside decompress_lzw before it gets here
The real-world trigger for me was a 7TV emote: 1.13 MB, 318 frames, 108x64, a local colour table per frame. It is a valid file — PIL decodes all 318 frames, and a reference LZW decode shows maxcode=4090, i.e. it genuinely uses the full 12-bit space. Twitch's first-party emotes are small enough that they never approach the boundary, which is presumably why this hasn't surfaced before.
Minimal fix
const MAX_CODE_SIZE: int = 12 # GIF89a ceiling; compress_lzw already respects it
...in decompress_lzw:
current_code_size = mini(new_code_size_candidate, MAX_CODE_SIZE)
Verified: unpatched dies on frame 0 of that emote deterministically; patched returns all 318 frames with every frame's pixel count exact.
The better fix is probably to re-vendor
Upstream replaced this code entirely: gdgifexporter PR #25, "Implement gif importing", merged 2025-11-05. The author's description says he "rewrote the LZW decompression code because I couldn't make the old one work" — it was never filed as an issue, just replaced. The rewritten decompress_lzw on master carries exactly the missing constraint:
if next_code > max_code and code_size < 12:
Note the signature changed (decompress_lzw(min_code_size, data) there vs decompress_lzw(data, min_code_size, colors) here), so it is not a drop-in swap.