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:
@@ -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 {
|
||||
|
||||
@@ -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.01–0.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
92
tools/port/check-modding
Executable 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
|
||||
Reference in New Issue
Block a user