port: check the five MODDING rules, and label the generated files in the asset tree

MODDING.md calls modding a constraint on the exporter TODAY and nothing verified
it -- the same shape as the black hold, skipped[], stop_bed and --focus. All five
rules pass, so check-modding is a guard rather than a fix, and it is proved able
to fail: a stripped .cmd header, a bogus.bmp, and one orphaned PNG each exit 1.

It found one thing: the .cmd encode-cache sidecars sat in the modder-facing tree
with nothing saying what they were. They now carry a header. The header is
excluded from the cache key so rewording it does not re-encode four minutes of
video, and the sidecar is refreshed whenever its text differs rather than only on
re-encode -- otherwise a header change could never reach an existing export.

Also partly answers my own question to the Decoder: there is no general
capture-path floor, because the port matches live-title-press-a at 0.00093%
full-frame and 0.000% across the band. The 0.301% is specific to that pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
This commit is contained in:
Sylpheed port agent
2026-08-30 02:46:26 +00:00
parent e811bb99e2
commit 606eee8f23
3 changed files with 189 additions and 2 deletions

View File

@@ -157,10 +157,35 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
let argv = args(&src, &ogv, ch);
let command = format!("ffmpeg {}", argv.join(" "));
let size = std::fs::metadata(&src)?.len();
let want = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
// The sidecar SAYS WHAT IT IS. It sits in the modder-facing asset tree next
// to the `.ogv`, and MODDING rule 2's principle is that a generated file
// should be tellable from a hand-made one by reading it -- a bare ffmpeg
// line beside a video looks like something a modder should edit or delete.
//
// The header is NOT part of the cache key: `fresh` compares only the lines
// that describe the encode. Otherwise rewording this comment would re-encode
// four minutes of video to no purpose, which is a cache that punishes
// documentation.
let key = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
let want = format!(
"# Generated by sylpheed-export. NOT an asset and not hand-editable: this\n\
# records how {}.ogv beside it was encoded, so a re-export can skip the\n\
# encode when the source and the command are both unchanged. Deleting it\n\
# only forces one re-encode. To change the video, override the .ogv under\n\
# data/mods/ (MODDING rule 4) -- editing this file changes nothing.\n{key}",
m.stem
);
let cache_key = |s: &str| -> String {
s.lines()
.filter(|l| !l.starts_with('#'))
.collect::<Vec<_>>()
.join("\n")
};
let fresh = ogv.exists()
&& std::fs::read_to_string(&stamp).map(|s| s == want).unwrap_or(false);
&& std::fs::read_to_string(&stamp)
.map(|s| cache_key(&s) == cache_key(&want))
.unwrap_or(false);
if !fresh {
// Encode to a temp name and rename on success. A reader that catches
// this mid-write sees no file at all rather than a valid-looking one
@@ -181,6 +206,17 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
bail!("ffmpeg failed on {}", m.src);
}
std::fs::rename(&partial, &ogv)?;
}
// Refresh the sidecar whenever its TEXT differs, encode or no encode.
//
// It used to be written only inside the `!fresh` branch, which is right for
// the cache and wrong for the file: a change to the header alone -- the part
// deliberately excluded from the key -- would then never reach an existing
// export, because nothing that reads the header can trigger the write that
// updates it. The explanation would be correct in the source and absent on
// disc, which is the same shape as every other documented-but-unexercised
// thing this port has had to find the hard way.
if std::fs::read_to_string(&stamp).map(|s| s != want).unwrap_or(true) {
std::fs::write(&stamp, &want)?;
}
Ok(Some(Transcoded {

View File

@@ -6161,3 +6161,62 @@ say so, or the next person tunes toward it.
and all three were blocked by the harness, not by the capture: an overlay posed at
t=9 that drew nothing, a `--focus=` overwritten on every menu entry, and a banded
comparison that did not exist.
## `MODDING.md` had five rules and no check. Now it has one, and all five pass
`MODDING.md` opens by calling modding *a constraint on the exporter **today**,
not a later feature*. Nothing verified it. That is the shape this port keeps
finding — a rule stated, believed and unexercised: the black hold implemented and
never called, `ScreenView.skipped` written and never read, `stop_bed` provided and
never used, `--focus` parsed and overwritten on every menu entry.
`tools/port/check-modding` covers all five. Every one passes today, so it is a
**guard, not a fix**: its value is that the next thing to break one says so.
| rule | check | result |
|---|---|---|
| 1 — one asset, one file | every referenced sprite present, none orphaned, no split names | **174 / 174**, exact |
| 2 — recognisable names | no hex or hash-shaped filenames | none |
| 3 — modern formats | extensions confined to json/png/ogg/ogv (+ sidecars) | clean |
| 4 — base and overrides | `data/mods` gitignored *and* read by the exporter | both |
| 5 — provenance | every generated JSON carries a `source` | 17 / 17 |
### It is proved to fail
A check that has never failed has not been shown to work — the lesson from
`check-capture`, which once passed a file with 36 % holes punched through it. Three
controls, each failing correctly with a non-zero exit:
* a `.cmd` sidecar with its header stripped → rule 3;
* a `bogus.bmp` in the sprite tree → rule 3;
* one orphaned PNG → rule 1, *"174 referenced, 175 present"*.
### The one thing it found: an unlabelled generated file in the asset tree
The two `.cmd` encode-cache sidecars sat beside the `.ogv`s in the modder-facing
tree with no line saying what they were — a bare ffmpeg command next to a video
reads as something to edit or delete. They now carry a header stating that they
are generated, are not assets, and that the way to change a video is an override
under `data/mods/`.
Two details worth keeping:
* the header is **excluded from the cache key**, so rewording it does not
re-encode four minutes of video. A cache that punishes documentation gets
documented once and never again.
* the sidecar is now refreshed whenever its **text** differs, not only when a
re-encode happens. It used to be written inside the `!fresh` branch — which
meant a header change could never reach an existing export, because nothing
that reads the header triggers the write that updates it. The explanation would
have been correct in the source and absent on disc. Confirmed: two consecutive
exports, 20 s and 19 s, header present, no re-encode.
### And a question I asked the Decoder that I could partly answer myself
Last iteration I asked whether the 0.301 % between two of their captures implies a
**capture-path floor on every comparison in the corpus**. It does not, and I had
the evidence already: the port matches `live-title-press-a.png` at **0.00093 %**
full-frame and **0.000 %** across the band. A general floor could not coexist with
either number. So the 0.301 % is specific to the attract band capture, and my
0.010.2 % rows are not sitting on a hidden floor. ⚠️ What that does *not* settle
is why those two frames differ — still theirs, and still worth an answer.

92
tools/port/check-modding Executable file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Check the export against MODDING.md's five rules.
#
# tools/port/check-modding
#
# `MODDING.md` opens by saying modding is a requirement and **a constraint on the
# exporter today, not a later feature**. Nothing checked it. That is the shape
# this port keeps finding: a rule stated, believed, and unexercised -- the black
# hold implemented and never called, `ScreenView.skipped` written and never read,
# `stop_bed` provided and never used, `--focus` parsed and overwritten.
#
# So this is a guard, not a fix: every rule passes as of 2026-08-30. Its value is
# that the next thing to break one of them says so.
#
# ⚠️ What it CANNOT check: rule 3's "modern, editable" is enforced by extension,
# which cannot tell a valid PNG from a renamed one, and rule 1's "one logical
# asset" is checked as one-file-per-reference -- an exporter that split a sprite
# and referenced both halves would pass. These are the rules' checkable shadows.
set -euo pipefail
cd "${PROJECT_DIR:-/work}"
EXPORT="${EXPORT:-export}"
fail=0
note() { printf ' %-6s %s\n' "$1" "$2"; [ "$1" = FAIL ] && fail=1 || true; }
echo "MODDING rule 1 -- one logical asset, one file"
python3 - "$EXPORT" <<'PY'
import json, glob, os, sys
E = sys.argv[1]
ref = set()
for p in glob.glob(f"{E}/screens/*/*.json"):
d = json.load(open(p))
for e in d["elements"]:
for k in ("sprite", "focus_sprite"):
if e.get(k): ref.add(e[k])
for sub in ("focus", "leaf"):
for fe in (e.get(sub) or {}).get("elements", []):
if fe.get("sprite"): ref.add(fe["sprite"])
files = {os.path.relpath(p, E) for p in glob.glob(f"{E}/sprites/**/*.png", recursive=True)}
missing, orphan = sorted(ref - files), sorted(files - ref)
split = [f for f in files if any(t in os.path.basename(f) for t in ("part", "seg", "chunk"))]
print(" %-6s %d sprites referenced, %d present" % ("OK" if not (missing or orphan) else "FAIL", len(ref), len(files)))
if missing: print(" FAIL referenced but absent:", missing[:5])
if orphan: print(" FAIL present but unreferenced:", orphan[:5])
if split: print(" FAIL split-looking names:", split[:5])
sys.exit(1 if (missing or orphan or split) else 0)
PY
[ $? -eq 0 ] || fail=1
echo "MODDING rule 2 -- names a person recognises"
hex=$(find "$EXPORT" -type f | grep -Ec '0x[0-9a-f]{6,}|/[0-9a-f]{8}\.' || true)
[ "$hex" -eq 0 ] && note OK "no hex or hash-shaped filenames" || note FAIL "$hex hash-shaped names"
echo "MODDING rule 3 -- modern, editable formats only"
bad=$(find "$EXPORT" -type f | sed 's/.*\.//' | sort -u | grep -vE '^(json|png|ogg|ogv|cmd)$' || true)
[ -z "$bad" ] && note OK "only json/png/ogg/ogv (+ .cmd sidecars)" || note FAIL "unexpected: $(echo $bad)"
# A `.cmd` is not an asset. It is allowed only because it SAYS SO in its own
# first line -- see video.rs. An unlabelled one reads as something to edit.
for c in $(find "$EXPORT" -name '*.cmd'); do
head -1 "$c" | grep -q '^# Generated by sylpheed-export' \
&& note OK "$(basename "$c") is self-describing" \
|| note FAIL "$(basename "$c") has no header saying what it is"
done
echo "MODDING rule 4 -- base and overrides, never one merged pile"
grep -q 'data/mods' .gitignore && note OK "data/mods contents are gitignored" \
|| note FAIL "data/mods is not gitignored -- a mod is usually a game asset"
grep -rq 'data/mods' crates/sylpheed-export/src/ && note OK "the exporter resolves overrides" \
|| note FAIL "nothing reads data/mods"
echo "MODDING rule 5 -- provenance in every generated file"
python3 - "$EXPORT" <<'PY'
import json, glob, os, sys
E = sys.argv[1]; miss = []
for p in sorted(glob.glob(f"{E}/**/*.json", recursive=True)):
d = json.load(open(p))
if os.path.basename(p) == "manifest.json":
# The manifest is the provenance -- it carries disc, exporter and the
# formats revision for the whole tree, so it has no `source` of its own.
if not all(k in d for k in ("disc", "exporter", "formats_rev")): miss.append(p)
continue
src = d.get("source") or {}
if not (isinstance(src, dict) and src): miss.append(p)
print(" %-6s %d json files carry provenance" % ("OK" if not miss else "FAIL",
len(glob.glob(f"{E}/**/*.json", recursive=True)) - len(miss)))
for m in miss[:5]: print(" FAIL ", m)
sys.exit(1 if miss else 0)
PY
[ $? -eq 0 ] || fail=1
echo
[ $fail -eq 0 ] && echo "all five rules pass" || echo "🔴 a MODDING rule is broken"
exit $fail