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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions