metal2vulkan 0.1.0

Translate Metal AIR / LLVM IR to Vulkan SPIR-V with a native Rust emitter
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
//! Public shader-reflection facade.
//!
//! The translator already parses everything a downstream consumer needs to bind a shader — the
//! stage-interface metadata in [`crate::meta`] (`FragMeta`/`VertMeta`/`KernMeta`) plus the fixed
//! descriptor-ABI convention the stage-input/output passes apply (`crate::passes::stage_input` and
//! `crate::passes::stage_output`). Historically
//! that knowledge was DROPPED at the crate boundary: `translate_*` returned bare `Result<Vec<u8>,
//! String>`, forcing consumers to re-reflect the produced SPIR-V and re-hardcode the ABI bases.
//!
//! This module exposes that knowledge as one consumer-shaped [`ShaderReflection`] value, computed as
//! a distilled facade over the parser-shaped meta structs plus the ABI constants below. The binding
//! numbers here are the SAME ones the interface pass decorates the module with (all in descriptor
//! [`RESOURCE_DESCRIPTOR_SET`], via `BASE + metal_index`); this is a pure re-shaping of already-parsed
//! data and never re-reads the emitted SPIR-V, so it is byte-neutral on the translator.

use crate::meta::{
    texture_shape_from_name, AirType, FragMeta, FragRole, FunctionConstant, KernMeta, KernRole,
    TextureComponent, TextureDimension, TextureShape, VertMeta, VertOutRole, VertRole,
};

/// Schema version of [`ShaderReflection`]. Bump on any breaking change to the serialized shape so a
/// consumer's persisted reflection cache invalidates cleanly rather than
/// deserializing stale fields. See plan Workstream M3.
///
/// v2 (cleanup-plan Workstream R): the shape grew the consumer-readiness fields — per-binding typed
/// `texture_shape` (dimension/arrayed/multisampled/component/writable/storage_format) and
/// `embedded_source`; stage-level `vertex_builtins`, `imageblock_layouts`, `function_constants`, and
/// the source `datalayout`; plus fragment/vertex buffer `address_space`/`declared_size` population.
pub const REFLECTION_VERSION: u32 = 2;

/// The single descriptor set every Metal-facing resource is bound in. The interface pass hardcodes
/// `DescriptorSet 0` for every resource (buffers, textures, samplers, color inputs).
pub const RESOURCE_DESCRIPTOR_SET: u32 = 0;

/// Descriptor binding base for `[[buffer(n)]]` resources: the binding is the Metal buffer index `n`
/// directly (range `0..32`).
pub const BUFFER_BINDING_BASE: u32 = 0;
/// Descriptor binding base for `[[texture(n)]]` resources: binding = `TEXTURE_BINDING_BASE + n`.
pub const TEXTURE_BINDING_BASE: u32 = 32;
/// Descriptor binding base for `[[sampler(n)]]` resources: binding = `SAMPLER_BINDING_BASE + n`.
pub const SAMPLER_BINDING_BASE: u32 = 64;
/// Descriptor binding base for `[[color(n)]]` framebuffer-fetch inputs (Vulkan input attachments):
/// binding = `COLOR_INPUT_BINDING_BASE + n`.
pub const COLOR_INPUT_BINDING_BASE: u32 = 96;

/// AIR address space 2 = constant memory (`constant` / `const device`) — a read-only buffer.
pub const ADDRESS_SPACE_CONSTANT: u32 = 2;
/// AIR address space 3 = threadgroup memory. A `[[buffer(n)]]` in this space becomes a Workgroup
/// `OpVariable` and consumes NO descriptor (its [`ResourceBinding::descriptor`] is `None`).
pub const ADDRESS_SPACE_THREADGROUP: u32 = 3;

/// The shader stage of a reflected module.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ShaderStage {
    Vertex,
    Fragment,
    Kernel,
}

impl From<crate::passes::Stage> for ShaderStage {
    fn from(stage: crate::passes::Stage) -> Self {
        match stage {
            crate::passes::Stage::Vertex => ShaderStage::Vertex,
            crate::passes::Stage::Fragment => ShaderStage::Fragment,
            crate::passes::Stage::Kernel => ShaderStage::Kernel,
        }
    }
}

/// The kind of a bound shader resource.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ResourceKind {
    /// A `[[buffer(n)]]` device/constant buffer. Bound at `BUFFER_BINDING_BASE + n`.
    Buffer,
    /// A `[[buffer(n)]]` in threadgroup address space — a Workgroup variable, no descriptor.
    ThreadgroupBuffer,
    /// A `[[texture(n)]]` sampled image. Bound at `TEXTURE_BINDING_BASE + n`.
    Texture,
    /// A runtime-indexed `array_ref<texture>` descriptor array. Bound at `TEXTURE_BINDING_BASE + n`.
    TextureArray,
    /// A write-only storage image (`texture` with `access::write`). Bound at `TEXTURE_BINDING_BASE + n`.
    StorageImage,
    /// A `[[sampler(n)]]`. Bound at `SAMPLER_BINDING_BASE + n`.
    Sampler,
    /// A `[[color(n)]]` framebuffer-fetch input (Vulkan input attachment). Bound at
    /// `COLOR_INPUT_BINDING_BASE + n`.
    ColorInput,
    /// A host-populated StorageBuffer shadow of an opaque acceleration structure. Bound at the
    /// resource's Metal buffer index.
    AccelerationStructureShadow,
    /// A texture embedded inside an `air.indirect_buffer` argument buffer, surfaced as a standalone
    /// sampled image. Bound at `TEXTURE_BINDING_BASE + synthetic_index`.
    EmbeddedArgBufferTexture,
}

/// The descriptor location the interface pass decorates a resource with. Absent for resources that
/// consume no descriptor (threadgroup buffers).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DescriptorLocation {
    pub set: u32,
    pub binding: u32,
}

/// Per-binding access classification. Populated at translate time from the declared Metal access:
/// texture access from the type-name qualifier (`sample`/`read` → `Sampled`, `write`/`read_write` →
/// `Storage`), and buffer access from the constant address space (`ReadOnly`). A DEVICE buffer's
/// precise read-vs-write requires IR dataflow the facade does not carry, so it stays `None` (the
/// consumer determines it SPIR-V-side). See plan Workstream M2.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ResourceAccess {
    /// A buffer read but never written by the shader.
    ReadOnly,
    /// A buffer written by the shader.
    ReadWrite,
    /// A sampled texture (`OpTypeImage Sampled=1`), read through a sampler.
    Sampled,
    /// A storage image (`OpTypeImage Sampled=2`), read/written directly.
    Storage,
}

/// The argument-buffer source of a translator-synthesized embedded texture: which
/// `air.indirect_buffer` kernel argument carries it and at what byte offset the texture handle sits.
/// Only the translator knows this mapping (the guest stream never names the synthetic texture), so a
/// consumer needs it to source/seed the texture the synthetic binding reads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EmbeddedArgBuffer {
    /// Kernel parameter index of the owning `air.indirect_buffer` argument.
    pub buffer_index: u32,
    /// Byte offset of the texture handle within the argument-buffer struct.
    pub field_offset: u32,
}

/// One bound shader resource: its Metal index, descriptor location, and any decoded layout facts.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ResourceBinding {
    pub kind: ResourceKind,
    /// The Metal resource index (`n` in `[[buffer(n)]]`/`[[texture(n)]]`/`[[sampler(n)]]`), or the
    /// synthetic index for an embedded argument-buffer texture.
    pub metal_index: u32,
    /// The SPIR-V descriptor location, or `None` for a resource that consumes no descriptor.
    pub descriptor: Option<DescriptorLocation>,
    /// The entry-parameter index this resource came from (SPIR-V `OpFunctionParameter` order), when
    /// applicable. `None` for synthesized resources (embedded argument-buffer textures).
    pub param_index: Option<u32>,
    /// For a buffer: the raw AIR address space (3 = threadgroup). `None` for non-buffers / when absent.
    pub address_space: Option<u32>,
    /// For a buffer: the declared AIR argument byte size, when the metadata carries one.
    pub declared_size: Option<u32>,
    /// For a buffer with `air.struct_type_info`: the reconstructed AIR aggregate layout.
    pub type_layout: Option<AirType>,
    /// The AIR argument type name (`texture2d<uint, read>`, a struct name, `char`, …), when carried.
    pub type_name: Option<String>,
    /// For a texture binding: the decoded shape (dimensionality, arrayed, multisampled, component,
    /// access qualifier), so a consumer need not re-parse the type name or walk the emitted
    /// `OpTypeImage`. `None` for non-textures / when no type name was carried.
    pub texture_shape: Option<TextureShape>,
    /// For an `EmbeddedArgBufferTexture`: the arg-buffer argument + offset it was synthesized from.
    /// `None` for every other binding.
    pub embedded_source: Option<EmbeddedArgBuffer>,
    /// Per-binding access, once Workstream M2 computes it; `None` otherwise.
    pub access: Option<ResourceAccess>,
}

impl ResourceBinding {
    fn descriptor_at(base: u32, metal_index: u32) -> Option<DescriptorLocation> {
        Some(DescriptorLocation {
            set: RESOURCE_DESCRIPTOR_SET,
            binding: base.saturating_add(metal_index),
        })
    }
}

/// A vertex-stage input attribute (`[[attribute(n)]]` / `[[stage_in]]`).
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct VertexAttribute {
    /// SPIR-V input Location.
    pub location: u32,
    /// AIR type name (`float2`, `float4`, …), when carried.
    pub type_name: Option<String>,
    /// Metal argument name, when carried.
    pub name: Option<String>,
}

/// A varying: a fragment `[[stage_in]]` input, or a vertex user-varying output, at a Location.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Varying {
    /// SPIR-V Location.
    pub location: u32,
    /// AIR type name, when carried.
    pub type_name: Option<String>,
    /// Metal argument/field name, when carried.
    pub name: Option<String>,
    /// Metal user semantic (`user(texturecoord)`), when carried.
    pub user_semantic: Option<String>,
}

/// Vertex-stage builtin usage: which SPIR-V builtins the entry consumes or writes. Parsed into
/// `VertMeta` roles; a consumer uses these to decide whether to bind vertex/instance index sources
/// and whether the pipeline emits clip position, without walking the emitted module for `OpDecorate
/// BuiltIn`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct VertexBuiltins {
    /// The entry reads `[[vertex_id]]` (SPIR-V `VertexIndex`).
    pub uses_vertex_index: bool,
    /// The entry reads `[[instance_id]]` (SPIR-V `InstanceIndex`).
    pub uses_instance_index: bool,
    /// The entry writes `[[position]]` (SPIR-V `Position`).
    pub writes_position: bool,
}

/// A fragment render-target output.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RenderTarget {
    /// Return-struct member index.
    pub member_index: u32,
    /// Color attachment Location.
    pub location: u32,
    /// AIR render-target type name (`float4`, `int4`, …), when carried.
    pub type_name: Option<String>,
}

/// A kernel `[[imageblock]]` threadgroup tile: the entry-parameter index and its reconstructed AIR
/// struct layout. An imageblock consumes NO descriptor (it is threadgroup-local storage), so it is
/// not a `ResourceBinding`; a consumer needs the layout to size the tile.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ImageblockLayout {
    /// Entry-parameter index of the imageblock argument.
    pub param_index: u32,
    /// The reconstructed AIR aggregate layout of the imageblock struct.
    pub type_layout: AirType,
}

/// The consumer-shaped reflection of one translated shader. Built as a facade over the parser-shaped
/// [`FragMeta`]/[`VertMeta`]/[`KernMeta`]; every binding number matches what the interface pass
/// decorated the emitted module with.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ShaderReflection {
    /// Schema version, always [`REFLECTION_VERSION`] at build time.
    pub reflection_version: u32,
    pub stage: ShaderStage,
    /// The ORIGINAL Metal entry-point function name (the emitted SPIR-V `OpEntryPoint` string is
    /// always `"main"`, so the meaningful identity a consumer keys on is this name).
    pub entry_point: Option<String>,
    /// Every bound resource, in entry-parameter order (synthesized embedded textures last).
    pub bindings: Vec<ResourceBinding>,
    /// Vertex input attributes (vertex stage only).
    pub vertex_attributes: Vec<VertexAttribute>,
    /// Varyings: fragment `[[stage_in]]` inputs, or vertex user-varying outputs.
    pub varyings: Vec<Varying>,
    /// Fragment render-target outputs (fragment stage only).
    pub render_targets: Vec<RenderTarget>,
    /// Fragment return-struct members tagged `[[depth]]`.
    pub depth_members: Vec<u32>,
    /// Fragment return-struct members tagged `[[stencil]]`.
    pub stencil_members: Vec<u32>,
    /// Kernel GLCompute local size (`[x, y, z]`), when the stage is a kernel.
    pub local_size: Option<[u32; 3]>,
    /// Vertex-stage builtin usage (`Some` only for the vertex stage).
    pub vertex_builtins: Option<VertexBuiltins>,
    /// Kernel `[[imageblock]]` threadgroup tiles (kernel stage only), sorted by parameter index.
    pub imageblock_layouts: Vec<ImageblockLayout>,
    /// The source LLVM-IR `target datalayout` string, when the reflected translate started from an
    /// unsanitized module (sanitization strips it). A consumer uses it to lay out struct members
    /// without re-reading the source `.ll`. `None` when translated from already-sanitized IR.
    pub datalayout: Option<String>,
    /// Metal `[[function_constant(N)]]` inventory (index/name/type), so a consumer can discover the
    /// module's spec-ids without scanning SPIR-V. Populated by the reflected translate paths; empty
    /// when reflection is built directly from meta (the `from_*` builders do not scan IR).
    pub function_constants: Vec<FunctionConstant>,
}

impl ShaderReflection {
    /// Build reflection for a fragment shader from its parsed meta and (optional) entry name.
    pub fn from_fragment(meta: &FragMeta, entry_point: Option<&str>) -> Self {
        let mut bindings = Vec::new();
        for (idx, role) in &meta.roles {
            let idx = *idx;
            let binding = match role {
                FragRole::Buffer(n) => ResourceBinding {
                    kind: ResourceKind::Buffer,
                    metal_index: *n,
                    descriptor: ResourceBinding::descriptor_at(BUFFER_BINDING_BASE, *n),
                    param_index: Some(idx),
                    address_space: meta.buffer_address_spaces.get(&idx).copied(),
                    declared_size: meta.buffer_type_sizes.get(&idx).copied(),
                    type_layout: meta.buffer_layouts.get(&idx).cloned(),
                    type_name: None,
                    texture_shape: None,
                    embedded_source: None,
                    access: None,
                },
                FragRole::Texture(n) => texture_binding(*n, Some(idx), &meta.texture_type_names),
                FragRole::Sampler(n) => sampler_binding(*n, Some(idx)),
                FragRole::ColorInput(n) => ResourceBinding {
                    kind: ResourceKind::ColorInput,
                    metal_index: *n,
                    descriptor: ResourceBinding::descriptor_at(COLOR_INPUT_BINDING_BASE, *n),
                    param_index: Some(idx),
                    address_space: None,
                    declared_size: None,
                    type_layout: None,
                    type_name: None,
                    texture_shape: None,
                    embedded_source: None,
                    access: None,
                },
                FragRole::Position | FragRole::Varying(_) | FragRole::Other => continue,
            };
            bindings.push(binding);
        }
        let varyings = meta
            .varying_types
            .keys()
            .chain(meta.varying_names.keys())
            .chain(meta.varying_user_semantics.keys())
            .copied()
            .collect::<std::collections::BTreeSet<_>>()
            .into_iter()
            .map(|loc| Varying {
                location: loc,
                type_name: meta.varying_types.get(&loc).cloned(),
                name: meta.varying_names.get(&loc).cloned(),
                user_semantic: meta.varying_user_semantics.get(&loc).cloned(),
            })
            .collect();
        let render_targets = meta
            .render_target_members
            .iter()
            .map(|(member, location)| RenderTarget {
                member_index: *member,
                location: *location,
                type_name: meta.render_target_type_names.get(member).cloned(),
            })
            .collect();
        ShaderReflection {
            reflection_version: REFLECTION_VERSION,
            stage: ShaderStage::Fragment,
            entry_point: entry_point.map(str::to_string),
            bindings,
            vertex_attributes: Vec::new(),
            varyings,
            render_targets,
            depth_members: meta.depth_members.clone(),
            stencil_members: meta.stencil_members.clone(),
            local_size: None,
            vertex_builtins: None,
            imageblock_layouts: Vec::new(),
            datalayout: None,
            function_constants: Vec::new(),
        }
    }

    /// Build reflection for a vertex shader from its parsed meta and (optional) entry name.
    pub fn from_vertex(meta: &VertMeta, entry_point: Option<&str>) -> Self {
        let vertex_builtins = VertexBuiltins {
            uses_vertex_index: meta.roles.iter().any(|(_, r)| *r == VertRole::VertexId),
            uses_instance_index: meta.roles.iter().any(|(_, r)| *r == VertRole::InstanceId),
            writes_position: meta.output_roles.contains(&VertOutRole::Position),
        };
        let mut bindings = Vec::new();
        for (idx, role) in &meta.roles {
            let idx = *idx;
            let binding = match role {
                VertRole::Buffer(n) => ResourceBinding {
                    kind: ResourceKind::Buffer,
                    metal_index: *n,
                    descriptor: ResourceBinding::descriptor_at(BUFFER_BINDING_BASE, *n),
                    param_index: Some(idx),
                    address_space: meta.buffer_address_spaces.get(&idx).copied(),
                    declared_size: meta.buffer_type_sizes.get(&idx).copied(),
                    type_layout: meta.buffer_layouts.get(&idx).cloned(),
                    type_name: None,
                    texture_shape: None,
                    embedded_source: None,
                    access: None,
                },
                VertRole::Texture(n) => texture_binding(*n, Some(idx), &meta.texture_type_names),
                VertRole::Sampler(n) => sampler_binding(*n, Some(idx)),
                VertRole::VertexInput(_)
                | VertRole::VertexId
                | VertRole::InstanceId
                | VertRole::Other => continue,
            };
            bindings.push(binding);
        }
        let vertex_attributes = meta
            .vertex_input_types
            .keys()
            .chain(meta.vertex_input_names.keys())
            .copied()
            .collect::<std::collections::BTreeSet<_>>()
            .into_iter()
            .map(|loc| VertexAttribute {
                location: loc,
                type_name: meta.vertex_input_types.get(&loc).cloned(),
                name: meta.vertex_input_names.get(&loc).cloned(),
            })
            .collect();
        let varyings = meta
            .output_roles
            .iter()
            .filter_map(|role| match role {
                VertOutRole::Varying(loc) => Some(Varying {
                    location: *loc,
                    type_name: None,
                    name: None,
                    user_semantic: None,
                }),
                _ => None,
            })
            .collect();
        ShaderReflection {
            reflection_version: REFLECTION_VERSION,
            stage: ShaderStage::Vertex,
            entry_point: entry_point.map(str::to_string),
            bindings,
            vertex_attributes,
            varyings,
            render_targets: Vec::new(),
            depth_members: Vec::new(),
            stencil_members: Vec::new(),
            local_size: None,
            vertex_builtins: Some(vertex_builtins),
            imageblock_layouts: Vec::new(),
            datalayout: None,
            function_constants: Vec::new(),
        }
    }

    /// Build reflection for a compute kernel from its parsed meta, entry name, and local size.
    pub fn from_kernel(meta: &KernMeta, entry_point: Option<&str>, local_size: [u32; 3]) -> Self {
        let mut bindings = Vec::new();
        for (idx, role) in &meta.roles {
            let idx = *idx;
            let binding = match role {
                KernRole::Buffer(n) => {
                    let address_space = meta.buffer_address_spaces.get(&idx).copied();
                    let threadgroup = address_space == Some(ADDRESS_SPACE_THREADGROUP);
                    let kind = if threadgroup {
                        ResourceKind::ThreadgroupBuffer
                    } else {
                        ResourceKind::Buffer
                    };
                    ResourceBinding {
                        kind,
                        metal_index: *n,
                        descriptor: if threadgroup {
                            None
                        } else {
                            ResourceBinding::descriptor_at(BUFFER_BINDING_BASE, *n)
                        },
                        param_index: Some(idx),
                        address_space,
                        declared_size: meta.buffer_type_sizes.get(&idx).copied(),
                        type_layout: meta.buffer_layouts.get(&idx).cloned(),
                        type_name: meta.buffer_type_names.get(&idx).cloned(),
                        texture_shape: None,
                        embedded_source: None,
                        access: buffer_access_from_address_space(address_space),
                    }
                }
                KernRole::Texture(n) => texture_binding(*n, Some(idx), &meta.texture_type_names),
                KernRole::Sampler(n) => sampler_binding(*n, Some(idx)),
                KernRole::AccelerationStructureShadow(n) => ResourceBinding {
                    kind: ResourceKind::AccelerationStructureShadow,
                    metal_index: *n,
                    descriptor: ResourceBinding::descriptor_at(BUFFER_BINDING_BASE, *n),
                    param_index: Some(idx),
                    address_space: None,
                    declared_size: None,
                    type_layout: None,
                    type_name: None,
                    texture_shape: None,
                    embedded_source: None,
                    access: None,
                },
                _ => continue,
            };
            bindings.push(binding);
        }
        for embedded in &meta.embedded_textures {
            bindings.push(ResourceBinding {
                kind: ResourceKind::EmbeddedArgBufferTexture,
                metal_index: embedded.synthetic_texture_index,
                descriptor: ResourceBinding::descriptor_at(
                    TEXTURE_BINDING_BASE,
                    embedded.synthetic_texture_index,
                ),
                param_index: None,
                address_space: None,
                declared_size: None,
                type_layout: None,
                type_name: None,
                texture_shape: Some(TextureShape {
                    dimension: TextureDimension::from_spirv_dim(embedded.dim),
                    arrayed: false,
                    multisampled: false,
                    component: TextureComponent::from_image_comp(embedded.comp),
                    writable: false,
                    array_ref: false,
                    storage_format: None,
                }),
                embedded_source: Some(EmbeddedArgBuffer {
                    buffer_index: embedded.buffer_index,
                    field_offset: embedded.field_offset,
                }),
                access: Some(ResourceAccess::Sampled),
            });
        }
        ShaderReflection {
            reflection_version: REFLECTION_VERSION,
            stage: ShaderStage::Kernel,
            entry_point: entry_point.map(str::to_string),
            bindings,
            vertex_attributes: Vec::new(),
            varyings: Vec::new(),
            render_targets: Vec::new(),
            depth_members: Vec::new(),
            stencil_members: Vec::new(),
            local_size: Some(local_size),
            vertex_builtins: None,
            imageblock_layouts: {
                let mut ibs: Vec<ImageblockLayout> = meta
                    .imageblock_layouts
                    .iter()
                    .map(|(idx, ty)| ImageblockLayout {
                        param_index: *idx,
                        type_layout: ty.clone(),
                    })
                    .collect();
                ibs.sort_by_key(|ib| ib.param_index);
                ibs
            },
            datalayout: None,
            function_constants: Vec::new(),
        }
    }

    /// The binding for a given resource kind + Metal index, if present.
    pub fn binding_at(&self, kind: ResourceKind, metal_index: u32) -> Option<&ResourceBinding> {
        self.bindings
            .iter()
            .find(|b| b.kind == kind && b.metal_index == metal_index)
    }
}

fn texture_binding(
    n: u32,
    param_index: Option<u32>,
    type_names: &std::collections::HashMap<u32, String>,
) -> ResourceBinding {
    let type_name = param_index.and_then(|idx| type_names.get(&idx).cloned());
    let texture_shape = type_name.as_deref().map(texture_shape_from_name);
    let (kind, access) = classify_texture(texture_shape.as_ref());
    ResourceBinding {
        kind,
        metal_index: n,
        descriptor: ResourceBinding::descriptor_at(TEXTURE_BINDING_BASE, n),
        param_index,
        address_space: None,
        declared_size: None,
        type_layout: None,
        type_name,
        texture_shape,
        embedded_source: None,
        access: Some(access),
    }
}

/// Classify a texture argument from its decoded [`TextureShape`], matching the interface pass's
/// `texture_arg_storage` (M2). A `write`/`read_write` texture lowers to a storage image; an
/// `array_ref<texture...>` is a runtime-indexed sampled descriptor array; everything else is a
/// sampled image. This is the DECLARED access — the authoritative Metal qualifier — so it is
/// translate-time exact for a top-level texture argument. No decoded shape (no type name carried)
/// falls back to a plain sampled 2D texture.
fn classify_texture(shape: Option<&TextureShape>) -> (ResourceKind, ResourceAccess) {
    let Some(shape) = shape else {
        return (ResourceKind::Texture, ResourceAccess::Sampled);
    };
    if shape.array_ref {
        (ResourceKind::TextureArray, ResourceAccess::Sampled)
    } else if shape.writable {
        (ResourceKind::StorageImage, ResourceAccess::Storage)
    } else {
        (ResourceKind::Texture, ResourceAccess::Sampled)
    }
}

/// Access for a buffer given its raw AIR address space: the CONSTANT space is read-only. A DEVICE
/// buffer may be written, and proving it is not requires IR dataflow the facade does not carry — so
/// its access stays `None` (the consumer determines it SPIR-V-side). Only kernel metadata carries
/// address spaces; fragment/vertex buffers report `None`.
fn buffer_access_from_address_space(address_space: Option<u32>) -> Option<ResourceAccess> {
    match address_space {
        Some(ADDRESS_SPACE_CONSTANT) => Some(ResourceAccess::ReadOnly),
        _ => None,
    }
}

fn sampler_binding(n: u32, param_index: Option<u32>) -> ResourceBinding {
    ResourceBinding {
        kind: ResourceKind::Sampler,
        metal_index: n,
        descriptor: ResourceBinding::descriptor_at(SAMPLER_BINDING_BASE, n),
        param_index,
        address_space: None,
        declared_size: None,
        type_layout: None,
        type_name: None,
        texture_shape: None,
        embedded_source: None,
        access: None,
    }
}

#[cfg(test)]
mod tests;