mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
185 lines
7.7 KiB
Python
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())
|