Skip to content

Silent sprite-load failures cached per-path; reset_sprite(s) don't clear #176

Description

@vocaro

Investigation by Claude Code, working on my project.

Summary

On macOS, when DragonRuby silently fails to load a sprite PNG, the failure is cached per path-string for the life of the project — pkill -f dragonruby + restart doesn't recover it, the file can be fully replaced or have its xattrs cleaned, and none of $gtk.reset_sprite "path", $gtk.reset_sprites, or $gtk.reset_sprites(directory: "sprites") clear it. The cursed path renders the checkerboard "missing sprite" placeholder forever.

The proximate trigger is the com.apple.macl extended attribute that macOS Shortcuts (the "Remove Background" Quick Action) stamps on its outputs — DR/SDL refuses to read files with this xattr. Stripping the xattr (the file is then byte-identical to a known-working one) does NOT recover the path.

I have a working bypass that ships in production for me — load via DR.get_pixels + render via args.pixel_array symbol path — so this isn't blocking, but the underlying cache still has the failed-load entries and the public reset APIs don't reach them.

Environment

  • DragonRuby: 7.3 Pro (May 20 2026 build, git e9c4348e). Reproduces on 7.2.
  • macOS: Tahoe 26.5 (build 25F71)
  • Hardware: Apple M5 Max (arm64)

Symptom

args.outputs.sprites << { path: "sprites/mything.png", ... } renders the checkerboard placeholder for some PNGs, even when the PNGs are valid (file confirms PNG image data, 1024x1024, 8-bit/color RGBA, non-interlaced; DR.get_pixels "sprites/mything.png" reads them fine and returns the correct dimensions).

Zero error output anywhere. Checked: stdout, stderr, logs/animal_crackers.log, ~/Library/Application Support/<game>/logs/animal_crackers.log, mygame/exceptions/, mygame/errors/. DR just silently shows the checkerboard.

What I've ruled out (with controlled experiments)

  1. It's not the files. Took the exact byte-identical PNGs that fail in my main game and dropped them into a fresh DR 7.3 sample project (different directory, different metadata, just ./dragonruby mygame). They load and render correctly there. So the bug is per-project-state, not per-file.

  2. It's not file-system loadability. DR.get_pixels "sprites/mything.png" returns OK with the correct dimensions for every "cursed" file in my main game. The file is loadable; the sprite cache just refuses to render at that path.

  3. It's not the documented reset_sprite / reset_sprites API. I call all three forms before rendering — per-path $gtk.reset_sprite "path", no-arg $gtk.reset_sprites, AND the keyword-arg form $gtk.reset_sprites(directory: "sprites") (per Amir's signature from Discord 4/29/23). All run without exception. The checkerboard persists.

    Tracing the OSS Ruby layer to confirm: reset_sprite path is just @ffi_draw.unload_sprite path, and reset_sprites(directory:) walks the directory's .png files and calls reset_sprite on each. Our cursed .png files all still exist on disk, so reset_sprites should iterate them and call unload_sprite for each. It does (no exceptions), but the curse persists. So whatever C-level cache holds the failed-load entries appears to be outside what unload_sprite clears.

  4. It's not com.apple.macl as the persistent factor, though that xattr IS the initial trigger. macOS Shortcuts ("Remove Background" Quick Action) stamps com.apple.macl on its outputs. DR/SDL silently rejects files with this xattr — that's the first failure. But stripping macl (via cat src > /tmp/clean && rm src && mv /tmp/clean src) does NOT recover the path. The bug appears to be in a deeper cache that gets populated by the initial failure and isn't cleared by anything I've tried.

What I've found about the cache mechanism

The cache appears keyed at minimum by (path, inode) combination:

  • mv (preserves inode) keeps the path cursed even at a new path-string.
  • cp from a cursed file → a fresh path-string AND fresh inode → loads correctly in subsequent DR launches.
  • BUT this fresh-inode workaround is intermittent: the same cp-created file at the same fresh path loaded in one DR launch and rendered as checkerboard in the next, with no file changes between them. I suspect there's a third dimension to the cache key I haven't identified — possibly directory-mtime-related, or some side effect of how many files exist in sprites/ at startup.

Cache location: searched and couldn't find it

  • mygame/.dragonruby/ (empty after pkill)
  • ~/Library/Caches/*dragonruby* (none)
  • ~/Library/Application Support/<game>/ (only logs)
  • ~/Library/Saved Application State/ (no DR entries)
  • ~/Library/Containers/* (no DR entries)
  • All .db and .cache files modified in 24h (nothing DR-shaped)
  • xattr -lx on cursed vs clean files: identical (only com.apple.provenance)
  • mdls on cursed vs clean files: identical

If the cache is on disk, I missed it. If it's in-memory only, I don't understand how it survives pkill -f dragonruby + new process launch.

Minimal reproduction

A self-contained MRE project is attached to this issue as sprite_curse_mre.zip — drop the mygame/ into any DR 7.x install and follow the step-by-step README.md. The reproduction takes about 90 seconds end-to-end from a clean checkout.

For convenience, the same steps inline:

# 1. Run macOS Shortcuts "Remove Background" Quick Action on any PNG; save as mygame/sprites/test.png
# 2. xattr -l mygame/sprites/test.png  → shows `com.apple.macl: <binary>`
# 3. Reference from tick:
#    args.outputs.sprites << { x: 100, y: 100, w: 200, h: 200, path: 'sprites/test.png' }
# 4. ./dragonruby mygame  → checkerboard placeholder; no errors in any log
# 5. pkill -f dragonruby
# 6. Clean the xattr (cat-via-/tmp produces a fresh file without macl):
#    cat mygame/sprites/test.png > /tmp/clean.png
#    rm mygame/sprites/test.png
#    mv /tmp/clean.png mygame/sprites/test.png
# 7. xattr -l mygame/sprites/test.png  → only `com.apple.provenance` now (macl is gone)
# 8. ./dragonruby mygame again  → STILL checkerboard despite clean file
# 9. DR.get_pixels "sprites/test.png" in your tick fn → returns OK pixel_array with valid dimensions
# 10. $gtk.reset_sprite "sprites/test.png" + $gtk.reset_sprites in your tick → no effect on the render
# 11. cp mygame/sprites/test.png mygame/sprites/test_v2.png (cp = fresh inode)
#     Update tick to reference 'sprites/test_v2.png'
# 12. ./dragonruby mygame → loads correctly (USUALLY — see flaky note below)

The "usually" in step 12 is the part I can't characterize. In my main project, the fresh-path/fresh-inode workaround sometimes works for several launches in a row, then stops working with no apparent trigger, and a different fresh-path/fresh-inode file then works in its place.

Working bypass (shipping)

I found a reliable way around it — use DR.get_pixels (which reads the cursed PNGs fine at file-system level) to populate args.pixel_array(:state) once on first tick, then render via the symbol path:

PUP_SPRITES.each do |state, path|
  pa = DR.get_pixels(path)
  args.pixel_array(state).w      = pa.w
  args.pixel_array(state).h      = pa.h
  args.pixel_array(state).pixels = pa.pixels
end
# …then each animal:
args.outputs.primitives << { x:, y:, w:, h:, path: state, angle: }

The pixel_array texture pipeline doesn't share storage with the file-path sprite cache, so the curse can't follow. My sprites render correctly under this bypass. The underlying bug is unchanged — args.outputs.sprites << { path: "sprites/<cursed>.png" } still renders checkerboard — I just don't use that code path anymore.

It's a fine shipping workaround for one character, but probably not a great pattern long-term: future asset work (more characters × more animation states) shouldn't all need to route through pixel_arrays just to avoid the cache, and reset_sprite should ideally do what its docs say regardless of why a path is cached.

Hypothesis (FWIW)

Amir's phrasing on Discord 10/22/25:

"Once a texture is loaded from disk we keep it cached in an atlas from there on out. Explicitly calling reset_sprite will remove it from the cache."

The phrase "once a texture is loaded" makes me wonder if unload_sprite is targeting the successfully-loaded atlas entries, and the failed-load paths I'm dealing with are in a different cache state (some "tried, failed" registry?) that doesn't share storage with the atlas.

Questions

  1. Where does the sprite-load failure cache live? It's clearly not where reset_sprite / reset_sprites clear from. Is there a separate cache layer?
  2. Is the intermittent behavior of fresh-path workarounds expected? Is there a cache eviction policy or directory-scan timing I'm not aware of?
  3. Are there flags or env vars that would make DR log sprite-load failures so they're not silent? Or expose the cache state?
  4. Has anyone else hit com.apple.macl causing silent sprite failures from Shortcuts / Quick Actions output?

Happy to dig in any direction or test patches — let me know what would help.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions