Skip to content

Vendored gif-lzw decompress_lzw has no 12-bit code cap — faults on large GIFs; upstream rewrote it in Nov 2025 #132

Description

@MADAL1EN

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions