From 8d5ccbf3bedfd8a8d72aab516fea912325673aa3 Mon Sep 17 00:00:00 2001 From: James Roome Date: Tue, 25 Aug 2026 11:17:03 +0530 Subject: [PATCH 1/2] feat: let a session pick its matrix layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slang's own two defaults disagree. `SessionDesc` in the C++ header defaults to row-major, and this library allocates that descriptor, so a session created here follows the header. The `slangc` command line defaults to column-major instead. A project that compiles some shaders offline with `slangc` and others at run time through these bindings therefore gets one half silently transposed against the other: both SPIR-V modules are valid, both load, and the only symptom is wrong geometry. `SessionBuilder.matrixLayout(MatrixLayout.COLUMN_MAJOR)` is the one line that makes the two halves agree. Leaving it unset keeps the header default, so nothing already compiled moves. The naming inverts on the way to SPIR-V — COLUMN_MAJOR emits `RowMajor` member decorations — which is exactly the kind of thing that turns into an afternoon of disbelief, so `MatrixLayoutTest` reads the decorations back out of the compiled module rather than trusting the enum: each layout emits its opposite-named decoration, the two differ, and an unset layout still lands on the header default. Verified on macos-aarch64 against the pinned 2026.14.1 payload: 39/39 tests, nothing skipped. Co-Authored-By: Claude Opus 5 --- README.md | 5 + .../io/github/refux/slang/MatrixLayout.java | 63 +++++++++++ .../io/github/refux/slang/SessionBuilder.java | 18 ++++ .../github/refux/slang/ffi/SessionDesc.java | 9 ++ .../github/refux/slang/MatrixLayoutTest.java | 102 ++++++++++++++++++ 5 files changed, 197 insertions(+) create mode 100644 slang/src/main/java/io/github/refux/slang/MatrixLayout.java create mode 100644 slang/src/test/java/io/github/refux/slang/MatrixLayoutTest.java diff --git a/README.md b/README.md index e791573..c5d48ed 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,11 @@ Session session = global.newSession() .create(); ``` +Matrices follow `SessionDesc`'s row-major default, which is **not** what the `slangc` command +line uses — compile the same shader offline with `slangc` and at run time through this library +and every matrix comes out transposed relative to the other build, with nothing to diagnose it. +`matrixLayout(MatrixLayout.COLUMN_MAJOR)` on the session builder matches `slangc`. + Everything is `AutoCloseable` with try-with-resources as the idiom; anything left unclosed is released by a Cleaner when unreachable, and `-Dio.github.refux.slang.debug=true` traces leaks to their allocation site and asserts session thread confinement. diff --git a/slang/src/main/java/io/github/refux/slang/MatrixLayout.java b/slang/src/main/java/io/github/refux/slang/MatrixLayout.java new file mode 100644 index 0000000..cddbfca --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/MatrixLayout.java @@ -0,0 +1,63 @@ +package io.github.refux.slang; + +import io.github.refux.slang.ffi.gen.SlangMatrixLayoutMode; +import java.util.HashMap; +import java.util.Map; + +/** + * How a session lays out matrix types ({@code SlangMatrixLayoutMode}), with {@link #value()} / + * {@link #of(int)} escape hatches to the raw ABI values. + * + *

Slang's own two defaults disagree, so a project that compiles the same shader both ways + * must say which it wants. {@code SessionDesc} in the C++ header defaults to + * {@link #ROW_MAJOR}, and {@link SessionBuilder} follows the header. The {@code slangc} command + * line defaults to column-major instead. Compile a shader offline with {@code slangc} and again at + * run time through this library, and the two SPIR-V modules carry opposite {@code RowMajor} / + * {@code ColMajor} member decorations for the same source — every matrix is silently transposed + * relative to the other build. Nothing diagnoses it: both modules are valid, both load, and the + * only symptom is wrong geometry. + * + *

Note the naming inverts on the way to SPIR-V: {@link #COLUMN_MAJOR} here emits {@code RowMajor} + * member decorations, matching what {@code slangc} produces by default. + */ +public enum MatrixLayout { + /** Slang's mode is unset; the compiler picks. */ + UNKNOWN(SlangMatrixLayoutMode.SLANG_MATRIX_LAYOUT_MODE_UNKNOWN), + /** The {@code SessionDesc} header default. Emits {@code ColMajor} in SPIR-V. */ + ROW_MAJOR(SlangMatrixLayoutMode.SLANG_MATRIX_LAYOUT_ROW_MAJOR), + /** What the {@code slangc} command line uses by default. Emits {@code RowMajor} in SPIR-V. */ + COLUMN_MAJOR(SlangMatrixLayoutMode.SLANG_MATRIX_LAYOUT_COLUMN_MAJOR); + + private static final Map BY_VALUE = new HashMap<>(); + + static { + for (MatrixLayout layout : values()) { + BY_VALUE.put(layout.value, layout); + } + } + + private final int value; + + MatrixLayout(int value) { + this.value = value; + } + + /** + * Maps a raw {@code SlangMatrixLayoutMode} to this enum. + * + * @param value the raw ABI value. + * @return the matching constant, or {@link #UNKNOWN} for a value this enum does not know. + */ + public static MatrixLayout of(int value) { + return BY_VALUE.getOrDefault(value, UNKNOWN); + } + + /** + * This layout's raw {@code SlangMatrixLayoutMode} value. + * + * @return the ABI value. + */ + public int value() { + return value; + } +} 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 e39cf45..802504d 100644 --- a/slang/src/main/java/io/github/refux/slang/SessionBuilder.java +++ b/slang/src/main/java/io/github/refux/slang/SessionBuilder.java @@ -27,6 +27,7 @@ public final class SessionBuilder { private final Map defines = new LinkedHashMap<>(); private Consumer onDiagnostics; private SlangFileSystem fileSystem; + private MatrixLayout matrixLayout; SessionBuilder(GlobalSession global) { this.global = global; @@ -67,6 +68,20 @@ public SessionBuilder onDiagnostics(Consumer consumer) { return this; } + /** + * Sets how this session lays out matrices, overriding the {@code SessionDesc} header default of + * {@link MatrixLayout#ROW_MAJOR}. + * + *

Set it to {@link MatrixLayout#COLUMN_MAJOR} to match what the {@code slangc} command line + * produces. A project that compiles some shaders offline with {@code slangc} and others at run + * time through this library and does NOT set this gets silently transposed matrices in whichever + * half it compiled here — see {@link MatrixLayout}. + */ + public SessionBuilder matrixLayout(MatrixLayout layout) { + this.matrixLayout = layout; + return this; + } + /** * Resolves the session's {@code import}s/{@code #include}s through Java instead of the OS * file system — see {@link SlangFileSystem} for ready-made map- and directory-backed @@ -105,6 +120,9 @@ public Session create() { MemorySegment sessionDesc = SessionDesc.allocate(arena); SessionDesc.setTargets(sessionDesc, targetArray, targets.size()); + if (matrixLayout != null) { + SessionDesc.setDefaultMatrixLayoutMode(sessionDesc, matrixLayout.value()); + } if (!searchPaths.isEmpty()) { String[] paths = searchPaths.stream().map(Path::toString).toArray(String[]::new); SessionDesc.setSearchPaths(sessionDesc, Marshal.utf8PointerArray(arena, paths), paths.length); 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 214bf0d..9de47a2 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 @@ -44,6 +44,15 @@ public static void setPreprocessorMacros(MemorySegment desc, MemorySegment macro io.github.refux.slang.ffi.gen.SessionDesc.setPreprocessorMacroCount(desc, count); } + /** + * Sets how the session lays out matrices. The header default this descriptor is allocated with + * is {@code SLANG_MATRIX_LAYOUT_ROW_MAJOR}, which is NOT what the {@code slangc} command line + * uses — see {@code MatrixLayout} for what that costs a project that compiles both ways. + */ + public static void setDefaultMatrixLayoutMode(MemorySegment desc, int mode) { + io.github.refux.slang.ffi.gen.SessionDesc.setDefaultMatrixLayoutMode(desc, mode); + } + /** * 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/test/java/io/github/refux/slang/MatrixLayoutTest.java b/slang/src/test/java/io/github/refux/slang/MatrixLayoutTest.java new file mode 100644 index 0000000..e9fbac3 --- /dev/null +++ b/slang/src/test/java/io/github/refux/slang/MatrixLayoutTest.java @@ -0,0 +1,102 @@ +package io.github.refux.slang; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.junit.jupiter.api.Test; + +/** + * Pins the matrix layout a session compiles with, and the fact that it is settable at all. + * + *

Why this is worth a test of its own: {@code SessionDesc}'s header default is row-major while + * the {@code slangc} command line defaults to column-major, so a project compiling the same shader + * both ways gets silently transposed matrices in one half. Nothing diagnoses that — both modules + * are valid SPIR-V — so the only thing standing between a consumer and a day of debugging is this + * knob and the decoration these assertions read. + */ +class MatrixLayoutTest { + + private static final String SOURCE = """ + struct Pc { float4x4 uProj; }; + [[vk::push_constant]] ConstantBuffer pc; + [shader("vertex")] + float4 vertexMain(float3 p) { return mul(pc.uProj, float4(p, 1.0)); } + """; + + private static byte[] compileWith(MatrixLayout layout) { + try (GlobalSession global = Slang.createGlobalSession()) { + SessionBuilder builder = global.newSession().target(CompileTarget.SPIRV, t -> t.profile("spirv_1_6")); + if (layout != null) { + builder = builder.matrixLayout(layout); + } + try (Session session = builder.create()) { + Module module = session.loadModuleFromSource("matrix_layout_probe", SOURCE); + try (ComponentType linked = session.composite(module, module.entryPoint("vertexMain")) + .link()) { + return linked.entryPointCode(0, 0); + } + } + } + } + + /** SPIR-V decoration opcodes are little-endian words; find OpMemberDecorate's operand. */ + private static boolean hasDecoration(byte[] spirv, int decoration) { + ByteBuffer words = ByteBuffer.wrap(spirv).order(ByteOrder.LITTLE_ENDIAN); + for (int offset = 5 * 4; offset + 4 <= spirv.length; offset += 4) { + int word = words.getInt(offset); + int opcode = word & 0xFFFF; + int wordCount = word >>> 16; + if (opcode == OP_MEMBER_DECORATE && wordCount >= 4 && words.getInt(offset + 3 * 4) == decoration) { + return true; + } + if (wordCount == 0) { + break; + } + offset += (wordCount - 1) * 4; + } + return false; + } + + private static final int OP_MEMBER_DECORATE = 72; + private static final int DECORATION_ROW_MAJOR = 4; + private static final int DECORATION_COL_MAJOR = 5; + + @Test + void columnMajorEmitsRowMajorDecorationsMatchingSlangc() { + byte[] spirv = compileWith(MatrixLayout.COLUMN_MAJOR); + + assertTrue(hasDecoration(spirv, DECORATION_ROW_MAJOR), "COLUMN_MAJOR should emit RowMajor, as slangc does"); + } + + @Test + void rowMajorEmitsColMajorDecorations() { + byte[] spirv = compileWith(MatrixLayout.ROW_MAJOR); + + assertTrue(hasDecoration(spirv, DECORATION_COL_MAJOR), "ROW_MAJOR should emit ColMajor"); + } + + @Test + void theTwoLayoutsProduceDifferentModules() { + assertNotEquals( + hasDecoration(compileWith(MatrixLayout.ROW_MAJOR), DECORATION_ROW_MAJOR), + hasDecoration(compileWith(MatrixLayout.COLUMN_MAJOR), DECORATION_ROW_MAJOR), + "the setting must actually reach the compiler"); + } + + @Test + void unsetKeepsTheSessionDescHeaderDefault() { + assertTrue( + hasDecoration(compileWith(null), DECORATION_COL_MAJOR), + "an unset layout keeps SessionDesc's row-major header default, which emits ColMajor"); + } + + @Test + void rawValuesRoundTrip() { + for (MatrixLayout layout : MatrixLayout.values()) { + assertEquals(layout, MatrixLayout.of(layout.value())); + } + } +} From c525bad54a976c76bdee59b679988261d5a6d924 Mon Sep 17 00:00:00 2001 From: James Roome Date: Tue, 25 Aug 2026 11:17:55 +0530 Subject: [PATCH 2/2] release: 0.0.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the matrix-layout knob from the previous commit — the first thing in this library a consumer could get silently wrong without it, since the header default this library follows and the one `slangc` uses are opposites. The Slang pin is unchanged at v2026.14.1, so this release is slang-java's own API moving, not Slang's. The library and both natives artifacts share one version by design (gradle.properties is the single source), so a consumer's two coordinates always agree; README's coordinates, manifest example and both dependency snippets move to 0.0.8 with it. Verified on macos-aarch64 against the pinned payload: 39/39 tests, nothing skipped, javadoc warning-free, and the jar states what it binds without being run: $ unzip -p slang-java-0.0.8.jar META-INF/MANIFEST.MF Implementation-Version: 0.0.8 Slang-Version: 2026.14.1 Co-Authored-By: Claude Opus 5 --- README.md | 18 +++++++++--------- gradle.properties | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c5d48ed..e67ce79 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,14 @@ directly. Windows, macOS, and Linux, on x86_64 and aarch64. Requires JDK 25+. | Artifact | Purpose | |--------------------------------------------------------|---| -| `io.github.refux:slang-java:0.0.7` | The library: compiler API, reflection, Java file systems | -| `io.github.refux:slang-java-natives:0.0.7:-` | The official Slang binaries, as one classifier per platform: `windows`, `linux`, or `macos` × `x86_64` or `aarch64` | +| `io.github.refux:slang-java:0.0.8` | The library: compiler API, reflection, Java file systems | +| `io.github.refux:slang-java-natives:0.0.8:-` | The official Slang binaries, as one classifier per platform: `windows`, `linux`, or `macos` × `x86_64` or `aarch64` | The library version tracks slang-java's own API, not Slang's. To see which Slang release a jar binds, read its manifest — no resolving, no running: ```bash -unzip -p slang-java-0.0.7.jar META-INF/MANIFEST.MF +unzip -p slang-java-0.0.8.jar META-INF/MANIFEST.MF ``` `Slang-Version` there is a **floor**, not a lockstep: it names the release the bindings were @@ -46,8 +46,8 @@ val slangNatives = "${ }-${if (System.getProperty("os.arch") in listOf("aarch64", "arm64")) "aarch64" else "x86_64"}" dependencies { - implementation("io.github.refux:slang-java:0.0.7") - runtimeOnly("io.github.refux:slang-java-natives:0.0.7:$slangNatives") + implementation("io.github.refux:slang-java:0.0.8") + runtimeOnly("io.github.refux:slang-java-natives:0.0.8:$slangNatives") } tasks.withType { @@ -60,10 +60,10 @@ classpath together and the loader picks the host's at runtime (LWJGL-style): ```kotlin dependencies { - implementation("io.github.refux:slang-java:0.0.7") - runtimeOnly("io.github.refux:slang-java-natives:0.0.7:macos-aarch64") - runtimeOnly("io.github.refux:slang-java-natives:0.0.7:windows-x86_64") - runtimeOnly("io.github.refux:slang-java-natives:0.0.7:linux-x86_64") + implementation("io.github.refux:slang-java:0.0.8") + runtimeOnly("io.github.refux:slang-java-natives:0.0.8:macos-aarch64") + runtimeOnly("io.github.refux:slang-java-natives:0.0.8:windows-x86_64") + runtimeOnly("io.github.refux:slang-java-natives:0.0.8:linux-x86_64") } ``` diff --git a/gradle.properties b/gradle.properties index 205eeab..650d39f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,4 +4,4 @@ org.gradle.caching=true # Single source of truth for the published version (library + natives artifacts). # Release tags must match: tag v0.0.1 <-> version=0.0.1. group=io.github.refux -version=0.0.7 +version=0.0.8