From 9b1df16c1030e7b98e1bdbce819bf1b25528999d Mon Sep 17 00:00:00 2001 From: Oleg Plakhotniuk Date: Mon, 20 Jul 2026 17:15:56 -0400 Subject: [PATCH 1/3] dasSpirv: base value/vector/matrix/swizzle emit machinery, with the inline pure-lvalue fix and tests --- daslib/shader_block_layout.das | 33 + daslib/shader_lingua_franca.das | 50 ++ doc/source/reference/spirv.rst | 7 +- modules/dasSpirv/spirv/spirv_builder.das | 13 + modules/dasSpirv/spirv/spirv_emit.das | 717 +++++++++++++++--- src/ast/ast_inline.cpp | 30 +- .../_fc_local_struct_var_nested.das | 28 + .../spirv/_fail_closed/_fc_matrix_dyn_col.das | 20 + tests/spirv/_fail_closed/_fc_wswz.das | 12 +- tests/spirv/_spirv_common.das | 19 + tests/spirv/_spirv_xmod.das | 34 + tests/spirv/test_bit_cast.das | 54 ++ tests/spirv/test_const_arrays.das | 16 + tests/spirv/test_fail_closed.das | 12 +- tests/spirv/test_fmod.das | 51 ++ tests/spirv/test_matrix_ctor.das | 167 ++++ tests/spirv/test_struct_local.das | 106 +++ tests/spirv/test_struct_value.das | 66 ++ tests/spirv/test_vec_compare.das | 59 ++ tests/spirv/test_vec_mod.das | 75 ++ tests/spirv/test_vec_shift.das | 72 ++ tests/spirv/test_write_swizzle.das | 143 ++++ 22 files changed, 1659 insertions(+), 125 deletions(-) create mode 100644 tests/spirv/_fail_closed/_fc_local_struct_var_nested.das create mode 100644 tests/spirv/_fail_closed/_fc_matrix_dyn_col.das create mode 100644 tests/spirv/test_bit_cast.das create mode 100644 tests/spirv/test_fmod.das create mode 100644 tests/spirv/test_matrix_ctor.das create mode 100644 tests/spirv/test_struct_local.das create mode 100644 tests/spirv/test_struct_value.das create mode 100644 tests/spirv/test_vec_compare.das create mode 100644 tests/spirv/test_vec_mod.das create mode 100644 tests/spirv/test_vec_shift.das create mode 100644 tests/spirv/test_write_swizzle.das diff --git a/daslib/shader_block_layout.das b/daslib/shader_block_layout.das index 5c083cf282..33d85ef2ae 100644 --- a/daslib/shader_block_layout.das +++ b/daslib/shader_block_layout.das @@ -80,12 +80,45 @@ def public component_count(bt : Type) : int { return 0 } +// Type -> (class, component bit WIDTH, lanes), extending the 32-bit classifiers above to the 64-bit +// scalars they exclude (int64/uint64/double, which lay out as block members but carry no arithmetic). +// Width travels with the class so a 32-bit-only consumer can test width, not just class. +def public numeric_info(bt : Type) : tuple { + if (bt == Type.tInt64) return (ok = true, cls = 0, width = 64, lanes = 1) + if (bt == Type.tUInt64) return (ok = true, cls = 1, width = 64, lanes = 1) + if (bt == Type.tDouble) return (ok = true, cls = 2, width = 64, lanes = 1) + let c = scalar_class(bt) + if (c < 0) return (ok = false, cls = -1, width = 0, lanes = 0) + return (ok = true, cls = c, width = scalar_width(bt), lanes = component_count(bt)) +} + // ===== interface-block layout rules ===== enum public BlockLayoutRules { std140 std430 } +// The widths the ARITHMETIC rail lowers: the 16/32-bit families (a native OpIAdd on OpTypeInt 16, +// as glslang gives i16vec2) plus the 64-bit FLOAT (OpFAdd on OpTypeFloat 64, GLSL's double). A +// 64-bit INT is excluded -- it lays out as a block member only, so it fails closed before an opcode. +def public arith_width_ok(cls, width : int) : bool { + return width <= 32 || (width == 64 && cls == 2) +} + +// Class and lane count for the arithmetic rail, gated on arith_width_ok rather than a 32-bit width +// (it carries no byte layout). The 32-bit-only scalar_class / component_count stay as they are, so +// no 16-bit component or double can slip into the std140 layout whose sizes are hardcoded 4-byte. +def public arith_class(bt : Type) : int { + let ni = numeric_info(bt) + return ni.ok && arith_width_ok(ni.cls, ni.width) ? ni.cls : -1 +} + +def public arith_lanes(bt : Type) : int { + if (bt == Type.tBool) return 1 + let ni = numeric_info(bt) + return ni.ok && arith_width_ok(ni.cls, ni.width) ? ni.lanes : 0 +} + // ===== std140 layout for a UBO interface block ===== def public std140_align(bt : Type) : int { let cc = component_count(bt) diff --git a/daslib/shader_lingua_franca.das b/daslib/shader_lingua_franca.das index d58695f88d..6047c2f0f8 100644 --- a/daslib/shader_lingua_franca.das +++ b/daslib/shader_lingua_franca.das @@ -17,6 +17,9 @@ options indenting = 4 module shader_lingua_franca shared public +// float4x4 (the matrix constructors below) lives in math; the vector types are builtin. +require math + // ===== opaque resource types ===== // GL-superset: the `texture2D`/`texture3D` uint carries the GL texture-unit handle the host binds // (glUniform1i). The SPIR-V emitter recognizes each type by NAME (classify_global / sampler_info) @@ -49,6 +52,53 @@ var gl_LocalInvocationID : uint3 // this invocation's position within its w var gl_GlobalInvocationID : uint3 // unique invocation index in the global grid var gl_LocalInvocationIndex : uint // 1D flattening of gl_LocalInvocationID +// ===== matrix constructors ===== +// matCxR(col0, ...) builds a matrix from its columns (GLSL core `mat4(vec4,...)`); daslang matrices +// are column-major, so the emitter lowers the call to one OpCompositeConstruct (glslang's mat4(cols)). +def public float4x4(c0, c1, c2, c3 : float4) : float4x4 { + var m : float4x4 + m[0] = c0 + m[1] = c1 + m[2] = c2 + m[3] = c3 + return m +} + +def public float3x3(c0, c1, c2 : float3) : float3x3 { + var m : float3x3 + m[0] = c0 + m[1] = c1 + m[2] = c2 + return m +} + +// ===== row-vector times matrix ===== +// GLSL's `v * m` (a ROW vector times m) that math lacks: component j is dot(v, column j), exactly +// SPIR-V's OpVectorTimesMatrix, which the emitter lowers to -- so this CPU body runs only on the host. +def public operator * (v : float4; m : float4x4) : float4 { + return float4(dot(v, m[0]), dot(v, m[1]), dot(v, m[2]), dot(v, m[3])) +} + +def public operator * (v : float3; m : float3x3) : float3 { + return float3(dot(v, m[0]), dot(v, m[1]), dot(v, m[2])) +} + +// ===== vector integer modulo ===== +// GLSL's `uvec3 % uvec3` that daslang lacks -> one native OpUMod (the emitter lowers it). Unsigned, +// 3-lane only; signed `%` stays absent since daslang's OpSRem and GLSL's OpSMod differ on sign. +def public operator % (a, b : uint3) : uint3 { + return uint3(a.x % b.x, a.y % b.y, a.z % b.z) +} + +// ===== float modulo ===== +// GLSL's mod(x,y) = x - y*floor(x/y) (sign-of-DIVISOR, one OpFMod) vs daslang's fmod (sign-of- +// dividend, OpFRem): they differ on a negative dividend, so a mod() port must reach THIS, not fmod. +[unused_argument(x, y), sideeffects] +def public mod(x, y : float) : float { return 0.0 } + +[unused_argument(x, y), sideeffects] +def public mod(x, y : float3) : float3 { return float3(0.0, 0.0, 0.0) } + // ===== texture sampling ===== // texture(s, uv): filtered sample with implicit LOD (fragment-only on both rails). textureSize: // the mip-0 dimensions in texels. diff --git a/doc/source/reference/spirv.rst b/doc/source/reference/spirv.rst index 3311243ac5..95dee2c88a 100644 --- a/doc/source/reference/spirv.rst +++ b/doc/source/reference/spirv.rst @@ -247,6 +247,9 @@ Type and layout mapping * - ``struct`` (in ``@uniform``) - ``OpTypeStruct`` + ``Block`` - std140 member ``Offset``\ s + * - ``struct`` (local / parameter / result) + - ``OpTypeStruct``, undecorated + - none — a value, not an interface block Matrix · vector and matrix · matrix use SPIR-V's default column-major convention, so a daslang ``M * v`` is ``OpMatrixTimesVector`` with the matrix uploaded as-is. @@ -263,7 +266,9 @@ string builders / ``goto`` — these are not shader constructs. (``OpLoopMerge``), ``break`` / ``continue``, early ``return``, and the ternary ``?:`` (``OpSelect``, branchless). * **Operators:** full scalar and vector arithmetic (``+ - * / %``, unary ``-``), comparisons - (``== != < > <= >=``), logical ``&&`` / ``||``, and matrix/vector products. + (``== != < > <= >=``), logical ``&&`` / ``||``, and matrix/vector products. A whole-vector + ``==`` / ``!=`` yields a single ``bool`` (like GLSL): the component-wise compare is reduced with + ``OpAll`` / ``OpAny``. * **Math:** ``dot`` (``OpDot``) plus the GLSL.std.450 set — ``sin`` / ``cos`` / ``tan`` / ``pow`` / ``exp`` / ``log`` / ``sqrt`` / ``rsqrt`` / ``floor`` / ``ceil`` / ``fract`` / ``abs`` / ``min`` / ``max`` / ``lerp`` / ``length`` / ``distance`` / ``normalize`` / ``cross`` / ``reflect`` / diff --git a/modules/dasSpirv/spirv/spirv_builder.das b/modules/dasSpirv/spirv/spirv_builder.das index c7c38f0ffa..3c457f2c7c 100644 --- a/modules/dasSpirv/spirv/spirv_builder.das +++ b/modules/dasSpirv/spirv/spirv_builder.das @@ -481,6 +481,19 @@ def public const_uint(var m : SpirvModule; v : uint) : uint { return id } +// The zero of ANY type, whatever its shape -- OpConstantNull takes the type id and needs no +// per-component constituents, so one entry serves scalars, vectors, matrices and arrays alike. +// Keyed by that type id, which the type pool has already made unique. +def public const_null(var m : SpirvModule; t : uint) : uint { + let key = "cn_{t}" + let ex = m.const_pool?[key] ?? 0u + return ex if (ex != 0u) + let id = alloc_id(m) + emit(m, SEC_TYPES, SpvOp.ConstantNull, t, id) + m.const_pool |> insert(key, id) + return id +} + // Signed-int and float OpConstants. The literal word is the raw 32-bit pattern: uint(v) carries an // int's two's-complement bits; reinterpret carries a float's IEEE-754 bits. Keyed separately from // const_uint so int 2 and uint 2 (distinct types) don't collide in the pool. diff --git a/modules/dasSpirv/spirv/spirv_emit.das b/modules/dasSpirv/spirv/spirv_emit.das index eadee3218c..97a5bf86c6 100644 --- a/modules/dasSpirv/spirv/spirv_emit.das +++ b/modules/dasSpirv/spirv/spirv_emit.das @@ -539,7 +539,7 @@ def private classify_mesh_output(var m : SpirvModule; var ctx : EmitCtx; v : Var // matrix / fixed array); returns 0 + a pushed error otherwise. def private build_plain_struct(var m : SpirvModule; sname : string; st : Structure?; var errors : array) : uint { if (st == null || empty(st.fields)) { - errors |> push("@task_payload '{sname}' must be a struct with at least one field") + errors |> push("plain struct '{sname}' must be a struct with at least one field") return 0u } var members : array @@ -548,7 +548,7 @@ def private build_plain_struct(var m : SpirvModule; sname : string; st : Structu // Offset / ArrayStride). Nested-struct fields would need a recursive PLAIN struct (build_block_struct // adds offsets, which TaskPayloadWorkgroupEXT must not have), so reject those fail-closed for now. if (!is_emittable_value_type(fld._type)) { - errors |> push("@task_payload '{sname}': field '{fld.name}' must be a scalar / vector / matrix / fixed array of those (nested-struct payload fields are not yet supported)") + errors |> push("plain struct '{sname}': field '{fld.name}' must be a scalar / vector / matrix / fixed array of those (nested-struct fields are not yet supported)") delete members return 0u } @@ -563,6 +563,102 @@ def private build_plain_struct(var m : SpirvModule; sname : string; st : Structu return id } +// The undecorated OpTypeStruct behind a struct VALUE -- a struct-typed function parameter, result, or +// local. Deduped by Structure identity, which build_plain_struct itself does not do: a payload emits +// its type once, but a value struct is named by every signature and every construction that mentions +// it, and each would otherwise mint a fresh (and, to spirv-val, distinct) OpTypeStruct. +def private value_struct_type(var m : SpirvModule; st : Structure?; var errors : array) : uint { + if (st == null) { + errors |> push("struct value: missing structure type") + return 0u + } + let key = "vstruct_{intptr(st)}" + let ex = m.type_pool?[key] ?? 0u + return ex if (ex != 0u) + let id = build_plain_struct(m, "{st.name}", st, errors) + if (id != 0u) { + m.type_pool |> insert(key, id) + } + return id +} + +// emit_type, plus the struct case it panics on. Only the sites that legitimately carry a struct VALUE +// (function signatures, struct construction) go through this; everywhere else keeps emit_type, so a +// struct still cannot slip into a std140 block layout or a @workgroup variable. +def private emit_value_type(var m : SpirvModule; t : TypeDecl?; var errors : array) : uint { + if (t != null && t.baseType == Type.tStructure) { + return value_struct_type(m, t.structType, errors) + } + return emit_type(m, t) +} + +// ===== named layout-index constant resolution: @binding / @set / @location / +// @input_attachment_index accept a module-scope `let` int constant by name, not only a literal ===== + +// Fold a module-scope `let NAME = ` to its value. The layout annotations store an +// identifier argument as a STRING, so `@binding = NAME` is resolved here by scanning the compiling +// program's globals for a constant `let` of that name. The returned `found` is false when no such +// int-constant global exists, letting the caller error instead of silently using the fallback. +def private const_int_by_name(name : string) : tuple { + var result = 0 + var got = false + program_for_each_module(compiling_program()) $(mod) { + for_each_global(mod) $(gv) { + if (!got && gv.name == name && gv._type != null && gv._type.flags.constant && gv.init != null) { + let ci = gv.init ?as ExprConstInt + if (ci != null) { + result = ci.value + got = true + } else { + let cu = gv.init ?as ExprConstUInt + if (cu != null) { + result = int(cu.value) + got = true + } else { + // A bool const folds to 0/1 -- lets a named boolean `let` (a config flag + // in its das form) resolve where an int constant is expected. + let cb = gv.init ?as ExprConstBool + if (cb != null) { + result = cb.value ? 1 : 0 + got = true + } + } + } + } + } + } + return (found = got, value = result) +} + +// Read an integer annotation arg. A literal int/uint is used as-is; an identifier (stored as a string +// arg) resolves to a module-scope constant `let` of that name; an absent or wrong-typed arg yields +// `deflt` (the historical `?as tInt ?? deflt` fallback). An unresolvable name is a hard error. +// Used by the layout indices (@binding / @set / @location / @input_attachment_index / @enabled) and by +// the [_shader] macro for local_size_{x,y,z}, so a shader names a platform-configured workgroup +// size instead of hardcoding it. Public for spirv_shader. +def public annotation_int(ann : AnnotationArgumentList; argn : string; deflt : int; var errors : array) : int { + let rv = ann |> find_arg(argn) + if (rv is tInt) { + return rv as tInt + } + if (rv is tUInt) { + return int(rv as tUInt) + } + if (rv is tBool) { // a literal bool arg (e.g. @enabled = false) -> 0/1 + return (rv as tBool) ? 1 : 0 + } + if (rv is tString) { + let nm = rv as tString + let r = const_int_by_name(nm) + if (!r.found) { + errors |> push("@{argn} = {nm}: not an integer constant `let` in scope") + return deflt + } + return r.value + } + return deflt +} + // ===== global variable classification + emission ===== def private classify_global(var m : SpirvModule; var ctx : EmitCtx; v : VariablePtr; stage : ShaderStage; var reflection : SpirvReflection; var errors : array) { let vname = "{v.name}" @@ -600,12 +696,18 @@ def private classify_global(var m : SpirvModule; var ctx : EmitCtx; v : Variable } // gl_PrimitiveID in a fragment of a mesh pipeline: the id is not fixed-function-generated, so // reading it pulls in MeshShadingEXT + SPV_EXT_mesh_shader (→ SPIR-V 1.4), same as the mesh stage. - // It is an integer fragment Input, so it MUST be Flat (no interpolation for ints; VUID-…-Flat-04744). - // In a CLOSEST-HIT shader the same builtin is the hit triangle's index: no interpolation happens - // there (Flat is fragment-interface-only) and the capability is RayTracingKHR, already enabled - // by the stage itself -- so the fragment-side extras are fragment-gated. + // In a CLOSEST-HIT shader the same builtin is the hit triangle's index, and the capability is + // RayTracingKHR, already enabled by the stage itself -- so this extra is fragment-gated. if (bi.bi == SpvBuiltIn.PrimitiveId && stage == ShaderStage.Fragment) { ensure_per_primitive(m, ctx) + } + // An integer-typed fragment Input cannot be interpolated, so SPIR-V requires it to be Flat + // (VUID-StandaloneSpirv-Flat-04744) -- gl_PrimitiveID is the int/uint-typed fragment Input + // emitted here. Flat is a fragment-interface rule: the same builtin in a closest-hit stage + // takes no decoration. + let builtin_cls = scalar_class(v._type.baseType) + if (stage == ShaderStage.Fragment && bi.storage == SpvStorageClass.Input && + builtin_cls >= 0 && builtin_cls <= 1) { emit(m, SEC_DECOR, SpvOp.Decorate, vid, uint(SpvDecoration.Flat)) } // The subgroup builtins are the GroupNonUniform feature's data plane, not part of the base @@ -636,7 +738,7 @@ def private classify_global(var m : SpirvModule; var ctx : EmitCtx; v : Variable errors |> push("stage variable '{vname}' cannot be both @in and @out") return } - let loc = v.annotation |> find_arg("location") ?as tInt ?? -1 + let loc = annotation_int(v.annotation, "location", -1, errors) if (loc < 0) { errors |> push("stage variable '{vname}' must specify a non-negative @location (e.g. @in @location = 0)") return @@ -755,8 +857,8 @@ def private classify_global(var m : SpirvModule; var ctx : EmitCtx; v : Variable let pt = type_pointer(m, SpvStorageClass.UniformConstant, arr) let vid = alloc_id(m) emit(m, SEC_TYPES, SpvOp.Variable, pt, vid, uint(SpvStorageClass.UniformConstant)) - let set = v.annotation |> find_arg("set") ?as tInt ?? 0 - let binding = v.annotation |> find_arg("binding") ?as tInt ?? 0 + let set = annotation_int(v.annotation, "set", 0, errors) + let binding = annotation_int(v.annotation, "binding", 0, errors) if (set < 0 || binding < 0) { errors |> push("sampler descriptor array '{vname}' set/binding must be non-negative (got set={set}, binding={binding})") return @@ -780,8 +882,8 @@ def private classify_global(var m : SpirvModule; var ctx : EmitCtx; v : Variable let pt = type_pointer(m, SpvStorageClass.UniformConstant, simg) let vid = alloc_id(m) emit(m, SEC_TYPES, SpvOp.Variable, pt, vid, uint(SpvStorageClass.UniformConstant)) - let set = v.annotation |> find_arg("set") ?as tInt ?? 0 - let binding = v.annotation |> find_arg("binding") ?as tInt ?? 0 + let set = annotation_int(v.annotation, "set", 0, errors) + let binding = annotation_int(v.annotation, "binding", 0, errors) if (set < 0 || binding < 0) { errors |> push("sampler '{vname}' set/binding must be non-negative (got set={set}, binding={binding})") return @@ -812,8 +914,8 @@ def private classify_global(var m : SpirvModule; var ctx : EmitCtx; v : Variable let pt = type_pointer(m, SpvStorageClass.UniformConstant, img) let vid = alloc_id(m) emit(m, SEC_TYPES, SpvOp.Variable, pt, vid, uint(SpvStorageClass.UniformConstant)) - let set = v.annotation |> find_arg("set") ?as tInt ?? 0 - let binding = v.annotation |> find_arg("binding") ?as tInt ?? 0 + let set = annotation_int(v.annotation, "set", 0, errors) + let binding = annotation_int(v.annotation, "binding", 0, errors) if (set < 0 || binding < 0) { errors |> push("storage image '{vname}' set/binding must be non-negative (got set={set}, binding={binding})") return @@ -844,9 +946,9 @@ def private classify_global(var m : SpirvModule; var ctx : EmitCtx; v : Variable let pt = type_pointer(m, SpvStorageClass.UniformConstant, img) let vid = alloc_id(m) emit(m, SEC_TYPES, SpvOp.Variable, pt, vid, uint(SpvStorageClass.UniformConstant)) - let set = v.annotation |> find_arg("set") ?as tInt ?? 0 - let binding = v.annotation |> find_arg("binding") ?as tInt ?? 0 - let iax = v.annotation |> find_arg("input_attachment_index") ?as tInt ?? -1 + let set = annotation_int(v.annotation, "set", 0, errors) + let binding = annotation_int(v.annotation, "binding", 0, errors) + let iax = annotation_int(v.annotation, "input_attachment_index", -1, errors) if (set < 0 || binding < 0) { errors |> push("subpassInput '{vname}' set/binding must be non-negative (got set={set}, binding={binding})") return @@ -877,8 +979,8 @@ def private classify_global(var m : SpirvModule; var ctx : EmitCtx; v : Variable errors |> push("accelerationStructureEXT '{vname}' is only valid in a raygen / miss / closest-hit shader (ray queries from other stages are not supported yet)") return } - let set = v.annotation |> find_arg("set") ?as tInt ?? 0 - let binding = v.annotation |> find_arg("binding") ?as tInt ?? 0 + let set = annotation_int(v.annotation, "set", 0, errors) + let binding = annotation_int(v.annotation, "binding", 0, errors) if (set < 0 || binding < 0) { errors |> push("accelerationStructureEXT '{vname}' set/binding must be non-negative (got set={set}, binding={binding})") return @@ -896,8 +998,8 @@ def private classify_global(var m : SpirvModule; var ctx : EmitCtx; v : Variable return } if (stn == "texture2D" || stn == "sampler") { - let set = v.annotation |> find_arg("set") ?as tInt ?? 0 - let binding = v.annotation |> find_arg("binding") ?as tInt ?? 0 + let set = annotation_int(v.annotation, "set", 0, errors) + let binding = annotation_int(v.annotation, "binding", 0, errors) if (set < 0 || binding < 0) { errors |> push("'{vname}' set/binding must be non-negative (got set={set}, binding={binding})") return @@ -934,8 +1036,8 @@ def private classify_global(var m : SpirvModule; var ctx : EmitCtx; v : Variable let pt = type_pointer(m, SpvStorageClass.StorageBuffer, st) let vid = alloc_id(m) emit(m, SEC_TYPES, SpvOp.Variable, pt, vid, uint(SpvStorageClass.StorageBuffer)) - let set = v.annotation |> find_arg("set") ?as tInt ?? 0 - let binding = v.annotation |> find_arg("binding") ?as tInt ?? 0 + let set = annotation_int(v.annotation, "set", 0, errors) + let binding = annotation_int(v.annotation, "binding", 0, errors) if (set < 0 || binding < 0) { errors |> push("ssbo '{vname}' set/binding must be non-negative (got set={set}, binding={binding})") return @@ -959,8 +1061,8 @@ def private classify_global(var m : SpirvModule; var ctx : EmitCtx; v : Variable let pt = type_pointer(m, SpvStorageClass.Uniform, bs.type_id) let vid = alloc_id(m) emit(m, SEC_TYPES, SpvOp.Variable, pt, vid, uint(SpvStorageClass.Uniform)) - let set = v.annotation |> find_arg("set") ?as tInt ?? 0 - let binding = v.annotation |> find_arg("binding") ?as tInt ?? 0 + let set = annotation_int(v.annotation, "set", 0, errors) + let binding = annotation_int(v.annotation, "binding", 0, errors) if (set < 0 || binding < 0) { errors |> push("uniform '{vname}' set/binding must be non-negative (got set={set}, binding={binding})") return @@ -1419,6 +1521,17 @@ def private vector_ctor(name : string) : tuple column-vector width + column count, else +// ok=false. GLSL's `matCxR(col0, col1, ...)` column form takes `cols` column vectors, each a +// floatW. daslang matrices are column-major (m[i] is column i), so the constituents map straight +// onto OpCompositeConstruct's column operands. Mirrors matrix_info's name->dims table. +def private matrix_ctor(name : string) : tuple { + if (name == "float4x4") return (ok = true, width = 4, cols = 4) + if (name == "float3x3") return (ok = true, width = 3, cols = 3) + if (name == "float3x4") return (ok = true, width = 3, cols = 4) + return (ok = false, width = 0, cols = 0) +} + // OpExtInstImport "GLSL.std.450" once per module; returns the cached set id. def private ensure_glsl_ext(var m : SpirvModule; var ctx : EmitCtx) : uint { if (ctx.glsl_ext != 0u) return ctx.glsl_ext @@ -1610,6 +1723,9 @@ def private glsl_ext_op(name : string; cls : int) : tuple uint (two 16-bit halfs) return (ok = false, op = GLSLstd450.Round) } @@ -1750,9 +1867,34 @@ def private emit_const(var m : SpirvModule; e : ExpressionPtr; var errors : arra let v = cu4.value return (ok = true, id = const_composite(m, emit_type(m, e._type), [const_uint(m, v.x), const_uint(m, v.y), const_uint(m, v.z), const_uint(m, v.w)])) } - // all-constant vector constructor -> OpConstantComposite + // all-constant vector / matrix constructor -> OpConstantComposite let call = e ?as ExprCall if (call != null) { + // matCxR(col0, ...) of constant columns: an OpConstantComposite of the column constants, + // which is what glslang folds a `const mat4` global to. Gated on the argument count like + // the emit path's matrix ctor, so a matrix-RETURNING user function stays not-a-constant. + let mc = matrix_ctor("{call.name}") + if (mc.ok && length(call.arguments) == mc.cols) { + var cols : array + cols |> reserve(mc.cols) + for (a in call.arguments) { + if (component_count(a._type.baseType) != mc.width || scalar_class(a._type.baseType) != 2) { + errors |> push("{call.name}: constant column of type {a._type.baseType} is not a float{mc.width}") + delete cols + return (ok = false, id = 0u) + } + let cr = emit_const(m, a, errors) + if (!cr.ok) { + errors |> push("{call.name}: constructor column is not a compile-time constant") + delete cols + return (ok = false, id = 0u) + } + cols |> push(cr.id) + } + let mid = const_composite(m, emit_type(m, e._type), cols) + delete cols + return (ok = true, id = mid) + } let vc = vector_ctor("{call.name}") if (!vc.ok) return (ok = false, id = 0u) var total = 0 @@ -1866,22 +2008,31 @@ def private splat_to_vec(var m : SpirvModule; vec_type : uint; scalar_id : uint; return res } +// The vector type a scalar OPERAND splats to: `n` lanes of that operand's own component type, NOT of +// the binop's result type. The two agree for arithmetic, but a shift's Shift operand only has to +// match Base's component COUNT -- `uint4 >> 8` (the only vector-shift spelling daslang has, the +// shift count being an int) would otherwise build a v4uint out of int constituents, which compiles +// and fails spirv-val. +def private splat_type(var m : SpirvModule; scalar : TypeDecl?; n : int) : uint { + return type_vector(m, emit_type(m, scalar), n) +} + // ===== `*` over matrices / vectors / scalars ===== // SPIR-V splits multiplication by operand shape: OpMatrixTimesMatrix / OpMatrixTimesVector / // OpVectorTimesScalar. Plain component-wise FMul/IMul (the scalar binop path) only works when both // operands have the SAME shape, so the mixed cases are routed here first. Returns handled=false for // same-shape operands (let binop_code emit FMul/IMul); handled=true with id=0 on an unsupported mix // (error already pushed). Operands l/r are pre-emitted result-ids. Handled: matrix*matrix, -// matrix*vector, float vector*scalar (OpVectorTimesScalar, either order), and integer vector*scalar -// (splat + OpIMul, either order). daslang has no matrix*scalar or vector*matrix operator, so those -// SPIR-V ops are intentionally absent. +// matrix*vector, vector*matrix, float vector*scalar (OpVectorTimesScalar, either order), and integer +// vector*scalar (splat + OpIMul, either order). daslang has no matrix*scalar operator, so that +// SPIR-V op is intentionally absent. def private emit_mul(var m : SpirvModule; op2 : ExprOp2?; e : ExpressionPtr; l, r : uint; var errors : array) : tuple { let lmat = matrix_info(op2.left._type) let rmat = matrix_info(op2.right._type) - let lcc = component_count(op2.left._type.baseType) - let rcc = component_count(op2.right._type.baseType) - let lcls = scalar_class(op2.left._type.baseType) - let rcls = scalar_class(op2.right._type.baseType) + let lcc = arith_lanes(op2.left._type.baseType) + let rcc = arith_lanes(op2.right._type.baseType) + let lcls = arith_class(op2.left._type.baseType) + let rcls = arith_class(op2.right._type.baseType) // same-shape (scalar*scalar or equal-width vector*vector): component-wise, defer to binop_code if (!lmat.ok && !rmat.ok && lcc == rcc) return (handled = false, id = 0u) let rt = emit_type(m, e._type) @@ -1890,6 +2041,8 @@ def private emit_mul(var m : SpirvModule; op2 : ExprOp2?; e : ExpressionPtr; l, emit(m, SEC_FUNCS, SpvOp.MatrixTimesMatrix, rt, res, l, r) } elif (lmat.ok && rcc > 1) { emit(m, SEC_FUNCS, SpvOp.MatrixTimesVector, rt, res, l, r) + } elif (rmat.ok && lcc > 1) { + emit(m, SEC_FUNCS, SpvOp.VectorTimesMatrix, rt, res, l, r) } elif (lcc > 1 && rcc == 1 && lcls == 2 && rcls == 2) { emit(m, SEC_FUNCS, SpvOp.VectorTimesScalar, rt, res, l, r) } elif (rcc > 1 && lcc == 1 && lcls == 2 && rcls == 2) { @@ -1915,16 +2068,22 @@ def private emit_mul(var m : SpirvModule; op2 : ExprOp2?; e : ExpressionPtr; l, def private declare_local_var(var m : SpirvModule; var ctx : EmitCtx; v : VariablePtr; var errors : array) { if (key_exists(ctx.local_vars, intptr(v))) return let bt = v._type.baseType - if (scalar_class(bt) < 0 && bt != Type.tBool && bt != Type.tFixedArray) { + // a matrix is a tHandle, so numeric_info does not see it -- emit_type lowers it to OpTypeMatrix, + // which is a perfectly good Function-storage pointee (glslang gives a local `mat4` an OpVariable too) + if (!is_emittable_value_type(v._type) && bt != Type.tFixedArray && bt != Type.tStructure && + !matrix_info(v._type).ok) { // emit_type would panic on an unsupported base type; reject with a clean error instead. if (cpu_only_lattice_width(bt)) { errors |> push("local '{v.name}': type {bt} is CPU-only (8/16-lane lattice forms do not exist in shaders — vectors cap at 4 lanes)") } else { - errors |> push("local '{v.name}': type {bt} is not supported yet (only int/uint/float scalars+vectors, bool, and fixed arrays)") + errors |> push("local '{v.name}': type {bt} is not supported yet (only int/uint/float scalars+vectors+matrices, bool, fixed arrays, and structs of those)") } return } - let vt = emit_type(m, v._type) + // a struct local takes the same undecorated type id its by-value signatures use, so passing one + // to a helper needs no relayout; emit_value_type returns 0 when a field has no lowering. + let vt = emit_value_type(m, v._type, errors) + if (vt == 0u) return let pt = type_pointer(m, SpvStorageClass.Function, vt) let pid = alloc_id(m) emit(m, SEC_FUNCS, SpvOp.Variable, pt, pid, uint(SpvStorageClass.Function)) @@ -2020,6 +2179,7 @@ class SpirvEmit : AstVisitor { ite_ids : table // intptr(ExprIfThenElse) -> its {merge, then, else} labels loop_ids : table // intptr(ExprWhile/ExprFor) -> its 5 loop-skeleton labels for_src : table // for-source range() call ptrs to intercept in visitExprCall (6.3) + copy_dst : uint64 // intptr(ExprCopy.left) while that destination subtree is walked errs : array // coerce a visited child to an rvalue id: a value passes through; a pointer is OpLoad'd here (the @@ -2042,6 +2202,21 @@ class SpirvEmit : AstVisitor { errs |> push("internal: no rvalue available for {e.__rtti}") return 0u } + // Does this expression name MEMORY -- something an OpAccessChain can chain a component off? + // A `var` local (its OpVariable), an already-chained pointer (an ssbo element, a block member), + // or a global with an OpVariable. A `let` local, a parameter and a call result are SSA VALUES, + // and a folded module-scope `let` global is an OpConstant: none of them has a pointer, so a + // component of one has to be extracted rather than chained. + def is_addressable(e : ExpressionPtr) : bool { + if (key_exists(e2ptr, intptr(e))) return true + let gv = global_var_of(e) + if (gv != null) return !(ctx.globals?[intptr(gv)] ?? GlobalInfo()).is_constant + let ev = e ?as ExprVar + if (ev != null && (ev.varFlags.local || ev.varFlags.argument || ev.varFlags._block)) { + return (ctx.local_vars?[intptr(ev.variable)] ?? LocalVar()).ptr_id != 0u + } + return false + } // Resolve a sampler/sampler-array argument to a loaded sampled-image SSA id. Handles BOTH // the scalar form `texture(my_sampler, uv)` (gi.is_sampler) AND the descriptor-array form // `texture(my_arr[idx], uv)` (gi.is_sampler_array): emits OpAccessChain + OpLoad and cascades @@ -2103,14 +2278,42 @@ class SpirvEmit : AstVisitor { } return (sg = arr_g, sg_type = arr_g._type.firstType, simg = simg_id, ok = true) } + // The storage class of the memory an addressable expression chains into: the root global's for a + // global-rooted chain, Function for one rooted at a `var` local (its OpVariable is Function-storage). + // An OpAccessChain result type must name the storage class it chains INTO, so a field of a struct + // local and a field of an SSBO element cannot share one default. + def root_storage_of(e : ExpressionPtr) : SpvStorageClass { + let rv = root_global_of(e) + let rgi = rv != null ? (ctx.globals?[intptr(rv)] ?? GlobalInfo()) : GlobalInfo() + return rgi.var_id != 0u ? rgi.storage : SpvStorageClass.Function + } + def ptr_of(e : ExpressionPtr) : tuple { let k = intptr(e) if (key_exists(e2ptr, k)) return (id = e2ptr?[k] ?? 0u, pty = e2pty?[k] ?? 0u) - // a swizzle that produced a value (not a pointer) used as a write target: a multi-component - // swizzle (`v.xy = …`) or a single-component swizzle of a value. Fail-closed with a clear - // message rather than the generic "internal: not an lvalue". + // ONE component of a vector that is itself addressable (ssbo[i].x, block.field.y, shared[i].z): + // chain a component index off the base pointer. Emitted HERE rather than in visitExprSwizzle + // because only a pointer CONSUMER (a store, a compound assign, an atomic target) needs it -- + // a read stays the CompositeExtract off the loaded vector that visitExprSwizzle already emits, + // so no body that compiles today gains an instruction. + let sw = e ?as ExprSwizzle + if (sw != null && length(sw.fields) == 1 && key_exists(e2ptr, intptr(sw.value))) { + var bm & = unsafe(*m) + let base = ptr_of(sw.value) + let storage = root_storage_of(sw.value) + let pointee = emit_type(bm, e._type) + let ptr_t = type_pointer(bm, storage, pointee) + let res = alloc_id(bm) + emit(bm, SEC_FUNCS, SpvOp.AccessChain, ptr_t, res, base.id, const_uint(bm, uint(sw.fields[0]))) + e2ptr[k] = res + e2pty[k] = pointee + return (id = res, pty = pointee) + } + // a swizzle of a VALUE used as a pointer: the base has no OpVariable to chain a component off + // (a swizzle of a swizzle, a call result). A multi-lane assignment target lands in + // emit_write_swizzle instead, and reports the same thing there. if (e is ExprSwizzle) { - errs |> push("cannot assign through a swizzle — write-swizzle (e.g. `v.xy = …`) is not supported; assign the whole vector instead") + errs |> push("cannot assign through a swizzle of a value — a write-swizzle target must be addressable (a global, a `var` local, or an SSBO / UBO element)") return (id = 0u, pty = 0u) } // a module-scope constant (`let` fold or @constant_id spec constant) used as a write target: @@ -2146,6 +2349,9 @@ class SpirvEmit : AstVisitor { e2id[intptr(expr)] = const_bool(bm, expr.value) return expr } + // daslang folds a narrowing convert of a literal (`int16(64)`, `2.0h`) to a 16-bit constant NODE, + // so it never reaches the ExprCall convert path -- it is a leaf here. The lattice VECTORS have no + // such leaf by design, so half4(2.0h) stays an OpCompositeConstruct splat of this one constant. def override visitExprConstFloat16(var expr : ExprConstFloat16?) : ExpressionPtr { var bm & = unsafe(*m) e2id[intptr(expr)] = const_float16(bm, float_to_half_bits(float(expr.value))) @@ -2256,9 +2462,16 @@ class SpirvEmit : AstVisitor { def override visitExprSwizzle(var expr : ExprSwizzle?) : ExpressionPtr { var bm & = unsafe(*m) let k = intptr(expr) + // an assignment DESTINATION whose base is addressable: ptr_of (one lane) or emit_write_swizzle + // (several) chains the component pointer off that base. Emitting a value here would be an + // OpLoad of the whole destination that nothing reads. + if (k == copy_dst && key_exists(e2ptr, intptr(expr.value))) return expr let gv = global_var_of(expr.value) - // single-component global -> AccessChain to that one component (keeps it an lvalue: g.x = …) - if (gv != null && length(expr.fields) == 1) { + // single-component global -> AccessChain to that one component (keeps it an lvalue: g.x = …). + // A CONSTANT global (gl_WorkGroupSize, a module-scope `let`) has no OpVariable to chain + // through, so it takes the value path below: CompositeExtract off the folded OpConstant. + if (gv != null && length(expr.fields) == 1 && + !(ctx.globals?[intptr(gv)] ?? GlobalInfo()).is_constant) { let gi = ctx.globals?[intptr(gv)] ?? GlobalInfo() if (gi.var_id == 0u) { // global not classified -> never AccessChain through a 0 base errs |> push("swizzle target global '{gv.name}' is not classified") @@ -2296,6 +2509,50 @@ class SpirvEmit : AstVisitor { def override visitExprAt(var expr : ExprAt?) : ExpressionPtr { var bm & = unsafe(*m) let k = intptr(expr) + // A matrix COLUMN read (m[i], GLSL's mat[i]): a matrix is an emittable VALUE here, not an + // addressable composite, so there is no OpVariable to chain through -- extract the column off + // the loaded matrix. OpCompositeExtract takes LITERAL indices, so the column must be a + // compile-time constant; a dynamic one is rejected rather than lowered into a blob that only + // spirv-val would catch. Must precede the local-variable branch below: a value-`let` matrix is + // an ExprVar with no ptr_id, which that branch would reject as non-indexable. + let mi = matrix_info(expr.subexpr._type) + if (mi.ok) { + let mci = expr.index ?as ExprConstInt + if (mci == null) { + errs |> push("indexing a matrix with a non-constant column index is not supported") + return expr + } + let mbase = value_of(expr.subexpr) + if (mbase == 0u) return expr // operand failed to lower (error already pushed) + let mrt = emit_type(bm, expr._type) + let mres = alloc_id(bm) + emit(bm, SEC_FUNCS, SpvOp.CompositeExtract, mrt, mres, mbase, uint(mci.value)) + e2id[k] = mres + return expr + } + // A vector component under an index (GLSL's v[i]) on a base that is a pure VALUE -- a call + // result, a `let` local, a parameter, a folded constant global -- has no OpVariable to chain a + // component pointer off, so the component comes off the LOADED vector: OpVectorExtractDynamic, + // which is exactly what glslang gives `textureLod(...)[i]`. A vector that IS addressable (a + // `var` local, an ssbo element, a block member) keeps the pointer path below, which is what + // glslang gives `h_min[i]` -- so this must test addressability, not merely vector-ness. + // A constant index takes OpCompositeExtract, the literal-operand form, like the swizzle read. + if (arith_lanes(expr.subexpr._type.baseType) >= 2 && !is_addressable(expr.subexpr)) { + let vbase = value_of(expr.subexpr) + if (vbase == 0u) return expr // operand failed to lower (error already pushed) + let vrt = emit_type(bm, expr._type) + let vres = alloc_id(bm) + let vci = expr.index ?as ExprConstInt + if (vci != null) { + emit(bm, SEC_FUNCS, SpvOp.CompositeExtract, vrt, vres, vbase, uint(vci.value)) + } else { + let vidx = value_of(expr.index) + if (vidx == 0u) return expr // index failed to lower (error already pushed) + emit(bm, SEC_FUNCS, SpvOp.VectorExtractDynamic, vrt, vres, vbase, vidx) + } + e2id[k] = vres + return expr + } let lev = expr.subexpr ?as ExprVar if (lev != null && (lev.varFlags.local || lev.varFlags.argument || lev.varFlags._block)) { let lv = ctx.local_vars?[intptr(lev.variable)] ?? LocalVar() @@ -2315,9 +2572,10 @@ class SpirvEmit : AstVisitor { } let bv = global_var_of(expr.subexpr) if (bv == null) { - // PR-G: indexing a fixed-array field inside a Block (ubo.lights[i] / ubo.material.kernel[i]). - // The subexpr is a chained ExprField whose e2ptr is a pointer to the array. Chain a - // single index off it; the storage class is inherited from the root global. For a + // Indexing a fixed-array field inside a Block (ubo.lights[i] / ubo.material.kernel[i]) or + // inside a struct local (s.arr[i]). The subexpr is a chained ExprField whose e2ptr is a + // pointer to the array. Chain a single index off it, in the storage class the chain roots + // in -- a Function-storage struct local's array must not be chained as StorageBuffer. For a // struct element, plain emit_type would panic (it doesn't lower tStructure) and would // also not return the laid-out (deduped) type — so re-acquire it via build_block_struct, // which is idempotent under the type/decoration dedup pools. @@ -2326,8 +2584,7 @@ class SpirvEmit : AstVisitor { if (idx == 0u) return expr // index failed to lower (error already pushed) let base = ptr_of(expr.subexpr) let rv = root_global_of(expr.subexpr) - let rgi = rv != null ? (ctx.globals?[intptr(rv)] ?? GlobalInfo()) : GlobalInfo() - let storage = rgi.var_id != 0u ? rgi.storage : SpvStorageClass.StorageBuffer + let storage = root_storage_of(expr.subexpr) let rname = rv != null ? string(rv.name) : "(?)" let pointee = field_pointee_type(bm, expr._type, "{rname}[]", storage) if (pointee == 0u) return expr @@ -2397,6 +2654,8 @@ class SpirvEmit : AstVisitor { var bm & = unsafe(*m) let op = "{expr.op}" if (op == "++" || op == "--") { + // scalar_class, NOT arith_class: the increment is const_one, which only ever emits a + // 32-bit constant, so a 16-bit ++ would build OpIAdd(%short, %int_1). Fail it closed. let cls = scalar_class(expr.subexpr._type.baseType) let bo = binop_code(op == "++" ? "+" : "-", cls) if (!bo.ok) { @@ -2423,7 +2682,7 @@ class SpirvEmit : AstVisitor { e2id[intptr(expr)] = res return expr } - let uo = unop_code(op, scalar_class(expr.subexpr._type.baseType)) + let uo = unop_code(op, arith_class(expr.subexpr._type.baseType)) if (!uo.ok) { errs |> push("unsupported unary op '{expr.op}' on {expr.subexpr._type.baseType}") return expr @@ -2457,16 +2716,16 @@ class SpirvEmit : AstVisitor { } } if (res == 0u) { - let bo = binop_code(compound_base(op), scalar_class(expr.left._type.baseType)) + let bo = binop_code(compound_base(op), arith_class(expr.left._type.baseType)) if (!bo.ok) { errs |> push("unsupported compound assignment '{expr.op}'") return expr } // vector = scalar: splat the scalar rhs (SPIR-V arithmetic operands must match) var rhs2 = rhs - let dn = component_count(expr.left._type.baseType) - if (dn > 1 && component_count(expr.right._type.baseType) == 1) { - rhs2 = splat_to_vec(bm, p.pty, rhs, dn) + let dn = arith_lanes(expr.left._type.baseType) + if (dn > 1 && arith_lanes(expr.right._type.baseType) == 1) { + rhs2 = splat_to_vec(bm, splat_type(bm, expr.right._type, dn), rhs, dn) } res = alloc_id(bm) emit(bm, SEC_FUNCS, bo.code, p.pty, res, cur, rhs2) @@ -2475,7 +2734,7 @@ class SpirvEmit : AstVisitor { } return expr } - let cls = scalar_class(expr.left._type.baseType) + let cls = arith_class(expr.left._type.baseType) let l = value_of(expr.left) let r = value_of(expr.right) if (l == 0u || r == 0u) return expr @@ -2491,6 +2750,20 @@ class SpirvEmit : AstVisitor { let cc = cmp_code(op, cls) if (cc.ok) { let bt = type_bool(bm) + // Whole-vector `==` / `!=` yields a SCALAR bool in daslang (and GLSL), but the SPIR-V + // component-wise compare op produces a bvecN — reduce it to the scalar with OpAll + // (`==`: all lanes equal) / OpAny (`!=`: any lane differs), matching what glslang emits. + // A scalar-bool-typed OpFOrdEqual on vector operands is invalid SPIR-V (drivers reject it). + let dn = arith_lanes(expr.left._type.baseType) + if (dn > 1) { + let bvec = type_vector(bm, bt, dn) + let cmp = alloc_id(bm) + emit(bm, SEC_FUNCS, cc.code, bvec, cmp, l, r) + let res = alloc_id(bm) + emit(bm, SEC_FUNCS, op == "!=" ? SpvOp.Any : SpvOp.All, bt, res, cmp) + e2id[intptr(expr)] = res + return expr + } let res = alloc_id(bm) emit(bm, SEC_FUNCS, cc.code, bt, res, l, r) e2id[intptr(expr)] = res @@ -2513,13 +2786,13 @@ class SpirvEmit : AstVisitor { // vector one (daslang broadcasts vec scalar; `*` already went through emit_mul above) var lid = l var rid = r - let rn = component_count(expr._type.baseType) + let rn = arith_lanes(expr._type.baseType) if (rn > 1) { - if (component_count(expr.left._type.baseType) == 1) { - lid = splat_to_vec(bm, rt, l, rn) + if (arith_lanes(expr.left._type.baseType) == 1) { + lid = splat_to_vec(bm, splat_type(bm, expr.left._type, rn), l, rn) } - if (component_count(expr.right._type.baseType) == 1) { - rid = splat_to_vec(bm, rt, r, rn) + if (arith_lanes(expr.right._type.baseType) == 1) { + rid = splat_to_vec(bm, splat_type(bm, expr.right._type, rn), r, rn) } } let res = alloc_id(bm) @@ -2569,8 +2842,22 @@ class SpirvEmit : AstVisitor { } // ----- store: dst = rhs ----- + // The destination subtree is marked while it is walked, so a swizzle target emits no value: it is + // a pointer its consumer builds, and the value path would spend an OpLoad of the whole destination + // that nothing reads. glslang emits neither. + def override preVisitExprCopy(var expr : ExprCopy?) : void { + copy_dst = intptr(expr.left) + } + def override preVisitExprCopyRight(var expr : ExprCopy?; right : ExpressionPtr) : void { + copy_dst = 0ul + } def override visitExprCopy(var expr : ExprCopy?) : ExpressionPtr { var bm & = unsafe(*m) + let sw = expr.left ?as ExprSwizzle + if (sw != null && length(sw.fields) > 1) { + emit_write_swizzle(sw, expr.right) + return expr + } let p = ptr_of(expr.left) let val = value_of(expr.right) if (p.id != 0u && val != 0u) { @@ -2579,11 +2866,48 @@ class SpirvEmit : AstVisitor { return expr } + // GLSL's write swizzle (`buf[i].xyz = pos`). SPIR-V has no partial store, and glslang + // does NOT load-shuffle-store it: it chains ONE component pointer per lane and stores the matching + // lane of the rhs. Lower it the same way, so the port comes out instruction for instruction. + // daslang only takes a CONTIGUOUS ascending mask as a reference (`.xz` is error[30915]), so the + // lanes are distinct and each is written exactly once -- the order of the stores cannot matter. + def emit_write_swizzle(sw : ExprSwizzle?; rhs : ExpressionPtr) : void { + var bm & = unsafe(*m) + if (!key_exists(e2ptr, intptr(sw.value))) { + errs |> push("cannot assign through a swizzle of a value — a write-swizzle target must be addressable (a global, a `var` local, or an SSBO / UBO element)") + return + } + let val = value_of(rhs) + if (val == 0u) return + let base = ptr_of(sw.value) + if (base.id == 0u) return + let storage = root_storage_of(sw.value) + let comp = emit_component_type(bm, sw._type) + let ptr_t = type_pointer(bm, storage, comp) + for (i in range(length(sw.fields))) { + let cptr = alloc_id(bm) + emit(bm, SEC_FUNCS, SpvOp.AccessChain, ptr_t, cptr, base.id, const_uint(bm, uint(sw.fields[i]))) + let lane = alloc_id(bm) + emit(bm, SEC_FUNCS, SpvOp.CompositeExtract, comp, lane, val, uint(i)) + emit(bm, SEC_FUNCS, SpvOp.Store, cptr, lane) + } + } + // ----- let: memory local -> store init; value-let -> bind SSA id ----- def override visitExprLetVariable(cexpr : ExprLet?; var arg : VariablePtr; lastArg : bool) : VariablePtr { var bm & = unsafe(*m) if (arg.init == null) { - errs |> push("uninitialized local '{arg.name}' is not supported (every let/var must have an initializer)") + // daslang DROPS an initializer it proves dead -- a `var` that every path assigns before + // it is ever read (`var l = 0.0; if (..) { l = a } else { l = b }`) reaches us with no + // init at all. das still zero-initializes such a local, so lower it to that zero instead + // of refusing it; a value-let cannot get here (it has nowhere to store one). + let vk0 = intptr(arg) + if (!key_exists(ctx.local_vars, vk0)) { + errs |> push("uninitialized local '{arg.name}' is not supported (every let/var must have an initializer)") + return arg + } + let lv0 = ctx.local_vars?[vk0] ?? LocalVar() + emit(bm, SEC_FUNCS, SpvOp.Store, lv0.ptr_id, const_null(bm, lv0.value_type)) return arg } let vk = intptr(arg) @@ -2631,11 +2955,12 @@ class SpirvEmit : AstVisitor { errs |> push("internal: null field type at {sname}") return 0u } - // TaskPayloadWorkgroupEXT is a PLAIN logical storage class (no Block / Offset / ArrayStride), so a - // field type lowers via emit_type -- the SAME id build_plain_struct used for the member, so the - // OpAccessChain types match. (Block storages below take the std140/std430 strided path.) - if (storage == SpvStorageClass.TaskPayloadWorkgroupEXT) { - return emit_type(bm, ft) + // TaskPayloadWorkgroupEXT (a @task_payload) and Function (a struct local) are PLAIN logical + // storage classes -- no Block / Offset / ArrayStride -- so a field type lowers via + // emit_value_type: the SAME id build_plain_struct gave the member, so the OpAccessChain types + // match. (Block storages below take the std140/std430 strided path.) + if (storage == SpvStorageClass.TaskPayloadWorkgroupEXT || storage == SpvStorageClass.Function) { + return emit_value_type(bm, ft, errs) } // The packing rules follow the root global's storage class: an @ssbo member access rebuilds // its member types under std430 (tight, width-aware strides), everything else under std140. @@ -2707,14 +3032,9 @@ class SpirvEmit : AstVisitor { errs |> push("member access '.{expr.name}': no such field") return expr } - // chain the field pointer in the root global's storage class (StorageBuffer for an - // SSBO, Uniform for a UBO, PushConstant for @push_constant). Walking the whole chain - // via root_global_of catches deeply nested forms (ubo.outer.inner under PR-G); the - // pre-PR-G code only handled ExprAt-rooted chains and defaulted everything else to - // StorageBuffer, which mismatched the base pointer's type for Uniform/PushConstant. - let rv = root_global_of(expr.value) - let rgi = rv != null ? (ctx.globals?[intptr(rv)] ?? GlobalInfo()) : GlobalInfo() - let storage = rgi.var_id != 0u ? rgi.storage : SpvStorageClass.StorageBuffer + // chain the field pointer in the storage class the chain roots in (StorageBuffer for an + // SSBO, Uniform for a UBO, PushConstant for @push_constant, Function for a struct local). + let storage = root_storage_of(expr.value) let pointee = field_pointee_type(bm, expr._type, "{est.name}.{expr.name}", storage) if (pointee == 0u) return expr let ptr_t = type_pointer(bm, storage, pointee) @@ -2785,7 +3105,7 @@ class SpirvEmit : AstVisitor { var ops <- [uf.ret_type, res, uf.fn_id] var ref_temps : array> for (arg, i in expr.arguments, count()) { - let is_ref = i < length(f.arguments) && f.arguments[i]._type.flags.ref && !f.arguments[i]._type.flags.constant + let is_ref = i < length(f.arguments) && is_out_param(f.arguments[i]._type) if (is_ref) { let ct = ctx.call_temps?[intptr(arg)] ?? CallTemp() if (ct.ptr_id == 0u) { @@ -3469,14 +3789,15 @@ class SpirvEmit : AstVisitor { let dst = scalar_type_name_class(name) if (dst.cls >= 0 && length(expr.arguments) == 1) { let a0 = expr.arguments[0] - if (component_count(a0._type.baseType) != 1) { - errs |> push("{name}: vector conversion is not supported (scalar only)") + let sn = numeric_info(a0._type.baseType) + if (!sn.ok || sn.lanes != 1) { + errs |> push("{name}: cannot convert from {a0._type.baseType} (numeric scalar only)") return expr } let aid = value_of(a0) if (aid == 0u) return expr - let src_cls = scalar_class(a0._type.baseType) - let src_w = scalar_width(a0._type.baseType) + let src_cls = sn.cls + let src_w = sn.width if (src_cls == dst.cls && src_w == dst.width) { // same class+width no-op, e.g. float(someFloat) e2id[k] = aid return expr @@ -3516,9 +3837,10 @@ class SpirvEmit : AstVisitor { return expr } } - // splat-with-same-class-convert: half4(floatScalar) — convert the scalar - // (single rounding, matches the CPU float16_t(float) RNE), then splat - if (cc == 1 && src_cls == vc.cls) { + // splat-with-convert: half4(floatScalar) same-class narrows, float3(intScalar) + // crosses class (ConvertSToF) — convert the scalar once (a single rounding, matching + // the CPU ctor), then splat. convert_op covers both, so any numeric scalar splats. + if (cc == 1 && src_cls >= 0) { let cv = convert_op(src_cls, src_w, vc.cls, vc.width) if (cv.ok) { let aid = value_of(a0) @@ -3575,7 +3897,11 @@ class SpirvEmit : AstVisitor { } let a_cls = scalar_class(a._type.baseType) let a_w = scalar_width(a._type.baseType) - if (a_cls != vc.cls || (a_w != vc.width && !(cc == 1 && a_cls == vc.cls))) { + let needs_conv = a_cls != vc.cls || a_w != vc.width + // a SCALAR constituent of a different component kind/width converts to the vector's + // component type (float2(intX, y) -> ConvertSToF the int lane); a vector constituent + // must already match, since a per-lane vector cross-component convert is not lowered here + if (a_cls < 0 || (needs_conv && cc != 1)) { errs |> push("{name}: constituent of type {a._type.baseType} has a different component kind (conversion not supported yet)") delete cons return expr @@ -3586,9 +3912,9 @@ class SpirvEmit : AstVisitor { delete cons return expr } - if (a_w != vc.width) { - // per-lane scalar constituent of a narrower/wider ctor (the CPU per-lane - // registrations take int32/float args): same-class convert, one scalar op + if (needs_conv) { + // scalar constituent of a different class/width: convert once (the CPU per-lane + // registrations take int32/float args), int -> float via ConvertSToF, etc. let cv = convert_op(a_cls, a_w, vc.cls, vc.width) if (!cv.ok) { errs |> push("{name}: constituent of type {a._type.baseType} cannot convert to the component type") @@ -3612,6 +3938,37 @@ class SpirvEmit : AstVisitor { e2id[k] = res return expr } + // matrix column constructor: matCxR(col0, col1, ...) -> OpCompositeConstruct of `cols` + // column vectors (each a floatW). Gated on the constructor NAME (not the result type), so a + // matrix-RETURNING user function is left to the OpFunctionCall path below. Only the column + // form lowers here; the no-arg identity / float3x4-widen forms fall through (unsupported). + let mc = matrix_ctor(name) + if (mc.ok && length(expr.arguments) == mc.cols) { + let rt = emit_type(bm, expr._type) + let res = alloc_id(bm) + var cons <- [rt, res] + cons |> reserve(2 + mc.cols) + var ok_cols = true + for (a in expr.arguments) { + if (scalar_class(a._type.baseType) != 2 || component_count(a._type.baseType) != mc.width) { + errs |> push("{name}: each column must be a float{mc.width} vector (got {a._type.baseType})") + ok_cols = false + break + } + let aid = value_of(a) + if (aid == 0u) { + ok_cols = false + break + } + cons |> push(aid) + } + if (ok_cols) { + emit_n(bm, SEC_FUNCS, SpvOp.CompositeConstruct, cons) + e2id[k] = res + } + delete cons + return expr + } // fmod: core OpFRem (C fmod semantics — sign-of-dividend). NOT a GLSL.std.450 ext-inst. if (name == "fmod") { if (length(expr.arguments) != 2) { @@ -3627,6 +3984,23 @@ class SpirvEmit : AstVisitor { e2id[k] = res return expr } + // mod: core OpFMod (GLSL mod semantics — sign-of-DIVISOR), the twin of + // fmod above. The two differ on a negative dividend, so a port of GLSL's + // mod() must reach this one and never fmod. + if (name == "mod") { + if (length(expr.arguments) != 2) { + errs |> push("mod expects 2 arguments") + return expr + } + let a = value_of(expr.arguments[0]) + let b = value_of(expr.arguments[1]) + if (a == 0u || b == 0u) return expr + let rt = emit_type(bm, expr._type) + let res = alloc_id(bm) + emit(bm, SEC_FUNCS, SpvOp.FMod, rt, res, a, b) + e2id[k] = res + return expr + } // dot: core OpDot (vector . vector -> scalar) if (name == "dot") { if (length(expr.arguments) != 2) { @@ -3642,8 +4016,30 @@ class SpirvEmit : AstVisitor { e2id[k] = res return expr } - // GLSL.std.450 math: OpExtInst - let cls = empty(expr.arguments) ? -1 : scalar_class(expr.arguments[0]._type.baseType) + // intBitsToFloat / uintBitsToFloat / floatBitsToInt / floatBitsToUint (daslib/math_bits' + // int_bits_to_float / uint_bits_to_float / float_bits_to_int / float_bits_to_uint): a bit-pattern + // reinterpret -> one core OpBitcast, with the result type taken from the overload's return. math_bits + // is a builtin module (is_user_shader_func == false), so its CPU-reinterpret body is never emitted; + // without this interception the call would fail-close as an uncallable host function below. + if (name == "int_bits_to_float" || name == "uint_bits_to_float" || + name == "float_bits_to_int" || name == "float_bits_to_uint") { + if (length(expr.arguments) != 1) { + errs |> push("{name} expects 1 argument") + return expr + } + let a = value_of(expr.arguments[0]) + if (a == 0u) return expr + let rt = emit_type(bm, expr._type) + let res = alloc_id(bm) + emit(bm, SEC_FUNCS, SpvOp.Bitcast, rt, res, a) + e2id[k] = res + return expr + } + // GLSL.std.450 math: OpExtInst. arith_class, NOT scalar_class: an ext-op operand carries no + // interface-block byte layout, so it may be narrower than 32 bits -- which is what lets + // max(int16, int16) emit OpExtInst SMax on %short, the shape glslang gives GLSL's 16-bit max, + // rather than failing closed. Same width-aware pair the arithmetic and compare rails use. + let cls = empty(expr.arguments) ? -1 : arith_class(expr.arguments[0]._type.baseType) let ge = glsl_ext_op(name, cls) if (ge.ok) { let set = ensure_glsl_ext(bm, *ctx) @@ -3654,7 +4050,7 @@ class SpirvEmit : AstVisitor { // against a vector result (mix/clamp/min/max/step/smoothstep), splat any scalar operand to // the result width via OpCompositeConstruct so the OpExtInst is valid. This is what makes // daslang lerp(vec, vec, float) emit correctly instead of an invalid (scalar-t) FMix. - let result_n = component_count(expr._type.baseType) + let result_n = arith_lanes(expr._type.baseType) let broadcast = result_n > 1 && ext_op_broadcasts_scalar(ge.op) for (a in expr.arguments) { var aid = value_of(a) @@ -3697,16 +4093,43 @@ class SpirvEmit : AstVisitor { return expr } - // ----- fixed-array literal -> OpConstantComposite (whole-array, deduped) ----- - // emit_const recurses into the (const) elements internally, so the elements' own default visit emits - // nothing — at patch they fold to ExprConstFloatN leaves, not sub-call nodes. Records the array's - // constant id; a fixed-array `let` then Stores it into the Function-storage OpVariable. + // ----- fixed-array literal -> OpConstantComposite / OpCompositeConstruct (whole-array) ----- + // All-constant elements fold to a deduped OpConstantComposite; emit_const recurses into them + // internally, so their own default visit emits nothing (at patch they are ExprConstFloatN leaves, + // not sub-call nodes). Elements that are RUNTIME values instead build the array with one + // OpCompositeConstruct of their already-emitted ids — the same shape glslang gives + // `int[5](a, b, c, d, e)`. Either way a fixed-array `let` / `var` then Stores the id into its + // Function-storage OpVariable. def override visitExprMakeArray(var expr : ExprMakeArray?) : ExpressionPtr { var bm & = unsafe(*m) - let r = emit_const(bm, expr, errs) + // a dynamic array literal is ALSO an ExprMakeArray; emit_type would panic on tArray + if (expr._type == null || expr._type.baseType != Type.tFixedArray) { + errs |> push("only fixed-array literals are supported (dynamic array literals are not)") + return expr + } + var probe : array + let r = emit_const(bm, expr, probe) + delete probe if (r.ok) { e2id[intptr(expr)] = r.id + return expr } + var cons <- [emit_type(bm, expr._type), 0u] + cons |> reserve(2 + length(expr.values)) + for (v in expr.values) { + let vid = value_of(v) + if (vid == 0u) { + errs |> push("array literal element is neither a compile-time constant nor an emittable value") + delete cons + return expr + } + cons |> push(vid) + } + let res = alloc_id(bm) + cons[1] = res + emit_n(bm, SEC_FUNCS, SpvOp.CompositeConstruct, cons) + delete cons + e2id[intptr(expr)] = res return expr } @@ -4010,11 +4433,57 @@ class SpirvEmit : AstVisitor { def override preVisitExprMakeTuple(expr : ExprMakeTuple?) : void { errs |> push("tuple construction is not supported in SPIR-V shaders") } - def override preVisitExprMakeStruct(expr : ExprMakeStruct?) : void { - errs |> push("struct construction is not supported in SPIR-V shaders") - } + // Struct construction (`S(a = x, b = y, ...)`) -> one OpCompositeConstruct of the + // field values, in the struct's DECLARED order -- which is what the OpTypeStruct members are in, and + // is NOT the order the named arguments are written in. Every field must be given: SPIR-V has no + // partial composite, and daslang only omits a field when its declaration carries a default, which + // would have to be materialised here; fail closed instead of constructing a short (invalid) composite. def override canVisitExprMakeStructBody(expr : ExprMakeStruct?) : bool { - return false // already rejected in preVisit; don't descend into the (unlowerable) field inits + return true // descend: the field initialisers are ordinary expressions and must lower first + } + def override visitExprMakeStruct(var expr : ExprMakeStruct?) : ExpressionPtr { + var bm & = unsafe(*m) + let st = expr.makeType != null ? expr.makeType.structType : null + if (st == null) { + errs |> push("struct construction: not a struct type") + return expr + } + if (length(expr.structs) != 1) { + errs |> push("struct construction '{st.name}': only a single struct value is supported (an array-of-struct initializer is not)") + return expr + } + let sid = value_struct_type(bm, st, errs) + if (sid == 0u) return expr + let nf = length(st.fields) + var vals : array + vals |> resize(nf) + for (fi in range(nf)) { + vals[fi] = 0u + } + for (fld in *expr.structs[0]) { + let fi = field_index_by_name(st, "{fld.name}") + if (fi < 0) { + errs |> push("struct construction '{st.name}': no such field '{fld.name}'") + delete vals + return expr + } + vals[fi] = value_of(fld.value) + } + for (fi in range(nf)) { + if (vals[fi] == 0u) { + errs |> push("struct construction '{st.name}': field '{st.fields[fi].name}' has no value (every field must be given)") + delete vals + return expr + } + } + let res = alloc_id(bm) + var ops <- [sid, res] + ops |> push_from(vals) + emit_n(bm, SEC_FUNCS, SpvOp.CompositeConstruct, ops) + delete ops + delete vals + e2id[intptr(expr)] = res + return expr } def override preVisitExprArrayComprehension(expr : ExprArrayComprehension?) : void { errs |> push("array comprehension is not supported in SPIR-V shaders") @@ -4104,8 +4573,22 @@ def private is_user_shader_func(f : Function const?; entry : FunctionPtr) : bool return !(mn == "builtin" || mn == "math" || mn == "math_bits" || mn == "spirv_builtins" || mn == "shader_lingua_franca") } +// Is this parameter WRITTEN THROUGH by the callee -- a Function pointer parameter fed by the +// copy-in/copy-out temp at the call site, rather than a by-value OpFunctionParameter? +// +// The two shapes of the test are not interchangeable. das passes a struct BY REFERENCE already, so a +// struct parameter never carries flags.ref -- `hp : HP` and `var np : NP&` differ ONLY in the const +// flag, and testing ref on one silently misreads every mutable struct param as read-only. A scalar's +// `var x : float` is a mutable LOCAL COPY (no propagation), so only the `&` form (ref AND non-const) +// is an out-param there. Hence: const alone for a struct, ref + const for everything else. +def private is_out_param(at : TypeDecl?) : bool { + if (at == null) return false + if (at.baseType == Type.tStructure) return !at.flags.constant + return at.flags.ref && !at.flags.constant +} + // Validate a user function's signature, allocate its OpFunction id + OpTypeFunction, and record it. -// Only 32-bit scalar/vector results+params are supported (by value, or by reference for params); +// Only 32-bit scalar/vector/matrix results+params are supported (by value, or by reference for params); // anything else is rejected here with a clean error so a later call site doesn't emit invalid SPIR-V. def private register_user_func(var m : SpirvModule; var ctx : EmitCtx; f : Function const?; var errors : array) { if (key_exists(ctx.user_funcs, intptr(f))) return @@ -4113,6 +4596,12 @@ def private register_user_func(var m : SpirvModule; var ctx : EmitCtx; f : Funct if (f.result.baseType == Type.tVoid) { uf.is_void = true uf.ret_type = type_void(m) + } elif (f.result.baseType == Type.tStructure) { + // A struct RESULT is returned by value, exactly as glslang returns one (OpFunction %S). + uf.ret_type = value_struct_type(m, f.result.structType, errors) + if (uf.ret_type == 0u) { + uf.valid = false + } } elif (is_emittable_value_type(f.result) && f.result.baseType != Type.tFixedArray) { uf.ret_type = emit_type(m, f.result) } else { @@ -4122,13 +4611,21 @@ def private register_user_func(var m : SpirvModule; var ctx : EmitCtx; f : Funct var ptypes : array for (arg in f.arguments) { let at = arg._type - if (!is_emittable_value_type(at) || at.baseType == Type.tFixedArray) { + // A struct parameter is exempt from the value-type guard below: its fields are what must be + // emittable, and emit_value_type reports any that is not. is_out_param draws the read-only / + // written-through line for every parameter kind alike. + let is_struct = at != null && at.baseType == Type.tStructure + if (!is_struct && (!is_emittable_value_type(at) || at.baseType == Type.tFixedArray)) { errors |> push("shader function '{f.name}': parameter '{arg.name}' of type {at.baseType} is not supported (only 32-bit scalar/vector/matrix parameters, by value or by reference)") uf.valid = false continue } - let vt = emit_type(m, at) - if (at.flags.ref && !at.flags.constant) { + let vt = emit_value_type(m, at, errors) + if (vt == 0u) { + uf.valid = false + continue + } + if (is_out_param(at)) { ptypes |> push(type_pointer(m, SpvStorageClass.Function, vt)) } else { ptypes |> push(vt) @@ -4228,6 +4725,7 @@ class SpirvTempAlloc : AstVisitor { adapter : VisitorAdapter? @do_not_delete m : SpirvModule? @do_not_delete ctx : EmitCtx? + errs : array def override preVisitExprCall(expr : ExprCall?) : void { let f = expr.func if (f == null || !key_exists(ctx.user_funcs, intptr(f))) return @@ -4237,9 +4735,9 @@ class SpirvTempAlloc : AstVisitor { for (arg, i in expr.arguments, count()) { if (i >= length(f.arguments)) break let pt = f.arguments[i]._type - if (pt.flags.ref && !pt.flags.constant) { + if (is_out_param(pt)) { if (key_exists(ctx.call_temps, intptr(arg))) continue - let vt = emit_type(bm, pt) + let vt = emit_value_type(bm, pt, errs) let ptr_t = type_pointer(bm, SpvStorageClass.Function, vt) let pid = alloc_id(bm) emit(bm, SEC_FUNCS, SpvOp.Variable, ptr_t, pid, uint(SpvStorageClass.Function)) @@ -4249,12 +4747,13 @@ class SpirvTempAlloc : AstVisitor { } } -def private alloc_call_temps(var m : SpirvModule; var ctx : EmitCtx; f : FunctionPtr) { +def private alloc_call_temps(var m : SpirvModule; var ctx : EmitCtx; f : FunctionPtr; var errors : array) { var v = new SpirvTempAlloc(m = unsafe(addr(m)), ctx = unsafe(addr(ctx))) make_visitor(*v) $(mv_adapter) { v.adapter = mv_adapter visit(f.body, v.adapter) } + errors |> push_from(v.errs) unsafe { delete v } @@ -4268,13 +4767,17 @@ def private emit_user_function(var m : SpirvModule; var ctx : EmitCtx; uf : User emit(m, SEC_FUNCS, SpvOp.Function, uf.ret_type, uf.fn_id, 0u, uf.type_id) for (arg in f.arguments) { let at = arg._type - let vt = emit_type(m, at) + let vt = emit_value_type(m, at, errors) let pid = alloc_id(m) - if (at.flags.ref && !at.flags.constant) { + if (is_out_param(at)) { + // written through: the pointer binds as a memory local, so a field of it access-chains + // and a bare write stores -- exactly like a `var` local declared in the body. let pt = type_pointer(m, SpvStorageClass.Function, vt) emit(m, SEC_FUNCS, SpvOp.FunctionParameter, pt, pid) ctx.local_vars |> insert(intptr(arg), LocalVar(ptr_id = pid, value_type = vt)) } else { + // read-only: binds an SSA id, off which a field read is an OpCompositeExtract like any + // other struct value. emit(m, SEC_FUNCS, SpvOp.FunctionParameter, vt, pid) ctx.locals |> insert(intptr(arg), pid) } @@ -4283,7 +4786,7 @@ def private emit_user_function(var m : SpirvModule; var ctx : EmitCtx; uf : User emit(m, SEC_FUNCS, SpvOp.Label, id_label) ctx.terminated = false collect_locals(m, ctx, f.body, errors) - alloc_call_temps(m, ctx, f) + alloc_call_temps(m, ctx, f, errors) drive_emit(m, ctx, f, errors) if (!ctx.terminated) { if (uf.is_void) { @@ -4317,7 +4820,7 @@ def generate_spirv(fn : FunctionPtr; var errors : array; stage : ShaderS // Fully overwrite the out-param: a caller that reuses a SpirvReflection (or retries after a // failed attempt) must not accumulate stale bindings / ranges from a previous call. reflection.stage = stage_flag(stage) - reflection.entry_point := "main" + reflection.entry_point = "main" // Mesh + Task workgroups are sized like compute via LocalSize, so the reflected local_size // mirrors the actual workgroup dispatch dims, not (1,1,1). let is_workgroupy = stage == ShaderStage.Compute || stage == ShaderStage.Mesh || stage == ShaderStage.Task @@ -4477,7 +4980,7 @@ def generate_spirv(fn : FunctionPtr; var errors : array; stage : ShaderS // Function-storage OpVariables must lead the entry block, before any body code. collect_locals(m, ctx, fn.body, errors) - alloc_call_temps(m, ctx, fn) + alloc_call_temps(m, ctx, fn, errors) // Body codegen: the SpirvEmit AstVisitor walks fn.body, emitting into m. drive_emit(m, ctx, fn, errors) diff --git a/src/ast/ast_inline.cpp b/src/ast/ast_inline.cpp index fdcb2da506..9d53c4ba6b 100644 --- a/src/ast/ast_inline.cpp +++ b/src/ast/ast_inline.cpp @@ -856,16 +856,26 @@ namespace das { if ( e->rtti_isTypeDecl() ) return true; // type<...> witness: a compile-time tag if ( e->rtti_isUnsafe() ) return inlinePure(static_cast(e)->body); if ( e->rtti_isVar() ) { - auto ev = static_cast(e); - // a manufactured, not-yet-resolved temp read (this pass's own __inl* - // vars, e.g. an earlier site's result temp): no flags, no type - reads - // as "global" and untyped, which would hoist it as an impure COPY. - // it is a plain local read - pure. everything else at patch time is - // resolved, so the flags can be trusted - if ( !ev->variable && !ev->type ) return true; - return !ev->isGlobalVariable(); - } - if ( e->rtti_isR2V() ) return inlinePure(static_cast(e)->subexpr); + // a bare (non-R2V) variable is an LVALUE - a reference to storage, + // not a value read. Storage identity is stable for BOTH a local and + // a global (global storage never moves), so referencing one is pure; + // hoisting a global array/struct being indexed would only make a + // pointless reference temp - and for a global array that temp is a + // local dynamic array the shader backends cannot represent at all. + // A global's VALUE read (which CAN change mid-inline) stays impure at + // the R2V case below, where it is snapshotted. + return true; + } + if ( e->rtti_isR2V() ) { + auto sub = static_cast(e)->subexpr; + // a global's value can be mutated between the inline site and the + // spliced body, so its rvalue read is snapshotted (kept impure) + if ( sub && sub->rtti_isVar() ) { + auto ev = static_cast(sub); + if ( (ev->variable || ev->type) && ev->isGlobalVariable() ) return false; + } + return inlinePure(sub); + } if ( e->rtti_isField() ) return inlinePure(static_cast(e)->value); if ( e->rtti_isSafeField() ) return inlinePure(static_cast(e)->value); if ( e->rtti_isAsVariant() ) return inlinePure(static_cast(e)->value); diff --git a/tests/spirv/_fail_closed/_fc_local_struct_var_nested.das b/tests/spirv/_fail_closed/_fc_local_struct_var_nested.das new file mode 100644 index 0000000000..0e000bfca3 --- /dev/null +++ b/tests/spirv/_fail_closed/_fc_local_struct_var_nested.das @@ -0,0 +1,28 @@ +// Fail-closed fixture: a `var` struct LOCAL whose field is itself a struct. The local's OpTypeStruct is +// undecorated (Function storage has no Offset cascade), so a nested field would need a recursive plain +// struct that build_plain_struct does not build -- it must be refused, never guessed at. Distinct from +// _fc_local_struct_nested, which refuses the same shape on the VALUE path (a `let` copy + extract). +// `_`-prefix + `expect 50501` keep dastest/lint off the failing shader. +expect 50501 + +options gen2 + +require spirv/spirv_shader +require spirv/spirv_builtins + +struct VnInner { + x : float4 +} +struct VnOuter { + inner : VnInner + tail : float +} +var @ssbo @binding = 0 vn_out : array + +[compute_shader(local_size_x=64, name="fc_lsv_var_nested_spv")] +def fc_lsv_var_nested { + let i = int(gl_GlobalInvocationID.x) + var s : VnOuter // nested-struct field of a struct LOCAL -> fail-closed + s.tail = float(i) + vn_out[i] = s.tail +} diff --git a/tests/spirv/_fail_closed/_fc_matrix_dyn_col.das b/tests/spirv/_fail_closed/_fc_matrix_dyn_col.das new file mode 100644 index 0000000000..f1db94ddda --- /dev/null +++ b/tests/spirv/_fail_closed/_fc_matrix_dyn_col.das @@ -0,0 +1,20 @@ +// Fail-closed fixture: a matrix column read (m[i]) lowers to OpCompositeExtract, whose column index is +// a LITERAL, not an id. A matrix is a value here, so there is no OpVariable to OpAccessChain a dynamic +// index through either. A non-constant column is therefore rejected at the emit, never lowered into a +// blob only spirv-val would catch. +expect 50501 + +options gen2 + +require spirv/spirv_shader +require spirv/spirv_builtins +require math + +var @ssbo @binding = 0 fc_mdc_cols : array + +[compute_shader(local_size_x=64, name="fc_matrix_dyn_col_spv")] +def fc_matrix_dyn_col { + let i = int(gl_GlobalInvocationID.x) + let m = float3x3(fc_mdc_cols[0].xyz, fc_mdc_cols[1].xyz, fc_mdc_cols[2].xyz) + fc_mdc_cols[0] = float4(m[i], 0.0f) +} diff --git a/tests/spirv/_fail_closed/_fc_wswz.das b/tests/spirv/_fail_closed/_fc_wswz.das index e04c4a7462..88c0bf921f 100644 --- a/tests/spirv/_fail_closed/_fc_wswz.das +++ b/tests/spirv/_fail_closed/_fc_wswz.das @@ -1,7 +1,9 @@ -// Fail-closed fixture (Phase 10.2): a multi-component swizzle used as an assignment target -// (`v.xy = …`) is not supported. The typer accepts it, so the emitter must reject it with a CLEAN -// message at ptr_of rather than the generic "internal: not an lvalue". `_`-prefix + `expect 50501` -// keep dastest/lint off. +// Fail-closed fixture: a write-swizzle whose BASE is not addressable. `fc_wswz_out.xyz` is itself a +// swizzle, so it lowers to a loaded VALUE with no OpVariable to chain a component pointer off -- there +// is nothing to store through. daslang's typer accepts the nested form (both masks are contiguous, so +// both are references), which is why the emitter has to reject it. The write-swizzle of an addressable +// base -- a global, a `var` local, an SSBO element -- ships. `_`-prefix + `expect 50501` keep +// dastest/lint off. expect 50501 options gen2 @@ -13,6 +15,6 @@ var @out @location = 0 fc_wswz_out : float4 [fragment_shader(name="fc_wswz_spv")] def fc_wswz { - fc_wswz_out.xy = float2(1.0, 0.0) // write-swizzle -> clean fail-closed rejection fc_wswz_out = float4(0.0, 0.0, 0.0, 1.0) + fc_wswz_out.xyz.xy = float2(1.0, 0.0) // swizzle of a swizzle -> no base pointer } diff --git a/tests/spirv/_spirv_common.das b/tests/spirv/_spirv_common.das index 1e4b0d6b76..efb566318a 100644 --- a/tests/spirv/_spirv_common.das +++ b/tests/spirv/_spirv_common.das @@ -470,6 +470,25 @@ def public catri_words : array { return clone_to_move(catri_spv) } +// ===== fixed-array literal of RUNTIME elements: glslang builds `int[5](a, b, c, d, e)` with one +// OpCompositeConstruct of the five values, then OpStores it into the array's Function-storage +// OpVariable. A `var` array is then written through OpAccessChain per element. ===== +var @ssbo @binding = 0 rta_buf : array +[compute_shader(local_size_x=8, name="rtarr_spv"), marker(no_coverage)] +def rtarr { + let i0 = int(gl_LocalInvocationID.x) + let ticks = fixed_array(i0 - 2, i0 - 1, i0, i0 + 1, i0 + 2) + var acc = float3(0.0, 0.0, 0.0) + for (i in range(5)) { + acc += rta_buf[ticks[i]].xyz + } + rta_buf[i0] = float4(acc, 0.0) +} + +def public rtarr_words : array { + return clone_to_move(rtarr_spv) +} + // ===== Phase-4a UBO fixture: a @uniform struct read through OpAccessChain by field index. The // struct's std140 layout exercises both packing edge cases — a scalar packing after a vec2 // (uv@16,flags@24,scale@28) and after a vec3 (dir@32,bias@44). Every field is read; member loads diff --git a/tests/spirv/_spirv_xmod.das b/tests/spirv/_spirv_xmod.das index f450d5502a..252034f511 100644 --- a/tests/spirv/_spirv_xmod.das +++ b/tests/spirv/_spirv_xmod.das @@ -26,3 +26,37 @@ def xm_sign_mag(x : float) : float { } return x + 1.0 } + +struct SvDraws { + scat_r : float + scat_temp : float + giant_r : float +} + +struct SvPhys { + temp : float + radius : float +} + +def xm_sv_seed(s : float) : SvDraws { + return SvDraws(scat_r = s * 2.0, scat_temp = s + 1.0, giant_r = s * s) +} + +// A struct-typed PARAMETER. das passes a struct by reference and `let`/no-`var` appends const, so `d` +// arrives as a const ref -- a read-only view, which lowers to a by-value OpFunctionParameter. +def xm_sv_phys(m : float; d : SvDraws) : SvPhys { + return SvPhys(temp = m * d.scat_temp, radius = d.scat_r + d.giant_r) +} + +// The optimizer DROPS an initializer it proves dead -- every path assigns `l` before it is read, so +// `= 0.0` never reaches the emitter and the local arrives with init == null. Same required-module +// asymmetry as the folded returns above: a same-file copy of this keeps its initializer. +def xm_sv_dropped_init(m : float) : float { + var l = 0.0 + if (m <= 1.0) { + l = m * 2.0 + } else { + l = m * 3.0 + } + return l +} diff --git a/tests/spirv/test_bit_cast.das b/tests/spirv/test_bit_cast.das new file mode 100644 index 0000000000..6db775713c --- /dev/null +++ b/tests/spirv/test_bit_cast.das @@ -0,0 +1,54 @@ +options gen2 +options indenting = 4 + +// intBitsToFloat / uintBitsToFloat / floatBitsToInt / floatBitsToUint (daslib/math_bits' +// int_bits_to_float / uint_bits_to_float / float_bits_to_int / float_bits_to_uint) each lower to a +// single core OpBitcast, result type from the overload's return. math_bits is a builtin module, so +// its CPU-reinterpret body is never emitted as an OpFunction. The blob is spirv-val clean. + +require dastest/testing_boost public +require _spirv_common // validate_spirv +require spirv/spirv_shader // [fragment_shader] +require spirv/spirv_builtins public // gl_FragCoord +require spirv/spirv_dis // count_op +require spirv/spirv_grammar // SpvOp +require daslib/math_bits // int_bits_to_float / ... + +var @out @location = 0 out_color : float4 + +[fragment_shader(name="bitcast_spv"), marker(no_coverage)] +def bitcast_frag { + let i = int(gl_FragCoord.x) + let u = uint(gl_FragCoord.y) + let j = float_bits_to_int(gl_FragCoord.z) + let k = float_bits_to_uint(gl_FragCoord.w) + out_color = float4( + int_bits_to_float(i), + uint_bits_to_float(u), + int_bits_to_float(j), + uint_bits_to_float(k)) +} + +def public bitcast_words : array { + return clone_to_move(bitcast_spv) +} + +[test] +def test_bit_cast(t : T?) { + t |> run("int/uint/float bit-casts -> OpBitcast; body never emitted; spirv-val clean") <| @(t : T?) { + var words <- clone_to_move(bitcast_spv) + // six bit-cast calls in the body -> six OpBitcast + t |> success(count_op(words, SpvOp.Bitcast) == 6, + "six bit-casts -> six OpBitcast (got {count_op(words, SpvOp.Bitcast)})") + // the math_bits reinterpret body must never become an OpFunction / OpFunctionCall + t |> success(count_op(words, SpvOp.FunctionCall) == 0, + "no OpFunctionCall: math_bits is a builtin, not a user function") + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } + delete words + } +} diff --git a/tests/spirv/test_const_arrays.das b/tests/spirv/test_const_arrays.das index a34fc8edf1..72698cfead 100644 --- a/tests/spirv/test_const_arrays.das +++ b/tests/spirv/test_const_arrays.das @@ -47,3 +47,19 @@ def test_const_array_triangle(t : T?) { delete words } } + +[test] +def test_runtime_array_literal(t : T?) { + t |> run("fixed-array literal of runtime elements -> one OpCompositeConstruct + OpStore, not a constant") <| @(t : T?) { + var words <- rtarr_words() + t |> success(count_op(words, SpvOp.TypeArray) == 1, "rtarr: one OpTypeArray (the int[5])") + // the array from its five runtime elements, plus the float3 acc zero-init is a CONSTANT + // (all-zero) -- so the only run-time composite builds are the array and the stored float4 + t |> success(count_op(words, SpvOp.CompositeConstruct) == 2, "rtarr: the int[5] and the stored float4") + t |> success(op_has_operand_at(words, SpvOp.Variable, 2, uint(SpvStorageClass.Function)), + "rtarr: the array lives in a Function-storage OpVariable") + t |> success(has_op(words, SpvOp.AccessChain), "rtarr: ticks[i] indexes through OpAccessChain") + validate(t, words, "rtarr") + delete words + } +} diff --git a/tests/spirv/test_fail_closed.das b/tests/spirv/test_fail_closed.das index 901151bcd1..9a052ac8ed 100644 --- a/tests/spirv/test_fail_closed.das +++ b/tests/spirv/test_fail_closed.das @@ -48,12 +48,20 @@ def test_fail_closed_rejections(t : T?) { check_rejects(t, "_fc_global", "unsupported global 'g_scale'") check_rejects(t, "_fc_image_format", "unsupported @format") check_rejects(t, "_fc_local_struct_nested", "struct/array members of a local struct value are not yet supported") + // the same nested shape on the MEMORY path: a `var` struct local's undecorated OpTypeStruct + // has no recursive plain-struct builder, so the field is refused rather than guessed at + check_rejects(t, "_fc_local_struct_var_nested", "nested-struct fields are not yet supported") check_rejects(t, "_fc_localtype", "type tDouble is not supported") check_rejects(t, "_fc_stmt", "memzero is not supported in SPIR-V shaders") check_rejects(t, "_fc_deadcode", "unreachable code after a block terminator") - check_rejects(t, "_fc_wswz", "cannot assign through a swizzle") + // a write-swizzle chains a component pointer off its base, so the base needs an OpVariable; a + // swizzle of a swizzle has only a loaded value, and there is nothing to store through + check_rejects(t, "_fc_wswz", "write-swizzle target must be addressable") check_rejects(t, "_fc_wgtype", "@workgroup type must be a scalar, vector, matrix, or fixed array") check_rejects(t, "_fc_atomicmem", "atomic target must live in @workgroup or @ssbo memory") + // a matrix column read is an OpCompositeExtract, whose index is a literal, and a matrix value + // has no OpVariable to chain a dynamic index through -- so m[i] needs a compile-time i + check_rejects(t, "_fc_matrix_dyn_col", "indexing a matrix with a non-constant column index") // user-defined function call rejections (Phase: shader functions) check_rejects(t, "_fc_recursion", "recursion is not supported") check_rejects(t, "_fc_mutual", "recursion is not supported") @@ -74,7 +82,7 @@ def test_fail_closed_rejections(t : T?) { check_rejects(t, "_fc_mesh_out_badtype", "not a supported varying type") // @task_payload outside a task/mesh stage, and a payload with a nested-struct field check_rejects(t, "_fc_payload_stage", "is only valid in a task or mesh shader") - check_rejects(t, "_fc_payload_nested", "nested-struct payload fields are not yet supported") + check_rejects(t, "_fc_payload_nested", "nested-struct fields are not yet supported") // ray tracing: builtins / the traceRayEXT intrinsic / the TLAS descriptor / the RT storage // classes are all RT-pipeline-stage-gated, and the trace payload must be a payload GLOBAL check_rejects(t, "_fc_rt_builtin_stage", "is not available in the compute stage") diff --git a/tests/spirv/test_fmod.das b/tests/spirv/test_fmod.das new file mode 100644 index 0000000000..a27998f837 --- /dev/null +++ b/tests/spirv/test_fmod.das @@ -0,0 +1,51 @@ +options gen2 +options indenting = 4 + +// mod / fmod are two DIFFERENT core opcodes, not one name spelled twice: GLSL's mod(x, y) is +// x - y*floor(x/y) and takes the DIVISOR's sign (OpFMod), while daslang's own fmod is C's and +// takes the DIVIDEND's sign (OpFRem). They agree only where the dividend is non-negative, so a +// port of a GLSL mod() that reached fmod would compile, spirv-val and silently disagree on +// negative input. This gate pins each name to its own opcode and asserts both are present, so a +// future emitter change cannot quietly collapse the two. + +require dastest/testing_boost public +require _spirv_common +require spirv/spirv_shader +require spirv/spirv_dis +require spirv/spirv_grammar +require daslib/shader_lingua_franca +require math + +var @ssbo @binding = 0 fmod_io : array + +[compute_shader(local_size_x=1, name="fmodm_spv"), marker(no_coverage)] +def fmodm { + let x = fmod_io[0].x + let y = fmod_io[0].y + let v = float3(x, y, x) + let w = float3(y, x, y) + fmod_io[1] = float4( + fmod(x, y) + mod(x, y), + (fmod(v, w) + mod(v, w)).x, + 0.0f, 1.0f) +} + +def private fmodm_words : array { + return clone_to_move(fmodm_spv) +} + +[test] +def test_fmod_vs_mod(t : T?) { + t |> run("mod is OpFMod and fmod is OpFRem, on scalar and vector") <| @(t : T?) { + var words <- fmodm_words() + t |> success(has_op(words, SpvOp.FMod), "mod: emits FMod") + t |> success(has_op(words, SpvOp.FRem), "fmod: emits FRem") + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "fmodm: spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } + delete words + } +} diff --git a/tests/spirv/test_matrix_ctor.das b/tests/spirv/test_matrix_ctor.das new file mode 100644 index 0000000000..6d72991839 --- /dev/null +++ b/tests/spirv/test_matrix_ctor.das @@ -0,0 +1,167 @@ +options gen2 +options indenting = 4 + +// Matrix column constructor gate. +// +// GLSL's `mat4(col0, col1, col2, col3)` builds a matrix from its columns. daslang matrices are +// column-major (m[i] is column i), so float4x4(c0, c1, c2, c3) lowers to a single OpCompositeConstruct +// whose operands are the four column vectors -- matching glslang's `mat4(cols)`. The columns are +// LOADED from an SSBO (not constructed), so the ONLY OpCompositeConstruct in the blob is the matrix +// itself; the count is exact. The matrix is then multiplied by a vector (OpMatrixTimesVector), and +// the whole module is spirv-val clean. + +require dastest/testing_boost public +require _spirv_common // validate_spirv +require spirv/spirv_shader // [vertex_shader] annotation +require spirv/spirv_builtins public // gl_Position + the float4x4 column ctor +require spirv/spirv_dis // count_op +require spirv/spirv_grammar // SpvOp +require math + +var @ssbo @binding = 0 mc_cols : array + +[vertex_shader(name="mctor_spv"), marker(no_coverage)] +def mctor { + let m = float4x4(mc_cols[0], mc_cols[1], mc_cols[2], mc_cols[3]) + gl_Position = m * float4(1.0f, 2.0f, 3.0f, 1.0f) +} + +// float3x3(col0..col2) is the same column form one width down (GLSL's `mat3(cols)`), and the columns +// are read back out with m[i] -- a matrix is an emittable VALUE, so a column read is an +// OpCompositeExtract off it, not an OpAccessChain through a variable that does not exist. mat3 * vec3 +// is the width-3 OpMatrixTimesVector. +[vertex_shader(name="mctor3_spv"), marker(no_coverage)] +def mctor3 { + let m = float3x3(mc_cols[0].xyz, mc_cols[1].xyz, mc_cols[2].xyz) + let v = m * mc_cols[3].xyz + gl_Position = float4(m[0] + m[1] + m[2] + v, 1.0f) +} + +// A MUTABLE matrix local: a matrix is a fine Function-storage pointee, so `var m : float4x4` gets its +// own OpVariable (OpLoad/OpStore on assignment) exactly as a local `mat4` does in glslang -- which is +// what an accumulate-a-product chain needs. daslang declares no `*=` over matrices, so the +// accumulation is spelled `tm = tm * m` (one OpMatrixTimesMatrix). +[vertex_shader(name="mvarloc_spv"), marker(no_coverage)] +def mvarloc { + var tm = float4x4(mc_cols[0], mc_cols[1], mc_cols[2], mc_cols[3]) + tm = tm * float4x4(mc_cols[3], mc_cols[2], mc_cols[1], mc_cols[0]) + gl_Position = tm[3] +} + +// A module-scope `let` matrix of constant columns folds to an OpConstantComposite of the four column +// constants -- no OpVariable, no OpLoad -- which is what glslang folds a `const mat4` global to. The +// row-vector form `v * m` is GLSL's `vec4 * mat4`, lowering to the one OpVectorTimesMatrix; the +// column form `m * v` beside it stays OpMatrixTimesVector, so the two spellings are not conflated. +let MC_BASIS = float4x4( + float4(-0.16666667f, 0.5f, -0.5f, 0.16666667f), + float4( 0.5f, -1.0f, 0.0f, 0.6666667f), + float4(-0.5f, 0.5f, 0.5f, 0.16666667f), + float4( 0.16666667f, 0.0f, 0.0f, 0.0f)) + +[vertex_shader(name="mvtm_spv"), marker(no_coverage)] +def mvtm { + gl_Position = (mc_cols[0] * MC_BASIS) + (MC_BASIS * mc_cols[1]) +} + +// The width-3 row-vector form (GLSL's `vec3 * mat3`) -- same opcode, one width down. +[vertex_shader(name="mvtm3_spv"), marker(no_coverage)] +def mvtm3 { + let m = float3x3(mc_cols[0].xyz, mc_cols[1].xyz, mc_cols[2].xyz) + gl_Position = float4(mc_cols[3].xyz * m, 1.0f) +} + +[test] +def test_matrix_ctor(t : T?) { + t |> run("float4x4(col0..col3) -> one OpCompositeConstruct (matrix) + OpMatrixTimesVector; spirv-val clean") <| @(t : T?) { + var words <- clone_to_move(mctor_spv) + t |> success(count_op(words, SpvOp.TypeMatrix) == 1, "one OpTypeMatrix (float4x4)") + // Columns are loaded from the SSBO, so the matrix is the only OpCompositeConstruct. + t |> success(count_op(words, SpvOp.CompositeConstruct) == 1, "one OpCompositeConstruct == the matrix from its columns") + t |> success(count_op(words, SpvOp.MatrixTimesVector) == 1, "one OpMatrixTimesVector (m * v)") + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } + delete words + } +} + +[test] +def test_matrix_var_local(t : T?) { + t |> run("`var` matrix local -> Function-storage OpVariable + OpMatrixTimesMatrix on `*=`; spirv-val clean") <| @(t : T?) { + var words <- clone_to_move(mvarloc_spv) + t |> success(op_has_operand_at(words, SpvOp.Variable, 2, uint(SpvStorageClass.Function)), + "the matrix local gets a Function-storage OpVariable") + t |> success(count_op(words, SpvOp.MatrixTimesMatrix) == 1, "one OpMatrixTimesMatrix (tm *= m)") + t |> success(count_op(words, SpvOp.Store) >= 2, "the local is stored into (init + compound assign)") + t |> success(count_op(words, SpvOp.CompositeExtract) == 1, "one OpCompositeExtract == the tm[3] column read") + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } + delete words + } +} + +[test] +def test_matrix_ctor3(t : T?) { + t |> run("float3x3(col0..col2) + m[i] column reads -> OpCompositeConstruct + OpCompositeExtract; spirv-val clean") <| @(t : T?) { + var words <- clone_to_move(mctor3_spv) + t |> success(count_op(words, SpvOp.TypeMatrix) == 1, "one OpTypeMatrix (float3x3)") + // the three columns are swizzled out of the SSBO (OpVectorShuffle), so the only composite + // CONSTRUCTs are the matrix itself and the float4 fed to gl_Position + t |> success(count_op(words, SpvOp.CompositeConstruct) == 2, "two OpCompositeConstruct == the matrix from its columns + the gl_Position float4") + t |> success(count_op(words, SpvOp.MatrixTimesVector) == 1, "one OpMatrixTimesVector (m * v)") + // m[0], m[1], m[2] -- one extract per column read, off the matrix value + t |> success(count_op(words, SpvOp.CompositeExtract) == 3, "three OpCompositeExtract == the three m[i] column reads") + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } + delete words + } +} + +[test] +def test_vector_times_matrix(t : T?) { + t |> run("v * m -> OpVectorTimesMatrix; a const-column module `let` matrix -> OpConstantComposite; spirv-val clean") <| @(t : T?) { + var words <- clone_to_move(mvtm_spv) + t |> success(count_op(words, SpvOp.VectorTimesMatrix) == 1, "one OpVectorTimesMatrix (v * m)") + t |> success(count_op(words, SpvOp.MatrixTimesVector) == 1, "one OpMatrixTimesVector (m * v) -- the two forms are distinct") + // the constant matrix is folded, so the only OpVariables are the SSBO and gl_Position -- + // MC_BASIS gets no storage of its own + t |> success(count_op(words, SpvOp.Variable) == 2, "the const matrix is a constant, not a third OpVariable") + // its four constant columns plus the matrix itself + t |> success(count_op(words, SpvOp.ConstantComposite) == 5, "five OpConstantComposite == the four columns + the matrix") + t |> success(count_op(words, SpvOp.CompositeConstruct) == 0, "the matrix is folded, not constructed at run time") + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } + delete words + } +} + +[test] +def test_vector_times_matrix3(t : T?) { + t |> run("float3 * float3x3 -> the width-3 OpVectorTimesMatrix; spirv-val clean") <| @(t : T?) { + var words <- clone_to_move(mvtm3_spv) + t |> success(count_op(words, SpvOp.VectorTimesMatrix) == 1, "one OpVectorTimesMatrix (v * m)") + t |> success(count_op(words, SpvOp.MatrixTimesVector) == 0, "no OpMatrixTimesVector") + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } + delete words + } +} diff --git a/tests/spirv/test_struct_local.das b/tests/spirv/test_struct_local.das new file mode 100644 index 0000000000..8499522d00 --- /dev/null +++ b/tests/spirv/test_struct_local.das @@ -0,0 +1,106 @@ +options gen2 +options indenting = 4 + +// Struct LOCALS: a mutable `var s : S` gets a Function-storage OpVariable, an OpConstantNull zero-init +// and one OpAccessChain per field touched -- glslang's shape for a local struct, and the exact opposite +// of the value path (test_local_struct_member, where a `let` copy of an SSBO element extracts instead). +// A struct written through by a callee rides the same rail: das gives a struct parameter no flags.ref, +// so `var o : SlOut&` differs from a read-only `c : SlCell` in the CONST flag alone, and it lowers to a +// Function POINTER parameter fed by the call site's copy-in/copy-out temp. +// +// spirv-val is the load-bearing oracle here, not the counts: an OpAccessChain whose result storage class +// disagrees with its base is well-formed to the emitter and rejected only by the validator. + +require dastest/testing_boost public +require _spirv_common // validate_spirv +require spirv/spirv_shader // [compute_shader] annotation +require spirv/spirv_builtins public // gl_GlobalInvocationID +require spirv/spirv_dis // has_op / count_op +require spirv/spirv_grammar // SpvOp + +struct SlCell { + n : float4 + k : float +} + +struct SlOut { + v : float4 + w : float +} + +struct SlArr { + arr : float[4] + tail : float +} + +var @ssbo @binding = 0 sl_data : array + +def private sl_fill(c : SlCell; var o : SlOut&) { + o.v = c.n * c.k + o.w = c.k +} + +[compute_shader(local_size_x=64, name="slocal_spv"), marker(no_coverage)] +def slocal { + let i = int(gl_GlobalInvocationID.x) + var c : SlCell // nolint:STYLE013 -- the zero-init is the point (asserts OpConstantNull) + c.n = sl_data[i] + c.k = 2.0f + var o : SlOut + sl_fill(c, o) + sl_data[i] = o.v + float4(o.w, 0.0f, 0.0f, 0.0f) +} + +// A fixed-array field of a struct local: the array is chained off the local's Function pointer, so its +// OpTypeArray carries NO ArrayStride (a Block's would) and the element pointer stays Function-storage. +[compute_shader(local_size_x=64, name="slarr_spv"), marker(no_coverage)] +def slarr { + let i = int(gl_GlobalInvocationID.x) + var s : SlArr + s.arr[2] = float(i) + s.tail = 3.0f + sl_data[i] = float4(s.arr[2], s.tail, 0.0f, 0.0f) +} + +def private validate(t : T?; words : array; lbl : string) { + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "{lbl}: spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } +} + +[test] +def test_struct_local(t : T?) { + t |> run("struct local: Function OpVariable + zero-init + access-chained fields, and an out-param") <| @(t : T?) { + var words <- clone_to_move(slocal_spv) + // entry + sl_fill = 2 OpFunctions, called once. + t |> equal(count_op(words, SpvOp.Function), 2) + t |> equal(count_op(words, SpvOp.FunctionCall), 1) + // sl_fill(c, o) -> 2 OpFunctionParameter: `c` by value, `o` as a Function pointer. + t |> equal(count_op(words, SpvOp.FunctionParameter), 2) + // SlCell + SlOut + the sl_data SSBO's Block = 3 OpTypeStruct. + t |> equal(count_op(words, SpvOp.TypeStruct), 3) + // THE discriminating assertion: exactly ONE OpMemberDecorate -- the SSBO Block's Offset. A + // struct local is not an interface block, so neither SlCell nor SlOut may carry a member + // decoration; a layout-decorated type here would mean the local took the Block path. + t |> equal(count_op(words, SpvOp.MemberDecorate), 1) + // both `var` struct locals zero-init from the das-guaranteed zero -> one OpConstantNull each. + t |> equal(count_op(words, SpvOp.ConstantNull), 2) + // the locals are MEMORY: fields are chained, not extracted off a loaded composite. + t |> success(has_op(words, SpvOp.AccessChain), "slocal: struct-local fields are access-chained") + validate(t, words, "slocal") + delete words + } + t |> run("struct local: a fixed-array field chains in Function storage (no ArrayStride)") <| @(t : T?) { + var words <- clone_to_move(slarr_spv) + // SlArr + the SSBO Block = 2 OpTypeStruct; the Block's Offset is again the only member decoration. + t |> equal(count_op(words, SpvOp.TypeStruct), 2) + t |> equal(count_op(words, SpvOp.MemberDecorate), 1) + t |> equal(count_op(words, SpvOp.ConstantNull), 1) + t |> success(has_op(words, SpvOp.TypeArray), "slarr: the fixed-array field is an OpTypeArray") + validate(t, words, "slarr") + delete words + } +} diff --git a/tests/spirv/test_struct_value.das b/tests/spirv/test_struct_value.das new file mode 100644 index 0000000000..b59d691bb5 --- /dev/null +++ b/tests/spirv/test_struct_value.das @@ -0,0 +1,66 @@ +options gen2 +options indenting = 4 +options no_aot // requires the no_aot _spirv_xmod fixture (optimizer-folded shape; AOT hash unstable) + +// Struct VALUES: a plain (undecorated) struct as a function parameter and as a function result, plus +// the local whose initializer the optimizer dropped as dead. Both are what porting a GLSL helper that +// returns a struct needs -- glslang emits `OpFunction %SvPhys` over an OpTypeStruct with no Block / +// Offset decoration, and so must dasSpirv. +// +// The fixtures live in the REQUIRED module _spirv_xmod on purpose: that is the real-world shape (a +// shared CPU/GPU core module), and the dead-initializer drop happens ONLY there -- a required module +// finishes optimization before the requiring module's macros run, so a same-file copy would keep its +// initializer and never exercise the path. spirv-val is the oracle for the composite/type correctness +// the counts cannot see. + +require dastest/testing_boost public +require _spirv_common // validate_spirv +require _spirv_xmod // struct-by-value + dropped-init helpers, optimized +require spirv/spirv_shader // [compute_shader] annotation +require spirv/spirv_builtins public // gl_GlobalInvocationID +require spirv/spirv_dis // has_op / count_op +require spirv/spirv_grammar // SpvOp + +var @ssbo @binding = 0 sv_data : array + +[compute_shader(local_size_x=64, name="sv_spv"), marker(no_coverage)] +def sv { + let i = int(gl_GlobalInvocationID.x) + let d = xm_sv_seed(sv_data[i]) + let p = xm_sv_phys(sv_data[i + 1], d) + sv_data[i] = p.temp + p.radius + xm_sv_dropped_init(sv_data[i + 2]) +} + +[test] +def test_struct_value(t : T?) { + t |> run("struct values: struct param + struct result, plain OpTypeStruct, dropped init") <| @(t : T?) { + var words <- clone_to_move(sv_spv) + // entry + xm_sv_seed + xm_sv_phys + xm_sv_dropped_init = 4 OpFunctions, each called once. + t |> equal(count_op(words, SpvOp.Function), 4) + t |> equal(count_op(words, SpvOp.FunctionCall), 3) + // seed(s) = 1 + phys(m, d) = 2 + dropped_init(m) = 1 -> 4 OpFunctionParameter. The struct + // param `d` is ONE by-value parameter, not a flattened field list. + t |> equal(count_op(words, SpvOp.FunctionParameter), 4) + // SvDraws + SvPhys + the sv_data SSBO's Block = 3 OpTypeStruct. SvDraws is named by the seed's + // result, the phys's parameter AND the construction inside the seed; without dedup by Structure + // identity each mention would mint its own, giving 5. + t |> equal(count_op(words, SpvOp.TypeStruct), 3) + // exactly ONE OpMemberDecorate -- the SSBO Block's Offset. A struct VALUE is not an interface + // block, so neither SvDraws nor SvPhys may carry a member decoration. + t |> equal(count_op(words, SpvOp.MemberDecorate), 1) + // the two constructions -> one OpCompositeConstruct each (no per-field store into a temp). + t |> equal(count_op(words, SpvOp.CompositeConstruct), 2) + // d.scat_temp / d.scat_r / d.giant_r / p.temp / p.radius -> 5 reads off a struct VALUE, + // each an OpCompositeExtract (glslang would AccessChain a Function-storage copy instead). + t |> equal(count_op(words, SpvOp.CompositeExtract), 5) + // the dropped initializer lowers to the zero das guarantees such a local: one OpConstantNull. + t |> equal(count_op(words, SpvOp.ConstantNull), 1) + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "struct_value: spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } + delete words + } +} diff --git a/tests/spirv/test_vec_compare.das b/tests/spirv/test_vec_compare.das new file mode 100644 index 0000000000..1e47d0a7aa --- /dev/null +++ b/tests/spirv/test_vec_compare.das @@ -0,0 +1,59 @@ +options gen2 +options indenting = 4 + +// Whole-vector `==` / `!=`. In daslang (and GLSL) comparing two vectors with `==` / `!=` yields a +// SINGLE bool (all lanes equal / any lane differs), but the SPIR-V compare op is component-wise and +// produces a bvecN — so the result must be reduced with OpAll (`==`) / OpAny (`!=`) to the scalar +// bool the branch consumes. The emitter used to type the compare's result `bool` directly on vector +// operands, which is invalid SPIR-V (spirv-val + drivers reject `OpFOrdEqual %bool %vecN %vecN`); +// the first shader to hit it (a vertex shader culling a black star with `if (rgb == float3(0))`) +// compiled but crashed the driver's shader compiler. This gates the OpAll/OpAny reduction and +// spirv-val's the blob (the invalid form never validated because no prior fixture compared vectors). + +require dastest/testing_boost public +require _spirv_common // validate_spirv +require spirv/spirv_shader // [vertex_shader] +require spirv/spirv_builtins public +require spirv/spirv_dis // has_op +require spirv/spirv_grammar // SpvOp + +var @in @location = 0 vc_a : float3 +var @in @location = 1 vc_b : float3 +var @out @location = 0 vc_col : float3 +[vertex_shader(name="vec_cmp_spv"), marker(no_coverage)] +def vec_cmp { + // `==` on vec3 -> OpFOrdEqual (bvec3) + OpAll -> scalar bool for the branch + if (vc_a == float3(0.0f, 0.0f, 0.0f)) { + gl_Position = float4(0.0f, 0.0f, 2.0f, 1.0f) + vc_col = float3(0.0f, 0.0f, 0.0f) + return + } + // `!=` on vec3 -> OpFUnordNotEqual/OpFOrdNotEqual (bvec3) + OpAny -> scalar bool + var tint = vc_a + if (vc_a != vc_b) { + tint = vc_b + } + gl_Position = float4(tint, 1.0f) + vc_col = tint +} + +def public vec_cmp_words : array { + return clone_to_move(vec_cmp_spv) +} + +[test] +def test_vec_compare(t : T?) { + t |> run("whole-vector ==/!= reduce via OpAll/OpAny to a scalar bool; spirv-val clean") <| @(t : T?) { + var words <- clone_to_move(vec_cmp_spv) + t |> success(length(words) > 0, "vector-compare shader lowers") + t |> success(has_op(words, SpvOp.All), "== reduces with OpAll") + t |> success(has_op(words, SpvOp.Any), "!= reduces with OpAny") + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } + delete words + } +} diff --git a/tests/spirv/test_vec_mod.das b/tests/spirv/test_vec_mod.das new file mode 100644 index 0000000000..751ab8c8b5 --- /dev/null +++ b/tests/spirv/test_vec_mod.das @@ -0,0 +1,75 @@ +options gen2 +options indenting = 4 + +// Vector modulo gate: `uvec3 % uvec3` lowers to ONE native OpUMod on v3uint. +// +// GLSL's `%` is closed over the integer vector families; daslang declares it only on scalars, so +// shader_lingua_franca declares operator %(uint3, uint3). The emitter needed nothing -- binop_code +// already keys `%` off the operand's class -- but the lowering has to be PINNED BY RESULT TYPE, not +// by opcode count alone: three scalar OpUMods and one vector OpUMod are told apart by the width of +// the result, and only the vector form matches what glslang gives a uvec3 modulo. +// +// The signed twin is deliberately NOT declared: daslang's scalar `%` takes the dividend's sign +// (OpSRem) where GLSL's takes the divisor's (OpSMod), so a signed vector `%` would lower to an +// opcode that disagrees with the glslang reference on a negative dividend. This gate pins zero of both. + +require dastest/testing_boost public +require _spirv_common // validate_spirv +require spirv/spirv_shader // [compute_shader] annotation +require spirv/spirv_builtins public // gl_GlobalInvocationID +require spirv/spirv_dis // count_op, foreach_inst +require spirv/spirv_grammar // SpvOp + +var @ssbo @binding = 0 vmod_uv : array +[compute_shader(local_size_x=64, name="vmod_spv"), marker(no_coverage)] +def vmod { + let i = gl_GlobalInvocationID.x + let a = vmod_uv[i].xyz + let m = uint3(vmod_uv[i].w) // the vector-scalar form, splatted as glslang splats it + vmod_uv[i] = uint4(a % m, 0u) +} + +// Every OpUMod's result must be a 3-component vector of 32-bit UNSIGNED int. A lane-by-lane +// lowering would report a scalar result here, which is exactly what this rules out. +def private all_umods_are_v3uint(words : array) : bool { + var int_info : table // id -> (width, signedness) + var vec_info : table // id -> (component type, count) + foreach_inst(words) $(opcode : uint; w : array; s, n : int) { + if (opcode == uint(SpvOp.TypeInt) && n >= 3) { + int_info[w[s]] = uint2(w[s + 1], w[s + 2]) + } elif (opcode == uint(SpvOp.TypeVector) && n >= 3) { + vec_info[w[s]] = uint2(w[s + 1], w[s + 2]) + } + } + var all_v3uint = true + var seen = 0 + foreach_inst(words) $(opcode : uint; w : array; s, n : int) { + if (opcode == uint(SpvOp.UMod) && n >= 1) { + seen++ + let v = vec_info?[w[s]] ?? uint2(0u, 0u) + let c = int_info?[v.x] ?? uint2(0u, 9u) + if (v.y != 3u || c.x != 32u || c.y != 0u) { + all_v3uint = false + } + } + } + return all_v3uint && seen == 1 +} + +[test] +def test_vec_mod(t : T?) { + t |> run("uvec3 % uvec3 is one native OpUMod on v3uint; spirv-val clean") <| @(t : T?) { + var words <- clone_to_move(vmod_spv) + t |> success(count_op(words, SpvOp.UMod) == 1, "one OpUMod (not three scalar ones)") + t |> success(all_umods_are_v3uint(words), "the OpUMod's result type is v3uint") + t |> success(count_op(words, SpvOp.SRem) == 0, "no OpSRem (unsigned operands)") + t |> success(count_op(words, SpvOp.SMod) == 0, "no OpSMod (unsigned operands)") + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } + delete words + } +} diff --git a/tests/spirv/test_vec_shift.das b/tests/spirv/test_vec_shift.das new file mode 100644 index 0000000000..1cdd6fef37 --- /dev/null +++ b/tests/spirv/test_vec_shift.das @@ -0,0 +1,72 @@ +options gen2 +options indenting = 4 + +// Vector shift gate: the splat takes the SHIFT operand's component type, not the result's. +// +// `uintN >> int` is the only vector-shift spelling daslang has (there is no uintN >> uintN overload), +// so the shift count reaches the emitter as an int while the base is a uint vector. SPIR-V requires +// Base and Shift to agree on component COUNT, not on component type -- so the scalar count splats to +// an int vector. Splatting it to the RESULT type instead built a v4uint out of int constituents: +// valid enough to emit, rejected by spirv-val ("Expected Constituents to be scalars or vectors of the +// same type as Result Type components"). Both operand widths and the compound form are exercised. + +require dastest/testing_boost public +require _spirv_common // validate_spirv +require spirv/spirv_shader // [compute_shader] annotation +require spirv/spirv_builtins public // gl_GlobalInvocationID +require spirv/spirv_dis // count_op, foreach_inst +require spirv/spirv_grammar // SpvOp + +var @ssbo @binding = 0 vsh_uv : array +var @ssbo @binding = 1 vsh_iv : array +[compute_shader(local_size_x=64, name="vsh_spv"), marker(no_coverage)] +def vsh { + let i = gl_GlobalInvocationID.x + vsh_uv[i] = vsh_uv[i] >> 8 // uint4 >> int -> splat to int4, OpShiftRightLogical + vsh_uv[i] = vsh_uv[i] << 8 // uint4 << int -> splat to int4, OpShiftLeftLogical + vsh_iv[i] = vsh_iv[i] >> 3 // int2 >> int -> splat to int2, arithmetic shift +} + +// The component type of every OpCompositeConstruct's result: signed iff OpTypeInt's signedness is 1. +// A splat that took the base's type would report unsigned here, which is exactly the bug. +def private all_splats_are_signed_int(words : array) : bool { + var int_signedness : table + var vec_comp : table + foreach_inst(words) $(opcode : uint; w : array; s, n : int) { + if (opcode == uint(SpvOp.TypeInt) && n >= 3) { + int_signedness[w[s]] = w[s + 2] + } elif (opcode == uint(SpvOp.TypeVector) && n >= 3) { + vec_comp[w[s]] = w[s + 1] + } + } + var all_signed = true + var seen = 0 + foreach_inst(words) $(opcode : uint; w : array; s, n : int) { + if (opcode == uint(SpvOp.CompositeConstruct) && n >= 1) { + seen++ + let comp = vec_comp?[w[s]] ?? 0u + if ((int_signedness?[comp] ?? 0u) != 1u) { + all_signed = false + } + } + } + return all_signed && seen == 3 +} + +[test] +def test_vec_shift(t : T?) { + t |> run("vecN >> int splats the count to an int vector; spirv-val clean") <| @(t : T?) { + var words <- clone_to_move(vsh_spv) + t |> success(count_op(words, SpvOp.ShiftRightLogical) == 1, "one OpShiftRightLogical (uint4)") + t |> success(count_op(words, SpvOp.ShiftLeftLogical) == 1, "one OpShiftLeftLogical (uint4)") + t |> success(count_op(words, SpvOp.ShiftRightArithmetic) == 1, "one OpShiftRightArithmetic (int2)") + t |> success(all_splats_are_signed_int(words), "the three splats are int vectors, not the base's type") + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } + delete words + } +} diff --git a/tests/spirv/test_write_swizzle.das b/tests/spirv/test_write_swizzle.das new file mode 100644 index 0000000000..067db3b649 --- /dev/null +++ b/tests/spirv/test_write_swizzle.das @@ -0,0 +1,143 @@ +options gen2 +options indenting = 4 + +// The write-swizzle lvalue (GLSL's `ssbo[i].xyz = pos`, `groups.xy = …`). +// +// SPIR-V has no partial store, so a write-swizzle has two possible lowerings, and they are NOT +// interchangeable. glslang picks the second one for a memory target, and a port must match it: +// load-shuffle-store OpLoad the whole destination, OpVectorShuffle the new lanes in, OpStore it +// back whole. Reads memory the shader never asked to read. +// per-lane store one OpAccessChain to each destination component, one OpCompositeExtract of +// the matching rhs lane, one OpStore. Touches only the lanes written. +// So the load-shuffle-store form is what the assertions below RULE OUT: every shader here builds its +// rhs with OpCompositeConstruct and reads no swizzle, which makes OpVectorShuffle == 0 the exact +// witness that no destination was loaded and shuffled. +// +// daslang only accepts a CONTIGUOUS ascending mask as a reference (`v.xz = …` is error[30915], "can +// only copy to a reference"), so the lanes are always distinct and each is written exactly once. +// +// THE LANE MAPPING IS THE SUBTLE PART, and wsw_ofs_shader is what pins it: `l.yzw = rhs` writes +// DESTINATION lanes 1,2,3 from SOURCE lanes 0,1,2. An implementation that reused the loop index for +// both would store to 0,1,2 -- still valid SPIR-V, still spirv-val-clean, and wrong. The test asserts +// the component AccessChains carry constants 1,2,3 and NOT 0. + +require dastest/testing_boost public +require _spirv_common // validate_spirv +require spirv/spirv_shader // [compute_shader] annotation +require spirv/spirv_builtins public // gl_GlobalInvocationID +require spirv/spirv_dis // count_op, op_has_operand_pair, count_op_operand_at +require spirv/spirv_grammar // SpvOp, SpvStorageClass + +var @ssbo @binding = 0 wsw_f4 : array +var @ssbo @binding = 1 wsw_i4 : array +var @ssbo @binding = 2 wsw_src : array + +// Two write-swizzles into SSBO elements, over both a float and an int vector. The rhs of each is an +// OpCompositeConstruct, never a read-swizzle, so the shader's OpVectorShuffle count is exactly the +// number of destinations that were load-shuffle-stored -- which must be zero. +[compute_shader(local_size_x=64, name="wsw_spv"), marker(no_coverage)] +def wsw_shader { + let i = int(gl_GlobalInvocationID.x) + let a = wsw_src[i] + wsw_f4[i].xyz = float3(a, a + 1.0, a + 2.0) + wsw_i4[i].xy = int2(int(a), int(a) + 1) +} + +// A write-swizzle into a `var` local (Function storage), at a lane OFFSET: destination lanes 1,2,3 +// from source lanes 0,1,2. This is the mapping wsw_ofs pins below. +[compute_shader(local_size_x=64, name="wsw_ofs_spv"), marker(no_coverage)] +def wsw_ofs_shader { + let i = int(gl_GlobalInvocationID.x) + let a = wsw_src[i] + var l = float4(0.0, 0.0, 0.0, 0.0) + l.yzw = float3(a, a + 1.0, a + 2.0) + wsw_f4[i] = l +} + +// The id of OpTypeInt 32 0 (the uint type). Operands are [result-id, width, signedness]. +def private uint_type_id(words : array) : uint { + var id = 0u + foreach_inst(words) $(opcode : uint; w : array; s, n : int) { + if (opcode == uint(SpvOp.TypeInt) && n >= 3 && w[s + 1] == 32u && w[s + 2] == 0u) { + id = w[s] + } + } + return id +} + +// The id of the uint OpConstant with `value`. Operands are [result-type, result-id, value]. +def private uint_const_id(words : array; uint_t : uint; value : uint) : uint { + var id = 0u + foreach_inst(words) $(opcode : uint; w : array; s, n : int) { + if (opcode == uint(SpvOp.Constant) && n >= 3 && w[s] == uint_t && w[s + 2] == value) { + id = w[s + 1] + } + } + return id +} + +// The id of the first Function-storage OpVariable. Operands are [result-type, result-id, storage]. +def private fn_var_id(words : array) : uint { + var id = 0u + foreach_inst(words) $(opcode : uint; w : array; s, n : int) { + if (opcode == uint(SpvOp.Variable) && n >= 3 + && w[s + 2] == uint(SpvStorageClass.Function) && id == 0u) { + id = w[s + 1] + } + } + return id +} + +def private validate(t : T?; words : array; lbl : string) { + let r = validate_spirv(words) + if (r.ran) { + t |> success(r.ok, "{lbl}: spirv-val: {r.msg}") + } else { + feint("spirv-val not found locally; skipping (CI enforces)\n") + } +} + +[test] +def test_write_swizzle_per_lane(t : T?) { + t |> run("write-swizzle: one AccessChain + CompositeExtract + Store per lane, never load-shuffle-store") <| @(t : T?) { + var words <- clone_to_move(wsw_spv) + // THE CRUX: the rhs of both writes is an OpCompositeConstruct and neither reads a swizzle, so + // any OpVectorShuffle at all would be a destination that was loaded and shuffled back. + t |> success(count_op(words, SpvOp.VectorShuffle) == 0, + "wsw: no OpVectorShuffle -- the destination is never loaded and shuffled") + // .xyz (3 lanes) + .xy (2 lanes) = 5 stores, each fed by one lane of the rhs + t |> success(count_op(words, SpvOp.Store) == 5, "wsw: one OpStore per written lane (5)") + t |> success(count_op(words, SpvOp.CompositeExtract) == 5, + "wsw: one OpCompositeExtract per written lane (5)") + validate(t, words, "wsw") + delete words + } +} + +[test] +def test_write_swizzle_lane_offset(t : T?) { + t |> run("write-swizzle: `l.yzw = rhs` writes destination lanes 1,2,3 from source lanes 0,1,2") <| @(t : T?) { + var words <- clone_to_move(wsw_ofs_spv) + let uint_t = uint_type_id(words) + t |> success(uint_t != 0u, "wsw_ofs: uint type present") + let lvar = fn_var_id(words) + t |> success(lvar != 0u, "wsw_ofs: the `var l` local has a Function OpVariable") + // an OpAccessChain is [result-type, result-id, base, index...], so `base` immediately followed + // by the component constant is the component pointer for that lane. + for (lane in range(1, 4)) { + let cid = uint_const_id(words, uint_t, uint(lane)) + t |> success(cid != 0u && op_has_operand_pair(words, SpvOp.AccessChain, lvar, cid), + "wsw_ofs: component pointer into lane {lane}") + } + // and lane 0 is NOT written -- an implementation that reused the source index would store there + let c0 = uint_const_id(words, uint_t, 0u) + t |> success(!op_has_operand_pair(words, SpvOp.AccessChain, lvar, c0), + "wsw_ofs: lane 0 is never written (the mapping is destination-lane, not source-lane)") + // the source lanes are 0,1,2: one extract per lane, and no shuffle + t |> success(count_op(words, SpvOp.VectorShuffle) == 0, "wsw_ofs: no OpVectorShuffle") + t |> success(count_op(words, SpvOp.CompositeExtract) == 3, + "wsw_ofs: one OpCompositeExtract per written lane (3)") + validate(t, words, "wsw_ofs") + delete words + } +} From 023b3a8182548df3e24633c619f187d72fb7d366 Mon Sep 17 00:00:00 2001 From: Oleg Plakhotniuk Date: Mon, 20 Jul 2026 18:02:19 -0400 Subject: [PATCH 2/3] dasSpirv: format shader_lingua_franca and test_matrix_ctor with das-fmt --- daslib/shader_lingua_franca.das | 6 +++--- tests/spirv/test_matrix_ctor.das | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/daslib/shader_lingua_franca.das b/daslib/shader_lingua_franca.das index 6047c2f0f8..7c4caa1236 100644 --- a/daslib/shader_lingua_franca.das +++ b/daslib/shader_lingua_franca.das @@ -75,18 +75,18 @@ def public float3x3(c0, c1, c2 : float3) : float3x3 { // ===== row-vector times matrix ===== // GLSL's `v * m` (a ROW vector times m) that math lacks: component j is dot(v, column j), exactly // SPIR-V's OpVectorTimesMatrix, which the emitter lowers to -- so this CPU body runs only on the host. -def public operator * (v : float4; m : float4x4) : float4 { +def public operator *(v : float4; m : float4x4) : float4 { return float4(dot(v, m[0]), dot(v, m[1]), dot(v, m[2]), dot(v, m[3])) } -def public operator * (v : float3; m : float3x3) : float3 { +def public operator *(v : float3; m : float3x3) : float3 { return float3(dot(v, m[0]), dot(v, m[1]), dot(v, m[2])) } // ===== vector integer modulo ===== // GLSL's `uvec3 % uvec3` that daslang lacks -> one native OpUMod (the emitter lowers it). Unsigned, // 3-lane only; signed `%` stays absent since daslang's OpSRem and GLSL's OpSMod differ on sign. -def public operator % (a, b : uint3) : uint3 { +def public operator %(a, b : uint3) : uint3 { return uint3(a.x % b.x, a.y % b.y, a.z % b.z) } diff --git a/tests/spirv/test_matrix_ctor.das b/tests/spirv/test_matrix_ctor.das index 6d72991839..2ddbcda657 100644 --- a/tests/spirv/test_matrix_ctor.das +++ b/tests/spirv/test_matrix_ctor.das @@ -54,9 +54,9 @@ def mvarloc { // column form `m * v` beside it stays OpMatrixTimesVector, so the two spellings are not conflated. let MC_BASIS = float4x4( float4(-0.16666667f, 0.5f, -0.5f, 0.16666667f), - float4( 0.5f, -1.0f, 0.0f, 0.6666667f), + float4(0.5f, -1.0f, 0.0f, 0.6666667f), float4(-0.5f, 0.5f, 0.5f, 0.16666667f), - float4( 0.16666667f, 0.0f, 0.0f, 0.0f)) + float4(0.16666667f, 0.0f, 0.0f, 0.0f)) [vertex_shader(name="mvtm_spv"), marker(no_coverage)] def mvtm { From 3780a017bf55988e04fa3563430481e39da2c047 Mon Sep 17 00:00:00 2001 From: Oleg Plakhotniuk Date: Mon, 20 Jul 2026 21:52:42 -0400 Subject: [PATCH 3/3] dasSpirv: handle the '.[]' bounds-checked index call form (the -jit lowering of a[i]) so value-composite indexing compiles under JIT --- modules/dasSpirv/spirv/spirv_emit.das | 128 +++++++++++++++++--------- 1 file changed, 85 insertions(+), 43 deletions(-) diff --git a/modules/dasSpirv/spirv/spirv_emit.das b/modules/dasSpirv/spirv/spirv_emit.das index 97a5bf86c6..39d3091dd6 100644 --- a/modules/dasSpirv/spirv/spirv_emit.das +++ b/modules/dasSpirv/spirv/spirv_emit.das @@ -2180,6 +2180,7 @@ class SpirvEmit : AstVisitor { loop_ids : table // intptr(ExprWhile/ExprFor) -> its 5 loop-skeleton labels for_src : table // for-source range() call ptrs to intercept in visitExprCall (6.3) copy_dst : uint64 // intptr(ExprCopy.left) while that destination subtree is walked + suppress_bounds_arg : bool = false // next node is a `.[]` call's __context__/__lineinfo__ tail — elide errs : array // coerce a visited child to an rvalue id: a value passes through; a pointer is OpLoad'd here (the @@ -2506,29 +2507,32 @@ class SpirvEmit : AstVisitor { } // ----- indexing: ssbo array or local fixed array -> pointer (AccessChain) ----- - def override visitExprAt(var expr : ExprAt?) : ExpressionPtr { + // Shared by visitExprAt (a raw a[i]) and visitExprCall's `.[]` operator form (the -jit / no-opt + // lowering of a bounds-checked a[i]): result_expr keys the side-map + gives the element type, so + // both spellings emit the identical instruction. + def emit_index_read(base_expr : ExpressionPtr; index_expr : ExpressionPtr; result_expr : ExpressionPtr) : void { var bm & = unsafe(*m) - let k = intptr(expr) + let k = intptr(result_expr) // A matrix COLUMN read (m[i], GLSL's mat[i]): a matrix is an emittable VALUE here, not an // addressable composite, so there is no OpVariable to chain through -- extract the column off // the loaded matrix. OpCompositeExtract takes LITERAL indices, so the column must be a // compile-time constant; a dynamic one is rejected rather than lowered into a blob that only // spirv-val would catch. Must precede the local-variable branch below: a value-`let` matrix is // an ExprVar with no ptr_id, which that branch would reject as non-indexable. - let mi = matrix_info(expr.subexpr._type) + let mi = matrix_info(base_expr._type) if (mi.ok) { - let mci = expr.index ?as ExprConstInt + let mci = index_expr ?as ExprConstInt if (mci == null) { errs |> push("indexing a matrix with a non-constant column index is not supported") - return expr + return } - let mbase = value_of(expr.subexpr) - if (mbase == 0u) return expr // operand failed to lower (error already pushed) - let mrt = emit_type(bm, expr._type) + let mbase = value_of(base_expr) + if (mbase == 0u) return // operand failed to lower (error already pushed) + let mrt = emit_type(bm, result_expr._type) let mres = alloc_id(bm) emit(bm, SEC_FUNCS, SpvOp.CompositeExtract, mrt, mres, mbase, uint(mci.value)) e2id[k] = mres - return expr + return } // A vector component under an index (GLSL's v[i]) on a base that is a pure VALUE -- a call // result, a `let` local, a parameter, a folded constant global -- has no OpVariable to chain a @@ -2537,40 +2541,40 @@ class SpirvEmit : AstVisitor { // `var` local, an ssbo element, a block member) keeps the pointer path below, which is what // glslang gives `h_min[i]` -- so this must test addressability, not merely vector-ness. // A constant index takes OpCompositeExtract, the literal-operand form, like the swizzle read. - if (arith_lanes(expr.subexpr._type.baseType) >= 2 && !is_addressable(expr.subexpr)) { - let vbase = value_of(expr.subexpr) - if (vbase == 0u) return expr // operand failed to lower (error already pushed) - let vrt = emit_type(bm, expr._type) + if (arith_lanes(base_expr._type.baseType) >= 2 && !is_addressable(base_expr)) { + let vbase = value_of(base_expr) + if (vbase == 0u) return // operand failed to lower (error already pushed) + let vrt = emit_type(bm, result_expr._type) let vres = alloc_id(bm) - let vci = expr.index ?as ExprConstInt + let vci = index_expr ?as ExprConstInt if (vci != null) { emit(bm, SEC_FUNCS, SpvOp.CompositeExtract, vrt, vres, vbase, uint(vci.value)) } else { - let vidx = value_of(expr.index) - if (vidx == 0u) return expr // index failed to lower (error already pushed) + let vidx = value_of(index_expr) + if (vidx == 0u) return // index failed to lower (error already pushed) emit(bm, SEC_FUNCS, SpvOp.VectorExtractDynamic, vrt, vres, vbase, vidx) } e2id[k] = vres - return expr + return } - let lev = expr.subexpr ?as ExprVar + let lev = base_expr ?as ExprVar if (lev != null && (lev.varFlags.local || lev.varFlags.argument || lev.varFlags._block)) { let lv = ctx.local_vars?[intptr(lev.variable)] ?? LocalVar() if (lv.ptr_id == 0u) { errs |> push("indexing local '{lev.name}': only fixed-array `var`/`let` locals are indexable") - return expr + return } - let idx = value_of(expr.index) - if (idx == 0u) return expr // index failed to lower (error already pushed) — no 0-operand access chain - let pointee = emit_type(bm, expr._type) + let idx = value_of(index_expr) + if (idx == 0u) return // index failed to lower (error already pushed) — no 0-operand access chain + let pointee = emit_type(bm, result_expr._type) let ptr_t = type_pointer(bm, SpvStorageClass.Function, pointee) let res = alloc_id(bm) emit(bm, SEC_FUNCS, SpvOp.AccessChain, ptr_t, res, lv.ptr_id, idx) e2ptr[k] = res e2pty[k] = pointee - return expr + return } - let bv = global_var_of(expr.subexpr) + let bv = global_var_of(base_expr) if (bv == null) { // Indexing a fixed-array field inside a Block (ubo.lights[i] / ubo.material.kernel[i]) or // inside a struct local (s.arr[i]). The subexpr is a chained ExprField whose e2ptr is a @@ -2579,24 +2583,24 @@ class SpirvEmit : AstVisitor { // struct element, plain emit_type would panic (it doesn't lower tStructure) and would // also not return the laid-out (deduped) type — so re-acquire it via build_block_struct, // which is idempotent under the type/decoration dedup pools. - if (key_exists(e2ptr, intptr(expr.subexpr))) { - let idx = value_of(expr.index) - if (idx == 0u) return expr // index failed to lower (error already pushed) - let base = ptr_of(expr.subexpr) - let rv = root_global_of(expr.subexpr) - let storage = root_storage_of(expr.subexpr) + if (key_exists(e2ptr, intptr(base_expr))) { + let idx = value_of(index_expr) + if (idx == 0u) return // index failed to lower (error already pushed) + let base = ptr_of(base_expr) + let rv = root_global_of(base_expr) + let storage = root_storage_of(base_expr) let rname = rv != null ? string(rv.name) : "(?)" - let pointee = field_pointee_type(bm, expr._type, "{rname}[]", storage) - if (pointee == 0u) return expr + let pointee = field_pointee_type(bm, result_expr._type, "{rname}[]", storage) + if (pointee == 0u) return let ptr_t = type_pointer(bm, storage, pointee) let res = alloc_id(bm) emit(bm, SEC_FUNCS, SpvOp.AccessChain, ptr_t, res, base.id, idx) e2ptr[k] = res e2pty[k] = pointee - return expr + return } errs |> push("indexing is only supported on a global ssbo array, a local fixed array, or an array field inside a @uniform/@push_constant/@ssbo block") - return expr + return } let gi = ctx.globals?[intptr(bv)] ?? GlobalInfo() // BARE array global (not wrapped in a Block struct), so the access chain is a single index off @@ -2606,15 +2610,15 @@ class SpirvEmit : AstVisitor { // emit_type can't lower, so use the classified elem_type; @workgroup scalar/vector elements // lower fine via emit_type. if (gi.storage == SpvStorageClass.Workgroup || gi.is_mesh_output) { - let idx = value_of(expr.index) - if (idx == 0u) return expr // index failed to lower (error already pushed) - let pointee = (gi.is_mesh_output && gi.elem_type != 0u) ? gi.elem_type : emit_type(bm, expr._type) + let idx = value_of(index_expr) + if (idx == 0u) return // index failed to lower (error already pushed) + let pointee = (gi.is_mesh_output && gi.elem_type != 0u) ? gi.elem_type : emit_type(bm, result_expr._type) let res = alloc_id(bm) let ptr_t = type_pointer(bm, gi.storage, pointee) emit(bm, SEC_FUNCS, SpvOp.AccessChain, ptr_t, res, gi.var_id, idx) e2ptr[k] = res e2pty[k] = pointee - return expr + return } // descriptor array of samplers (sampler2D[N], etc.): the ExprAt is read STRUCTURALLY by the // texture/textureLod/textureCompare/textureSize/texelFetch handler (resolve_sampler_arg), @@ -2623,16 +2627,16 @@ class SpirvEmit : AstVisitor { // directly; any other consumer (which doesn't exist for an opaque sampler value) would // surface a clean "no rvalue available" fail-closed. if (gi.is_sampler_array) { - return expr + return } if (!gi.is_ssbo) { errs |> push("only ssbo, @workgroup, or sampler descriptor-array indexing is supported for now") - return expr + return } - let idx = value_of(expr.index) - if (idx == 0u) return expr // index failed to lower (error already pushed) — no 0-operand access chain + let idx = value_of(index_expr) + if (idx == 0u) return // index failed to lower (error already pushed) — no 0-operand access chain // use the classified element type-id: a struct element can't go through emit_type - let pointee = gi.elem_type != 0u ? gi.elem_type : emit_type(bm, expr._type) + let pointee = gi.elem_type != 0u ? gi.elem_type : emit_type(bm, result_expr._type) let res = alloc_id(bm) let ptr_t = type_pointer(bm, gi.storage, pointee) var ops <- [ptr_t, res, gi.var_id, const_uint(bm, 0u), idx] @@ -2640,6 +2644,9 @@ class SpirvEmit : AstVisitor { delete ops e2ptr[k] = res e2pty[k] = pointee + } + def override visitExprAt(var expr : ExprAt?) : ExpressionPtr { + emit_index_read(expr.subexpr, expr.index, expr) return expr } @@ -3147,6 +3154,13 @@ class SpirvEmit : AstVisitor { } } + // Flag EXACTLY a `.[]` runtime-index call's __context__ / __lineinfo__ tail (args 2/3) by node + // identity, so the ExprFakeContext / ExprFakeLineInfo rejects elide them; scoping to those nodes + // (not the whole `.[]` subtree) keeps an explicit __context__ in the base / index failing closed. + def override preVisitExprCallArgument(expr : ExprCall?; arg : ExpressionPtr; last : bool) : void { + suppress_bounds_arg = (expr.name == ".[]" && length(expr.arguments) > 3 && + (arg == expr.arguments[2] || arg == expr.arguments[3])) + } // ----- calls: texture / vector constructors / dot / GLSL.std.450 math (args pre-visited) ----- // Mirrors emit_call but reads operand ids via value_of (children already visited). for-source // range()/urange() calls are consumed by preVisitExprForBody and skipped here. @@ -3155,6 +3169,18 @@ class SpirvEmit : AstVisitor { var bm & = unsafe(*m) let name = "{expr.name}" let k = intptr(expr) + // A bounds-checked a[i] lowers to a `.[](base, index, __context__, __lineinfo__)` OPERATOR call + // under -jit / no-opt (a const index folds to a raw ExprAt), so route base/index through the + // same emit_index_read the ExprAt path uses. The __context__ / __lineinfo__ tail (a trapping + // out-of-range panic in the interpreter) is elided in preVisitExprCallArgument; SPIR-V has no + // trap. The unchecked form carries just (base, index). + if (name == ".[]") { + let argc = length(expr.arguments) + if (argc == 2 || argc == 4) { + emit_index_read(expr.arguments[0], expr.arguments[1], expr) + return expr + } + } // discard(): abandon the fragment -> OpKill. A block terminator (sets ctx.terminated), so any // following statement on the same path is rejected as unreachable. Fragment-only. if (name == "discard") { @@ -4391,6 +4417,22 @@ class SpirvEmit : AstVisitor { def override preVisitExprConstPtr(expr : ExprConstPtr?) : void { errs |> push("pointer constant is not supported in SPIR-V shaders") } + // __context__ / __lineinfo__ ARE lowerable as a `.[]` bounds-check tail: elided when + // preVisitExprCallArgument flagged them, but fail closed anywhere else. + def override preVisitExprFakeContext(expr : ExprFakeContext?) : void { + if (suppress_bounds_arg) { + suppress_bounds_arg = false + return + } + errs |> push("__context__ is not supported in SPIR-V shaders") + } + def override preVisitExprFakeLineInfo(expr : ExprFakeLineInfo?) : void { + if (suppress_bounds_arg) { + suppress_bounds_arg = false + return + } + errs |> push("__lineinfo__ is not supported in SPIR-V shaders") + } def override preVisitExprSafeAt(expr : ExprSafeAt?) : void { errs |> push("safe index (?[]) is not supported in SPIR-V shaders") }