2026-08-04 The Card Kept Its Picture in the Field Nothing Reads
What I set out to do
Explain why a handful of recent cards show a blank white tile in SillyTavern instead of artwork. Your Bro Is The Main Event was the example.
What I actually did
The tell was the file size. That card is 881 KB of PNG whose IHDR declares 1 x 1, 8-bit RGB. Its IDAT is twelve bytes holding a single pixel of RGB(255,255,255), and the other 881,226 bytes are the chara tEXt chunk. SillyTavern was rendering exactly what it was given: one white pixel scaled to fill the avatar slot. Nothing was corrupt, and the character data was completely intact.
Scanning the library found 12 such cards out of 1,211, every one stamped creator: "OpenRouter Interceptor". Eleven dated 2026-03-20, one (Isabella) 2026-04-20.
The interesting part was the split. Five of the twelve still carried their artwork as a base64 WebP data URL in the card JSON’s own avatar field, up to 609 KB of it, while their PNG carried the single white pixel. The other seven had avatar: "none", meaning the fetch failed at capture time and there is nothing to recover.
The structural cause is two independent avatar-acquisition paths inside the same function. src/background.ts:1068-1125 fetches the avatar into characterCard.avatar as a data URL; the actual pixels come from a separate injected-canvas step at src/background.ts:1163-1226 driven off capture.tabInfo.avatarImages scraped from the DOM. When those two disagree you get a card whose JSON has the image and whose raster does not, and SillyTavern only ever displays the raster. Current code should not reproduce it (the sidebar path draws a 400 x 400 placeholder at src/sidebar/playground/card-png.ts:66, and the background path falls back to a .json download rather than emitting a degenerate PNG), so these came from an older build. I did not pin down which commit.
Rebuilt the five recoverable ones: decoded the embedded WebP, rasterized with magick, then re-attached the original chara chunk as raw chunk bytes including its CRC rather than re-serializing the JSON, so the character data is provably untouched. Dropped ImageMagick’s own tEXt chunks so chara stays the only one. 1 x 1 became 1696 x 2528, 2449 x 3072, 2048 x 2048, 1736 x 1724 and 915 x 891. Verified each result had real IDAT and a pixel standard deviation of 49 to 74 on a 0-255 scale, which rules out having swapped one flat fill for another. Files grew four to five times because PNG is lossless and the source was WebP; the largest is now 7.3 MB.
Installed with the originals backed up, and cleared the three stale entries in thumbnails/avatar/ that would otherwise have kept serving the cached white tile. Seven cards remain blank and unrecoverable.
What was striking
A size-versus-dimensions mismatch is a good smell to have. An 881 KB file containing a one-pixel image is a contradiction that points straight at the answer, and it was faster than opening anything.
The image was never lost. It was sitting in a field that no consumer reads for display. There is a real difference between “the data is gone” and “the data is in the wrong place,” and only the second one is repairable, which is why the twelve split five/seven rather than all being write-offs.
This dovetails with 2026-08-04 The ccv3 Chunk SillyTavern Actually Reads from earlier today, and it is the same shape of bug one layer up. That one was two chunks disagreeing about the character text; this one is the JSON and the raster disagreeing about the picture. Both times the fix required knowing which copy SillyTavern actually looks at. Worth noting these five carried only a chara chunk, no ccv3, so the precedence work did not apply; the rebuild script asserted exactly one tEXt chunk per card, which would have caught it if any had.
Later the same day: the fix, and the other seven
Took the follow-up above and found it was only half the bug. Correcting myself on two counts.
First, I wrote earlier that current code should not reproduce this. It reproduces it exactly. The five cards came out of the success path, not the fallback. The check was results[0].length > 0, and a canvas whose source image never decoded still encodes cleanly as a 1 x 1 PNG, so the byte array was never empty and the .json fallback never fired. Rasterizing from the data URL in the fallback, on its own, would have prevented nothing. Two defects, and the one I proposed fixing was the second one.
So both got fixed. toCardImage in src/utils/png-character-utils.ts now parses the injected script’s result instead of counting it: real array, PNG signature, IHDR at least 2 x 2. Anything smaller throws rather than reaching disk. Then the fallback renders locally from the card’s own avatar data URL, placeholder when there is none, and only falls through to .json if that throws too. card-png.ts moved from src/sidebar/playground/ to src/utils/ on the way, because the manifest v2 background page is a page with a DOM and can draw its own canvas. The comment claiming otherwise was wrong.
Second, the seven were never unrecoverable. They are the same 1 x 1 shape as the other five, not files missing image data, and each carries its source chat URL in capture_metadata. What they lack is only the embedded copy.
Getting their avatars was the interesting part. Cookies from the PWA profile returned 401 on all seven chats, so the session is not cookie-borne; the app attaches a bearer token from somewhere in JS. Rather than hunt for where, I patched window.fetch and XMLHttpRequest.prototype.setRequestHeader in the PWA console, let the app make one authenticated request of its own, borrowed the Authorization header it used, unpatched, and replayed it for the seven. All seven resolved first try. The avatar CDN itself needs no auth at all.
Grafted them the same way as the five, raw chunk bytes carried across verbatim and verified byte-identical against the original. 512 x 768 up to 1279 x 1920. Cleared the seven stale thumbnails. The library now contains zero 1 x 1 cards out of 1,211.
A third failure mode, found by asking about one card
Then a card called Amanda (Tracy’s Friend) Appear came up. Its filename contains a literal newline, and ls had been wrapping it across two lines the whole time, so it read as two files. Two cards in the library are like this. The image is fine in both; only the name is broken, in the filename and in the card’s own name field alike.
No fix needed in the code: sanitizeName collapses \s+, which covers newlines, so no current build can produce this. Added the case to filename.test.ts to keep it that way. Both are legacy.
Amanda cut cleanly at the break. The other did not. Its extracted name is Lena\nPersonality Gentle, grie, but its description opens [Name: Diana, and both Lena.png and Diana.png were already taken by unrelated characters, with a Diana(1).png besides. Four cards, four different description hashes, four different chats. I stopped and asked rather than pick; it is now Diana (Secrets Your Wife Carries), the name its own text gives plus its capture title.
The checkers were wrong too
With the library repaired I ran the duplicate checkers, and they disagreed with each other in a way that hid a real duplicate. Unfaithful Girlfriend - Claire Bennett and its (1) twin are identical in every content field and differ only in capture metadata. Both scripts reported zero.
cleanup-duplicates compares content correctly but only compares files whose base names match, and extractBaseName stripped the capture timestamp and nothing else. The (1) that a download conflict adds, and the bare 1 that a SillyTavern import collision adds, both survived, so the twin never shared a group with what it collided with. dedupe-characters grouped by character name correctly but tested equality with a hash over the raw chunk bytes, which carry capture_metadata, so no re-capture could ever match by construction. Two blind spots, complementary, one duplicate straight through the middle.
Fixed both, trashed the duplicate. Over-grouping turned out to be the safe direction for the first one: a group is only a shortlist, and every caller still has to establish a match before acting.
Then the question that made the day worth writing down: is there a script that just tells me which cards are broken? There was a parser, parseCard, and nothing that ran it over a library. Wrote validate-cards.ts, and the useful part was deciding it should ask two questions rather than one. Structural validity is “does this file hold a card” — PNG, card chunk, parseable, named. Presentation is “will SillyTavern draw something a person recognises” — has IDAT, at least 2x2, no control characters in the name or filename. Every card I fixed today passed the first and failed the second. A validator that only asked the first would have called all fourteen healthy.
Writing its tests turned up two more bugs. parseCard accepted a JSON array, because valibot’s looseObject treats an array as an object and every field carries a default, so ["not","a","card"] parsed into a complete card of empty strings. And PNG_SIGNATURE was declared twice in the same file. The README was worse: it documented a script that does not exist, twenty .js paths for files that are all .ts, and node scripts/foo.js invocations that cannot run TypeScript.
First run found four more cards nobody knew about. Clean filenames, but the name field runs on into the description: "Caroline Elise Monroe\n Age 30", "Clara Monroe\nAge 25\nHair & Ey". Invisible from a directory listing, which is why the earlier sweep for newline filenames missed them. All four carry both a chara and a ccv3 chunk, so both had to be written or the fix lands in the copy ST does not read. Library now reports 1211 healthy, zero problems.
What was striking the second time
Claude Code’s permission classifier refused to let me read the cookie jar, and that refusal was correct for a reason I did not anticipate: cookies were not the credential. The path it blocked was also the path that would not have worked. I had proposed it confidently.
The disk cache held 3,105 bot-avatars images and not one of the seven I needed, plus no cached chat responses at all. A promising shortcut that measured out to nothing, which was worth two minutes to establish rather than assume either way.
The general shape, again: a check that counts bytes instead of reading them will call any well-formed failure a success. length > 0 on an image is not a check, it is a hope. The newline filenames are the same lesson in a different medium. ls renders a newline as a line break and nothing flags it, so for months the display was quietly lying about how many files were there, and ls | wc -l was counting 1,222 where there were 1,220.
Three distinct bugs in the cards, all reached by pulling on one blank tile, and then four more in the tools that were supposed to find them. Worth remembering that each one surfaced only because I went back and checked something I had already called finished: the seven I wrote off as unrecoverable, the checkers I ran expecting a clean bill, the parser I assumed was exercised.
The through-line is that every one of these was a check that asked an easier question than the one that mattered. length > 0 instead of “is this an image”. Same filename instead of “same card”. Same bytes instead of “same character”. Parses instead of “displays”. Each was true, and each was beside the point.
The tooling lesson is narrower and I want to keep it: a validator earns its keep on the first run or not at all. This one found four cards in a library I had just spent a day auditing by hand.
The validator kept its own list, and was wrong about it
Split the validator’s checks into errors and warnings, on the line between “will not load or will display something that is not the card” and “loads fine and its contents are wrong.” Added the checks that split implied, of which flat-image is the one worth keeping: a canvas that drew nothing is a flat fill, and a flat fill compresses to almost nothing at any size, so a 512x512 white PNG fails exactly the way the 1x1s did while sailing past any dimension check. Measured against the library, real artwork runs 0.36 compressed bytes per pixel and up with a median of 1.23; the threshold sits at 0.01. It fires zero times today. It is insurance against the bug this whole day was about, at a size the dimension check cannot see.
Rejected two candidate checks against measurements rather than taste. Filename drift flags 174 cards, almost all of it the sanitizer having changed over time, and acting on it would rewrite SillyTavern’s avatar keys and break existing chats. Chunk divergence is already handled by reconcile-card-chunks, and my probe’s 15 hits turned out to differ only in character_version and extensions, which it rightly ignores. My probe was wrong, not the script.
Then the dry run of the renamer, which was supposed to be routine, disagreed with the validator: 12 cards to fix against the 7 the validator reported. The five it missed were named This, rules, example_dialogs, npcs, and Narrator. I had written a fresh regex of the placeholders I happened to have seen while cleanCharacterName already carried a forty-entry list built from real observed failures. Two lists, and mine was the worse one. Deleted mine and asked the extractor.
The parser bug under it
Which raised the question I would not have asked: does the capture path still produce these? Replayed today’s extractor over all twelve cards’ own stored content. Nine come out right, so those are legacy captures from before the pipeline gained its closing cleanCharacterName pass. Three do not, and how they fail is the point: Narrator.png extracts as Dialogue, Scenario.png as Frat, Broken_Crown_Tavern.png as Broken.
Dialogue came from a description opening “Dialogue for any character you control must be formatted precisely as:” — the leading capitalized word of a formatting instruction became the character’s name. extractCharacterName returned it unchecked, while both of its salvage branches refuse a generic result. The asymmetry had been there the whole time. A wrong name that looks real is worse than Character: the validator cannot see it and the renamer will not touch it, so it is permanent. Guarded the extraction chain and put dialogue on the list. Replayed over 1211 cards, exactly one result changes.
The change I was about to make did not survive measurement. The renamer has a looksAuthoritative guard requiring an extracted name to appear in a persona tag or a Name: field before it is trusted, which would also catch Frat and Broken, and porting it into the capture path looked obviously correct. Measured, it rejects 409 of 1020 extractions including Adeline for Adeline.png. The capture strips persona tags into structured fields, so the signal it looks for is not in the stored body; it works in the renamer only because that runs on an already-broken population. An earlier pass of the same probe reported 682 suspect names, all of them JanitorAI scenario titles legitimately used as card names. Threw both away.
Applied the renames. Metadata only — every proposed filename was identical to the existing one, so no file moved and no avatar key changed. Warnings 14 to 5, images verified byte-identical, all 320 dual-chunk cards still in agreement.
Naming the last three, and asking the container which key matters
The three the renamer refused had to be named by hand, and the interesting question was not what to call them but whether to move their files. SillyTavern keys chats/<dir> off the avatar filename with .png stripped — src/endpoints/chats.js:554 and :610 in the running container — so renaming a PNG orphans its chat history. I tried to infer this from the library first by looking at cards whose filename and name disagree, and got 11 for filename against 9 for name, which is worthless: the name-side hits were coincidental collisions where a card named Claire matched a chats/Claire belonging to a different Claire. The container answered it in one grep. Reading the code beat inferring from data that could not distinguish the cases.
Narrator.png had a 270 KB chat from January, so it kept its filename and took Your Tragic New Body as its name. The other two had no chats. Broken_Crown_Tavern became Broken Crown Tavern, which sanitizes back to the same stem anyway; its [CORE CHARACTERS] block names nobody, only {{user}} and “Wife”, so the venue is the only proper noun it has. Scenario.png became Cece, Drake, Karen from its own <Cece, Drake, Karen's Persona> tag, matching what the renamer had just done to its sibling Tyra.png, and its file moved to match. Cleared the three stale thumbnails. Images verified byte-identical, chunks still in agreement, zero placeholder names left in 1211 cards.
Extract by alias, remove by primary
The validator’s new duplicated-section check found 64 cards carrying a section in both the description and the field it was hoisted into. extractCleanDescription removes personality, scenario and example dialogue precisely so SillyTavern is not sent both copies, and it was failing on a fourth form I had not considered: the patterns extract on aliases (traits, setting), and removal was passed only the primary keyword. Every one of the 64 was written Personality Traits: and searched for as personality:. The docstring said the alias forms were “rare enough that the extra passes cost more than the residue they’d remove,” which is a judgment that was cheap to make and expensive to leave: median residue 1110 characters, maximum 9272.
Fixed by naming the keyword lists and sharing them between extraction and removal, plus letting the header form eat one optional qualifier word so Personality Traits: does not leave Personality stranded. Then wrote repair-card-text.ts for the cards already written that way.
The repair is where the day’s lesson repeated itself one more time. I checked three cards by hand, the cuts looked clean, and I nearly applied it. Running the same logic across all 1211 first showed eleven descriptions that are the duplicated section, so the cut would have emptied them, and ten more that open a bracket inside the section and close it outside, so the cut would have stranded the closer. Twenty-one cards silently mangled, in a repair whose whole justification was that it only deletes text already present twice. Added a guard that refuses a cut leaving an empty description or a changed bracket balance, and reports the card instead. 44 repaired, 82,660 characters removed, 20 declined. Verified against a full copy of the library beforehand that no image, no other field and no bracket balance changed.
Severity is decided by who can fix it
Then added the checks I had rejected the round before: unbalanced braces and brackets, triple braces, and prose wrapped in braces like {{PHYSIOLOGICAL RESPONSES}}, which SillyTavern prints to the reader verbatim. 153 findings. I had been right that they should not sit alongside the actionable warnings and wrong that the answer was to leave them out; the answer was a third severity. They get their own section, their own count, and no effect on the exit code.
The framing that made it obvious: severity is not how bad a problem is, it is who can fix it. error is broken and ours. warning is wrong and ours. authored is wrong and someone else’s, which is worth seeing once and worth never blocking on. Attempting to enumerate SillyTavern’s valid macro names to sharpen this was the one thread I deliberately dropped — its registry is dynamic, and checking brace syntax answers the same question without the rabbit hole.
There are two copies, so there are two ways to delete one
The 24 warnings left after the repair were the cuts it had refused, and the way through was not better string surgery. The duplication is the same text in two fields, so removing either copy fixes it, and clearing a whole field needs no seam and cannot strand a bracket. The script now prefers cutting the description, because that keeps the split the extractor was going for, and clears the field whenever the cut is unsafe. Three ways it is unsafe, all of which turned up only by running the whole library: the description is the section, the section opens a bracket it closes outside itself, or the two copies differ in whitespace so there is no verbatim substring to cut at all. That last one had been failing silently, because the validator compares collapsed text and removeBlock used a raw indexOf.
The last two cards needed a different fix. toCharacterCardText is not an HTML stripper and did nothing to one of them while the repair reported success. stripHtmlSafely is the right tool and already existed, but it ends by collapsing every newline, which would have flattened a 9,590-character structured description to repair one stray <p>. Split htmlToText out of it: same tag removal and entity decoding, line structure left alone.
Adam then asked the question that mattered: can this go in the parsing layer so it cannot come back? It can, and that is the difference between a repair script and a fix. characterCardFromPrompt now runs the same rule before building the card, so a card leaves the parser unable to carry the same text in two fields rather than merely unlikely to. Parser, validator and repair share one function and one threshold; both scripts had been declaring their own copy of the 40-character floor, and a floor that differs between “is this duplicated” and “remove the duplicate” produces cards that are reported forever and never fixed.
0 errors and 0 warnings across 1211 cards.
What was striking the third time
Two probes wrong in one session, both caught only because I looked at what they printed instead of at the number they returned. 682 and 409 were both plausible; Adeline for Adeline.png in the output was not. A measurement I would have quoted as evidence in both cases.
The recurring shape all day was a check asking an easier question than the one that mattered. The validator’s placeholder list was one more instance, and it was mine, written hours after I had written that exact lesson down. Reaching for a fresh list is the easy question; asking the code that already knows is the real one. The tell is duplication: two definitions of the same thing means one of them is going to be wrong, and it will be the newer one.
Related
- 2026-08-04 The ccv3 Chunk SillyTavern Actually Reads
- 2026-07-09 OpenRouter Interceptor Capture Consolidation
src/background.ts,src/utils/card-png.ts,src/utils/png-character-utils.ts,src/character-card/avatar.ts