diff --git a/slang/src/main/java/io/github/refux/slang/BitfieldPacking.java b/slang/src/main/java/io/github/refux/slang/BitfieldPacking.java new file mode 100644 index 0000000..2f7d5cb --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/BitfieldPacking.java @@ -0,0 +1,53 @@ +package io.github.refux.slang; + +import io.github.refux.slang.ffi.gen.BitfieldPackingRules; +import java.util.HashMap; +import java.util.Map; + +/** + * How bitfield members are packed into storage ({@code slang::BitfieldPackingRules}), set with + * {@link SessionBuilder#bitfieldPacking}. {@link #value()} / {@link #of(int)} are escape hatches to + * the raw ABI values; unmapped (newer) values return {@link #UNKNOWN}. + */ +public enum BitfieldPacking { + /** Sentinel for ABI values this enum does not know (carries no raw value). */ + UNKNOWN(Integer.MIN_VALUE), + /** Least-significant bit first; fields may share storage across underlying type sizes. */ + DEFAULT(BitfieldPackingRules.Default), + /** + * As MSVC packs on little-endian platforms: least-significant bit first, and a change of + * underlying type size starts new storage. Rejects zero-width fields. + */ + MSVC(BitfieldPackingRules.MSVC), + /** + * What Slang's older {@code -msvc-style-bitfield-packing} flag did: most-significant bit first, + * new storage on a type-size change. Not what MSVC does; kept for existing data layouts only. + */ + LEGACY_MSB_FIRST_MSVC(BitfieldPackingRules.LegacyMSBFirstMSVC); + + private static final Map BY_VALUE = new HashMap<>(); + + static { + for (BitfieldPacking rules : values()) { + if (rules != UNKNOWN) { + BY_VALUE.put(rules.value, rules); + } + } + } + + private final int value; + + BitfieldPacking(int value) { + this.value = value; + } + + /** The raw {@code slang::BitfieldPackingRules} ABI value ({@code Integer.MIN_VALUE} for UNKNOWN). */ + public int value() { + return value; + } + + /** Maps a raw ABI value; unmapped (newer) values return {@link #UNKNOWN}. */ + public static BitfieldPacking of(int value) { + return BY_VALUE.getOrDefault(value, UNKNOWN); + } +} diff --git a/slang/src/main/java/io/github/refux/slang/ComponentType.java b/slang/src/main/java/io/github/refux/slang/ComponentType.java index 859ea68..93d502b 100644 --- a/slang/src/main/java/io/github/refux/slang/ComponentType.java +++ b/slang/src/main/java/io/github/refux/slang/ComponentType.java @@ -44,6 +44,19 @@ public byte[] entryPointCode(long entryPointIndex, long targetIndex) { return componentHandle().getEntryPointCode(entryPointIndex, targetIndex); } + /** + * Target code for a whole linked component — all its entry points in one artifact, such as a + * single SPIR-V module, or a WGSL or Metal library — where {@link #entryPointCode} gives one + * entry point at a time. + * + * @param targetIndex index into the session's target list, in {@link SessionBuilder#target} + * order + */ + public byte[] targetCode(long targetIndex) { + session.checkThread(); + return componentHandle().getTargetCode(targetIndex); + } + /** The lazy reflection tree for {@code targetIndex} (parameters, entry points, layouts). */ public ShaderReflection layout(long targetIndex) { session.checkThread(); diff --git a/slang/src/main/java/io/github/refux/slang/DebugInfoLevel.java b/slang/src/main/java/io/github/refux/slang/DebugInfoLevel.java new file mode 100644 index 0000000..f859666 --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/DebugInfoLevel.java @@ -0,0 +1,46 @@ +package io.github.refux.slang; + +import io.github.refux.slang.ffi.gen.SlangDebugInfoLevel; +import java.util.HashMap; +import java.util.Map; + +/** + * How much debug information Slang emits ({@code SlangDebugInfoLevel}), set with + * {@link SessionBuilder#debugInfo}. {@link #value()} / {@link #of(int)} are escape hatches to the + * raw ABI values; unmapped (newer) values return {@link #UNKNOWN}. + */ +public enum DebugInfoLevel { + /** Sentinel for ABI values this enum does not know (carries no raw value). */ + UNKNOWN(Integer.MIN_VALUE), + /** No debug information — Slang's default. */ + NONE(SlangDebugInfoLevel.SLANG_DEBUG_INFO_LEVEL_NONE), + MINIMAL(SlangDebugInfoLevel.SLANG_DEBUG_INFO_LEVEL_MINIMAL), + STANDARD(SlangDebugInfoLevel.SLANG_DEBUG_INFO_LEVEL_STANDARD), + MAXIMAL(SlangDebugInfoLevel.SLANG_DEBUG_INFO_LEVEL_MAXIMAL); + + private static final Map BY_VALUE = new HashMap<>(); + + static { + for (DebugInfoLevel level : values()) { + if (level != UNKNOWN) { + BY_VALUE.put(level.value, level); + } + } + } + + private final int value; + + DebugInfoLevel(int value) { + this.value = value; + } + + /** The raw {@code SlangDebugInfoLevel} ABI value ({@code Integer.MIN_VALUE} for UNKNOWN). */ + public int value() { + return value; + } + + /** Maps a raw ABI value; unmapped (newer) values return {@link #UNKNOWN}. */ + public static DebugInfoLevel of(int value) { + return BY_VALUE.getOrDefault(value, UNKNOWN); + } +} diff --git a/slang/src/main/java/io/github/refux/slang/Enums.java b/slang/src/main/java/io/github/refux/slang/Enums.java new file mode 100644 index 0000000..994bb93 --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/Enums.java @@ -0,0 +1,15 @@ +package io.github.refux.slang; + +/** Shared handling for the ABI-mirroring enums, whose {@code UNKNOWN} sentinel has no raw value. */ +final class Enums { + private Enums() {} + + /** {@code constant}'s raw ABI value, refusing the {@code UNKNOWN} sentinel, which carries none. */ + static int raw(Enum constant, int value) { + if (value == Integer.MIN_VALUE) { + throw new IllegalArgumentException( + constant.getDeclaringClass().getSimpleName() + "." + constant.name() + " has no raw value"); + } + return value; + } +} diff --git a/slang/src/main/java/io/github/refux/slang/GlobalSession.java b/slang/src/main/java/io/github/refux/slang/GlobalSession.java index 702a7a9..f650c71 100644 --- a/slang/src/main/java/io/github/refux/slang/GlobalSession.java +++ b/slang/src/main/java/io/github/refux/slang/GlobalSession.java @@ -1,7 +1,10 @@ package io.github.refux.slang; import io.github.refux.slang.ffi.IGlobalSession; +import io.github.refux.slang.ffi.SlangNative; +import java.nio.file.Path; import java.util.Arrays; +import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -54,6 +57,41 @@ public String buildTagString() { return ffi().getBuildTagString(); } + /** + * Looks up a capability id by name (e.g. {@code "spirv_1_5"}, {@code "SPV_KHR_ray_query"}). + * {@link SessionBuilder#capability(String)} takes the name directly; this is for checking one + * up front. Returns 0 ({@code SLANG_CAPABILITY_UNKNOWN}) for unknown names. + */ + public int findCapability(String name) { + return ffi().findCapability(name); + } + + /** + * Whether this Slang build can produce {@code target}: false when the target needs a downstream + * compiler that cannot be found (DXC for {@link CompileTarget#DXIL}, say). + */ + public boolean isTargetSupported(CompileTarget target) { + return SlangNative.succeeded(ffi().checkCompileTargetSupport(target.value())); + } + + /** Whether the downstream compiler {@code passThrough} is available to this Slang build. */ + public boolean isPassThroughSupported(PassThrough passThrough) { + return SlangNative.succeeded(ffi().checkPassThroughSupport(Enums.raw(passThrough, passThrough.value()))); + } + + /** + * Where the library Slang would load for the downstream compiler {@code passThrough} lives, + * found the same way compilation finds it — e.g. the {@code slang-glslang} in the natives + * payload for {@link PassThrough#GLSLANG}, or the exact NVRTC that would compile CUDA. + * + * @return the path, or empty when the compiler cannot be found or is not a shared library + * (executable-based compilers such as Clang, GCC or Metal) + */ + public Optional downstreamCompilerPath(PassThrough passThrough) { + return Optional.ofNullable(ffi().getDownstreamCompilerPath(Enums.raw(passThrough, passThrough.value()))) + .map(Path::of); + } + /** * The serialized-module version this Slang build writes — and therefore one it certainly reads. * diff --git a/slang/src/main/java/io/github/refux/slang/Module.java b/slang/src/main/java/io/github/refux/slang/Module.java index 8d3272b..1db5e2d 100644 --- a/slang/src/main/java/io/github/refux/slang/Module.java +++ b/slang/src/main/java/io/github/refux/slang/Module.java @@ -1,6 +1,7 @@ package io.github.refux.slang; import io.github.refux.slang.ffi.IModule; +import java.util.List; /** * A compiled Slang module. Modules are owned by their {@link Session} — this wrapper is @@ -24,7 +25,56 @@ public final class Module extends ComponentType { */ public EntryPoint entryPoint(String name) { session().checkThread(); - return new EntryPoint(session(), ((IModule) componentHandle()).findEntryPointByName(name)); + return new EntryPoint(session(), module().findEntryPointByName(name)); + } + + /** + * Finds a function by name and checks it as an entry point for {@code stage} — for functions + * without a {@code [shader("...")]} attribute (one that has it is returned as is). + * + * @throws SlangCompileException with the compiler's diagnostics when there is no such function or + * it is not a valid {@code stage} entry point + */ + public EntryPoint entryPoint(String name, Stage stage) { + session().checkThread(); + return new EntryPoint(session(), module().findAndCheckEntryPoint(name, Enums.raw(stage, stage.value()))); + } + + /** + * The entry points Slang found when checking this module, in declaration order: functions marked + * {@code [shader("...")]}, and compute functions marked only {@code [numthreads]}. Others need + * {@link #entryPoint(String, Stage)}. + */ + public List entryPoints() { + session().checkThread(); + return module().getDefinedEntryPoints().stream() + .map(entryPoint -> new EntryPoint(session(), entryPoint)) + .toList(); + } + + /** The module's name — what an {@code import} refers to it by. */ + public String name() { + session().checkThread(); + return module().getName(); + } + + /** + * The path the module was loaded from: the source file {@link Session#loadModule} found, or for + * {@link Session#loadModuleFromSource} the synthetic {@code .slang}. + */ + public String filePath() { + session().checkThread(); + return module().getFilePath(); + } + + /** + * The files this module was compiled from — its own source, everything it {@code #include}s, and + * the sources of the modules it {@code import}s — which is what a build system or hot-reload + * watcher should watch to know when to recompile it. + */ + public List dependencyFiles() { + session().checkThread(); + return List.copyOf(module().getDependencyFilePaths()); } /** @@ -35,6 +85,10 @@ public EntryPoint entryPoint(String name) { */ public byte[] serialize() { session().checkThread(); - return ((IModule) componentHandle()).serialize(); + return module().serialize(); + } + + private IModule module() { + return (IModule) componentHandle(); } } diff --git a/slang/src/main/java/io/github/refux/slang/OptimizationLevel.java b/slang/src/main/java/io/github/refux/slang/OptimizationLevel.java new file mode 100644 index 0000000..ac3447d --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/OptimizationLevel.java @@ -0,0 +1,47 @@ +package io.github.refux.slang; + +import io.github.refux.slang.ffi.gen.SlangOptimizationLevel; +import java.util.HashMap; +import java.util.Map; + +/** + * How hard Slang optimizes generated code ({@code SlangOptimizationLevel}), set with + * {@link SessionBuilder#optimization}. {@link #value()} / {@link #of(int)} are escape hatches to the + * raw ABI values; unmapped (newer) values return {@link #UNKNOWN}. + */ +public enum OptimizationLevel { + /** Sentinel for ABI values this enum does not know (carries no raw value). */ + UNKNOWN(Integer.MIN_VALUE), + NONE(SlangOptimizationLevel.SLANG_OPTIMIZATION_LEVEL_NONE), + /** Slang's default: balances code quality against compile time. */ + DEFAULT(SlangOptimizationLevel.SLANG_OPTIMIZATION_LEVEL_DEFAULT), + HIGH(SlangOptimizationLevel.SLANG_OPTIMIZATION_LEVEL_HIGH), + /** Includes optimizations that may take a very long time or be unsafe. */ + MAXIMAL(SlangOptimizationLevel.SLANG_OPTIMIZATION_LEVEL_MAXIMAL); + + private static final Map BY_VALUE = new HashMap<>(); + + static { + for (OptimizationLevel level : values()) { + if (level != UNKNOWN) { + BY_VALUE.put(level.value, level); + } + } + } + + private final int value; + + OptimizationLevel(int value) { + this.value = value; + } + + /** The raw {@code SlangOptimizationLevel} ABI value ({@code Integer.MIN_VALUE} for UNKNOWN). */ + public int value() { + return value; + } + + /** Maps a raw ABI value; unmapped (newer) values return {@link #UNKNOWN}. */ + public static OptimizationLevel of(int value) { + return BY_VALUE.getOrDefault(value, UNKNOWN); + } +} diff --git a/slang/src/main/java/io/github/refux/slang/OptionList.java b/slang/src/main/java/io/github/refux/slang/OptionList.java new file mode 100644 index 0000000..fe1bc3f --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/OptionList.java @@ -0,0 +1,45 @@ +package io.github.refux.slang; + +import io.github.refux.slang.ffi.CompilerOptionEntry; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Compiler option entries collected by {@link SessionBuilder} and {@link TargetOptions}, kept in + * call order: Slang treats some options (capabilities, warning groups) as repeatable. + */ +final class OptionList { + private record Entry(int name, int int0, int int1, String string) {} + + private final List entries = new ArrayList<>(); + + void add(int name, int value0, int value1) { + entries.add(new Entry(name, value0, value1, null)); + } + + void add(int name, String value) { + entries.add(new Entry(name, 0, 0, Objects.requireNonNull(value, "value"))); + } + + int size() { + return entries.size(); + } + + /** Writes the entries as a native {@code CompilerOptionEntry[]} whose strings live in {@code arena}. */ + MemorySegment write(Arena arena) { + MemorySegment array = CompilerOptionEntry.allocateArray(arena, entries.size()); + for (int i = 0; i < entries.size(); i++) { + Entry entry = entries.get(i); + if (entry.string() == null) { + CompilerOptionEntry.setInt(array, i, entry.name(), entry.int0(), entry.int1()); + } else { + CompilerOptionEntry.setString( + array, i, entry.name(), arena.allocateFrom(entry.string()), MemorySegment.NULL); + } + } + return array; + } +} diff --git a/slang/src/main/java/io/github/refux/slang/PassThrough.java b/slang/src/main/java/io/github/refux/slang/PassThrough.java new file mode 100644 index 0000000..32da159 --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/PassThrough.java @@ -0,0 +1,58 @@ +package io.github.refux.slang; + +import io.github.refux.slang.ffi.gen.SlangPassThrough; +import java.util.HashMap; +import java.util.Map; + +/** + * The downstream compilers Slang can hand code to ({@code SlangPassThrough}), as queried by + * {@link GlobalSession#downstreamCompilerPath} and {@link GlobalSession#isPassThroughSupported}. + * {@link #value()} / {@link #of(int)} are escape hatches to the raw ABI values; unmapped (newer) + * values return {@link #UNKNOWN}. + */ +public enum PassThrough { + /** Sentinel for ABI values this enum does not know (carries no raw value). */ + UNKNOWN(Integer.MIN_VALUE), + NONE(SlangPassThrough.SLANG_PASS_THROUGH_NONE), + FXC(SlangPassThrough.SLANG_PASS_THROUGH_FXC), + DXC(SlangPassThrough.SLANG_PASS_THROUGH_DXC), + /** Backs GLSL-to-SPIR-V; shipped in the natives payload as {@code slang-glslang}. */ + GLSLANG(SlangPassThrough.SLANG_PASS_THROUGH_GLSLANG), + SPIRV_DIS(SlangPassThrough.SLANG_PASS_THROUGH_SPIRV_DIS), + CLANG(SlangPassThrough.SLANG_PASS_THROUGH_CLANG), + VISUAL_STUDIO(SlangPassThrough.SLANG_PASS_THROUGH_VISUAL_STUDIO), + GCC(SlangPassThrough.SLANG_PASS_THROUGH_GCC), + GENERIC_C_CPP(SlangPassThrough.SLANG_PASS_THROUGH_GENERIC_C_CPP), + NVRTC(SlangPassThrough.SLANG_PASS_THROUGH_NVRTC), + LLVM(SlangPassThrough.SLANG_PASS_THROUGH_LLVM), + SPIRV_OPT(SlangPassThrough.SLANG_PASS_THROUGH_SPIRV_OPT), + METAL(SlangPassThrough.SLANG_PASS_THROUGH_METAL), + TINT(SlangPassThrough.SLANG_PASS_THROUGH_TINT), + SPIRV_LINK(SlangPassThrough.SLANG_PASS_THROUGH_SPIRV_LINK); + + private static final Map BY_VALUE = new HashMap<>(); + + static { + for (PassThrough p : values()) { + if (p != UNKNOWN) { + BY_VALUE.put(p.value, p); + } + } + } + + private final int value; + + PassThrough(int value) { + this.value = value; + } + + /** The raw {@code SlangPassThrough} ABI value ({@code Integer.MIN_VALUE} for UNKNOWN). */ + public int value() { + return value; + } + + /** Maps a raw ABI value; unmapped (newer) values return {@link #UNKNOWN}. */ + public static PassThrough of(int value) { + return BY_VALUE.getOrDefault(value, UNKNOWN); + } +} diff --git a/slang/src/main/java/io/github/refux/slang/Session.java b/slang/src/main/java/io/github/refux/slang/Session.java index 86b5e72..6d1628b 100644 --- a/slang/src/main/java/io/github/refux/slang/Session.java +++ b/slang/src/main/java/io/github/refux/slang/Session.java @@ -43,6 +43,43 @@ public Module loadModuleFromSource(String name, String source) { return new Module(this, session.loadModuleFromSourceString(name, name + ".slang", source, onDiagnostics)); } + /** + * Loads a module by the name an {@code import} would use (e.g. {@code "lighting"}, or + * {@code "materials.pbr"} for {@code materials/pbr.slang}), finding its source through the + * session's {@link SessionBuilder#searchPath(java.nio.file.Path...) search paths} and + * {@link SessionBuilder#fileSystem(SlangFileSystem) file system}. A module already loaded under that name is + * returned again rather than recompiled. + * + * @throws SlangCompileException with the compiler's diagnostics when the module cannot be found + * or fails to compile; success-with-warnings text goes to the + * {@link SessionBuilder#onDiagnostics(java.util.function.Consumer)} consumer + */ + public Module loadModule(String name) { + checkThread(); + return new Module(this, session.loadModule(name, onDiagnostics)); + } + + /** + * Whether serialized IR is still current for this session: written by this Slang build with + * this session's compiler options, from source files whose contents have not changed since. A + * cache of {@link Module#serialize()} output can use it to decide between + * {@link #loadModuleFromIr} and recompiling. + * + *

Source files are read through this session, which keeps what it has already read: an edit + * made after the session read a file goes unseen, so check with a fresh session. And one case + * answers {@code true} without comparing the build or options: when the module's own source + * file cannot be found at all, Slang treats the IR as a standalone precompiled artifact. + * {@link #loadModuleFromIr} still refuses IR this build cannot read. + * + * @param modulePath where the serialized module lives (or would); the source files recorded in + * it are looked up relative to this and on the session's search paths + * @param ir the serialized module bytes + */ + public boolean isIrUpToDate(String modulePath, byte[] ir) { + checkThread(); + return session.isBinaryModuleUpToDate(modulePath, ir); + } + /** * Reads what {@code ir} declares about itself — its serialized-module version, the Slang build * that wrote it, and its name — without loading it. diff --git a/slang/src/main/java/io/github/refux/slang/SessionBuilder.java b/slang/src/main/java/io/github/refux/slang/SessionBuilder.java index 802504d..115dc31 100644 --- a/slang/src/main/java/io/github/refux/slang/SessionBuilder.java +++ b/slang/src/main/java/io/github/refux/slang/SessionBuilder.java @@ -4,6 +4,7 @@ import io.github.refux.slang.ffi.PreprocessorMacroDesc; import io.github.refux.slang.ffi.SessionDesc; import io.github.refux.slang.ffi.TargetDesc; +import io.github.refux.slang.ffi.gen.CompilerOptionName; import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; import java.nio.file.Path; @@ -16,15 +17,19 @@ /** * Configures and creates a compilation {@link Session}: code-generation targets, search paths - * for {@code import}/{@code #include}, preprocessor defines, and an optional diagnostics - * consumer for warnings. Slang copies all of it during {@code createSession}, so nothing built - * here outlives the {@link #create()} call. + * for {@code import}/{@code #include}, preprocessor defines, compiler options, and an optional + * diagnostics consumer for warnings. Slang copies all of it during {@code createSession}, so + * nothing built here outlives the {@link #create()} call. + * + *

Compiler options set here apply to every target; a target's own + * {@link TargetOptions#option(int, int) options} override them. */ public final class SessionBuilder { private final GlobalSession global; private final List targets = new ArrayList<>(); private final List searchPaths = new ArrayList<>(); private final Map defines = new LinkedHashMap<>(); + private final OptionList compilerOptions = new OptionList(); private Consumer onDiagnostics; private SlangFileSystem fileSystem; private MatrixLayout matrixLayout; @@ -92,6 +97,91 @@ public SessionBuilder fileSystem(SlangFileSystem fileSystem) { return this; } + /** How hard to optimize generated code; Slang's default is {@link OptimizationLevel#DEFAULT}. */ + public SessionBuilder optimization(OptimizationLevel level) { + return option(CompilerOptionName.Optimization, Enums.raw(level, level.value())); + } + + /** How much debug information to emit; Slang's default is {@link DebugInfoLevel#NONE}. */ + public SessionBuilder debugInfo(DebugInfoLevel level) { + return option(CompilerOptionName.DebugInformation, Enums.raw(level, level.value())); + } + + /** + * Turns warnings into errors, so code that warns fails to load with + * {@link SlangCompileException}: every warning when called with no arguments, otherwise the + * given ones, by code or name (e.g. {@code "30081"}). + */ + public SessionBuilder warningsAsErrors(String... warnings) { + return option(CompilerOptionName.WarningsAsErrors, warnings.length == 0 ? "all" : String.join(",", warnings)); + } + + /** Silences warnings, by code or name (e.g. {@code "30081"}). */ + public SessionBuilder disableWarnings(String... warnings) { + if (warnings.length == 0) { + throw new IllegalArgumentException("name at least one warning to disable"); + } + return option(CompilerOptionName.DisableWarnings, String.join(",", warnings)); + } + + /** + * The Slang language version to compile as, e.g. {@code 2026} — a {@code SlangLanguageVersion} + * value. The global session's default is 2025. + */ + public SessionBuilder languageVersion(int version) { + return option(CompilerOptionName.LanguageVersion, version); + } + + /** + * Requires a capability by name (e.g. {@code "spirv_1_5"}, {@code "SPV_KHR_ray_query"}); repeat + * for several. + * + * @throws IllegalArgumentException if this Slang build does not know the name + */ + public SessionBuilder capability(String name) { + int capability = global.findCapability(name); + if (capability == 0) { + throw new IllegalArgumentException("unknown capability: " + name); + } + return option(CompilerOptionName.Capability, capability); + } + + /** How bitfield members are packed; Slang's default is {@link BitfieldPacking#DEFAULT}. */ + public SessionBuilder bitfieldPacking(BitfieldPacking rules) { + return option(CompilerOptionName.BitfieldPackingRules, Enums.raw(rules, rules.value())); + } + + /** + * Sets a compiler option by its raw {@code slang::CompilerOptionName} value, as found in + * {@code io.github.refux.slang.ffi.gen.CompilerOptionName} — the escape hatch for options without a + * method of their own. The value must be the kind the option expects (its comment in + * {@code slang.h} says): this overload for int and enum options. + * + *

Options that {@code TargetDesc} also carries as fields — the floating-point mode, line + * directive mode and GLSL scalar layout — are reapplied from each target after the session's + * options, so set those per target with {@link TargetOptions#option(int, int)} instead. + */ + public SessionBuilder option(int name, int value) { + return option(name, value, 0); + } + + /** As {@link #option(int, int)}, for bool options. */ + public SessionBuilder option(int name, boolean value) { + return option(name, value ? 1 : 0); + } + + /** As {@link #option(int, int)}, for the few options that take two ints. */ + public SessionBuilder option(int name, int value0, int value1) { + compilerOptions.add(name, value0, value1); + return this; + } + + /** As {@link #option(int, int)}, for string options. */ + public SessionBuilder option(int name, String value) { + compilerOptions.add(name, value); + return this; + } + /** Creates the session. At least one {@link #target} is required. */ public Session create() { if (targets.isEmpty()) { @@ -116,6 +206,10 @@ public Session create() { if (options.forceGlslScalarBufferLayout != null) { TargetDesc.setForceGlslScalarBufferLayout(desc, options.forceGlslScalarBufferLayout); } + if (options.compilerOptions.size() > 0) { + TargetDesc.setCompilerOptionEntries( + desc, options.compilerOptions.write(arena), options.compilerOptions.size()); + } } MemorySegment sessionDesc = SessionDesc.allocate(arena); @@ -136,6 +230,9 @@ public Session create() { } SessionDesc.setPreprocessorMacros(sessionDesc, macros, defines.size()); } + if (compilerOptions.size() > 0) { + SessionDesc.setCompilerOptionEntries(sessionDesc, compilerOptions.write(arena), compilerOptions.size()); + } io.github.refux.slang.ffi.JavaFileSystem nativeFileSystem = null; if (fileSystem != null) { diff --git a/slang/src/main/java/io/github/refux/slang/TargetOptions.java b/slang/src/main/java/io/github/refux/slang/TargetOptions.java index ebf7f36..92cb18f 100644 --- a/slang/src/main/java/io/github/refux/slang/TargetOptions.java +++ b/slang/src/main/java/io/github/refux/slang/TargetOptions.java @@ -3,10 +3,12 @@ /** * Per-target configuration collected by {@link SessionBuilder#target}. Unset options keep the * C++ header defaults ({@code TargetDesc}'s initializers, including - * {@code flags = kDefaultTargetFlags}). + * {@code flags = kDefaultTargetFlags}), and compiler options set here override the session's for + * this target only. */ public final class TargetOptions { private final CompileTarget target; + final OptionList compilerOptions = new OptionList(); String profileName; Integer flags; Boolean forceGlslScalarBufferLayout; @@ -36,4 +38,29 @@ public TargetOptions forceGlslScalarBufferLayout(boolean force) { this.forceGlslScalarBufferLayout = force; return this; } + + /** + * Sets a compiler option for this target only, by its raw {@code slang::CompilerOptionName} + * value — see {@link SessionBuilder#option(int, int)}. This overload is for int and enum options. + */ + public TargetOptions option(int name, int value) { + return option(name, value, 0); + } + + /** As {@link #option(int, int)}, for bool options. */ + public TargetOptions option(int name, boolean value) { + return option(name, value ? 1 : 0); + } + + /** As {@link #option(int, int)}, for the few options that take two ints. */ + public TargetOptions option(int name, int value0, int value1) { + compilerOptions.add(name, value0, value1); + return this; + } + + /** As {@link #option(int, int)}, for string options. */ + public TargetOptions option(int name, String value) { + compilerOptions.add(name, value); + return this; + } } diff --git a/slang/src/main/java/io/github/refux/slang/ffi/CompilerOptionEntry.java b/slang/src/main/java/io/github/refux/slang/ffi/CompilerOptionEntry.java new file mode 100644 index 0000000..1e03417 --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/ffi/CompilerOptionEntry.java @@ -0,0 +1,43 @@ +package io.github.refux.slang.ffi; + +import io.github.refux.slang.ffi.gen.CompilerOptionValue; +import io.github.refux.slang.ffi.gen.CompilerOptionValueKind; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; + +/** + * Convenience over the generated {@code ffi.gen.CompilerOptionEntry} layout (offsets + * clang-verified, size 40): a {@code CompilerOptionName} plus a {@code CompilerOptionValue} holding + * either two ints or two strings. {@link SessionDesc} and {@link TargetDesc} each point at an array + * of these, which Slang copies during {@code createSession} like the rest of the descriptor. + */ +public final class CompilerOptionEntry { + private CompilerOptionEntry() {} + + /** Allocates a zeroed {@code CompilerOptionEntry[count]}. */ + public static MemorySegment allocateArray(Arena arena, int count) { + return io.github.refux.slang.ffi.gen.CompilerOptionEntry.allocateArray(arena, count); + } + + /** Sets element {@code index} to an int-valued option; bool options take 0 or 1. */ + public static void setInt(MemorySegment array, int index, int name, int value0, int value1) { + MemorySegment value = start(array, index, name, CompilerOptionValueKind.Int); + CompilerOptionValue.setIntValue0(value, value0); + CompilerOptionValue.setIntValue1(value, value1); + } + + /** Sets element {@code index} to a string-valued option; {@code value1} may be {@code NULL}. */ + public static void setString(MemorySegment array, int index, int name, MemorySegment value0, MemorySegment value1) { + MemorySegment value = start(array, index, name, CompilerOptionValueKind.String); + CompilerOptionValue.setStringValue0(value, value0); + CompilerOptionValue.setStringValue1(value, value1); + } + + private static MemorySegment start(MemorySegment array, int index, int name, int kind) { + MemorySegment entry = io.github.refux.slang.ffi.gen.CompilerOptionEntry.element(array, index); + io.github.refux.slang.ffi.gen.CompilerOptionEntry.setName(entry, name); + MemorySegment value = io.github.refux.slang.ffi.gen.CompilerOptionEntry.value(entry); + CompilerOptionValue.setKind(value, kind); + return value; + } +} diff --git a/slang/src/main/java/io/github/refux/slang/ffi/IComponentType.java b/slang/src/main/java/io/github/refux/slang/ffi/IComponentType.java index 66a4d32..a622083 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/IComponentType.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/IComponentType.java @@ -55,6 +55,25 @@ public MemorySegment getLayout(long targetIndex) { } } + /** + * Compiles (or fetches) target code for a whole linked component — every entry point it + * contains, in one artifact (e.g. one SPIR-V module, or one WGSL or Metal source file). + * + * @param targetIndex index into the session's {@code SessionDesc.targets} array + */ + public byte[] getTargetCode(long targetIndex) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outCode = arena.allocate(ADDRESS); + MemorySegment outDiag = arena.allocate(ADDRESS); + int result = io.github.refux.slang.ffi.gen.IComponentType.getTargetCode( + segment(), targetIndex, outCode, outDiag); + Diagnostics.check("IComponentType::getTargetCode", result, outDiag); + try (ISlangBlob code = new ISlangBlob(outCode.get(ADDRESS, 0))) { + return code.toByteArray(); + } + } + } + /** * Compiles (or fetches) target code for one entry point of a fully linked component. * diff --git a/slang/src/main/java/io/github/refux/slang/ffi/IGlobalSession.java b/slang/src/main/java/io/github/refux/slang/ffi/IGlobalSession.java index 7078219..90d18c8 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/IGlobalSession.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/IGlobalSession.java @@ -114,4 +114,48 @@ public int findProfile(String name) { public String getBuildTagString() { return SlangNative.readUtf8(io.github.refux.slang.ffi.gen.IGlobalSession.getBuildTagString(segment())); } + + /** {@code SLANG_OK} when this build can produce {@code compileTarget} (a {@code SlangCompileTarget}). */ + public int checkCompileTargetSupport(int compileTarget) { + return io.github.refux.slang.ffi.gen.IGlobalSession.checkCompileTargetSupport(segment(), compileTarget); + } + + /** {@code SLANG_OK} when the downstream compiler {@code passThrough} (a {@code SlangPassThrough}) is available. */ + public int checkPassThroughSupport(int passThrough) { + return io.github.refux.slang.ffi.gen.IGlobalSession.checkPassThroughSupport(segment(), passThrough); + } + + /** + * Looks up a capability id by name (e.g. {@code "spirv_1_5"}), as the {@code Capability} compiler + * option takes it. Returns {@code SLANG_CAPABILITY_UNKNOWN} (0) for unknown names. + */ + public int findCapability(String name) { + try (Arena arena = Arena.ofConfined()) { + return io.github.refux.slang.ffi.gen.IGlobalSession.findCapability(segment(), arena.allocateFrom(name)); + } + } + + /** + * The on-disk path of the library Slang would load for the downstream compiler + * {@code passThrough}, or null when there is none: {@code SLANG_E_NOT_FOUND} (not located) and + * {@code SLANG_E_NOT_AVAILABLE} (loaded, but not from a shared library — executable-based + * compilers such as Clang, GCC or Metal) both mean no path. Added in Slang 2026.17, in the vtable + * slot that held {@code getDownstreamCompilerVersion} before it. + */ + public String getDownstreamCompilerPath(int passThrough) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outPath = arena.allocate(ADDRESS); + int result = io.github.refux.slang.ffi.gen.IGlobalSession.getDownstreamCompilerPath( + segment(), passThrough, outPath); + if (result == SlangNative.SLANG_E_NOT_FOUND || result == SlangNative.SLANG_E_NOT_AVAILABLE) { + return null; + } + if (!SlangNative.succeeded(result)) { + throw new SlangException("IGlobalSession::getDownstreamCompilerPath failed", result); + } + try (ISlangBlob path = new ISlangBlob(outPath.get(ADDRESS, 0))) { + return path.toUtf8String(); + } + } + } } diff --git a/slang/src/main/java/io/github/refux/slang/ffi/IModule.java b/slang/src/main/java/io/github/refux/slang/ffi/IModule.java index 127e823..ad58320 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/IModule.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/IModule.java @@ -5,6 +5,8 @@ import io.github.refux.slang.SlangException; import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; +import java.util.ArrayList; +import java.util.List; /** * Wrapper for {@code slang::IModule}. Raw vtable dispatch lives in the generated @@ -35,6 +37,70 @@ public IEntryPoint findEntryPointByName(String name) { } } + /** + * Finds a function by name and checks it as an entry point for {@code stage} (a + * {@code SlangStage}) — for functions without a {@code [shader("...")]} attribute; one that has + * it is returned as is. Caller-owned, like {@link #findEntryPointByName}. + * + * @throws io.github.refux.slang.SlangCompileException with the compiler's diagnostics when there + * is no such function or it is not a valid entry point for the stage + */ + public IEntryPoint findAndCheckEntryPoint(String name, int stage) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(ADDRESS); + MemorySegment outDiag = arena.allocate(ADDRESS); + int result = io.github.refux.slang.ffi.gen.IModule.findAndCheckEntryPoint( + segment(), arena.allocateFrom(name), stage, out, outDiag); + Diagnostics.check("IModule::findAndCheckEntryPoint", result, outDiag); + return new IEntryPoint(out.get(ADDRESS, 0)); + } + } + + /** + * The entry points Slang found when checking this module — functions marked + * {@code [shader("...")]}, and compute functions marked only {@code [numthreads]} — as + * caller-owned wrappers. + */ + public List getDefinedEntryPoints() { + int count = io.github.refux.slang.ffi.gen.IModule.getDefinedEntryPointCount(segment()); + List entryPoints = new ArrayList<>(count); + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(ADDRESS); + for (int i = 0; i < count; i++) { + int result = io.github.refux.slang.ffi.gen.IModule.getDefinedEntryPoint(segment(), i, out); + if (!SlangNative.succeeded(result)) { + entryPoints.forEach(IUnknown::close); + throw new SlangException("IModule::getDefinedEntryPoint failed", result); + } + entryPoints.add(new IEntryPoint(out.get(ADDRESS, 0))); + } + } + return entryPoints; + } + + /** The module's name — what an {@code import} refers to it by. */ + public String getName() { + return SlangNative.readUtf8(io.github.refux.slang.ffi.gen.IModule.getName(segment())); + } + + /** The path the module was loaded from: a found source file, or the synthetic path it was given. */ + public String getFilePath() { + return SlangNative.readUtf8(io.github.refux.slang.ffi.gen.IModule.getFilePath(segment())); + } + + /** + * Every file the module's compilation depended on: its own source, anything it + * {@code #include}s, and the source files of the modules it {@code import}s. + */ + public List getDependencyFilePaths() { + int count = io.github.refux.slang.ffi.gen.IModule.getDependencyFileCount(segment()); + List paths = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + paths.add(SlangNative.readUtf8(io.github.refux.slang.ffi.gen.IModule.getDependencyFilePath(segment(), i))); + } + return paths; + } + /** * Serializes this module's checked IR to bytes that {@code ISession.loadModuleFromIrBlob} can * reload without re-parsing. The bytes are only readable by a compatible Slang build — key any diff --git a/slang/src/main/java/io/github/refux/slang/ffi/ISession.java b/slang/src/main/java/io/github/refux/slang/ffi/ISession.java index ae41bb3..ea0ff89 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/ISession.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/ISession.java @@ -62,6 +62,53 @@ public IModule loadModuleFromSourceString( } } + /** + * Loads a module by the name an {@code import} would use, finding its source through the + * session's search paths and file system. A module already loaded under that name is returned + * again rather than recompiled. Borrowed, like {@link #loadModuleFromSourceString}'s result. + * + * @throws SlangCompileException with the compiler's diagnostics when the module cannot be found + * or fails to compile + */ + public IModule loadModule(String moduleName, Consumer onDiagnostics) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outDiag = arena.allocate(ADDRESS); + MemorySegment module = io.github.refux.slang.ffi.gen.ISession.loadModule( + segment(), arena.allocateFrom(moduleName), outDiag); + String diagnostics = Diagnostics.consume(outDiag); + if (module.address() == 0) { + throw new SlangCompileException( + diagnostics != null ? diagnostics : "module load failed: " + moduleName, + SlangNative.SLANG_FAIL); + } + if (diagnostics != null && onDiagnostics != null) { + onDiagnostics.accept(diagnostics); + } + return new IModule(module); + } + } + + /** + * Wraps {@code isBinaryModuleUpToDate}: whether serialized IR still matches this Slang build, this + * session's compiler options and the current contents of the source files it was compiled from, + * which are looked up relative to {@code modulePath} and on the session's search paths. + */ + public boolean isBinaryModuleUpToDate(String modulePath, byte[] ir) { + if (ir.length == 0) { + return false; // not a module at all; slang_createBlob has nothing to wrap + } + try (Arena arena = Arena.ofConfined()) { + MemorySegment irData = arena.allocate(ir.length); + MemorySegment.copy(ir, 0, irData, ValueLayout.JAVA_BYTE, 0, ir.length); + // Slang only reads the blob during the call, so the creation reference is ours to drop. + try (ISlangBlob blob = + new ISlangBlob(io.github.refux.slang.ffi.gen.SlangAPI.slang_createBlob(irData, ir.length))) { + return io.github.refux.slang.ffi.gen.ISession.isBinaryModuleUpToDate( + segment(), arena.allocateFrom(modulePath), blob.segment()); + } + } + } + /** * Combines modules and entry points into one unit of shader code; the result (caller-owned) * is what gets {@link IComponentType#link() linked} and compiled. The order of components diff --git a/slang/src/main/java/io/github/refux/slang/ffi/SessionDesc.java b/slang/src/main/java/io/github/refux/slang/ffi/SessionDesc.java index 9de47a2..edfa586 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/SessionDesc.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/SessionDesc.java @@ -53,6 +53,15 @@ public static void setDefaultMatrixLayoutMode(MemorySegment desc, int mode) { io.github.refux.slang.ffi.gen.SessionDesc.setDefaultMatrixLayoutMode(desc, mode); } + /** + * Points {@code compilerOptionEntries} at a {@link CompilerOptionEntry} array of {@code count}. + * Every target inherits these, and its own entries override them. + */ + public static void setCompilerOptionEntries(MemorySegment desc, MemorySegment entryArray, int count) { + io.github.refux.slang.ffi.gen.SessionDesc.setCompilerOptionEntries(desc, entryArray); + io.github.refux.slang.ffi.gen.SessionDesc.setCompilerOptionEntryCount(desc, count); + } + /** * Points {@code fileSystem} at an {@code ISlangFileSystem} COM pointer (e.g. a * {@link JavaFileSystem}'s {@code segment()}); the session add-refs it during creation. diff --git a/slang/src/main/java/io/github/refux/slang/ffi/SlangNative.java b/slang/src/main/java/io/github/refux/slang/ffi/SlangNative.java index b717505..bc15734 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/SlangNative.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/SlangNative.java @@ -29,6 +29,13 @@ public static boolean succeeded(int slangResult) { /** {@code SLANG_FAIL} — the generic failure result (COM {@code E_FAIL}). */ public static final int SLANG_FAIL = 0x80004005; + /** + * {@code SLANG_E_NOT_FOUND} — {@code SLANG_MAKE_CORE_ERROR(5)} in slang.h, i.e. facility + * {@code 0x200}, code 5: what {@code getDownstreamCompilerPath} returns when the compiler cannot + * be located or loaded at all. + */ + public static final int SLANG_E_NOT_FOUND = 0x82000005; + /** * {@code SLANG_E_NOT_AVAILABLE} — {@code SLANG_MAKE_CORE_ERROR(7)} in slang.h, i.e. facility * {@code 0x200}, code 7. Since Slang 2026.18.3, {@code slang_loadModuleInfoFromIRBlob} returns it diff --git a/slang/src/main/java/io/github/refux/slang/ffi/TargetDesc.java b/slang/src/main/java/io/github/refux/slang/ffi/TargetDesc.java index c56236c..91a8e2f 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/TargetDesc.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/TargetDesc.java @@ -55,6 +55,15 @@ public static void setFlags(MemorySegment desc, int flags) { io.github.refux.slang.ffi.gen.TargetDesc.setFlags(desc, flags); } + /** + * Points {@code compilerOptionEntries} at a {@link CompilerOptionEntry} array of {@code count}; + * they override the session's entries for this target. + */ + public static void setCompilerOptionEntries(MemorySegment desc, MemorySegment entryArray, int count) { + io.github.refux.slang.ffi.gen.TargetDesc.setCompilerOptionEntries(desc, entryArray); + io.github.refux.slang.ffi.gen.TargetDesc.setCompilerOptionEntryCount(desc, count); + } + public static void setForceGlslScalarBufferLayout(MemorySegment desc, boolean force) { io.github.refux.slang.ffi.gen.TargetDesc.setForceGLSLScalarBufferLayout(desc, force); } diff --git a/slang/src/test/java/io/github/refux/slang/CompilerOptionsTest.java b/slang/src/test/java/io/github/refux/slang/CompilerOptionsTest.java new file mode 100644 index 0000000..cf93147 --- /dev/null +++ b/slang/src/test/java/io/github/refux/slang/CompilerOptionsTest.java @@ -0,0 +1,177 @@ +package io.github.refux.slang; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.refux.slang.ffi.gen.CompilerOptionName; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.Test; + +/** Compiler options reach Slang — each typed setter checked by an effect it has on the output. */ +class CompilerOptionsTest { + + private static final String COMPUTE = """ + RWStructuredBuffer output; + float scaled(float x) { float y = x * 2.0; float z = y + 0.0; return z * 1.0; } + [shader("compute")] [numthreads(1,1,1)] + void main(uint3 tid : SV_DispatchThreadID) + { + float sum = 0; + for (int i = 0; i < 4; i++) sum += scaled(float(i)); + output[0] = sum; + } + """; + + /** Warns with E30081 (implicit float-to-int conversion) and nothing else. */ + private static final String WARNS = """ + [shader("compute")] [numthreads(1,1,1)] + void main(uint3 tid : SV_DispatchThreadID) { int truncated = 1.5; } + """; + + private static byte[] compile( + GlobalSession global, CompileTarget target, String source, UnaryOperator configure) { + try (Session session = + configure.apply(global.newSession().target(target)).create()) { + Module module = session.loadModuleFromSource("options_probe", source); + try (ComponentType linked = + session.composite(module, module.entryPoint("main")).link()) { + return linked.entryPointCode(0, 0); + } + } + } + + private static boolean hasDebugInfo(byte[] spirv) { + return new String(spirv, StandardCharsets.ISO_8859_1).contains("NonSemantic.Shader.DebugInfo"); + } + + @Test + void debugInfoEmitsSourceLevelDebugInformation() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertFalse( + hasDebugInfo(compile(global, CompileTarget.SPIRV, COMPUTE, s -> s.debugInfo(DebugInfoLevel.NONE)))); + assertTrue(hasDebugInfo( + compile(global, CompileTarget.SPIRV, COMPUTE, s -> s.debugInfo(DebugInfoLevel.STANDARD)))); + } + } + + @Test + void optimizationShrinksTheCode() { + try (GlobalSession global = Slang.createGlobalSession()) { + int unoptimized = + compile(global, CompileTarget.SPIRV, COMPUTE, s -> s.optimization(OptimizationLevel.NONE)).length; + int maximal = compile(global, CompileTarget.SPIRV, COMPUTE, s -> s.optimization(OptimizationLevel.MAXIMAL)) + .length; + assertTrue(maximal < unoptimized, "MAXIMAL (" + maximal + " bytes) vs NONE (" + unoptimized + " bytes)"); + } + } + + @Test + void warningsAsErrorsFailsTheLoad() { + try (GlobalSession global = Slang.createGlobalSession()) { + List warnings = new ArrayList<>(); + compile(global, CompileTarget.SPIRV, WARNS, s -> s.onDiagnostics(warnings::add)); + assertTrue(String.join("", warnings).contains("30081"), "the probe source warns: " + warnings); + + assertThrows( + SlangCompileException.class, + () -> compile(global, CompileTarget.SPIRV, WARNS, SessionBuilder::warningsAsErrors)); + assertThrows( + SlangCompileException.class, + () -> compile(global, CompileTarget.SPIRV, WARNS, s -> s.warningsAsErrors("30081"))); + } + } + + @Test + void disableWarningsSilencesThem() { + try (GlobalSession global = Slang.createGlobalSession()) { + List warnings = new ArrayList<>(); + compile( + global, + CompileTarget.SPIRV, + WARNS, + s -> s.onDiagnostics(warnings::add).disableWarnings("30081")); + assertTrue(warnings.isEmpty(), "disabled warning still reported: " + warnings); + } + } + + @Test + void capabilitiesResolveByName() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertTrue(compile(global, CompileTarget.SPIRV, COMPUTE, s -> s.capability("spirv_1_5")).length > 0); + assertThrows( + IllegalArgumentException.class, () -> global.newSession().capability("not_a_capability")); + } + } + + /** MSVC packing starts new storage when the underlying type size changes; Slang's default shares it. */ + @Test + void bitfieldPackingChangesTheStorageLayout() { + String source = """ + struct Packed { uint8_t a : 4; uint16_t b : 4; }; + RWStructuredBuffer output; + [shader("compute")] [numthreads(1,1,1)] + void main(uint3 tid : SV_DispatchThreadID) { Packed p; p.a = 1; p.b = 2; output[0] = p; } + """; + try (GlobalSession global = Slang.createGlobalSession()) { + assertFalse(declaresByteBacking( + compile(global, CompileTarget.HLSL, source, s -> s.bitfieldPacking(BitfieldPacking.DEFAULT)))); + assertTrue(declaresByteBacking( + compile(global, CompileTarget.HLSL, source, s -> s.bitfieldPacking(BitfieldPacking.MSVC)))); + } + } + + private static boolean declaresByteBacking(byte[] hlsl) { + return new String(hlsl, StandardCharsets.UTF_8) + .lines() + .anyMatch(line -> line.contains("uint8_t") && line.contains("bit_field_backing")); + } + + @Test + void languageVersionIsAccepted() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertTrue(compile(global, CompileTarget.SPIRV, COMPUTE, s -> s.languageVersion(2026)).length > 0); + } + } + + /** The raw escape hatch, at session level and per target (where a target's entries land on its own desc). */ + @Test + void rawOptionsReachTheSessionAndEachTarget() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertThrows( + SlangCompileException.class, + () -> compile( + global, + CompileTarget.SPIRV, + WARNS, + s -> s.option(CompilerOptionName.WarningsAsErrors, "all"))); + + try (Session session = global.newSession() + .target(CompileTarget.SPIRV) + .target( + CompileTarget.SPIRV, + t -> t.option(CompilerOptionName.DebugInformation, DebugInfoLevel.STANDARD.value())) + .create()) { + Module module = session.loadModuleFromSource("per_target", COMPUTE); + try (ComponentType linked = + session.composite(module, module.entryPoint("main")).link()) { + assertFalse(hasDebugInfo(linked.entryPointCode(0, 0)), "target 0 has no debug option"); + assertTrue(hasDebugInfo(linked.entryPointCode(0, 1)), "target 1 asked for debug info"); + } + } + } + } + + @Test + void unknownSentinelsAreRefused() { + try (GlobalSession global = Slang.createGlobalSession()) { + SessionBuilder builder = global.newSession(); + assertThrows(IllegalArgumentException.class, () -> builder.optimization(OptimizationLevel.UNKNOWN)); + assertThrows(IllegalArgumentException.class, () -> builder.debugInfo(DebugInfoLevel.UNKNOWN)); + assertThrows(IllegalArgumentException.class, () -> builder.bitfieldPacking(BitfieldPacking.UNKNOWN)); + } + } +} diff --git a/slang/src/test/java/io/github/refux/slang/GlobalQueriesTest.java b/slang/src/test/java/io/github/refux/slang/GlobalQueriesTest.java new file mode 100644 index 0000000..6d4d111 --- /dev/null +++ b/slang/src/test/java/io/github/refux/slang/GlobalQueriesTest.java @@ -0,0 +1,57 @@ +package io.github.refux.slang; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +/** What a global session can say about the Slang build and the downstream compilers it finds. */ +class GlobalQueriesTest { + + @Test + void findCapabilityKnowsCapabilityNames() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertTrue(global.findCapability("spirv_1_5") > 0); + assertEquals(0, global.findCapability("not_a_capability")); + } + } + + /** SPIR-V is emitted directly, so needs no downstream compiler on any platform. */ + @Test + void spirvIsAlwaysSupported() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertTrue(global.isTargetSupported(CompileTarget.SPIRV)); + } + } + + /** glslang ships in the natives payload, which the binding points Slang at. */ + @Test + void glslangIsTheLibraryInThePayload() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertTrue(global.isPassThroughSupported(PassThrough.GLSLANG)); + + Path glslang = global.downstreamCompilerPath(PassThrough.GLSLANG).orElseThrow(); + assertTrue(Files.isRegularFile(glslang), glslang + " exists"); + assertTrue(glslang.getFileName().toString().contains("slang-glslang"), glslang.toString()); + } + } + + /** Clang runs as an executable when found at all, so it never has a library path. */ + @Test + void executableCompilersHaveNoLibraryPath() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertTrue(global.downstreamCompilerPath(PassThrough.CLANG).isEmpty()); + } + } + + @Test + void unknownPassThroughIsRefused() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertThrows(IllegalArgumentException.class, () -> global.downstreamCompilerPath(PassThrough.UNKNOWN)); + assertThrows(IllegalArgumentException.class, () -> global.isPassThroughSupported(PassThrough.UNKNOWN)); + } + } +} diff --git a/slang/src/test/java/io/github/refux/slang/ModuleLoadingTest.java b/slang/src/test/java/io/github/refux/slang/ModuleLoadingTest.java new file mode 100644 index 0000000..57004c3 --- /dev/null +++ b/slang/src/test/java/io/github/refux/slang/ModuleLoadingTest.java @@ -0,0 +1,190 @@ +package io.github.refux.slang; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Loading modules by name, and what a module reports about itself: name, files, entry points, code. */ +class ModuleLoadingTest { + + private static final String LIGHTING = """ + module lighting; + import helper; + #include "common.slangh" + RWStructuredBuffer output; + + [shader("compute")] [numthreads(1,1,1)] + void first(uint3 tid : SV_DispatchThreadID) { output[0] = twice(helperValue()); } + + [shader("compute")] [numthreads(1,1,1)] + void second(uint3 tid : SV_DispatchThreadID) { output[1] = 1.0; } + + float4 vertexMain(float3 position : POSITION) : SV_Position { return float4(position, 1.0); } + """; + + @TempDir + Path dir; + + private void writeSources() throws IOException { + Files.writeString(dir.resolve("lighting.slang"), LIGHTING); + Files.writeString(dir.resolve("common.slangh"), "float twice(float x) { return x * 2.0; }\n"); + Files.writeString(dir.resolve("helper.slang"), "module helper;\npublic float helperValue() { return 3.0; }\n"); + } + + private Session searchPathSession(GlobalSession global) { + return global.newSession().target(CompileTarget.SPIRV).searchPath(dir).create(); + } + + private static List fileNames(List paths) { + return paths.stream() + .map(path -> Path.of(path).getFileName().toString()) + .toList(); + } + + @Test + void loadModuleFindsItsSourceOnTheSearchPath() throws IOException { + writeSources(); + try (GlobalSession global = Slang.createGlobalSession(); + Session session = searchPathSession(global)) { + Module module = session.loadModule("lighting"); + + assertEquals("lighting", module.name()); + assertEquals( + "lighting.slang", Path.of(module.filePath()).getFileName().toString()); + assertEquals( + List.of("lighting.slang", "common.slangh", "helper.slang"), + fileNames(module.dependencyFiles()), + "its source, its #include, and the module it imports"); + } + } + + @Test + void loadModuleReturnsTheModuleAlreadyLoaded() throws IOException { + writeSources(); + try (GlobalSession global = Slang.createGlobalSession(); + Session session = searchPathSession(global)) { + Module first = session.loadModule("lighting"); + Module again = session.loadModule("lighting"); + + assertEquals( + first.componentHandle().segment().address(), + again.componentHandle().segment().address()); + } + } + + @Test + void loadModuleResolvesThroughAJavaFileSystem() { + try (GlobalSession global = Slang.createGlobalSession(); + Session session = global.newSession() + .target(CompileTarget.SPIRV) + .fileSystem(SlangFileSystem.ofMap(Map.of( + "lighting.slang", LIGHTING, + "common.slangh", "float twice(float x) { return x * 2.0; }\n", + "helper.slang", "module helper;\npublic float helperValue() { return 3.0; }\n"))) + .create()) { + assertEquals("lighting", session.loadModule("lighting").name()); + } + } + + @Test + void missingModuleThrows() { + try (GlobalSession global = Slang.createGlobalSession(); + Session session = searchPathSession(global)) { + assertThrows(SlangCompileException.class, () -> session.loadModule("no_such_module")); + } + } + + @Test + void entryPointsListsWhatSlangFoundAndStagedLookupFindsTheRest() throws IOException { + writeSources(); + try (GlobalSession global = Slang.createGlobalSession(); + Session session = searchPathSession(global)) { + Module module = session.loadModule("lighting"); + List entryPoints = module.entryPoints(); + assertEquals(2, entryPoints.size(), "the two [shader] functions, not the undecorated vertexMain"); + + List parts = new ArrayList<>(List.of(module)); + parts.addAll(entryPoints); + try (ComponentType linked = + session.composite(parts.toArray(ComponentType[]::new)).link()) { + assertEquals( + List.of("first", "second"), + linked.layout(0).entryPoints().stream() + .map(EntryPointReflection::name) + .toList()); + } + + try (ComponentType linked = session.composite(module, module.entryPoint("vertexMain", Stage.VERTEX)) + .link()) { + EntryPointReflection vertex = linked.layout(0).entryPoints().getFirst(); + assertEquals("vertexMain", vertex.name()); + assertEquals(Stage.VERTEX, vertex.stage()); + } + assertThrows(SlangCompileException.class, () -> module.entryPoint("noSuchFunction", Stage.COMPUTE)); + } + } + + @Test + void targetCodeCoversEveryEntryPoint() throws IOException { + writeSources(); + try (GlobalSession global = Slang.createGlobalSession(); + Session session = searchPathSession(global)) { + Module module = session.loadModule("lighting"); + try (ComponentType linked = session.composite( + module, module.entryPoint("first"), module.entryPoint("second")) + .link()) { + byte[] spirv = linked.targetCode(0); + + assertEquals( + 0x0723_0203, + ByteBuffer.wrap(spirv).order(ByteOrder.LITTLE_ENDIAN).getInt(0), + "SPIR-V magic"); + String text = new String(spirv, StandardCharsets.ISO_8859_1); + assertTrue(text.contains("first") && text.contains("second"), "one module holding both entry points"); + } + } + } + + /** Fresh sessions each time: a session keeps the source files it has read, so it would not see the edit. */ + @Test + void isIrUpToDateTracksSourcesAndOptions() throws IOException { + writeSources(); + Path irPath = dir.resolve("lighting.slang-module"); + try (GlobalSession global = Slang.createGlobalSession()) { + byte[] ir; + try (Session session = searchPathSession(global)) { + ir = session.loadModule("lighting").serialize(); + } + try (Session session = searchPathSession(global)) { + assertTrue(session.isIrUpToDate(irPath.toString(), ir), "nothing changed"); + } + try (Session session = global.newSession() + .target(CompileTarget.SPIRV) + .searchPath(dir) + .optimization(OptimizationLevel.MAXIMAL) + .create()) { + assertFalse(session.isIrUpToDate(irPath.toString(), ir), "compiler options changed"); + } + Files.writeString(dir.resolve("common.slangh"), "float twice(float x) { return x * 3.0; }\n"); + try (Session session = searchPathSession(global)) { + assertFalse(session.isIrUpToDate(irPath.toString(), ir), "an #included file changed"); + } + try (Session session = searchPathSession(global)) { + assertFalse(session.isIrUpToDate(irPath.toString(), new byte[0]), "no IR at all"); + } + } + } +}