Answer
aGt@!!?eeP4565tql
Seventeen characters, and (per the hint below) it is meant to be read as a regular expression, not decoded any further.
The chain
puzzle.html is a pandoc-generated page whose
entire body is:
<h1 id="puzzle">Puzzle</h1>
<p><img src="puzzle.gif" alt="Puzzle" /></p>No hint text, no comments, no hidden fields. Everything is in
puzzle.gif.
| Layer | Result |
|---|---|
puzzle.gif |
7-frame 519×519 animated QR, each frame a QR code |
| QR payloads | 7 chunks of base64: 2,500 chars ×6 + 2,232 = 17,232 chars |
| base64 → bytes | 12,924 bytes starting fd 37 7a 58 5a 00 →
XZ stream |
| XZ → file | 13,079-byte grayscale JPEG, 531×67 |
| JPEG → glyphs | 17 characters, constant 6 px gaps |
Reproducible end to end:
curl -sO https://eddiecampbell.uk/puzzle.gif
zbarimg -q puzzle.gif # 7 base64 payloads
cat payloads.txt | tr -d '\n' > joined.b64
base64 -d joined.b64 > joined.xz
xz -t joined.xz && xz -dk joined.xz # -> answer.bin
file answer.bin # JPEG, 531 x 67sha256(answer.bin) = 7042c4b5425832d31bc2ad7ff48ffd23a155ce1cb7070aaf9de10c935a1a7ee7
There is no further layer: the JPEG’s EOI marker
sits at offset 0x3315 with zero trailing bytes, and
no APPn/comment segments are present.
Segmenting the glyphs
Threshold at 128, take column runs, merge runs separated by ≤3 px. That yields exactly 17 runs, and the gap between consecutive glyph bounding boxes is 6 px for every single pair.
That constant gap is worth stating explicitly, because it is a trap: the glyphs were re-spaced after rendering, so advance widths carry no information and cannot be used to identify a character. Any method that leans on spacing is dead on arrival here.
Reading the glyphs
| # | char | x-range | evidence |
|---|---|---|---|
| 0 | a |
3–28 | bowl + straight vertical right stem ending at the baseline |
| 1 | G |
35–82 | C-shape + crossbar (y20–24) + bottom-right stem (y25–40), 0 holes |
| 2, 14 | t |
89–106, 463–480 | full-height stem + crossbar + curved foot; identical pair |
| 3 | @ |
113–152 | outer ring + inner a bowl with a right stem +
tail |
| 4, 5 | ! |
159–164, 171–176 | bar (y8–36) + separated dot (y41–50); identical pair |
| 6 | ? |
183–205 | hook + descending diagonal + dot |
| 7, 8 | e |
212–239, 246–273 | crossbar at y30–33, aperture bottom-right; identical pair |
| 9 | P |
280–302 | stem + closed upper bowl |
| 10 | 4 |
309–346 | open-top 4, diagonal overshooting the stem |
| 11, 13 | 5 |
353–384, 425–456 | flat top bar + left stem + lower bowl; identical pair |
| 12 | 6 |
391–418 | curved entry stroke sweeping into a closed bowl |
| 15 | q |
487–515 | bowl + right stem descending to y55 |
| 16 | l |
522–526 | plain 4 px stem, y1–43 |
The two that mattered
Glyph 0 — a, not
o. The giveaway is that the right side
does not curve. For a round letter the bottom-right stroke must
sweep back left to close the bowl. Compare the e
(glyph 7), whose right side is at col 29–30 at y26–28 and then
retreats to col 19 by y43. Glyph 0’s right edge instead sits at
cols 22–25 from y28 all the way to y42, then tapers to col 25 at
y44 — a straight stem that terminates on the baseline. A
stem-plus-bowl at x-height ending at the baseline is a
single-story a. The q (glyph 15) is
the same construction with a 12 px descender, which is what
rules out g, p and Q
here.
Glyph 16 — l, not I or
1. A bare 4 px vertical stem, so shape
alone cannot separate l from I
(identical in most sans faces). Height does the work: the flat
cap line is y2 (from P at y2–44 and t
at y2–44), while this stem starts at y1 — one
pixel above the cap, i.e. ascender height rather than cap
height. It also has no flag or base serif, which rules out
1.
What I got wrong first
- Glyphs 7/8 read as
cuntil I checked for a crossbar. They have one, at y30–33. They aree. - Glyph 0 read as
o(and OCR called ita) until I compared its right-hand contour against theein the same image. - The 48 px-wide glyph 1 and 23 px-wide glyph 9 looked metrically impossible for one font and sent me hunting for the typeface. The proportions are just unusual; the letterforms are unambiguous.
Cross-checks
Independent OCR, both wrong in instructive ways:
tesseract --psm 7 actell?eeP+565tal 17 chars, agrees on 11 positions
tesseract --psm 8 actell?eeP+5e65taql
gocr aGt@!!?eeP__G_tqI
Tesseract independently returns a first and
l last, and confirms @!!?eeP. Its
errors are all explicable as shape confusions: c→G,
e→@, l→!, +→4,
a→q.
The checks that actually settled it were internal to the image:
- Duplicate pairs. Four characters appear
twice (
t,!,e,5). If two glyph crops are pixel-identical, that is a free consistency check. - Hole topology via flood fill from the
border: 1 hole for
a,@,e×2,P,q; 0 for everything else. This is what confirmed@has exactly one counter (an innera) and killed the&reading. - Grayscale inspection of the suspicious gaps, rather than trusting the threshold. g0’s bottom-right “missing” stroke turned out to be real ink at grey values 53–57, i.e. antialiased, not a thresholding artifact.
- Intra-image comparison.
avsqandovseare decidable because their counterparts are sitting right next to them in the same typeface.
The hint
https://eddiecampbell.uk/cv.html, under Skills →
Misc:
fan of regex
That is the confirmation that the decoded string is the
answer in its own right. It also says stop:
base64, ROT13, Atbash and ROT47 all fail
immediately on the @ and the !!.
Retrospective
The extraction was trivial; the transcription was not.
| Phase | Wall clock |
|---|---|
| QR → base64 → XZ → JPEG | ~12 seconds |
| 17 glyphs → a correct string | ~2 h 15 m |
Almost all of that time went into two characters. The
genuinely wasted block was font identification:
template-matching ~2,150 installed faces by IoU produced a best
whole-string score of 0.31, and the one apparently strong hit
(Chilanka-Regular.otf, IoU 0.908 on glyph 1) was a
shape-only false positive — the per-glyph bboxes were
normalised, so a generic G scores well against any
round G. The font was never identified and never
needed to be. What worked was the boring stuff: measure the
glyphs, diff them against each other, count holes, and look at
the greyscale.
Files
| File | |
|---|---|
puzzle.gif |
the original animated QR (sha256
112e4033…) |
answer.jpg |
the decoded payload, byte-identical to the XZ output |
answer.png |
lossless grayscale, 531×67 |
answer@4x.png |
2124×268 upscale — legible at post width |
answer-annotated.png |
per-glyph boxes, indices and x-ranges |