Skip to content

fix(indexed): prevent transparentColor from colliding with a real palette color#21

Open
Fuitad wants to merge 6 commits into
willibrandon:mainfrom
Fuitad:main
Open

fix(indexed): prevent transparentColor from colliding with a real palette color#21
Fuitad wants to merge 6 commits into
willibrandon:mainfrom
Fuitad:main

Conversation

@Fuitad

@Fuitad Fuitad commented Jul 2, 2026

Copy link
Copy Markdown

Summary

Fixes an indexed-color-mode transparency corruption bug I hit while using the pixel-plugin Claude Code plugin (which bundles this server) for a game project. Opening the fix here directly since this is where the root cause actually lives.

Palette:resize silently clamps Sprite.transparentColor into the palette's new valid index range. CreateCanvas sets transparentColor = 255 for indexed sprites specifically so palette index 0 can hold a real color (see b5175b8). But every tool that later resizes the palette - set_palette, quantize_palette, add_palette_color, apply_auto_shading - loses that protection: after resizing to an N-color palette, transparentColor becomes N-1, colliding with whatever real color the caller just set at the last index.

Once that collision exists, anything that paints "transparent" (most commonly fill_area with a fully-transparent color, which is how a locked-palette sprite typically establishes its background) writes that colliding index. Reading it back reports the real color's opaque RGBA instead of transparency, and a pixel intentionally drawn in that color can be auto-trimmed away as if it were transparent. In the reproduction I hit, this manifested as background regions rendering as an unrelated opaque palette color, and in a smaller repro, an entire cel being deleted because Aseprite's auto-trim decided all of its content (foreground included) was "transparent."

This is the same class of bug already fixed for index 0 in b5175b8, just reintroduced at index N-1 by the newer palette-resizing tools.

Fix

Adds ReassertIndexedTransparentColor() in pkg/aseprite/lua_core.go, a small Lua snippet that re-pins transparentColor to 255 for indexed sprites. Spliced in immediately after every Palette:resize call in:

  • SetPalette
  • AddPaletteColor
  • ApplyQuantizedPalette (after the optional RGB->indexed conversion, so the check sees the final color mode)
  • ApplyAutoShading

No changes needed in get_pixels or export: its existing "index out of palette bounds -> transparent" branch already does the right thing once transparentColor stays safely out of the real, addressable palette range.

Root cause verification

I traced this by hand with the actual aseprite CLI outside the Go test suite (create indexed sprite -> transparentColor=255 -> set_palette with a 32-color array -> transparentColor reads back as 31), then confirmed the downstream effect (a "transparent" fill_area writes index 31, which is a real, opaque color, so it reads back as that color instead of transparent). Full before/after logs available if useful.

Testing

  • go build ./..., go vet ./...: clean
  • go test ./...: 627 passed (no regressions)
  • go test -tags=integration ./...: 796 passed (two SetFrameDuration/ApplyShading_HardStyle failures on the full run were 30s timeouts from process contention running the whole suite at once; both pass individually)
  • Added TestIntegration_SetPalette_TransparentColorCollisionBug (pkg/tools/palette_transparent_color_collision_bug_test.go), which reproduces the exact collision (4-color palette, draw with the last color, fill_area a separate region transparent, get_pixels both). Verified RED without the fix (get_pixels errors with "No cel found in frame 1" because the whole cel got trimmed away) and GREEN with it.
  • Also verified the existing PaletteIndex0/CelPosition/NewLayer/indexed-export bug regression tests still pass.

Changelog

Added an [Unreleased] / Fixed entry in CHANGELOG.md.

Christopher Hanson and others added 4 commits June 26, 2026 18:54
draw_pixels wrote to cel.image via Image:putPixel using sprite
coordinates, but putPixel expects cel-LOCAL coordinates. Once a layer's
cel is trimmed to a non-(0,0) origin (e.g. after a shape tool runs),
every pixel was offset by the cel's position, and pixels falling outside
the cel's bounds were dropped entirely. The shape tools are unaffected
because they use app.useTool, which operates in sprite space.

Normalize the target cel to a full-canvas image at (0,0) before writing
(compositing any existing content first), so sprite coordinates equal
image coordinates. This also gives a freshly created cel a valid image,
fixing draw_pixels on newly added layers. Aseprite re-trims the cel on
save, so canvas dimensions and file size are unchanged.

Tests:
- pkg/aseprite/lua_test.go: unit assertion that the generated script
  normalizes the cel to full-canvas at (0,0). Runs without Aseprite, so
  it guards the fix in CI, where the integration tests do not run.
- pkg/tools: integration tests for the offset-after-shape case and for
  an asymmetric cel origin with a pixel outside the shape's bounds (the
  lost-pixel case). Both fail on stock and pass with this change.
…ette color

Palette:resize silently clamps Sprite.transparentColor into the new
palette's valid index range. After set_palette, quantize_palette,
add_palette_color, or apply_auto_shading resize an indexed sprite's
palette, transparentColor can land on the same index as a real,
in-use color (typically the palette's last entry).

Once that happens, painting "transparent" (fill_area with a fully
transparent color) writes the colliding index, so background fills
read back as an opaque real color instead of transparent, and pixels
intentionally drawn in that color can be auto-trimmed away as if they
were transparent - the same class of bug fixed for index 0 in
b5175b8, reintroduced at index N-1.

Fix: re-assert transparentColor = 255 after every palette resize on
an indexed sprite via a shared ReassertIndexedTransparentColor()
helper, keeping it out of the palette's real, addressable range.

Adds a regression test reproducing the exact collision and verifying
both the drawn color and the transparent fill read back correctly.
Root cause: on an indexed sprite, img:putPixel and app.useTool both
expect a palette index, not an arbitrary RGBA value. Passing a Color
object directly resolves correctly when the sprite's palette was set
earlier in the SAME Aseprite process, but pixel-mcp runs each tool
call as its own `aseprite --batch` invocation: the palette is set in
one process and drawing happens in the next. In that (normal, for
every real tool call) cross-process scenario, Aseprite's own
Color-to-index resolution for img:putPixel/app.useTool does not
reliably match the sprite's actual, correctly-loaded palette (verified
by reading spr.palettes[1] back at the point of failure) and silently
resolves to an unrelated index instead, corrupting the drawn color
with no error.

Affects draw_pixels, draw_line, draw_contour, draw_rectangle,
draw_circle, and fill_area whenever use_palette is false (the default)
on an indexed sprite.

Fix: resolveExactPaletteColor (pkg/aseprite/lua_core.go) resolves the
color itself by reading the sprite's actual active palette at Lua
runtime, bypassing Aseprite's unreliable resolution entirely.
- Fully-transparent colors (alpha 0, e.g. a "paint transparent"
  fill_area call) resolve directly to spr.transparentColor, since
  transparency is a designated index, not a color expected to exist
  in the palette.
- Any other color requires an exact match in the active palette and
  errors if none exists, so a caller drawing an approximate/non-locked
  color silently gets the wrong pixel instead of an error telling them
  to pass use_palette=true to snap to the nearest palette color.

Wired into DrawPixels (FormatColorWithPalette) and DrawLine/
DrawContour/DrawRectangle/DrawCircle/FillArea (FormatColorWithPaletteForTool).

Testing:
- go test ./...: 628 passed
- go test -tags=integration ./...: 805 passed
- Added TestIntegration_IndexedMode_ExactColorResolution
  (pkg/tools/exact_palette_color_indexed_mode_bug_test.go): exact match
  via draw_rectangle, exact match via draw_pixels, and error on no
  match. Verified RED without the fix (all three fail/misbehave
  exactly as described above) and GREEN with it.
- Updated two pre-existing tests that asserted the old, buggy
  behavior: TestFormatColorWithPalette_NoPalette and
  TestLuaGenerator_DrawPixels now expect resolveExactPaletteColor(...)
  instead of a raw Color(...) literal.
- Fixed TestIntegration_SetPalette_ThenApplyShading, which drew an
  approximate "mid-tone" color with use_palette=false even though that
  color was never one of the locked palette's 16 exact entries; it now
  correctly passes use_palette=true to snap it.
Fuitad added 2 commits July 2, 2026 10:21
MedianCutQuantization split each bucket at the pixel-array midpoint
(mid := len(pixels) / 2), not at a boundary between distinct colors.
Pixel art typically has wildly imbalanced color frequency (a large
solid fill vastly outnumbering a 1-2px accent detail), so splitting
at the raw pixel-count median lands the cut inside the dominant
color's own run instead of between colors.

The dominant color got needlessly re-subdivided into near-duplicate
buckets while rare accent colors never got isolated into their own
bucket, and were instead averaged into a blended, off-palette hex
value - even when target_colors comfortably exceeded the number of
unique colors actually present, which should guarantee lossless
recovery. On a 32x32 sprite with a ~200px tunic fill, ~90px skin
fill, a thin outline, and 2px highlight/buckle accents, quantizing
to target_colors=32 (13 unique colors present) returned colors like
#373130 that did not exist anywhere in the source image.

Fix: reduce to a frequency-weighted histogram of unique colors
before bucketing, so a split always falls between distinct colors.
The split point still favors common colors over rare ones by
cumulative pixel count, preserving normal median-cut reduction
behavior when target_colors is genuinely smaller than the number of
unique colors.

I initially suspected a second bug in ApplyQuantizedPalette/
ChangePixelFormat (pixels resolving to the wrong palette index,
transparency lost on conversion to indexed mode). Verified via
aseprite --batch scripts and full end-to-end integration tests
(Go clustering -> Lua apply -> get_pixels) that ChangePixelFormat
correctly honors a palette set immediately before it runs, in both
the lossless (target_colors >= unique colors) and genuine lossy
reduction cases. The apparent scrambling in my initial hand-written
repro script was caused by omitting the existing
ReassertIndexedTransparentColor() call, not a new bug - so no change
was needed there.

Testing:
- go test ./...: 633 passed
- go test -tags=integration ./...: unchanged pre-existing suite passed,
  plus the new regression test
- Added TestMedianCutQuantization_ImbalancedFrequencyPreservesExactColors
  (pkg/aseprite/quantization_test.go): pure-Go unit test, no Aseprite
  needed, guards the fix in CI
- Added TestIntegration_QuantizePalette_ImbalancedFrequencyPreservesExactColors
  (pkg/tools/quantize_palette_imbalanced_frequency_bug_test.go):
  reproduces the exact hero-sprite shape end to end through the real
  quantize_palette pipeline (export -> Go quantize -> Lua apply ->
  get_pixels), checking both exact colors and transparency survive
  conversion to indexed mode. Verified RED without the fix (blended
  colors and wrong pixels, matching the originally observed
  corruption) and GREEN with it.
On indexed sprites CreateCanvas sets transparentColor = 255 so palette index 0
can hold a real color, which makes index 0 an opaque color. Two paths left
transparent areas showing that opaque index-0 background:

- draw_pixels normalizes the target cel into a full-canvas Image() (filled with
  index 0) and composites the existing content with the default NORMAL blend,
  which skips the source's transparent (index 255) pixels. Any pixel a previous
  call had made transparent but the current call does not rewrite flipped to
  opaque black. Fixed by clearing the full-canvas image to spr.transparentColor
  before compositing/drawing, in both the existing-cel and new-cel paths.

- create_canvas left the initial cel filled with index 0, so a freshly created
  indexed canvas started with an opaque background. Fixed by clearing the initial
  cel to spr.transparentColor right after creation.

RGB and grayscale behavior is unchanged (both guarded to indexed mode). Adds
behavioral integration tests that reproduce both cases and CI-safe string checks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant