Skip to main content

d3d9_reader/
disasm.rs

1//! A text listing of a Direct3D 9 program in the dialect of Microsoft's disassembler (`D3DDisassemble`): the
2//! constant table as comments, the program's lines indented four spaces, and the instruction slots it uses.
3use std::collections::BTreeMap;
4use std::fmt::Write;
5
6use crate::{
7    Comparison, Constant, ConstantTable, DebugInfo, Declared, Destination, Error, Instruction, Item, Literal,
8    Modifier, Opcode, Program, Register, RegisterType, Relative, Result, Source, SourceLine, Stage,
9    TextureType, Type, read,
10};
11
12/// What the listing writes, as the `D3D_DISASM_*` flags of Microsoft's disassembler choose it.
13#[derive(Clone, Debug, PartialEq, Eq)]
14#[non_exhaustive]
15pub struct Options {
16    /// The constant table's comments (cleared by `D3D_DISASM_INSTRUCTION_ONLY`, 0x40; the slot count is written
17    /// either way).
18    pub header: bool,
19    /// The arithmetic slots before each instruction, `%4u: ` (texture slots not counted; `%4u  ` before an offset),
20    /// and six spaces before a co-issued instruction, a `nop` and the other lines but the version
21    /// (`D3D_DISASM_ENABLE_INSTRUCTION_NUMBERING`, 0x04).
22    pub numbering: bool,
23    /// Each line's byte offset from the version token, `0x%08X: `, before every line but the version
24    /// (`D3D_DISASM_ENABLE_INSTRUCTION_OFFSET`, 0x20).
25    pub offsets: bool,
26    /// A `def`'s floats as their bits, `0x%08x` (`D3D_DISASM_PRINT_HEX_LITERALS`, 0x80); `defi` stays decimal.
27    pub hex_literals: bool,
28    /// What a `/Zi` build's `DBUG` comment says: a `#line` before an instruction whose source line moved, and after a
29    /// line the variables it writes (cleared by `D3D_DISASM_DISABLE_DEBUG_INFO`, 0x10).
30    pub debug_info: bool,
31}
32
33impl Default for Options {
34    fn default() -> Self {
35        Self {
36            header: true,
37            numbering: false,
38            offsets: false,
39            hex_literals: false,
40            debug_info: true,
41        }
42    }
43}
44
45impl Options {
46    /// The options of a `D3D_DISASM_*` flags word. 0x02 changes nothing. Refuses 0x01 (an HTML listing), 0x08
47    /// (which Microsoft's disassembler refuses too) and bits it does not define, naming the bit.
48    pub fn from_flags(flags: u32) -> Result<Self> {
49        let refuse = |detail: String| Error { word: 0, detail };
50        if flags & 0x01 != 0 {
51            return Err(refuse(
52                "flag 0x01, D3D_DISASM_ENABLE_COLOR_CODE (an HTML listing), is not written".into(),
53            ));
54        }
55        if flags & 0x08 != 0 {
56            return Err(refuse(
57                "flag 0x08, D3D_DISASM_ENABLE_INSTRUCTION_CYCLE, which Microsoft's disassembler refuses too, is not \
58                 written"
59                    .into(),
60            ));
61        }
62        let unknown = flags & !0xf6;
63        if unknown != 0 {
64            return Err(refuse(format!(
65                "flag bit 0x{:x} is not a D3D_DISASM flag",
66                1u32 << unknown.trailing_zeros()
67            )));
68        }
69        Ok(Self {
70            header: flags & 0x40 == 0,
71            numbering: flags & 0x04 != 0,
72            offsets: flags & 0x20 != 0,
73            hex_literals: flags & 0x80 != 0,
74            debug_info: flags & 0x10 == 0,
75        })
76    }
77}
78
79const LANES: [char; 4] = ['x', 'y', 'z', 'w'];
80
81/// The listing of a Direct3D 9 program (its version token to `END`, no container) as Microsoft's disassembler
82/// writes it with no flags, its lines ending in `\n` (the disassembler's blob also ends in a zero byte, which is not
83/// part of the text). Refuses what [`read`] refuses.
84pub fn disassemble(bytes: &[u8]) -> Result<String> {
85    disassemble_with(bytes, &Options::default())
86}
87
88/// The listing [`disassemble`] writes, with what `options` choose.
89pub fn disassemble_with(bytes: &[u8], options: &Options) -> Result<String> {
90    let program = read(bytes)?;
91    let mut out = String::new();
92    // a program without a constant table starts at its version (as inside a feature level 9 container's listing)
93    if let Some(table) = program.constants.as_ref().filter(|_| options.header) {
94        header(&mut out, table);
95    }
96    let _ = writeln!(out, "    {}", program.version);
97    let debug = program.debug.as_ref().filter(|_| options.debug_info);
98    let notes = debug.map(variables).unwrap_or_default();
99    // the line record of each record, by its byte offset
100    let records: BTreeMap<u32, usize> = debug
101        .map(|d| d.lines.iter().enumerate().map(|(n, l)| (l.offset, n)).collect())
102        .unwrap_or_default();
103    // the file and line of the last record that had one
104    let mut source: Option<(u16, u16)> = None;
105    let mut depth: usize = 0;
106    // the arithmetic slots before the next instruction, which the numbering writes (texture slots don't count)
107    let mut slot: u32 = 0;
108    for item in &program.items {
109        if matches!(item, Item::Comment(_)) {
110            continue;
111        }
112        let record = u32::try_from(item.word().saturating_mul(4))
113            .ok()
114            .and_then(|offset| records.get(&offset))
115            .copied();
116        // a `#line` where the file changes or the line moves by more than 2, after a blank line and before the prefix
117        let line = record.and_then(|n| debug.and_then(|d| d.lines.get(n)));
118        if let (
119            Some(d),
120            Some(SourceLine {
121                line,
122                file: Some(file),
123                ..
124            }),
125        ) = (debug, line)
126        {
127            match source {
128                Some((f, l)) if f == *file => {
129                    if line.abs_diff(l) > 2 {
130                        let _ = writeln!(out, "\n#line {line}");
131                    }
132                }
133                _ => {
134                    let path = d.files.get(usize::from(*file)).map_or("", String::as_str);
135                    let _ = writeln!(out, "\n#line {line} \"{path}\"");
136                }
137            }
138            source = Some((*file, *line));
139        }
140        // the number (`%4u: `, or `%4u  ` before an offset; a co-issued instruction and a `nop` have none), then the offset
141        let mut prefix = String::new();
142        if options.numbering {
143            match item {
144                Item::Instruction(i) if !i.coissue && i.opcode != Opcode::Nop => {
145                    let colon = if options.offsets { ' ' } else { ':' };
146                    let _ = write!(prefix, "{slot:>4}{colon} ");
147                    slot = slot.saturating_add(slots(i, &program).1);
148                }
149                _ => prefix.push_str("      "),
150            }
151        }
152        if options.offsets {
153            let _ = write!(prefix, "0x{:08X}: ", item.word().saturating_mul(4));
154        }
155        // a co-issued instruction's `+` takes the place of the indent's last two spaces
156        let indent = match item {
157            Item::Instruction(i) if i.coissue => "  + ",
158            _ => "    ",
159        };
160        let line = match item {
161            Item::Comment(_) => continue,
162            Item::Declaration(d) => declaration(d, &program),
163            Item::Definition(d) => definition(&d.literal, d.destination.register, &program, options),
164            Item::Instruction(i) => {
165                if matches!(
166                    i.opcode,
167                    Opcode::Else | Opcode::EndIf | Opcode::EndLoop | Opcode::EndRep
168                ) {
169                    depth = depth.saturating_sub(1);
170                }
171                let text = instruction(i, &program);
172                let line = format!("{}{text}", "  ".repeat(depth));
173                if matches!(
174                    i.opcode,
175                    Opcode::If | Opcode::IfC | Opcode::Else | Opcode::Loop | Opcode::Rep
176                ) {
177                    depth = depth.saturating_add(1);
178                }
179                line
180            }
181        };
182        let _ = write!(out, "{prefix}{indent}{}", line.trim_end());
183        if let Some(written) = record.and_then(|n| notes.get(n)).filter(|w| !w.is_empty()) {
184            let _ = write!(out, "  // {}", written.join(", "));
185        }
186        out.push('\n');
187    }
188    out.push('\n');
189    footer(&mut out, &program);
190    Ok(out)
191}
192
193/// What each line record's instruction writes, by the record's place: `name<components>` for each location of each
194/// variable, in the variable table's order. A variable of the entry point's parameters is its bare name, one of another
195/// function or a constant buffer `scope::name`, and a local or a function's return value `::name`; the components are
196/// those of the lanes that hold one, in lane order.
197fn variables(debug: &DebugInfo) -> Vec<Vec<String>> {
198    let mut notes = vec![Vec::new(); debug.lines.len()];
199    for v in &debug.variables {
200        let name = match &v.scope {
201            Some(scope) if *scope == debug.entry => v.name.clone(),
202            Some(scope) => format!("{scope}::{}", v.name),
203            None => format!("::{}", v.name),
204        };
205        for l in &v.locations {
206            let components: Vec<String> = l.components.iter().flatten().map(u16::to_string).collect();
207            if let Some(n) = usize::try_from(l.instruction).ok().and_then(|n| notes.get_mut(n)) {
208                n.push(format!("{name}<{}>", components.join(",")));
209            }
210        }
211    }
212    notes
213}
214
215/// `D3DXPARAMETER_TYPE` as HLSL names it.
216fn type_kind(kind: u16) -> &'static str {
217    match kind {
218        0 => "void",
219        1 => "bool",
220        2 => "int",
221        3 => "float",
222        4 => "string",
223        5 => "texture",
224        6 => "texture1D",
225        7 => "texture2D",
226        8 => "texture3D",
227        9 => "textureCUBE",
228        10 => "sampler",
229        11 => "sampler1D",
230        12 => "sampler2D",
231        13 => "sampler3D",
232        14 => "samplerCUBE",
233        _ => "unknown",
234    }
235}
236
237/// A parameter's type as the Parameters list writes it before the name: `float4`, `row_major float4x4`,
238/// `sampler2D`.
239fn type_name(ty: &Type) -> String {
240    let kind = type_kind(ty.kind);
241    match ty.class {
242        1 => format!("{kind}{}", ty.columns),
243        2 => format!("row_major {kind}{}x{}", ty.rows, ty.columns),
244        3 => format!("{kind}{}x{}", ty.rows, ty.columns),
245        _ => kind.to_owned(),
246    }
247}
248
249/// The register bank of a `D3DXREGISTER_SET`: `b`, `i`, `c`, `s`.
250fn bank(set: u16) -> &'static str {
251    match set {
252        0 => "b",
253        1 => "i",
254        3 => "s",
255        _ => "c",
256    }
257}
258
259/// The constant table as comments: the compiler, every parameter in name order, and the registers in register
260/// order, then a blank line. A table with no constants is the compiler line alone, with no blank line after it.
261fn header(out: &mut String, table: &ConstantTable) {
262    let _ = writeln!(out, "//\n// Generated by {}", table.creator);
263    let mut constants: Vec<&Constant> = table.constants.iter().collect();
264    if constants.is_empty() {
265        return;
266    }
267    constants.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes()));
268    out.push_str("//\n// Parameters:\n//\n");
269    // a uniform with a range in two register sets is one parameter
270    let mut parameters = constants.clone();
271    parameters.dedup_by(|a, b| a.name == b.name);
272    for c in &parameters {
273        parameter(out, 3, &c.ty, &c.name);
274    }
275    out.push_str("//\n//\n// Registers:\n//\n");
276    constants.sort_by_key(|c| (c.register_set, c.register));
277    // the name column is never narrower than 12
278    let width = constants.iter().map(|c| c.name.len()).max().unwrap_or(0).max(12);
279    let _ = writeln!(out, "//   {:<width$} Reg   Size", "Name");
280    let _ = writeln!(out, "//   {} ----- ----", "-".repeat(width));
281    for c in &constants {
282        let register = format!("{}{}", bank(c.register_set), c.register);
283        let _ = writeln!(out, "//   {:<width$} {register:<5} {:>4}", c.name, c.count);
284    }
285    out.push_str("//\n\n");
286}
287
288/// One line of the Parameters list, `type name[elements];`, at `indent` spaces after the `//`. A struct is a block, with
289/// its members `indent + 4` in and a blank comment line before and after it (one between two structs):
290///
291/// ```text
292/// //
293/// //   struct
294/// //   {
295/// //       float4 colour;
296/// //
297/// //   } lights[3];
298/// //
299/// ```
300fn parameter(out: &mut String, indent: usize, ty: &Type, name: &str) {
301    let pad = " ".repeat(indent);
302    let array = if ty.elements > 1 {
303        format!("[{}]", ty.elements)
304    } else {
305        String::new()
306    };
307    if ty.class == 5 {
308        let blank = format!("//{pad}\n");
309        if !out.ends_with(&blank) {
310            out.push_str(&blank);
311        }
312        let _ = write!(out, "//{pad}struct\n//{pad}{{\n");
313        for member in &ty.members {
314            parameter(out, indent.saturating_add(4), &member.ty, &member.name);
315        }
316        let _ = write!(out, "//\n//{pad}}} {name}{array};\n//{pad}\n");
317    } else {
318        let _ = writeln!(out, "//{pad}{} {name}{array};", type_name(ty));
319    }
320}
321
322fn register(r: Register, program: &Program) -> String {
323    let n = r.number;
324    let vertex3 = program.version.stage == Stage::Vertex && program.version.major == 3;
325    let indexed = |names: &[&str]| -> String {
326        usize::try_from(n)
327            .ok()
328            .and_then(|i| names.get(i))
329            .map_or_else(|| format!("?{n}"), |s| (*s).to_owned())
330    };
331    match r.kind {
332        RegisterType::Temp => format!("r{n}"),
333        RegisterType::Input => format!("v{n}"),
334        RegisterType::Const => format!("c{n}"),
335        RegisterType::Const2 => format!("c{}", n.saturating_add(2048)),
336        RegisterType::Const3 => format!("c{}", n.saturating_add(4096)),
337        RegisterType::Const4 => format!("c{}", n.saturating_add(6144)),
338        RegisterType::Address if program.version.stage == Stage::Pixel => format!("t{n}"),
339        RegisterType::Address => "a0".into(),
340        RegisterType::RasterizerOut => indexed(&["oPos", "oFog", "oPts"]),
341        RegisterType::AttributeOut => format!("oD{n}"),
342        RegisterType::Output if vertex3 => format!("o{n}"),
343        RegisterType::Output => format!("oT{n}"),
344        RegisterType::ConstInt => format!("i{n}"),
345        RegisterType::ColorOut => format!("oC{n}"),
346        RegisterType::DepthOut => "oDepth".into(),
347        RegisterType::Sampler => format!("s{n}"),
348        RegisterType::ConstBool => format!("b{n}"),
349        RegisterType::Loop => "aL".into(),
350        RegisterType::Misc => indexed(&["vPos", "vFace"]),
351        RegisterType::Label => format!("l{n}"),
352        RegisterType::Predicate => format!("p{n}"),
353        RegisterType::Texture => format!("t{n}"),
354    }
355}
356
357fn lane(l: u8) -> char {
358    LANES.get(usize::from(l)).copied().unwrap_or('?')
359}
360
361/// A source swizzle: none for `.xyzw`, one letter for a replicate, all four otherwise.
362fn swizzle(s: [u8; 4]) -> String {
363    match s {
364        [0, 1, 2, 3] => String::new(),
365        [a, b, c, d] if a == b && b == c && c == d => format!(".{}", lane(a)),
366        _ => format!(".{}", s.iter().map(|&l| lane(l)).collect::<String>()),
367    }
368}
369
370fn relative(r: Option<Relative>, program: &Program) -> String {
371    r.map_or(String::new(), |r| {
372        format!("[{}{}]", register(r.register, program), swizzle(r.swizzle))
373    })
374}
375
376fn source(s: &Source, program: &Program) -> String {
377    let name = format!(
378        "{}{}",
379        register(s.register, program),
380        relative(s.relative, program)
381    );
382    let body = match s.modifier {
383        Modifier::None | Modifier::Negate | Modifier::Not => name,
384        Modifier::Bias | Modifier::BiasNegate => format!("{name}_bias"),
385        Modifier::Sign | Modifier::SignNegate => format!("{name}_bx2"),
386        Modifier::Complement => format!("1-{name}"),
387        Modifier::X2 | Modifier::X2Negate => format!("{name}_x2"),
388        Modifier::DivideZ => format!("{name}_dz"),
389        Modifier::DivideW => format!("{name}_dw"),
390        Modifier::Abs | Modifier::AbsNegate => format!("{name}_abs"),
391    };
392    let sign = match s.modifier {
393        Modifier::Negate
394        | Modifier::BiasNegate
395        | Modifier::SignNegate
396        | Modifier::X2Negate
397        | Modifier::AbsNegate => "-",
398        Modifier::Not => "!",
399        _ => "",
400    };
401    let swizzled = if s.register.kind == RegisterType::Sampler {
402        String::new()
403    } else {
404        swizzle(s.swizzle)
405    };
406    format!("{sign}{body}{swizzled}")
407}
408
409fn destination(d: &Destination, program: &Program) -> String {
410    let mask = if d.mask == 15 {
411        String::new()
412    } else {
413        let lanes: String = (0..4u8).filter(|n| d.mask & (1 << n) != 0).map(lane).collect();
414        format!(".{lanes}")
415    };
416    format!(
417        "{}{}{mask}",
418        register(d.register, program),
419        relative(d.relative, program)
420    )
421}
422
423/// The result modifiers after a mnemonic: the pixel 1.x shift, `_sat`, `_pp`, `_centroid`.
424fn suffixes(d: Option<&Destination>) -> String {
425    let Some(d) = d else { return String::new() };
426    let mut s = match d.shift {
427        1 => "_x2",
428        2 => "_x4",
429        3 => "_x8",
430        -1 => "_d2",
431        -2 => "_d4",
432        -3 => "_d8",
433        _ => "",
434    }
435    .to_owned();
436    if d.saturate {
437        s.push_str("_sat");
438    }
439    if d.partial_precision {
440        s.push_str("_pp");
441    }
442    if d.centroid {
443        s.push_str("_centroid");
444    }
445    s
446}
447
448fn mnemonic(i: &Instruction, program: &Program) -> String {
449    let v = program.version;
450    let from14 = v.stage == Stage::Pixel && (v.major > 1 || v.minor == 4);
451    let comparison = i.comparison().map_or("", |c| match c {
452        Comparison::Greater => "_gt",
453        Comparison::Equal => "_eq",
454        Comparison::GreaterEqual => "_ge",
455        Comparison::Less => "_lt",
456        Comparison::NotEqual => "_ne",
457        Comparison::LessEqual => "_le",
458    });
459    match i.opcode {
460        Opcode::Tex if from14 => match i.control {
461            0 => "texld",
462            1 => "texldp",
463            _ => "texldb",
464        }
465        .to_owned(),
466        Opcode::TexCoord if from14 => "texcrd".into(),
467        Opcode::IfC => format!("if{comparison}"),
468        Opcode::BreakC => format!("break{comparison}"),
469        Opcode::SetP => format!("setp{comparison}"),
470        other => other.name().into(),
471    }
472}
473
474fn instruction(i: &Instruction, program: &Program) -> String {
475    let mut args = Vec::new();
476    if let Some(d) = &i.destination {
477        args.push(destination(d, program));
478    }
479    args.extend(i.sources.iter().map(|s| source(s, program)));
480    let predicate = i
481        .predicate
482        .as_ref()
483        .map_or(String::new(), |p| format!("({}) ", source(p, program)));
484    format!(
485        "{predicate}{}{} {}",
486        mnemonic(i, program),
487        suffixes(i.destination.as_ref()),
488        args.join(", ")
489    )
490}
491
492/// The usage names of `dcl_<usage><index>`.
493const USAGES: [&str; 14] = [
494    "position",
495    "blendweight",
496    "blendindices",
497    "normal",
498    "psize",
499    "texcoord",
500    "tangent",
501    "binormal",
502    "tessfactor",
503    "positiont",
504    "color",
505    "fog",
506    "depth",
507    "sample",
508];
509
510fn declaration(d: &crate::Declaration, program: &Program) -> String {
511    let what = match d.declared {
512        Declared::Sampler(TextureType::D2) => "_2d".to_owned(),
513        Declared::Sampler(TextureType::Cube) => "_cube".to_owned(),
514        Declared::Sampler(TextureType::Volume) => "_volume".to_owned(),
515        Declared::Semantic { .. }
516            if matches!(
517                d.destination.register.kind,
518                RegisterType::Texture | RegisterType::Misc
519            ) || (program.version.stage == Stage::Pixel && program.version.major == 2) =>
520        {
521            String::new()
522        }
523        Declared::Semantic { usage, index } => {
524            let index = if index == 0 {
525                String::new()
526            } else {
527                index.to_string()
528            };
529            let usage = USAGES.get(usize::from(usage)).copied().unwrap_or("unknown");
530            format!("_{usage}{index}")
531        }
532    };
533    format!(
534        "dcl{what}{} {}",
535        suffixes(Some(&d.destination)),
536        destination(&d.destination, program)
537    )
538}
539
540fn definition(literal: &Literal, register_: Register, program: &Program, options: &Options) -> String {
541    let (op, values) = match literal {
542        Literal::Float(f) => (
543            "def",
544            f.iter()
545                .map(|&w| {
546                    if options.hex_literals {
547                        format!("0x{w:08x}")
548                    } else {
549                        general(f32::from_bits(w))
550                    }
551                })
552                .collect::<Vec<_>>()
553                .join(", "),
554        ),
555        Literal::Integer(i) => (
556            "defi",
557            i.iter().map(ToString::to_string).collect::<Vec<_>>().join(", "),
558        ),
559        Literal::Bool(b) => ("defb", (if *b != 0 { "true" } else { "false" }).to_owned()),
560    };
561    format!("{op} {}, {values}", register(register_, program))
562}
563
564/// A float as C's `%.9g` writes it: nine significant digits, trailing zeros dropped, an exponent below 1e-4 and from
565/// 1e9.
566fn general(value: f32) -> String {
567    let value = f64::from(value);
568    if value == 0.0 {
569        return if value.is_sign_negative() { "-0" } else { "0" }.to_owned();
570    }
571    if !value.is_finite() {
572        return format!("{value}");
573    }
574    let scientific = format!("{value:.8e}");
575    let (mantissa, exponent) = scientific.split_once('e').unwrap_or((&scientific, "0"));
576    let exponent: i32 = exponent.parse().unwrap_or(0);
577    let trim = |text: &str| -> String {
578        if text.contains('.') {
579            text.trim_end_matches('0').trim_end_matches('.').to_owned()
580        } else {
581            text.to_owned()
582        }
583    };
584    if !(-4..9).contains(&exponent) {
585        let sign = if exponent < 0 { '-' } else { '+' };
586        return format!("{}e{sign}{:03}", trim(mantissa), exponent.unsigned_abs());
587    }
588    let decimals = usize::try_from(8i32.saturating_sub(exponent)).unwrap_or(0);
589    trim(&format!("{value:.decimals$}"))
590}
591
592/// The texture and arithmetic instruction slots an instruction takes, as Microsoft's listings count them: checked
593/// against every footer of a vs_3_0 and ps_3_0 corpus and against fixtures of one operation each, and of co-issue. `loop`, `endloop`,
594/// `call`, `callnz`, `ret`, `break`, `breakp` and `setp` take what Microsoft's instruction reference states, unchecked.
595fn slots(i: &Instruction, program: &Program) -> (u32, u32) {
596    // a co-issued instruction runs in the slot of the one before it, and a `nop` takes none
597    if i.coissue || i.opcode == Opcode::Nop {
598        return (0, 0);
599    }
600    let pixel = program.version.stage == Stage::Pixel;
601    match i.opcode {
602        Opcode::Tex => (1, 0),
603        Opcode::TexLdl => (2, 0),
604        Opcode::TexLdd => (1, 2),
605        Opcode::SinCos => (0, 8),
606        Opcode::M4x4 | Opcode::M3x4 => (0, 4),
607        Opcode::Nrm | Opcode::Pow | Opcode::M4x3 | Opcode::M3x3 => (0, 3),
608        Opcode::If
609        | Opcode::IfC
610        | Opcode::BreakC
611        | Opcode::BreakP
612        | Opcode::CallNz
613        | Opcode::Rep
614        | Opcode::Loop => (0, 3),
615        Opcode::EndRep
616        | Opcode::EndLoop
617        | Opcode::Call
618        | Opcode::M3x2
619        | Opcode::Crs
620        | Opcode::Dsx
621        | Opcode::Dsy => (0, 2),
622        Opcode::Dp2Add if pixel => (0, 2),
623        Opcode::Label | Opcode::Phase => (0, 0),
624        _ => (0, 1),
625    }
626}
627
628/// `// approximately N instruction slots used`, split into texture and arithmetic slots when the program samples.
629fn footer(out: &mut String, program: &Program) {
630    let (texture, arithmetic) = program.instructions().fold((0u32, 0u32), |(t, a), i| {
631        let (ti, ai) = slots(i, program);
632        (t.saturating_add(ti), a.saturating_add(ai))
633    });
634    let total = texture.saturating_add(arithmetic);
635    let slots = if total == 1 { "slot" } else { "slots" };
636    let _ = write!(out, "// approximately {total} instruction {slots} used");
637    if texture > 0 {
638        let _ = write!(out, " ({texture} texture, {arithmetic} arithmetic)");
639    }
640    out.push('\n');
641}