Skip to content

Commit 1f9299f

Browse files
RefuXclaude
andcommitted
0.0.4: expose ISession::getDynamicObjectRTTIBytes
Add Session.getDynamicObjectRTTIBytes(type, interfaceType), returning the 16-byte RTTI header a dynamic-dispatch shader expects at the start of each StructuredBuffer<Interface> element: byte 0-3 the valid-type marker (1), byte 8-11 the per-interface sequential conformance id. This lets a CPU-side packer write the header the compiler defines rather than hardcoding its byte layout. - ffi.ISession.getDynamicObjectRTTIBytes(seg, seg): wraps the generated vtable-slot-21 downcall into a 16-byte out buffer, with a SlangResult check - Session.getDynamicObjectRTTIBytes(TypeReflection, TypeReflection): the idiomatic, thread-checked wrapper - Slang0_0_4Test: verifies the 16-byte format (marker + id), that the id is the dispatch OpSwitch case, and that the id is per-interface across separate compiles in one session - version -> 0.0.4 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4f20ee6 commit 1f9299f

5 files changed

Lines changed: 232 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ directly. Windows, macOS, and Linux, on x86_64 and aarch64. Requires JDK 25+.
1515

1616
| Artifact | Purpose |
1717
|--------------------------------------------------------|---|
18-
| `io.github.refux:slang-java:0.0.3` | The library: compiler API, reflection, Java file systems |
19-
| `io.github.refux:slang-java-natives:0.0.3:<os>-<arch>` | The official Slang binaries, as one classifier per platform: `windows`, `linux`, or `macos` × `x86_64` or `aarch64` |
18+
| `io.github.refux:slang-java:0.0.4` | The library: compiler API, reflection, Java file systems |
19+
| `io.github.refux:slang-java-natives:0.0.4:<os>-<arch>` | The official Slang binaries, as one classifier per platform: `windows`, `linux`, or `macos` × `x86_64` or `aarch64` |
2020

2121
## Getting started
2222

@@ -34,8 +34,8 @@ val slangNatives = "${
3434
}-${if (System.getProperty("os.arch") in listOf("aarch64", "arm64")) "aarch64" else "x86_64"}"
3535

3636
dependencies {
37-
implementation("io.github.refux:slang-java:0.0.3")
38-
runtimeOnly("io.github.refux:slang-java-natives:0.0.3:$slangNatives")
37+
implementation("io.github.refux:slang-java:0.0.4")
38+
runtimeOnly("io.github.refux:slang-java-natives:0.0.4:$slangNatives")
3939
}
4040

4141
tasks.withType<JavaExec> {
@@ -48,10 +48,10 @@ classpath together and the loader picks the host's at runtime (LWJGL-style):
4848

4949
```kotlin
5050
dependencies {
51-
implementation("io.github.refux:slang-java:0.0.3")
52-
runtimeOnly("io.github.refux:slang-java-natives:0.0.3:macos-aarch64")
53-
runtimeOnly("io.github.refux:slang-java-natives:0.0.3:windows-x86_64")
54-
runtimeOnly("io.github.refux:slang-java-natives:0.0.3:linux-x86_64")
51+
implementation("io.github.refux:slang-java:0.0.4")
52+
runtimeOnly("io.github.refux:slang-java-natives:0.0.4:macos-aarch64")
53+
runtimeOnly("io.github.refux:slang-java-natives:0.0.4:windows-x86_64")
54+
runtimeOnly("io.github.refux:slang-java-natives:0.0.4:linux-x86_64")
5555
}
5656
```
5757

‎gradle.properties‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ org.gradle.caching=true
44
# Single source of truth for the published version (library + natives artifacts).
55
# Release tags must match: tag v0.0.1 <-> version=0.0.1.
66
group=io.github.refux
7-
version=0.0.3
7+
version=0.0.4

‎slang/src/main/java/io/github/refux/slang/Session.java‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,22 @@ public TypeConformance createTypeConformance(
6565
this, session.createTypeConformance(type.segment(), interfaceType.segment(), conformanceId));
6666
}
6767

68+
/**
69+
* The 16-byte RTTI header identifying {@code type}'s conformance to {@code interfaceType}, to be
70+
* written at the start of that value's element in a {@code StructuredBuffer<Interface>} for dynamic
71+
* dispatch (the concrete payload follows it; the sequential dispatch id from
72+
* {@link #createTypeConformance} occupies bytes 8-11). Register the conformance first so the id is
73+
* assigned.
74+
*
75+
* @param type the concrete type
76+
* @param interfaceType the interface it conforms to
77+
* @return the 16 header bytes
78+
*/
79+
public byte[] getDynamicObjectRTTIBytes(TypeReflection type, TypeReflection interfaceType) {
80+
checkThread();
81+
return session.getDynamicObjectRTTIBytes(type.segment(), interfaceType.segment());
82+
}
83+
6884
/**
6985
* Combines modules and entry points into one unit of shader code; the order determines the
7086
* parameter layout order. The result is typically {@link ComponentType#link() linked} next.

‎slang/src/main/java/io/github/refux/slang/ffi/ISession.java‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import static java.lang.foreign.ValueLayout.ADDRESS;
44

55
import io.github.refux.slang.SlangCompileException;
6+
import io.github.refux.slang.SlangException;
67
import java.lang.foreign.Arena;
78
import java.lang.foreign.MemorySegment;
9+
import java.lang.foreign.ValueLayout;
810
import java.util.function.Consumer;
911

1012
/**
@@ -126,4 +128,31 @@ public ITypeConformance createTypeConformance(
126128
return new ITypeConformance(out.get(ADDRESS, 0));
127129
}
128130
}
131+
132+
/**
133+
* The 16-byte RTTI header identifying {@code type}'s conformance to {@code interfaceType} for
134+
* dynamic dispatch, to be written at the start of that value's element in a
135+
* {@code StructuredBuffer<Interface>} (the concrete payload follows it). Slang's layout is: bytes
136+
* 0-7 an RTTI marker (1 = valid type, 0 = null), bytes 8-11 the sequential dispatch id assigned by
137+
* {@link #createTypeConformance}, bytes 12-15 unused. Register the conformance first so the id is
138+
* assigned. Wraps {@code ISession::getDynamicObjectRTTIBytes}.
139+
*
140+
* @param type the concrete type ({@code slang::TypeReflection*})
141+
* @param interfaceType the interface it conforms to ({@code slang::TypeReflection*})
142+
* @return the 16 header bytes
143+
*/
144+
public byte[] getDynamicObjectRTTIBytes(MemorySegment type, MemorySegment interfaceType) {
145+
try (Arena arena = Arena.ofConfined()) {
146+
MemorySegment out = arena.allocate(RTTI_HEADER_BYTES);
147+
int result = io.github.refux.slang.ffi.gen.ISession.getDynamicObjectRTTIBytes(
148+
segment(), type, interfaceType, out, RTTI_HEADER_BYTES);
149+
if (!SlangNative.succeeded(result)) {
150+
throw new SlangException("ISession::getDynamicObjectRTTIBytes failed", result);
151+
}
152+
return out.toArray(ValueLayout.JAVA_BYTE);
153+
}
154+
}
155+
156+
/** The dynamic-object RTTI header is a fixed 16 bytes (Slang: 8-byte type marker, 4-byte id, 4 unused). */
157+
private static final int RTTI_HEADER_BYTES = 16;
129158
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package io.github.refux.slang;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNotNull;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
7+
import java.nio.ByteBuffer;
8+
import java.nio.ByteOrder;
9+
import java.nio.charset.StandardCharsets;
10+
import java.util.HashSet;
11+
import java.util.Set;
12+
import org.junit.jupiter.api.Test;
13+
14+
/**
15+
* Covers the slang-java 0.0.4 addition: {@link Session#getDynamicObjectRTTIBytes}, the CPU's source of
16+
* truth for the 16-byte header written at the start of each element of a
17+
* {@code StructuredBuffer<Interface>} in dynamic-dispatch shaders.
18+
*/
19+
class Slang0_0_4Test {
20+
21+
private static final String MODIFIERS = """
22+
public interface IModifier { float apply(float x); }
23+
public struct Doubler : IModifier { public float apply(float x) { return x * 2.0; } }
24+
public struct Negator : IModifier { public float apply(float x) { return -x; } }
25+
""";
26+
27+
private static final String DISPATCH_SHADER = """
28+
import modifiers;
29+
RWStructuredBuffer<float> result;
30+
StructuredBuffer<IModifier> gModifiers;
31+
[shader("compute")] [numthreads(1,1,1)]
32+
void main(uint3 tid : SV_DispatchThreadID) {
33+
float v = 1.0;
34+
for (uint i = 0; i < 2; ++i) { v = gModifiers[i].apply(v); }
35+
result[tid.x] = v;
36+
}
37+
""";
38+
39+
/**
40+
* The header is a fixed 16 bytes: byte 0-3 is Slang's valid-type marker (1) and byte 8-11 is the
41+
* conformance id. {@link Session#getDynamicObjectRTTIBytes} returns the **registration-order**
42+
* (sequential) id, while the shader's dispatch {@code OpSwitch} branches on the
43+
* {@code conformanceIdOverride} — so they agree only when the override equals the registration
44+
* order, which is how a caller must register (id 0 first, id 1 next, …). Here the ids match the
45+
* order, so the header id is an OpSwitch case and a CPU writing this header dispatches correctly.
46+
*/
47+
@Test
48+
void headerCarriesTheMarkerAndTheIdTheDispatchSwitchUses() {
49+
try (GlobalSession global = Slang.createGlobalSession();
50+
Session session = global.newSession()
51+
.target(CompileTarget.SPIRV_ASM, t -> t.profile("spirv_1_5"))
52+
.create()) {
53+
Module modifiers = session.loadModuleFromSource("modifiers", MODIFIERS);
54+
Module shader = session.loadModuleFromSource("shader", DISPATCH_SHADER);
55+
ShaderReflection refl = modifiers.layout(0);
56+
TypeReflection iface = refl.findTypeByName("IModifier");
57+
TypeReflection doubler = refl.findTypeByName("Doubler");
58+
TypeReflection negator = refl.findTypeByName("Negator");
59+
assertNotNull(iface);
60+
assertNotNull(doubler);
61+
assertNotNull(negator);
62+
63+
// Register in id order (0 then 1), the only regime in which the header id matches the switch.
64+
try (TypeConformance c0 = session.createTypeConformance(doubler, iface, 0);
65+
TypeConformance c1 = session.createTypeConformance(negator, iface, 1);
66+
ComponentType linked = session.composite(shader, shader.entryPoint("main"), c0, c1)
67+
.link()) {
68+
byte[] doublerHeader = session.getDynamicObjectRTTIBytes(doubler, iface);
69+
byte[] negatorHeader = session.getDynamicObjectRTTIBytes(negator, iface);
70+
71+
assertEquals(16, doublerHeader.length, "RTTI header is 16 bytes");
72+
assertEquals(1, intAt(doublerHeader, 0), "byte 0-3 is the valid-type marker");
73+
assertEquals(0, intAt(doublerHeader, 8), "Doubler's id (0) sits at offset 8");
74+
assertEquals(1, intAt(negatorHeader, 0), "byte 0-3 is the valid-type marker");
75+
assertEquals(1, intAt(negatorHeader, 8), "Negator's id (1) sits at offset 8");
76+
77+
String asm = new String(linked.entryPointCode(0, 0), StandardCharsets.UTF_8);
78+
Set<Integer> switchCases = opSwitchCaseLiterals(asm);
79+
assertTrue(switchCases.contains(0), "header id 0 is an OpSwitch case; cases=" + switchCases);
80+
assertTrue(switchCases.contains(1), "header id 1 is an OpSwitch case; cases=" + switchCases);
81+
}
82+
}
83+
}
84+
85+
/**
86+
* Mimics the sprite composer: two stages on different interfaces, compiled separately in one
87+
* session, each registering its own modifiers from id 0. If the sequential id were session-global
88+
* rather than per-interface, stage B's header id (from getDynamicObjectRTTIBytes) would drift off
89+
* the per-stage id its OpSwitch branches on. This proves it does not.
90+
*/
91+
@Test
92+
void headerIdIsPerInterfaceAcrossSeparateCompilesInOneSession() {
93+
String stageA = """
94+
public interface IA { float apply(float x); }
95+
public struct A0 : IA { public float apply(float x) { return x + 1.0; } }
96+
public struct A1 : IA { public float apply(float x) { return x + 2.0; } }
97+
""";
98+
String stageB = """
99+
public interface IB { float apply(float x); }
100+
public struct B0 : IB { public float apply(float x) { return x * 3.0; } }
101+
public struct B1 : IB { public float apply(float x) { return x * 4.0; } }
102+
""";
103+
String shaderA = """
104+
import a;
105+
RWStructuredBuffer<float> r;
106+
StructuredBuffer<IA> g;
107+
[shader("compute")] [numthreads(1,1,1)]
108+
void main(uint3 t : SV_DispatchThreadID) { float v = 1.0; for (uint i = 0; i < 2; ++i) v = g[i].apply(v); r[t.x] = v; }
109+
""";
110+
String shaderB = """
111+
import b;
112+
RWStructuredBuffer<float> r;
113+
StructuredBuffer<IB> g;
114+
[shader("compute")] [numthreads(1,1,1)]
115+
void main(uint3 t : SV_DispatchThreadID) { float v = 1.0; for (uint i = 0; i < 2; ++i) v = g[i].apply(v); r[t.x] = v; }
116+
""";
117+
try (GlobalSession global = Slang.createGlobalSession();
118+
Session session = global.newSession()
119+
.target(CompileTarget.SPIRV_ASM, t -> t.profile("spirv_1_5"))
120+
.create()) {
121+
Module a = session.loadModuleFromSource("a", stageA);
122+
Module b = session.loadModuleFromSource("b", stageB);
123+
Module sa = session.loadModuleFromSource("sa", shaderA);
124+
Module sb = session.loadModuleFromSource("sb", shaderB);
125+
ShaderReflection ra = a.layout(0);
126+
ShaderReflection rb = b.layout(0);
127+
TypeReflection ia = ra.findTypeByName("IA");
128+
TypeReflection ib = rb.findTypeByName("IB");
129+
130+
// Stage A first: two conformances (ids 0, 1) — this advances any session-global counter.
131+
try (TypeConformance ca0 = session.createTypeConformance(ra.findTypeByName("A0"), ia, 0);
132+
TypeConformance ca1 = session.createTypeConformance(ra.findTypeByName("A1"), ia, 1);
133+
ComponentType la = session.composite(sa, sa.entryPoint("main"), ca0, ca1)
134+
.link()) {
135+
la.entryPointCode(0, 0);
136+
}
137+
138+
// Stage B in the same session: its modifiers registered from id 0 again (per-interface).
139+
try (TypeConformance cb0 = session.createTypeConformance(rb.findTypeByName("B0"), ib, 0);
140+
TypeConformance cb1 = session.createTypeConformance(rb.findTypeByName("B1"), ib, 1);
141+
ComponentType lb = session.composite(sb, sb.entryPoint("main"), cb0, cb1)
142+
.link()) {
143+
int b0Id = intAt(session.getDynamicObjectRTTIBytes(rb.findTypeByName("B0"), ib), 8);
144+
Set<Integer> switchCasesB =
145+
opSwitchCaseLiterals(new String(lb.entryPointCode(0, 0), StandardCharsets.UTF_8));
146+
assertEquals(0, b0Id, "stage B's first modifier is id 0 (per-interface, not session-global)");
147+
assertTrue(
148+
switchCasesB.contains(b0Id),
149+
"B0 header id " + b0Id + " is a stage-B OpSwitch case; cases=" + switchCasesB);
150+
}
151+
}
152+
}
153+
154+
private static int intAt(byte[] bytes, int offset) {
155+
return ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).getInt(offset);
156+
}
157+
158+
// The integer case labels of every OpSwitch in the SPIR-V assembly. Format:
159+
// OpSwitch %selector %default <lit> %target <lit> %target ...
160+
private static Set<Integer> opSwitchCaseLiterals(String asm) {
161+
Set<Integer> cases = new HashSet<>();
162+
for (String line : asm.split("\n")) {
163+
int at = line.indexOf("OpSwitch");
164+
if (at < 0) {
165+
continue;
166+
}
167+
String[] tokens = line.substring(at).trim().split("\\s+");
168+
for (int i = 3; i + 1 < tokens.length; i += 2) {
169+
try {
170+
cases.add(Integer.parseInt(tokens[i]));
171+
} catch (NumberFormatException notALiteral) {
172+
// A comment token or non-integer operand — skip.
173+
}
174+
}
175+
}
176+
return cases;
177+
}
178+
}

0 commit comments

Comments
 (0)