Skill: blank the NULs in ANSI art without touching its SAUCE record

An unpainted cell exports as 0x00, which no terminal draws as nothing — it
comes out as tofu, as a stray glyph, or swallowed with the rest of the row
shifted left. One byte wide to fix, and this makes that one substitution and
no other: same length out, every escape sequence where it was.

The trap it exists to avoid is the obvious version of the job. An ANSI file's
last 128 bytes are a SAUCE record whose NULs are structure — binary fields and
the padding on the font name — so a tr over the whole file corrupts the
metadata and fixes no art at all. It cuts at the 0x1A that opens the record and
counts what it left alone. Both splashes are in exactly that state today: 24
NULs each, all of them SAUCE.

A space is only black if the span it lands in left the background black, so
the ones that would come out coloured are named by row and column rather than
quietly repainted — that is an editing call, not a substitution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-26 19:59:51 -04:00
1 parent 80a2a9bd0b
commit 9ec72dbf48
2 files changed
+269

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
---
name: fix-ans-nulls
description: Replace the NUL (0x00) bytes in an ANSI art .ans file with spaces, and nothing else. Use when a .ans file exported from Moebius (or any ANSI editor) carries NULs where cells were never painted, when the splash logo renders with tofu / stray glyphs / a row shifted left, or whenever asked to blank, clean, or "black out" the nulls in ANSI art.
model: haiku
---
# Replacing the NULs in an ANSI art file
A `0x00` in the **art body** of a `.ans` file is a cell the editor never painted. It is not
a character — terminals draw it as tofu, as a stray glyph, or swallow it and shift the rest
of the row left. The fix is one byte wide: write a space there.
This skill makes **only** that substitution. Every other byte, escape sequences included,
stays exactly where it was, so the file's length never changes.
## Run
```
$DEV_VENV_PYTHON .claude\skills\fix-ans-nulls\denul.py --dry-run --width 71 meshterm/assets/splash/logo.71.ans
$DEV_VENV_PYTHON .claude\skills\fix-ans-nulls\denul.py --width 71 meshterm/assets/splash/logo.71.ans
```
- Always `--dry-run` first and read the counts.
- `--width` is only used to report a NUL's row/column in the warnings; it never changes
which bytes are replaced. The repo's splashes are `--width 71` and `--width 53`
(the number in the filename).
- A real run writes `<name>.ans.bak` next to each file it changes. Delete the `.bak`
once the art is confirmed — it is not a repo artifact.
- Confirm visually afterwards: the splash is what this touches, and only a look at it
says whether the cell reads right.
## The SAUCE record is not art — never blank its NULs
An ANSI art file's last 128 bytes are usually a **SAUCE** metadata record: title, author,
group, date, binary TInfo/Flags fields, and a NUL-padded font name (`IBM VGA`). It is
introduced by the DOS end-of-file marker `0x1A`.
Those NULs are **structure**, not unpainted cells. Blanking them corrupts the record — the
font name stops matching, the binary fields become spaces. The script cuts at that `0x1A`
and never touches anything past it; NULs found there are counted in the report as "left
alone".
This matters here, not in theory: as of 2026-08-26 **every** NUL in both
`meshterm/assets/splash/logo.71.ans` and `logo.53.ans` (24 each) lives inside the SAUCE
record. A naive `tr '\0' ' '` over these files damages them and fixes nothing. So a clean
"no NULs in the art (24 in the SAUCE/metadata tail, left alone)" is the expected result on
the current splashes, not a sign the script missed something.
## About "black" spaces
A space renders in whatever background the art last set. That is black in the ordinary case
— the default background, and what an unpainted cell should look like.
Where a NUL sits inside a span that set a non-black background, the new space inherits
*that* colour and reads as a coloured block instead. The script warns, naming each such
cell by row and column, and stops there: repainting a cell or closing a colour span is an
editing decision, not a substitution, and the whole point of this skill is that it makes
one substitution only. Take those to the artist (or to Moebius).
## Do not
- Do not run `tr`, `sed`, or a `.replace(b"\x00", b" ")` over the whole file — that is the
SAUCE-corrupting version of this job.
- Do not "tidy" anything else while in there: no trimming trailing whitespace, no
normalising line endings, no collapsing escape sequences. The art is hand-drawn
(Moebius, by the author, no AI) and byte-exact.
+203
View File
@@ -0,0 +1,203 @@
"""Replace the NUL bytes in ANSI art files with spaces — and change nothing else.
A ``0x00`` in the *art body* of a ``.ans`` file is a cell the editor never painted. It is
not a character: terminals draw it as tofu, as a stray glyph, or swallow it and shift the
rest of the row left, so a logo that looked right in Moebius comes apart on a console. The
fix is one byte wide — write a space there — and this script makes exactly that
substitution and no other. Every other byte, including every escape sequence, stays where
it was, so the file's length never changes.
**The SAUCE record is not art.** An ANSI art file's last 128 bytes are usually a SAUCE
metadata record (title, author, group, date, and a NUL-padded font name), introduced by the
DOS end-of-file marker ``0x1A``. Its NULs are structure — padding and binary fields — and
blanking them corrupts the record. Nothing from that ``0x1A`` onward is touched; it is
metadata, and it never reaches the screen anyway. NULs found there are reported, not
replaced.
A space renders in whatever background the surrounding art last set. That is black in the
ordinary case — the default background, and what an unpainted cell should look like — but a
NUL inside a span that set a non-black background becomes a space in *that* colour, and
reads as a coloured block. Those cells are called out by row and column at the end of a
run: fixing one is an editing decision (repaint the cell, or close the colour span), which
is not a substitution this script will make on its own.
``--dry-run`` reports without writing; a real run copies each file it changes to
``<name>.bak`` first.
"""
from __future__ import annotations
import argparse
import re
import shutil
import sys
from pathlib import Path
#: The DOS end-of-file marker. In a ``.ans`` file it closes the art and introduces the
#: SAUCE record (and any COMNT block before it) — everything from here on is metadata.
EOF_MARKER = 0x1A
#: The SAUCE record's signature, at the head of its 128-byte block.
SAUCE_SIG = b"SAUCE00"
#: What a NUL becomes: a plain space. The one substitution this script makes.
NUL, SPACE = 0x00, 0x20
#: An ANSI escape sequence (CSI + params + a final letter). Only the ``m`` ones — Select
#: Graphic Rendition — carry the colour state the background check follows.
_ESC = re.compile(rb"\x1b\[[0-9;]*[A-Za-z]")
#: Default art width in cells, for reporting a NUL's row/column. ANSI art wraps at the
#: file's own width rather than at newlines, so a wrong guess only mislabels a coordinate
#: in a warning — it never changes which bytes are replaced.
DEFAULT_WIDTH = 80
def art_end(data: bytes) -> int:
"""The index where the art stops and the trailing metadata begins.
The SAUCE record is anchored on the ``0x1A`` that introduces it, not on the file's
length: a file may also carry a COMNT block, and a file may carry an ``0x1A`` with no
SAUCE at all (a plain DOS end-of-file). Either way, nothing from that marker on is
drawn, so nothing from that marker on is ours to edit.
Args:
data: The whole file.
Returns:
The length of the art body — the whole file when it has no trailing marker.
"""
sauce = data.rfind(SAUCE_SIG)
if sauce > 0:
# Walk back over the record (and any COMNT block) to the marker that opens it.
marker = data.rfind(bytes([EOF_MARKER]), 0, sauce)
return marker if marker >= 0 else sauce
marker = data.rfind(bytes([EOF_MARKER]))
return marker if marker >= 0 else len(data)
def _black_background(params: str) -> bool:
"""Whether an SGR parameter list leaves the background black (and un-reversed).
Black is the default background, ``40`` (black), and ``49`` (reset to default); ``0``
resets everything back to it. Reverse video (``7``) swings the foreground into the
background, so a space under it is not black either.
Args:
params: The parameters of one ``…m`` sequence, e.g. ``"0;34"``.
Returns:
``True`` if a space written under this state renders black.
"""
black = True
for part in (params or "0").split(";"):
code = int(part) if part.isdigit() else 0
if code in (0, 40, 49):
black = True
elif code == 7 or 41 <= code <= 47 or 100 <= code <= 107:
black = False
return black
def scan(data: bytes, width: int) -> tuple[list[tuple[int, int, int, bool]], int]:
"""Find every NUL in the art body, with where it lands and what colour it lands on.
Walks the body a cell at a time, stepping over escape sequences (which occupy no cell)
and tracking the background the SGR state leaves in force.
Args:
data: The whole file.
width: Cells per row, for the reported row/column.
Returns:
``(hits, metadata_nuls)`` — one ``(offset, row, col, on_black)`` per NUL in the
art, and how many NULs sit in the trailing metadata (never touched).
"""
end = art_end(data)
hits: list[tuple[int, int, int, bool]] = []
i, row, col, black = 0, 1, 1, True
while i < end:
esc = _ESC.match(data, i)
if esc:
if esc.group()[-1:] == b"m":
black = _black_background(esc.group()[2:-1].decode("ascii", "replace"))
i = esc.end()
continue
byte = data[i]
if byte == 0x0A: # newline: the next row starts here
row, col, i = row + 1, 1, i + 1
continue
if byte == 0x0D:
i += 1
continue
if byte == NUL:
hits.append((i, row, col, black))
col += 1
if col > width:
row, col = row + 1, 1
i += 1
return hits, data.count(NUL) - len(hits)
def fix(path: Path, width: int, dry_run: bool) -> int:
"""Replace the art body's NULs in one file; return how many went.
Args:
path: The ``.ans`` file.
width: Cells per row, for the reported coordinates.
dry_run: Report only — write nothing.
Returns:
The number of NULs replaced (or that would be).
"""
data = bytearray(path.read_bytes())
hits, in_metadata = scan(bytes(data), width)
tail = f" ({in_metadata} in the SAUCE/metadata tail, left alone)" if in_metadata else ""
if not hits:
print(f"{path}: no NULs in the art{tail}")
return 0
for offset, _row, _col, _black in hits:
data[offset] = SPACE
verb = "would replace" if dry_run else "replaced"
plural = "" if len(hits) == 1 else "s"
print(f"{path}: {verb} {len(hits)} NUL{plural}{tail}")
coloured = [(r, c) for _o, r, c, black in hits if not black]
if coloured:
where = ", ".join(f"row {r} col {c}" for r, c in coloured[:12])
more = f", +{len(coloured) - 12} more" if len(coloured) > 12 else ""
print(
f" ! {len(coloured)} of them sit on a non-black background, so the new space "
f"takes that colour instead of reading black: {where}{more}. Repainting those "
"cells is an editing call, not a substitution — left to you."
)
if not dry_run:
shutil.copyfile(path, path.with_suffix(path.suffix + ".bak"))
path.write_bytes(bytes(data))
return len(hits)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("paths", nargs="+", type=Path, help="the .ans files to fix")
parser.add_argument("--dry-run", action="store_true", help="report without writing")
parser.add_argument(
"--width",
type=int,
default=DEFAULT_WIDTH,
help=f"art width in cells, for reported coordinates (default {DEFAULT_WIDTH})",
)
args = parser.parse_args()
total = 0
for path in args.paths:
if not path.is_file():
print(f"{path}: not a file", file=sys.stderr)
continue
total += fix(path, args.width, args.dry_run)
print(f"\n{'would replace' if args.dry_run else 'replaced'} {total} NUL(s) in total")
if __name__ == "__main__":
main()