fix: C allocator contract, shadertoy overloads/MSL stride, minidumps

Closes the remaining library rows of the final completeness audit
(docs/rewrite/notes/final-Z3.md):
- libghostty-vt: OSC parser storage goes through the caller-supplied C
  allocator via a ByteStorage abstraction; ghostty_osc_new reports
  GHOSTTY_OUT_OF_MEMORY and failed grows invalidate the OSC like Zig.
  Tested with a counting/failing/limiting C allocator.
- shadertoy: sampler parameter rewriting is keyed by full signature so
  overloads work; the Metal uniform block keeps std140 strides for
  iChannelTime[4] (MSL offsets checked against the upload layout).
- tripwire.zig ported (named trip points, hit counts, verification) and
  used to port the previously skipped allocation-failure tests that have
  a fallible Rust path; the rest are documented.
- crash: fatal signals capture siginfo + registers and a forked helper
  writes a minidump (minidump-writer) as an envelope attachment, like
  sentry-native/Breakpad; tests parse it with the minidump crate.
- kitty LoadingImage::debug_dump (debug builds) and the Darwin
  Instruments signpost around benchmark runs.

Test Plan:
- cargo test -p ghostty-vt-capi --test c_allocator
- cargo test -p ghostty-crash --test minidump
This commit is contained in:
ddidderr committed 2026-10-12 08:14:02 +02:00
1 parent 96314c1a8b
commit f6002d7519
22 files changed
+2079 -57

No files matched your search

+232 -1
View File
@@ -371,7 +371,118 @@ pub fn msl_from_compiled(compiled: &Compiled) -> Result<String, Error> {
let (msl, _info) = msl_out::write_string(&module, &compiled.info, &options, &pipeline)
.map_err(|e| fail(&e))?;
Ok(msl)
Ok(fix_uniform_array_strides(&module, msl))
}
/// A uniform-block member that is an array of scalars. std140 gives it a
/// 16-byte element stride (what we upload and what spirv-cross emits padded
/// types for), but naga's MSL writer ignores the array `stride` and emits a
/// tightly packed `float[N]`.
struct StridedArray {
member: String,
/// naga's MSL name of the array wrapper struct (`type_<handle index>`).
type_name: String,
/// MSL scalar element type.
scalar: &'static str,
len: u32,
/// Lanes of the padded element (stride / scalar size).
lanes: u32,
}
fn strided_uniform_arrays(module: &naga::Module) -> Vec<StridedArray> {
let mut out = Vec::new();
for (_, gv) in module.global_variables.iter() {
if gv.space != naga::AddressSpace::Uniform {
continue;
}
let naga::TypeInner::Struct { members, .. } = &module.types[gv.ty].inner else {
continue;
};
for m in members {
let naga::TypeInner::Array {
base,
size: naga::ArraySize::Constant(n),
stride,
} = &module.types[m.ty].inner
else {
continue;
};
let naga::TypeInner::Scalar(sc) = &module.types[*base].inner else {
continue;
};
let scalar = match (sc.kind, sc.width) {
(naga::ScalarKind::Float, 4) => "float",
(naga::ScalarKind::Sint, 4) => "int",
(naga::ScalarKind::Uint, 4) => "uint",
_ => continue,
};
let size = u32::from(sc.width);
if *stride <= size {
continue;
}
let (Some(name), true) = (m.name.clone(), stride % size == 0) else {
continue;
};
out.push(StridedArray {
member: name,
type_name: format!("type_{}", m.ty.index()),
scalar,
len: n.get(),
lanes: stride / size,
});
}
}
out
}
/// Make the MSL uniform block match the std140 layout we upload: scalar
/// arrays become arrays of `vecN` (stride 16 for std140) and element reads
/// take `.x`. The writer ignores array strides, so this is done on the text.
fn fix_uniform_array_strides(module: &naga::Module, mut msl: String) -> String {
for a in strided_uniform_arrays(module) {
let wrapper = format!("{}_std140", a.type_name);
// 1. The block member uses the padded wrapper.
let decl = format!(" {} {};", a.type_name, a.member);
if !msl.contains(&decl) {
continue;
}
msl = msl.replace(&decl, &format!(" {wrapper} {};", a.member));
// 2. Define the wrapper before the struct that uses it.
let def = format!(
"struct {wrapper} {{\n metal::vec<{}, {}> inner[{}];\n}};\n",
a.scalar, a.lanes, a.len
);
if let Some(at) = msl.find(&format!("\n {wrapper} {};", a.member))
&& let Some(start) = msl[..at].rfind("\nstruct ")
{
msl.insert_str(start + 1, &def);
}
// 3. Element reads: `<expr>.member.inner[idx]` -> `... [idx].x`.
let needle = format!(".{}.inner[", a.member);
let mut from = 0;
while let Some(rel) = msl[from..].find(&needle) {
let open = from + rel + needle.len() - 1;
let mut depth = 0usize;
let mut close = None;
for (k, ch) in msl[open..].char_indices() {
match ch {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
close = Some(open + k);
break;
}
}
_ => {}
}
}
let Some(close) = close else { break };
msl.insert_str(close + 1, ".x");
from = close + 3;
}
}
msl
}
/// Convert a compiled shader to GLSL 430 core (`glslFromSpv`).
@@ -628,4 +739,124 @@ mod tests {
assert_eq!(offset_of!(Uniforms, selection_foreground_color), 4480);
assert_eq!(size_of::<Uniforms>(), 4496);
}
/// (size, align) of an MSL type as emitted by naga for the shadertoy
/// uniform block, resolving the wrapper structs defined in `msl`.
fn msl_size_align(msl: &str, ty: &str) -> (usize, usize) {
match ty {
"float" | "int" | "uint" => (4, 4),
"metal::packed_float3" => (12, 4),
"metal::float2" => (8, 8),
"metal::float3" | "metal::float4" => (16, 16),
_ => {
if let Some(rest) = ty.strip_prefix("metal::vec<") {
let (scalar, n) = rest.trim_end_matches('>').split_once(", ").unwrap();
let n: usize = n.parse().unwrap();
assert!(matches!(scalar, "float" | "int" | "uint"));
let n = if n == 3 { 4 } else { n };
return (4 * n, 4 * n);
}
// A wrapper struct `struct <ty> { <elem> inner[N]; };`.
let head = format!("struct {ty} {{\n ");
let at = msl.find(&head).unwrap_or_else(|| panic!("no struct {ty}"));
let body = &msl[at + head.len()..];
let line = &body[..body.find(';').unwrap()];
let (elem, count) = line.rsplit_once(" inner[").unwrap();
let count: usize = count.trim_end_matches(']').parse().unwrap();
let (es, ea) = msl_size_align(msl, elem);
(es * count, ea)
}
}
}
/// The `(offset, name)` of each member of `struct Globals` in `msl`,
/// skipping `char _padN[k]` fillers, plus the struct size.
fn msl_globals_layout(msl: &str) -> (Vec<(usize, String)>, usize) {
let at = msl.find("struct Globals {\n").unwrap();
let body = &msl[at + "struct Globals {\n".len()..];
let body = &body[..body.find("\n};").unwrap()];
let mut off = 0usize;
let mut max_align = 1;
let mut members = Vec::new();
for line in body.lines() {
let line = line.trim().trim_end_matches(';');
let (ty, name) = line.rsplit_once(' ').unwrap();
if ty == "char" {
let n: usize = name
.split('[')
.nth(1)
.unwrap()
.trim_end_matches(']')
.parse()
.unwrap();
off += n;
continue;
}
let (size, align) = msl_size_align(msl, ty);
off = off.next_multiple_of(align);
max_align = max_align.max(align);
members.push((off, name.to_owned()));
off += size;
}
(members, off.next_multiple_of(max_align))
}
#[test]
fn msl_uniform_block_matches_std140_upload_layout() {
use std::mem::offset_of as o;
// A shader that reads the scalar array so the accessor path is used.
let user = "void mainImage(out vec4 c, in vec2 p) { \
int i = int(p.x) & 3; c = vec4(iChannelTime[i], iChannelTime[2], iTime, iSampleRate); }";
let compiled = compile_shadertoy(user, None).unwrap();
let msl = msl_from_compiled(&compiled).unwrap();
let (members, size) = msl_globals_layout(&msl);
let expect = [
("iResolution", o!(Uniforms, resolution)),
("iTime", o!(Uniforms, time)),
("iTimeDelta", o!(Uniforms, time_delta)),
("iFrameRate", o!(Uniforms, frame_rate)),
("iFrame", o!(Uniforms, frame)),
("iChannelTime", o!(Uniforms, channel_time)),
("iChannelResolution", o!(Uniforms, channel_resolution)),
("iMouse", o!(Uniforms, mouse)),
("iDate", o!(Uniforms, date)),
("iSampleRate", o!(Uniforms, sample_rate)),
("iCurrentCursor", o!(Uniforms, current_cursor)),
("iPreviousCursor", o!(Uniforms, previous_cursor)),
("iCurrentCursorColor", o!(Uniforms, current_cursor_color)),
("iPreviousCursorColor", o!(Uniforms, previous_cursor_color)),
("iCurrentCursorStyle", o!(Uniforms, current_cursor_style)),
("iPreviousCursorStyle", o!(Uniforms, previous_cursor_style)),
("iCursorVisible", o!(Uniforms, cursor_visible)),
("iTimeCursorChange", o!(Uniforms, cursor_change_time)),
("iTimeFocus", o!(Uniforms, time_focus)),
("iFocus", o!(Uniforms, focus)),
("iPalette", o!(Uniforms, palette)),
("iBackgroundColor", o!(Uniforms, background_color)),
("iForegroundColor", o!(Uniforms, foreground_color)),
("iCursorColor", o!(Uniforms, cursor_color)),
("iCursorText", o!(Uniforms, cursor_text)),
// Upstream quirk: the Zig struct (and so ours) has the selection
// colors swapped relative to the GLSL block; both are vec3 so the
// offsets agree.
(
"iSelectionForegroundColor",
o!(Uniforms, selection_background_color),
),
(
"iSelectionBackgroundColor",
o!(Uniforms, selection_foreground_color),
),
];
assert_eq!(members.len(), expect.len(), "{msl}");
for ((off, name), (ename, eoff)) in members.iter().zip(expect) {
assert_eq!(name, ename);
assert_eq!(*off, eoff, "MSL offset of {name}\n{msl}");
}
assert_eq!(size, std::mem::size_of::<Uniforms>());
// Reads of the padded array take the first lane.
assert!(msl.contains(".iChannelTime.inner["), "{msl}");
assert!(msl.contains("].x"), "{msl}");
}
}
@@ -18,9 +18,12 @@
//!
//! Limitations (documented, reported as compile errors by naga): a sampler
//! argument must be a plain identifier (no ternaries or struct members),
//! samplers cannot be stored in local variables/arrays/struct fields, and
//! overloads of one function name must agree on which parameters are
//! samplers.
//! samplers cannot be stored in local variables/arrays/struct fields.
//!
//! Overloads are keyed by their full signature (the per-parameter sampler
//! layout), not by name. At a call site the overload is chosen by arity and by
//! which arguments are samplers (`iChannelN` or a sampler parameter of the
//! enclosing function), which is what GLSL overload resolution would see.
use std::collections::HashMap;
@@ -171,10 +174,11 @@ struct Def {
samplers: Vec<Option<String>>,
}
struct FnInfo {
/// Per parameter: the sampler name, if the parameter is a `sampler2D`.
samplers: Vec<Option<String>>,
}
/// Overload signature: per parameter, whether it is a `sampler2D`.
type SamplerLayout = Vec<bool>;
/// The combined samplers the host provides.
const GLOBAL_SAMPLERS: [&str; 4] = ["iChannel0", "iChannel1", "iChannel2", "iChannel3"];
/// Rewrite the user's ShaderToy source. Returns the source unchanged if it
/// needs no adaptation other than `texture2D(` -> `texture(`.
@@ -188,7 +192,7 @@ pub fn rewrite(src: &str) -> String {
.collect();
// Pass 1: find top-level functions with sampler2D parameters.
let mut funcs: HashMap<&str, FnInfo> = HashMap::new();
let mut funcs: HashMap<&str, Vec<SamplerLayout>> = HashMap::new();
let mut defs: Vec<Def> = Vec::new();
let mut k = 0;
let mut brace = 0i32;
@@ -211,7 +215,6 @@ pub fn rewrite(src: &str) -> String {
if next == Some("{") || next == Some(";") {
let params = split_args(&toks, &sig, k + 3, close);
let mut samplers = Vec::new();
let mut any = false;
for &(a, b) in &params {
let has = (a..b).any(|q| toks[sig[q]].text == "sampler2D");
if has {
@@ -220,7 +223,6 @@ pub fn rewrite(src: &str) -> String {
.map(|q| &toks[sig[q]])
.find(|t| t.kind == Kind::Ident && t.text != "sampler2D")
.map(|t| t.text.to_owned());
any |= name.is_some();
samplers.push(name);
} else {
samplers.push(None);
@@ -231,12 +233,12 @@ pub fn rewrite(src: &str) -> String {
} else {
None
};
if any {
funcs
.entry(toks[sig[name_k]].text)
.or_insert_with(|| FnInfo {
samplers: samplers.clone(),
});
// Every overload is recorded (also the ones without
// samplers) so call sites can tell them apart.
let layout: SamplerLayout = samplers.iter().map(Option::is_some).collect();
let list = funcs.entry(toks[sig[name_k]].text).or_default();
if !list.contains(&layout) {
list.push(layout);
}
defs.push(Def {
name_k,
@@ -293,18 +295,32 @@ pub fn rewrite(src: &str) -> String {
if def_names.contains(&k) {
continue;
}
let Some(info) = funcs.get(t.text) else {
let Some(layouts) = funcs.get(t.text) else {
continue;
};
let Some(close) = matching(&toks, &sig, k + 1) else {
continue;
};
let args = split_args(&toks, &sig, k + 2, close);
for (ai, &(a, b)) in args.iter().enumerate() {
if info.samplers.get(ai).is_some_and(Option::is_some)
&& b == a + 1
// Sampler parameters of the function this call is in.
let enclosing: Vec<&str> = defs
.iter()
.filter(|d| d.body.is_some_and(|(o, c)| o < k && k < c))
.flat_map(|d| d.samplers.iter().flatten().map(String::as_str))
.collect();
let is_sampler_arg = |&(a, b): &(usize, usize)| {
b == a + 1
&& toks[sig[a]].kind == Kind::Ident
{
&& (GLOBAL_SAMPLERS.contains(&toks[sig[a]].text)
|| enclosing.contains(&toks[sig[a]].text))
};
let actual: SamplerLayout = args.iter().map(is_sampler_arg).collect();
// Pick the overload with this arity and sampler positions.
if !layouts.contains(&actual) {
continue;
}
for (ai, &(a, _)) in args.iter().enumerate() {
if actual[ai] {
let n = toks[sig[a]].text;
repl.insert(sig[a], format!("{n}_tex, {n}_smp"));
}
@@ -336,6 +352,35 @@ mod tests {
assert!(out.contains("fetch(iChannel0_tex, iChannel0_smp, p)"));
}
#[test]
fn overloads_are_keyed_by_signature() {
let out = rewrite(
"vec4 f(sampler2D s, vec2 uv) { return texture(s, uv); }\n\
vec4 f(vec2 uv, sampler2D s) { return texture(s, uv); }\n\
vec4 f(vec2 uv, float x) { return vec4(uv, x, 1.0); }\n\
void mainImage(out vec4 c, in vec2 p) {\n\
c = f(iChannel0, p) + f(p, iChannel1) + f(p, 1.0);\n\
}",
);
assert!(out.contains("vec4 f(texture2D s_tex, sampler s_smp, vec2 uv)"));
assert!(out.contains("vec4 f(vec2 uv, texture2D s_tex, sampler s_smp)"));
assert!(out.contains("vec4 f(vec2 uv, float x)"));
assert!(out.contains("f(iChannel0_tex, iChannel0_smp, p)"));
assert!(out.contains("f(p, iChannel1_tex, iChannel1_smp)"));
assert!(out.contains("f(p, 1.0)"));
}
#[test]
fn overloads_through_sampler_parameters() {
let out = rewrite(
"float g(sampler2D t) { return texture(t, vec2(0.0)).x; }\n\
float g(float t) { return t; }\n\
float h(sampler2D u, float v) { return g(u) + g(v); }\n",
);
assert!(out.contains("g(u_tex, u_smp)"));
assert!(out.contains("+ g(v)"));
}
#[test]
fn leaves_plain_code_and_rewrites_legacy_texture2d() {
let src = "void mainImage(out vec4 c, in vec2 p) { c = texture2D(iChannel0, p); }";