"""
Compiles the `.ifstf` transform-plugin library into a GLSL compute kernel.
This is a Python reimplementation of `IFSEngine/Model/TransformPlugin.cs`
(`FromString`) plus `RendererGL.InitComputeProgram`, so a Blender addon can build
the same kernel the .NET engine builds, from the same plugin files, with no C#
at runtime.
Byte-compatibility with the C# parser is the whole point: the plugin body is GLSL
whose `@Param` references are rewritten into indices into the iterator's parameter
block. If the rewrite differs by one index, the fractal renders differently rather
than failing, so `test_codegen.py` diffs every plugin against reference output
dumped from the real C# parser.
The behaviours below are deliberate copies of the C# and are easy to "tidy" into
bugs:
* Lines are split with `RemoveEmptyEntries` and THEN trimmed, so a whitespace-only
line survives the split and becomes an empty line. Indentation is discarded.
* Field lines are removed by STRING EQUALITY against the whole line set, so a
duplicated field line is removed everywhere it appears.
* Duplicate field keys keep the FIRST occurrence (`DistinctBy`).
* Parameter indices are the insertion order of the real/vec3 dictionaries — i.e.
order of first appearance in the file, counted separately per kind — while the
SUBSTITUTION runs longest-name-first, so `@A` cannot clobber `@AB`.
* A parameter whose default is neither a real nor a vec3 is silently dropped, and
its `@Name` is then left unsubstituted in the GLSL.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
# `^(\s*)@.+:.+$` — TransformPlugin.cs:30. Lines are pre-trimmed, so the leading
# group never matches anything, but it is kept so the pattern reads as the original.
_FIELD_RE = re.compile(r"^(\s*)@.+:.+$")
# TransformPlugin.cs:27 — these field names are metadata, not parameters.
_RESERVED = ("Name", "Version", "Description", "Tags", "Reference", "Use")
_DEFAULT_DESCRIPTION = "Description not provided by the plugin developer"
class PluginError(Exception):
"""A plugin file could not be parsed. Mirrors the C# SerializationException."""
@dataclass
class TransformPlugin:
name: str
version: str
description: str
tags: list[str]
include_uses: list[str]
reference_url: str | None
source_code: str
real_params: dict[str, float]
vec3_params: dict[str, tuple[float, float, float]]
file_path: str | None = None
def __str__(self) -> str:
return f"{self.name} ({self.version})"
def _split_lines(text: str) -> list[str]:
"""Split, drop empties, then trim — in that order, as the C# does."""
raw = re.split(r"\r\n|\r|\n", text)
return [line.strip() for line in raw if line != ""]
def _parse_real(text: str) -> float | None:
"""
Parse a real default the way `double.TryParse(.., NumberStyles.Any, Invariant)`
does, minus the currency and thousands forms that cannot occur here — the value
has already been split on ',' so no thousands separator can survive.
"""
try:
return float(text)
except ValueError:
return None
def parse_plugin(text: str, file_path: str | None = None) -> TransformPlugin:
"""Parses one `.ifstf` source string. Mirrors `TransformPlugin.FromString`."""
lines = _split_lines(text)
field_lines = [line for line in lines if _FIELD_RE.match(line)]
kvps: list[tuple[str, str]] = []
for line in field_lines:
key = line.split(":")[0].lstrip("@").strip()
value = line.split(":", 1)[1].strip()
kvps.append((key, value))
include_uses = [v for k, v in kvps if k == "Use"]
fields: dict[str, str] = {}
for key, value in kvps:
fields.setdefault(key, value) # DistinctBy: first occurrence wins
if "Name" not in fields or "Version" not in fields:
raise PluginError("The plugin must define the @Name and @Version fields.")
real_params: dict[str, float] = {}
vec3_params: dict[str, tuple[float, float, float]] = {}
for param_name, param_def in fields.items():
if param_name in _RESERVED:
continue
default = param_def.split(",")[0].strip()
real = _parse_real(default)
if real is not None:
real_params[param_name] = real
elif default.count(" ") == 2:
parts = [p for p in default.split(" ") if p != ""]
try:
vec3_params[param_name] = (float(parts[0]), float(parts[1]), float(parts[2]))
except (ValueError, IndexError) as ex:
raise PluginError(f"Bad vec3 default for @{param_name}: {default!r}") from ex
# else: silently dropped, exactly as the C# does
body_lines = [line for line in lines if line not in field_lines]
source = "\n".join(body_lines)
# Longest name first, so @A cannot clobber @AB. The INDEX is insertion order,
# which is not the same ordering — do not "simplify" these into one loop.
real_order = list(real_params.keys())
for name in sorted(real_order, key=len, reverse=True):
source = source.replace(
"@" + name,
f"(real_params[iter.real_params_index + {real_order.index(name)}])",
)
vec3_order = list(vec3_params.keys())
for name in sorted(vec3_order, key=len, reverse=True):
source = source.replace(
"@" + name,
f"(vec3_params[iter.vec3_params_index + {vec3_order.index(name)}].xyz)",
)
tags: list[str] = []
if "Tags" in fields:
tags = [t.strip().lower() for t in fields["Tags"].split(",")]
return TransformPlugin(
name=fields["Name"],
version=fields["Version"],
description=fields.get("Description", _DEFAULT_DESCRIPTION),
tags=tags,
include_uses=include_uses,
reference_url=fields.get("Reference"),
source_code=source,
real_params=real_params,
vec3_params=vec3_params,
file_path=file_path,
)
def parse_plugin_file(path: str) -> TransformPlugin:
with open(path, "r", encoding="utf-8-sig") as handle:
return parse_plugin(handle.read(), path)
@dataclass
class PluginLibrary:
"""The loaded, filtered plugin set and the includes they compile against."""
plugins: list[TransformPlugin] = field(default_factory=list)
include_sources: list[str] = field(default_factory=list)
skipped: list[tuple[str, str]] = field(default_factory=list) # (name, why)
@property
def tf_ids(self) -> dict[str, int]:
"""
Transform name -> `tfId`, the index the kernel branches on.
Documents reference transforms by NAME (`IFS.UpdatePlugins`), so this index
is internal to one compiled kernel. It must be used to fill the iterator
buffer for the SAME kernel it was generated with.
"""
return {p.name: i for i, p in enumerate(self.plugins)}
def load_library(transforms_dir: str, includes_dir: str | None = None) -> PluginLibrary:
"""
Loads every `.ifstf` under `transforms_dir`, dropping any whose `@Use` includes
are unavailable — the filter at `RenderService.cs:136`.
Files are sorted by path. The C# uses `Directory.GetFiles`, whose order is the
filesystem's and therefore unstable; since `tfId` is internal and documents key
on the transform NAME, imposing a stable order here is strictly better and makes
a generated kernel reproducible.
"""
include_sources: list[str] = []
include_names: list[str] = []
if includes_dir and os.path.isdir(includes_dir):
for name in sorted(os.listdir(includes_dir)):
path = os.path.join(includes_dir, name)
if os.path.isfile(path):
include_names.append(name)
with open(path, "r", encoding="utf-8-sig") as handle:
include_sources.append(handle.read())
paths: list[str] = []
for root, _dirs, files in os.walk(transforms_dir):
paths.extend(os.path.join(root, f) for f in files if f.endswith(".ifstf"))
paths.sort()
library = PluginLibrary(include_sources=include_sources)
for path in paths:
plugin = parse_plugin_file(path)
missing = [u for u in plugin.include_uses if u not in include_names]
if missing:
library.skipped.append((plugin.name, "missing include(s): " + ", ".join(missing)))
continue
library.plugins.append(plugin)
return library
def generate_transforms_block(plugins: list[TransformPlugin]) -> str:
"""
Builds the `if (iter.tfId == N) { ... }` chain.
Byte-identical to `RendererGL.InitComputeProgram`'s interpolated string,
including its leading and trailing newlines — a generated kernel should diff
cleanly against one the engine would have produced.
"""
out = ""
for tf_index, plugin in enumerate(plugins):
out += f"\nif (iter.tfId == {tf_index})\n{{\n{plugin.source_code}\n}}\n"
return out
def generate_kernel(template: str, library: PluginLibrary) -> str:
"""Fills the kernel template's `@includes` and `@transforms` placeholders."""
source = template.replace("@includes", "\n".join(library.include_sources))
return source.replace("@transforms", generate_transforms_block(library.plugins))
# `const vec3 x = vec3(intBitsToFloat(...))` is rejected by glslang as a non-constant
# global initializer; NVIDIA's GL compiler accepts it, which is why the engine ships
# with it. A #define is equivalent and portable.
_DISCARDED_POINT_CONST = "const vec3 discarded_point = vec3(intBitsToFloat(0x7fc00000));"
_DISCARDED_POINT_DEFINE = "#define discarded_point vec3(intBitsToFloat(0x7fc00000))"
def apply_portability_fixes(source: str) -> tuple[str, list[str]]:
"""
Rewrites the constructs a stricter compiler than NVIDIA's GL front-end rejects.
VERIFIED with `glslangValidator -G -S comp`: with this fix the full 46-plugin
kernel compiles to SPIR-V; without it, it fails on the global const.
Returns the source and a list of what was applied. What is deliberately NOT
fixed here: the six loose `uniform` declarations. glslang wants explicit
locations for them, and the Blender port has to move them into a `uniform_buf`
anyway (Blender warns they exceed the 128-byte portable push-constant limit).
That is a resource-layout decision, not a textual patch.
"""
applied: list[str] = []
if _DISCARDED_POINT_CONST in source:
source = source.replace(_DISCARDED_POINT_CONST, _DISCARDED_POINT_DEFINE)
applied.append("discarded_point: global const -> #define")
return source, applied
def _default_paths(repo_root: str) -> tuple[str, str, str]:
return (
os.path.join(repo_root, "Transforms"),
os.path.join(repo_root, "WpfDisplay", "Library", "Includes"),
os.path.join(repo_root, "IFSEngine", "Rendering", "Shaders", "ifs_kernel.comp.shader"),
)
def main(argv: list[str] | None = None) -> int:
import argparse
import json
repo_default = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
parser.add_argument("--repo", default=repo_default, help="IFSRenderer repo root")
parser.add_argument("--out", help="write the assembled kernel here")
parser.add_argument("--dump-json", help="write parsed plugin data here")
parser.add_argument("--portable", action="store_true",
help="apply the fixes a stricter compiler than NVIDIA's GL needs")
args = parser.parse_args(argv)
transforms_dir, includes_dir, template_path = _default_paths(args.repo)
library = load_library(transforms_dir, includes_dir)
for name, why in library.skipped:
print(f"skipped {name}: {why}")
print(f"{len(library.plugins)} plugins, "
f"{sum(len(p.real_params) for p in library.plugins)} real params, "
f"{sum(len(p.vec3_params) for p in library.plugins)} vec3 params")
if args.dump_json:
payload = [
{
"name": p.name,
"version": p.version,
"realParams": p.real_params,
"vec3Params": {k: list(v) for k, v in p.vec3_params.items()},
"sourceCode": p.source_code,
}
for p in library.plugins
]
with open(args.dump_json, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=1, sort_keys=False)
print(f"wrote {args.dump_json}")
if args.out:
with open(template_path, "r", encoding="utf-8-sig") as handle:
template = handle.read()
kernel = generate_kernel(template, library)
if args.portable:
kernel, applied = apply_portability_fixes(kernel)
for fix in applied:
print(f"fix: {fix}")
with open(args.out, "w", encoding="utf-8") as handle:
handle.write(kernel)
print(f"wrote {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())