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:
1 parent
80a2a9bd0b
commit
9ec72dbf48
2 files changed
+269
No files matched your search
@@ -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.
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user