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)
-
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.
-
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.
-
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.
-
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
- 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?
- Is the intermittent behavior of fresh-path workarounds expected? Is there a cache eviction policy or directory-scan timing I'm not aware of?
- Are there flags or env vars that would make DR log sprite-load failures so they're not silent? Or expose the cache state?
- 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.
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.maclextended 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 viaargs.pixel_arraysymbol 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
e9c4348e). Reproduces on 7.2.Symptom
args.outputs.sprites << { path: "sprites/mything.png", ... }renders the checkerboard placeholder for some PNGs, even when the PNGs are valid (fileconfirmsPNG 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)
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.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.It's not the documented
reset_sprite/reset_spritesAPI. 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 pathis just@ffi_draw.unload_sprite path, andreset_sprites(directory:)walks the directory's.pngfiles and callsreset_spriteon each. Our cursed.pngfiles all still exist on disk, soreset_spritesshould iterate them and callunload_spritefor each. It does (no exceptions), but the curse persists. So whatever C-level cache holds the failed-load entries appears to be outside whatunload_spriteclears.It's not
com.apple.maclas the persistent factor, though that xattr IS the initial trigger. macOS Shortcuts ("Remove Background" Quick Action) stampscom.apple.maclon its outputs. DR/SDL silently rejects files with this xattr — that's the first failure. But stripping macl (viacat 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.cpfrom a cursed file → a fresh path-string AND fresh inode → loads correctly in subsequent DR launches.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 insprites/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).dband.cachefiles modified in 24h (nothing DR-shaped)xattr -lxon cursed vs clean files: identical (onlycom.apple.provenance)mdlson cursed vs clean files: identicalIf 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 themygame/into any DR 7.x install and follow the step-by-stepREADME.md. The reproduction takes about 90 seconds end-to-end from a clean checkout.For convenience, the same steps inline:
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 populateargs.pixel_array(:state)once on first tick, then render via the symbol path: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_spriteshould ideally do what its docs say regardless of why a path is cached.Hypothesis (FWIW)
Amir's phrasing on Discord 10/22/25:
The phrase "once a texture is loaded" makes me wonder if
unload_spriteis 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
reset_sprite/reset_spritesclear from. Is there a separate cache layer?com.apple.maclcausing silent sprite failures from Shortcuts / Quick Actions output?Happy to dig in any direction or test patches — let me know what would help.