Files
MobileGL/scripts/bake_internal_shaders.py

185 lines
7.7 KiB
Python

#!/usr/bin/env python3
# MobileGL - scripts/bake_internal_shaders.py
# Copyright (c) 2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
"""Regenerate the tree's baked SPIR-V headers from their committed GLSL.
python3 scripts/bake_internal_shaders.py --build-dir build-split
python3 scripts/bake_internal_shaders.py --build-dir build-split --check
THIS SCRIPT DOES NOT COMPILE ANYTHING. It runs MG_Test's BakedInternalShadersTest with
MOBILEGL_BAKE_INTERNAL_SHADERS set, takes the words that binary emits, and splices them into
the headers. The compiler is therefore the IN-TREE glslang the gate itself uses - one compiler
and one answer.
The alternative, which is what the four headers in the tree were made with before this script
existed, is a glslangValidator somebody happened to have on their PATH. That is how the
iterationRP witness ended up carrying words its own source no longer produces: nothing named
the compiler, nothing re-ran it, and the header's prose was the only record that it was stale.
A gate whose baker is a different program from the gate has the same failure one level up - the
day the CLI's defaults and the library's diverge, the gate reds on a header nobody can
regenerate, and the fix is to edit the words by hand.
FORMATTING IS PRESERVED PER HEADER, not normalized: the headers in the tree disagree about
words per line and about the `u` suffix, and reformatting one of them would bury a one-word
change in a whole-file diff. The style is read back out of the array being replaced.
--check exits non-zero and names the headers that would change, without writing. It is the
same verdict the ctest entry gives; this mode exists so the regeneration path can be checked
from a shell without reading gtest output.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TEST_TARGET = "BakedInternalShadersTest"
BAKE_ENV = "MOBILEGL_BAKE_INTERNAL_SHADERS"
# ` 0x07230203u, 0x00010000u, ...` - the body of one baked array.
WORD_RE = re.compile(r"0x([0-9a-fA-F]{1,8})u?")
def find_test_binary(build_dir: Path) -> Path:
"""The test executable, wherever this generator put it."""
candidates = sorted(build_dir.rglob(TEST_TARGET))
candidates = [c for c in candidates if c.is_file() and os.access(c, os.X_OK)]
if not candidates:
raise SystemExit(
f"{TEST_TARGET} not found under {build_dir}. Build it first:\n"
f" cmake --build {build_dir} --target {TEST_TARGET}"
)
return candidates[0]
def emit_words(binary: Path, out_dir: Path) -> dict:
"""Run the gate in bake mode; return {symbol: (header, [words])}."""
env = dict(os.environ)
env[BAKE_ENV] = str(out_dir)
result = subprocess.run([str(binary)], env=env, capture_output=True, text=True)
if result.returncode != 0:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
raise SystemExit(f"{TEST_TARGET} failed in bake mode (rc={result.returncode})")
baked = {}
for path in sorted(out_dir.glob("*.words")):
lines = path.read_text().splitlines()
if not lines:
raise SystemExit(f"{path} is empty")
header = lines[0].strip()
words = [int(line, 16) for line in lines[1:] if line.strip()]
baked[path.stem] = (header, words)
if not baked:
raise SystemExit(f"{TEST_TARGET} emitted nothing into {out_dir}")
return baked
def locate_array(text: str, symbol: str):
"""(body_start, body_end, style) for `symbol`'s array body."""
opener = re.search(re.escape(symbol) + r"\s*\[\s*\]\s*=\s*\{", text)
if not opener:
raise SystemExit(f"cannot find the array `{symbol}` to replace")
body_start = opener.end()
closer = text.find("};", body_start)
if closer < 0:
raise SystemExit("the array `" + symbol + "` has no closing `};`")
body = text[body_start:closer]
rows = [row for row in body.split("\n") if WORD_RE.search(row)]
if not rows:
raise SystemExit(f"the array `{symbol}` holds no words to learn its style from")
style = {
"indent": re.match(r"[ \t]*", rows[0]).group(0),
# The LONGEST row, not the first: a one-row array and a ragged last row would both
# teach the wrong width.
"per_line": max(len(WORD_RE.findall(row)) for row in rows),
"suffix": "u" if re.search(r"0x[0-9a-fA-F]+u", body) else "",
# The whitespace the closing brace sits behind. It is part of the SPAN being
# replaced, so a body written without it silently outdents every array it
# touches - a diff on three headers that changed no word at all.
"close_indent": re.search(r"[ \t]*$", text[:closer]).group(0),
}
return body_start, closer, style
def format_body(words, style) -> str:
per_line = style["per_line"]
out = ["\n"]
for at in range(0, len(words), per_line):
chunk = words[at:at + per_line]
out.append(style["indent"]
+ ", ".join(f"0x{word:08x}{style['suffix']}" for word in chunk) + ",\n")
out.append(style["close_indent"])
return "".join(out)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--build-dir", default="build-split",
help="a configured build tree that has BakedInternalShadersTest")
parser.add_argument("--build", action="store_true",
help="build the test target first")
parser.add_argument("--check", action="store_true",
help="report headers that would change and exit non-zero; write nothing")
args = parser.parse_args()
build_dir = Path(args.build_dir)
if not build_dir.is_absolute():
build_dir = ROOT / build_dir
if args.build:
subprocess.run(["cmake", "--build", str(build_dir), "--target", TEST_TARGET],
check=True)
binary = find_test_binary(build_dir)
scratch = Path(tempfile.mkdtemp(prefix="mgl-bake-"))
try:
baked = emit_words(binary, scratch)
finally:
shutil.rmtree(scratch, ignore_errors=True)
# One read/write per HEADER, not per symbol: two arrays share
# WireColorBlitSpirv.h and three share PrimitivesGeneratedNoXfbProbeSpv.h, and
# rewriting the file once per array would splice into a stale copy.
by_header = {}
for symbol, (header, words) in baked.items():
by_header.setdefault(header, []).append((symbol, words))
changed = []
for header, entries in sorted(by_header.items()):
path = ROOT / header
if not path.exists():
raise SystemExit(f"{header} does not exist")
original = path.read_text()
text = original
for symbol, words in sorted(entries):
start, end, style = locate_array(text, symbol)
text = text[:start] + format_body(words, style) + text[end:]
if text == original:
print(f"bake: {header} is already fresh ({len(entries)} array(s))")
continue
changed.append(header)
if args.check:
print(f"bake: {header} WOULD CHANGE ({', '.join(s for s, _ in sorted(entries))})")
else:
path.write_text(text)
print(f"bake: {header} rewritten ({', '.join(s for s, _ in sorted(entries))})")
if args.check and changed:
print(f"bake: {len(changed)} header(s) are stale against their sources", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())